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
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
#
# Pre-push gate: run the full project check before anything leaves the machine.
# Enable once with:
# git config core.hooksPath .githooks
#
# Skip in a pinch with: git push --no-verify
#
# To include the backend integration tests, export TEST_DATABASE_URL pointing at
# a throwaway database (see scripts/check.sh).
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
exec "$ROOT/scripts/check.sh" all
+51
View File
@@ -359,6 +359,24 @@ dependencies = [
"version_check", "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]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -529,6 +547,15 @@ dependencies = [
"syn 2.0.118", "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]] [[package]]
name = "dotenvy" name = "dotenvy"
version = "0.15.7" version = "0.15.7"
@@ -1291,6 +1318,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -1737,6 +1770,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]] [[package]]
name = "ptr_meta" name = "ptr_meta"
version = "0.1.4" version = "0.1.4"
@@ -1757,6 +1796,16 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -1968,6 +2017,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"cookie",
"cookie_store",
"futures-core", "futures-core",
"http", "http",
"http-body", "http-body",
+6
View File
@@ -39,3 +39,9 @@ dotenvy = "0.15"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] }
url = "2" url = "2"
scraper = "0.20" 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) .verify_password(plain.as_bytes(), &parsed)
.is_ok()) .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())) 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") { if let Some(t) = map.get("@type") {
let is_product = match t { let is_product = match t {
serde_json::Value::String(s) => s == "Product", serde_json::Value::String(s) => s == "Product",
serde_json::Value::Array(a) => { serde_json::Value::Array(a) => a.iter().any(|x| x.as_str() == Some("Product")),
a.iter().any(|x| x.as_str() == Some("Product"))
}
_ => false, _ => false,
}; };
if is_product { if is_product {
@@ -195,8 +193,7 @@ fn from_microdata(doc: &Html) -> Candidate {
fn from_meta(doc: &Html) -> Candidate { fn from_meta(doc: &Html) -> Candidate {
Candidate { Candidate {
title: meta_content(doc, "property", "og:title"), title: meta_content(doc, "property", "og:title"),
price: meta_content(doc, "property", "product:price:amount") price: meta_content(doc, "property", "product:price:amount").and_then(|s| parse_price(&s)),
.and_then(|s| parse_price(&s)),
currency: meta_content(doc, "property", "product:price:currency") currency: meta_content(doc, "property", "product:price:currency")
.map(|s| normalize_currency(&s)), .map(|s| normalize_currency(&s)),
image: meta_content(doc, "property", "og:image"), image: meta_content(doc, "property", "og:image"),
@@ -297,3 +294,123 @@ fn parse_price(raw: &str) -> Option<Decimal> {
} }
Decimal::from_str(&s).ok() 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? { if let Some(p) = generic::fetch(client, url, default_currency).await? {
return Ok(p); 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; //! and the price is genuinely in our requested currency;
//! - if they're equal → no conversion happened, so the price is in the shop's //! - 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. //! 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. //! This stops us from mislabelling e.g. "821 EUR" when the value is 821 PLN.
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -183,3 +184,82 @@ fn product_doc_url(raw: &str, ext: &str) -> Option<String> {
let origin = &u[..Position::BeforePath]; let origin = &u[..Position::BeforePath];
Some(format!("{origin}/products/{handle}.{ext}")) 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 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 tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use config::Config; use shoplist_backend::config::Config;
use mail::Mailer; use shoplist_backend::mail::Mailer;
use state::AppState; use shoplist_backend::state::AppState;
use shoplist_backend::{build_router, db, fetch, worker};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
@@ -38,55 +20,24 @@ async fn main() -> anyhow::Result<()> {
let pool = db::connect(&config.database_url).await?; let pool = db::connect(&config.database_url).await?;
let mailer = Mailer::from_config(&config.smtp)?; let mailer = Mailer::from_config(&config.smtp)?;
// Session store (separate table set, managed by the store's own migrations). let host = config.host.clone();
let session_store = PostgresStore::new(pool.clone()); let port = config.port;
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 state = AppState { let state = AppState {
pool, pool,
config: Arc::new(config.clone()), config: Arc::new(config),
mailer, mailer,
http: fetch::http_client(), http: fetch::http_client(),
}; };
worker::spawn(state.clone()); worker::spawn(state.clone());
let api = Router::new() let app = build_router(state).await?;
.merge(routes::router())
.nest("/auth", auth::routes::router());
let app = Router::new() let addr = format!("{host}:{port}");
.nest("/api", api)
.layer(cors)
.layer(session_layer)
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr = format!("{}:{}", config.host, config.port);
let listener = tokio::net::TcpListener::bind(&addr).await?; let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("listening on {addr}"); tracing::info!("listening on {addr}");
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
Ok(()) 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> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/lists/{id}/invites", get(list_invites).post(create_invite))
.route( .route(
"/lists/{id}/invites", "/lists/{id}/invites/{invite_id}",
get(list_invites).post(create_invite), 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", get(list_collaborators))
.route( .route(
"/lists/{id}/collaborators/{user_id}", "/lists/{id}/collaborators/{user_id}",
+12 -9
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.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"; 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"; position, created_at, updated_at";
// Same, prefixed with the `l` alias (for the collaborator UNION arm). // 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 /// The caller's role on a list: "owner", "editor", "crosser", or None (no
/// access). Owner wins over any collaborator row. /// access). Owner wins over any collaborator row.
async fn list_role( async fn list_role(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<Option<String>> {
state: &AppState,
list_id: Uuid,
user_id: Uuid,
) -> AppResult<Option<String>> {
let role = sqlx::query_scalar::<_, String>( let role = sqlx::query_scalar::<_, String>(
"SELECT 'owner' FROM lists WHERE id = $1 AND user_id = $2 "SELECT 'owner' FROM lists WHERE id = $1 AND user_id = $2
UNION ALL UNION ALL
@@ -467,7 +464,9 @@ async fn refetch_item(
AuthUser(user): AuthUser, AuthUser(user): AuthUser,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> AppResult<Json<Item>> { ) -> AppResult<Json<Item>> {
let url = viewable_item_url(&state, id, user.id).await?.ok_or_else(|| { 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()) AppError::BadRequest("this temptation has no URL to keep vigil over".into())
})?; })?;
@@ -550,7 +549,9 @@ async fn claim_item(
let name = opt_trim(req.name) let name = opt_trim(req.name)
.or_else(|| user.display_name.clone()) .or_else(|| user.display_name.clone())
.unwrap_or_else(|| user.email.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( async fn unclaim_item(
@@ -595,7 +596,9 @@ async fn guest_claim(
// user has no display name set. // user has no display name set.
let name = opt_trim(req.name) let name = opt_trim(req.name)
.or_else(|| user.as_ref().and_then(|u| u.display_name.clone())) .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); let uid = user.as_ref().map(|u| u.id);
Ok(Json(set_claim(&state, item_id, uid, Some(name)).await?)) 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;
}
}
};
}
+6
View File
@@ -0,0 +1,6 @@
build/
.svelte-kit/
node_modules/
pnpm-lock.yaml
package-lock.json
static/
+9
View File
@@ -0,0 +1,9 @@
{
"useTabs": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+39
View File
@@ -0,0 +1,39 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
export default ts.config(
// Never lint generated / vendored output.
{
ignores: ['build/', '.svelte-kit/', 'node_modules/', 'dist/', 'static/']
},
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
}
},
{
// Svelte files parse <script lang="ts"> blocks with the TS parser.
files: ['**/*.svelte', '**/*.svelte.ts'],
languageOptions: {
parserOptions: { parser: ts.parser }
}
},
{
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
// This project links/navigates with plain hrefs and goto(path); we don't
// use the resolve() route helper. Off rather than churn every link.
'svelte/no-navigation-without-resolve': 'off',
// Nice-to-have, not worth failing the build over for static lists.
'svelte/require-each-key': 'warn',
// `svelte-ignore` comments target svelte-check (a11y), which eslint can't
// see — so it wrongly reports them as unused.
'svelte/no-unused-svelte-ignore': 'off'
}
}
);
+18 -2
View File
@@ -7,17 +7,33 @@
"dev": "vite dev --port 5173", "dev": "vite dev --port 5173",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-node": "^5.2.0", "@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.8.0", "@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/node": "^25.9.3",
"eslint": "^10.5.0",
"eslint-plugin-svelte": "^3.19.0",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.4",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.15.0", "svelte": "^5.15.0",
"svelte-check": "^4.1.0", "svelte-check": "^4.1.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.0.0" "typescript-eslint": "^8.61.1",
"vite": "^6.0.0",
"vitest": "^4.1.9"
} }
} }
+1721 -32
View File
File diff suppressed because it is too large Load Diff
+11 -33
View File
@@ -1,4 +1,4 @@
@import "tailwindcss"; @import 'tailwindcss';
/* Web fonts loaded via <link> in app.html (ethereal serif + clean sans + mono). */ /* Web fonts loaded via <link> in app.html (ethereal serif + clean sans + mono). */
@@ -19,9 +19,9 @@
--color-gold: #ffe6a3; /* divine gilt */ --color-gold: #ffe6a3; /* divine gilt */
--color-holo: #cbd6ff; /* holographic sheen */ --color-holo: #cbd6ff; /* holographic sheen */
--font-display: "Space Grotesk", system-ui, sans-serif; --font-display: 'Space Grotesk', system-ui, sans-serif;
--font-gospel: "Fraunces", "Times New Roman", serif; --font-gospel: 'Fraunces', 'Times New Roman', serif;
--font-mono: "Space Mono", ui-monospace, monospace; --font-mono: 'Space Mono', ui-monospace, monospace;
--radius-none: 0px; --radius-none: 0px;
--radius-soft: 0.625rem; /* one radius language for panels/fields/buttons */ --radius-soft: 0.625rem; /* one radius language for panels/fields/buttons */
@@ -48,32 +48,16 @@
/* Soft drifting aurora — celestial light pollution. */ /* Soft drifting aurora — celestial light pollution. */
body::before { body::before {
content: ""; content: '';
position: fixed; position: fixed;
inset: -20%; inset: -20%;
pointer-events: none; pointer-events: none;
z-index: -1; z-index: -1;
background: background:
radial-gradient( radial-gradient(40% 35% at 18% 12%, rgba(185, 167, 255, 0.18), transparent 70%),
40% 35% at 18% 12%, radial-gradient(38% 40% at 85% 20%, rgba(255, 174, 203, 0.14), transparent 70%),
rgba(185, 167, 255, 0.18), radial-gradient(45% 45% at 70% 88%, rgba(154, 247, 216, 0.12), transparent 70%),
transparent 70% radial-gradient(50% 50% at 30% 80%, rgba(203, 214, 255, 0.1), transparent 70%);
),
radial-gradient(
38% 40% at 85% 20%,
rgba(255, 174, 203, 0.14),
transparent 70%
),
radial-gradient(
45% 45% at 70% 88%,
rgba(154, 247, 216, 0.12),
transparent 70%
),
radial-gradient(
50% 50% at 30% 80%,
rgba(203, 214, 255, 0.1),
transparent 70%
);
filter: blur(10px); filter: blur(10px);
animation: drift 26s ease-in-out infinite alternate; animation: drift 26s ease-in-out infinite alternate;
} }
@@ -119,8 +103,7 @@
position: relative; position: relative;
border-radius: var(--radius-soft); border-radius: var(--radius-soft);
background: background:
linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 60%), linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 60%), var(--color-panel);
var(--color-panel);
border: 1px solid var(--color-smoke); border: 1px solid var(--color-smoke);
box-shadow: box-shadow:
0 0 0 1px rgba(185, 167, 255, 0.06), 0 0 0 1px rgba(185, 167, 255, 0.06),
@@ -146,12 +129,7 @@
font-family: var(--font-gospel); font-family: var(--font-gospel);
font-style: italic; font-style: italic;
font-weight: 300; font-weight: 300;
background: linear-gradient( background: linear-gradient(100deg, var(--color-gold), var(--color-rose), var(--color-iris));
100deg,
var(--color-gold),
var(--color-rose),
var(--color-iris)
);
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
+42 -7
View File
@@ -61,7 +61,9 @@
const linePath = $derived( const linePath = $derived(
coords.length coords.length
? coords.map((c, i) => `${i === 0 ? 'M' : 'L'} ${c.cx.toFixed(1)} ${c.cy.toFixed(1)}`).join(' ') ? coords
.map((c, i) => `${i === 0 ? 'M' : 'L'} ${c.cx.toFixed(1)} ${c.cy.toFixed(1)}`)
.join(' ')
: '' : ''
); );
// Area = the line, then down to the baseline and back — fills under the curve. // Area = the line, then down to the baseline and back — fills under the curve.
@@ -278,10 +280,32 @@
stroke-width="1" stroke-width="1"
opacity="0.25" opacity="0.25"
/> />
<circle cx={active.cx} cy={active.cy} r="5.5" fill="none" stroke="var(--color-ink)" stroke-width="1.5" /> <circle
cx={active.cx}
cy={active.cy}
r="5.5"
fill="none"
stroke="var(--color-ink)"
stroke-width="1.5"
/>
<g transform="translate({tipX}, {Math.max(active.cy - 16, PAD.t + 8)})"> <g transform="translate({tipX}, {Math.max(active.cy - 16, PAD.t + 8)})">
<rect x="-54" y="-26" width="108" height="34" rx="6" fill="var(--color-ash)" stroke="var(--color-smoke)" /> <rect
<text x="0" y="-12" text-anchor="middle" class="fill-ink" font-size="11" font-weight="700"> x="-54"
y="-26"
width="108"
height="34"
rx="6"
fill="var(--color-ash)"
stroke="var(--color-smoke)"
/>
<text
x="0"
y="-12"
text-anchor="middle"
class="fill-ink"
font-size="11"
font-weight="700"
>
{fmtMoney(active.price)} {fmtMoney(active.price)}
</text> </text>
<text x="0" y="2" text-anchor="middle" class="fill-mute" font-size="9"> <text x="0" y="2" text-anchor="middle" class="fill-mute" font-size="9">
@@ -303,10 +327,21 @@
<figcaption class="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs"> <figcaption class="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs">
<span class="flex items-center gap-3 text-mute"> <span class="flex items-center gap-3 text-mute">
<span class="flex items-center gap-1"><span class="inline-block size-2 rounded-full" style="background:var(--color-gold)"></span>low {fmtMoney(coords[lowIdx].price)}</span> <span class="flex items-center gap-1"
<span class="flex items-center gap-1"><span class="inline-block size-2 rounded-full" style="background:var(--color-rose)"></span>high {fmtMoney(coords[highIdx].price)}</span> ><span class="inline-block size-2 rounded-full" style="background:var(--color-gold)"
></span>low {fmtMoney(coords[lowIdx].price)}</span
>
<span class="flex items-center gap-1"
><span class="inline-block size-2 rounded-full" style="background:var(--color-rose)"
></span>high {fmtMoney(coords[highIdx].price)}</span
>
{#if anyOos} {#if anyOos}
<span class="flex items-center gap-1"><span class="inline-block h-2 w-3 rounded-sm" style="background:var(--color-rose);opacity:0.35"></span>out of stock</span> <span class="flex items-center gap-1"
><span
class="inline-block h-2 w-3 rounded-sm"
style="background:var(--color-rose);opacity:0.35"
></span>out of stock</span
>
{/if} {/if}
</span> </span>
{#if latest} {#if latest}
+37
View File
@@ -0,0 +1,37 @@
import '@testing-library/jest-dom/vitest';
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/svelte';
import PriceChart from './PriceChart.svelte';
import type { PricePoint } from './lists.svelte';
function point(price: number, day: string, in_stock: boolean | null = true): PricePoint {
return { price, currency: 'EUR', in_stock, fetched_at: `2026-06-${day}T12:00:00Z` };
}
describe('PriceChart', () => {
it('shows an empty state when there is no history', () => {
const { getByText, container } = render(PriceChart, { props: { history: [] } });
expect(getByText(/no price checks yet/i)).toBeInTheDocument();
expect(container.querySelector('svg')).toBeNull();
});
it('renders an svg with low/high stats from history', () => {
const history = [point(20, '10'), point(12, '11'), point(25, '12')];
const { container, getByText } = render(PriceChart, {
props: { history, target: 15, currency: 'EUR' }
});
expect(container.querySelector('svg')).not.toBeNull();
// Lowest and highest are surfaced in the caption legend.
expect(getByText(/low EUR 12\.00/)).toBeInTheDocument();
expect(getByText(/high EUR 25\.00/)).toBeInTheDocument();
// Latest (25.00) is above the 15 target, so not on sale (no ✦).
expect(getByText(/now EUR 25\.00/)).toBeInTheDocument();
});
it('flags an out-of-stock legend when a check was sold out', () => {
const history = [point(20, '10', true), point(18, '11', false)];
const { getByText } = render(PriceChart, { props: { history } });
expect(getByText(/out of stock/i)).toBeInTheDocument();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { api, ApiError } from './api';
/** Minimal stand-in for a fetch Response. */
function fakeRes(status: number, body?: unknown): Response {
return {
status,
ok: status >= 200 && status < 300,
statusText: `status ${status}`,
text: async () => (body === undefined ? '' : JSON.stringify(body))
} as Response;
}
describe('api client', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
it('GET parses JSON and sends credentials', async () => {
const fetchMock = vi.fn().mockResolvedValue(fakeRes(200, { hello: 'world' }));
vi.stubGlobal('fetch', fetchMock);
const data = await api.get<{ hello: string }>('/x');
expect(data).toEqual({ hello: 'world' });
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/x',
expect.objectContaining({ credentials: 'include' })
);
});
it('POST serialises the body and sets the method', async () => {
const fetchMock = vi.fn().mockResolvedValue(fakeRes(201, { id: '1' }));
vi.stubGlobal('fetch', fetchMock);
await api.post('/lists', { name: 'Gadgets' });
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/lists',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ name: 'Gadgets' }) })
);
});
it('returns undefined for 204 No Content', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes(204)));
await expect(api.del('/lists/1')).resolves.toBeUndefined();
});
it('throws ApiError carrying status and server message', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes(404, { error: 'not found' })));
const err = (await api.get('/missing').catch((e) => e)) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.status).toBe(404);
expect(err.message).toBe('not found');
});
it('falls back to statusText when the body has no error field', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes(500, {})));
const err = (await api.get('/boom').catch((e) => e)) as ApiError;
expect(err.message).toBe('status 500');
});
});
+10 -10
View File
@@ -1,6 +1,6 @@
import { env } from "$env/dynamic/public"; import { env } from '$env/dynamic/public';
const BASE = env.PUBLIC_API_BASE || "http://localhost:8080"; const BASE = env.PUBLIC_API_BASE || 'http://localhost:8080';
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
@@ -12,9 +12,9 @@ export class ApiError extends Error {
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> { async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}/api${path}`, { const res = await fetch(`${BASE}/api${path}`, {
credentials: "include", credentials: 'include',
headers: { "Content-Type": "application/json", ...(opts.headers ?? {}) }, headers: { 'Content-Type': 'application/json', ...(opts.headers ?? {}) },
...opts, ...opts
}); });
if (res.status === 204) return undefined as T; if (res.status === 204) return undefined as T;
@@ -32,13 +32,13 @@ export const api = {
get: <T>(p: string) => request<T>(p), get: <T>(p: string) => request<T>(p),
post: <T>(p: string, body?: unknown) => post: <T>(p: string, body?: unknown) =>
request<T>(p, { request<T>(p, {
method: "POST", method: 'POST',
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined
}), }),
patch: <T>(p: string, body?: unknown) => patch: <T>(p: string, body?: unknown) =>
request<T>(p, { request<T>(p, {
method: "PATCH", method: 'PATCH',
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined
}), }),
del: <T>(p: string) => request<T>(p, { method: "DELETE" }), del: <T>(p: string) => request<T>(p, { method: 'DELETE' })
}; };
+3 -3
View File
@@ -1,4 +1,4 @@
import { api } from "./api"; import { api } from './api';
export type User = { export type User = {
id: string; id: string;
@@ -23,7 +23,7 @@ class AuthStore {
async refresh() { async refresh() {
try { try {
const me = await api.get<Me>("/auth/me"); const me = await api.get<Me>('/auth/me');
this.user = me.user; this.user = me.user;
this.settings = me.settings; this.settings = me.settings;
} catch { } catch {
@@ -42,7 +42,7 @@ class AuthStore {
async logout() { async logout() {
try { try {
await api.post("/auth/logout"); await api.post('/auth/logout');
} finally { } finally {
this.user = null; this.user = null;
this.settings = null; this.settings = null;
+20 -26
View File
@@ -1,8 +1,8 @@
import { api } from "./api"; import { api } from './api';
export type ItemStatus = "coveted" | "acquired" | "renounced"; export type ItemStatus = 'coveted' | 'acquired' | 'renounced';
export type ListRole = "owner" | "editor" | "crosser"; export type ListRole = 'owner' | 'editor' | 'crosser';
export type List = { export type List = {
id: string; id: string;
@@ -23,13 +23,13 @@ export type SharedView = { list: List; items: Item[] };
export type Invite = { export type Invite = {
id: string; id: string;
token: string; token: string;
role: "editor" | "crosser"; role: 'editor' | 'crosser';
created_at: string; created_at: string;
}; };
export type Collaborator = { export type Collaborator = {
user_id: string; user_id: string;
role: "editor" | "crosser"; role: 'editor' | 'crosser';
display_name: string | null; display_name: string | null;
email: string; email: string;
created_at: string; created_at: string;
@@ -39,12 +39,12 @@ export type InvitePreview = {
list_id: string; list_id: string;
list_name: string; list_name: string;
emoji: string | null; emoji: string | null;
role: "editor" | "crosser"; role: 'editor' | 'crosser';
}; };
export type Subscription = { export type Subscription = {
id: string; id: string;
kind: "list" | "item"; kind: 'list' | 'item';
created_at: string; created_at: string;
list_id: string | null; list_id: string | null;
item_id: string | null; item_id: string | null;
@@ -109,11 +109,11 @@ export type NewItem = {
// ---- Lists ---------------------------------------------------------------- // ---- Lists ----------------------------------------------------------------
export const listsApi = { export const listsApi = {
all: () => api.get<List[]>("/lists"), all: () => api.get<List[]>('/lists'),
create: (b: NewList) => api.post<List>("/lists", b), create: (b: NewList) => api.post<List>('/lists', b),
update: ( update: (
id: string, id: string,
b: Partial<NewList> & { position?: number; allow_guest_crossoff?: boolean }, b: Partial<NewList> & { position?: number; allow_guest_crossoff?: boolean }
) => api.patch<List>(`/lists/${id}`, b), ) => api.patch<List>(`/lists/${id}`, b),
remove: (id: string) => api.del<{ deleted: string }>(`/lists/${id}`), remove: (id: string) => api.del<{ deleted: string }>(`/lists/${id}`),
share: (id: string) => api.post<List>(`/lists/${id}/share`, {}), share: (id: string) => api.post<List>(`/lists/${id}/share`, {}),
@@ -123,8 +123,7 @@ export const listsApi = {
api.get<PricePoint[]>(`/shared/${token}/items/${itemId}/history`), api.get<PricePoint[]>(`/shared/${token}/items/${itemId}/history`),
// Cross off / claim (collaborator route). // Cross off / claim (collaborator route).
claim: (id: string, name?: string) => claim: (id: string, name?: string) => api.post<Item>(`/items/${id}/claim`, name ? { name } : {}),
api.post<Item>(`/items/${id}/claim`, name ? { name } : {}),
unclaim: (id: string) => api.del<Item>(`/items/${id}/claim`), unclaim: (id: string) => api.del<Item>(`/items/${id}/claim`),
// Cross off via public share link (guest crossoff must be enabled). // Cross off via public share link (guest crossoff must be enabled).
guestClaim: (token: string, itemId: string, name: string) => guestClaim: (token: string, itemId: string, name: string) =>
@@ -134,34 +133,29 @@ export const listsApi = {
// Collaboration: invites + collaborators. // Collaboration: invites + collaborators.
invites: (listId: string) => api.get<Invite[]>(`/lists/${listId}/invites`), invites: (listId: string) => api.get<Invite[]>(`/lists/${listId}/invites`),
createInvite: (listId: string, role: "editor" | "crosser") => createInvite: (listId: string, role: 'editor' | 'crosser') =>
api.post<Invite>(`/lists/${listId}/invites`, { role }), api.post<Invite>(`/lists/${listId}/invites`, { role }),
revokeInvite: (listId: string, inviteId: string) => revokeInvite: (listId: string, inviteId: string) =>
api.del<{ deleted: string }>(`/lists/${listId}/invites/${inviteId}`), api.del<{ deleted: string }>(`/lists/${listId}/invites/${inviteId}`),
collaborators: (listId: string) => collaborators: (listId: string) => api.get<Collaborator[]>(`/lists/${listId}/collaborators`),
api.get<Collaborator[]>(`/lists/${listId}/collaborators`),
removeCollaborator: (listId: string, userId: string) => removeCollaborator: (listId: string, userId: string) =>
api.del<{ removed: string }>(`/lists/${listId}/collaborators/${userId}`), api.del<{ removed: string }>(`/lists/${listId}/collaborators/${userId}`),
previewInvite: (token: string) => previewInvite: (token: string) => api.get<InvitePreview>(`/invites/${token}`),
api.get<InvitePreview>(`/invites/${token}`),
acceptInvite: (token: string) => acceptInvite: (token: string) =>
api.post<{ list_id: string; role: string }>(`/invites/${token}/accept`, {}), api.post<{ list_id: string; role: string }>(`/invites/${token}/accept`, {}),
items: (listId: string) => api.get<Item[]>(`/lists/${listId}/items`), items: (listId: string) => api.get<Item[]>(`/lists/${listId}/items`),
addItem: (listId: string, b: NewItem) => addItem: (listId: string, b: NewItem) => api.post<Item>(`/lists/${listId}/items`, b),
api.post<Item>(`/lists/${listId}/items`, b), updateItem: (id: string, b: Partial<NewItem> & { status?: ItemStatus; position?: number }) =>
updateItem: ( api.patch<Item>(`/items/${id}`, b),
id: string,
b: Partial<NewItem> & { status?: ItemStatus; position?: number },
) => api.patch<Item>(`/items/${id}`, b),
removeItem: (id: string) => api.del<{ deleted: string }>(`/items/${id}`), removeItem: (id: string) => api.del<{ deleted: string }>(`/items/${id}`),
refetch: (id: string) => api.post<Item>(`/items/${id}/refetch`, {}), refetch: (id: string) => api.post<Item>(`/items/${id}/refetch`, {}),
history: (id: string) => api.get<PricePoint[]>(`/items/${id}/history`), history: (id: string) => api.get<PricePoint[]>(`/items/${id}/history`),
subscriptions: () => api.get<Subscription[]>("/subscriptions"), subscriptions: () => api.get<Subscription[]>('/subscriptions'),
subscribe: (b: { list_id?: string; item_id?: string }) => subscribe: (b: { list_id?: string; item_id?: string }) =>
api.post<{ id: string }>("/subscriptions", b), api.post<{ id: string }>('/subscriptions', b),
unsubscribe: (id: string) => api.del<{ deleted: string }>(`/subscriptions/${id}`), unsubscribe: (id: string) => api.del<{ deleted: string }>(`/subscriptions/${id}`)
}; };
/** Reactive store for the user's lists. */ /** Reactive store for the user's lists. */
+75
View File
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { lists, subs, type List, type Subscription } from './lists.svelte';
function list(id: string, name = 'L'): List {
return {
id,
name,
emoji: null,
description: null,
share_token: null,
allow_guest_crossoff: false,
position: 0,
created_at: '',
updated_at: '',
role: 'owner'
};
}
function sub(id: string, opts: Partial<Subscription>): Subscription {
return {
id,
kind: 'list',
created_at: '',
list_id: null,
item_id: null,
title: 'T',
emoji: null,
share_token: null,
url: null,
image_url: null,
current_price: null,
currency: null,
in_stock: null,
target_price: null,
...opts
};
}
describe('ListsStore.replace', () => {
beforeEach(() => {
lists.items = [];
});
it('swaps a list in place by id', () => {
lists.items = [list('a', 'old'), list('b')];
lists.replace(list('a', 'new'));
expect(lists.items.find((l) => l.id === 'a')?.name).toBe('new');
expect(lists.items).toHaveLength(2);
});
it('is a no-op when the id is unknown', () => {
lists.items = [list('a')];
lists.replace(list('zzz', 'ghost'));
expect(lists.items).toHaveLength(1);
expect(lists.items[0].id).toBe('a');
});
});
describe('SubsStore lookups', () => {
beforeEach(() => {
subs.items = [];
});
it('finds an existing subscription id by list', () => {
subs.items = [sub('s1', { kind: 'list', list_id: 'L1' })];
expect(subs.forList('L1')).toBe('s1');
expect(subs.forList('nope')).toBeNull();
});
it('finds an existing subscription id by item', () => {
subs.items = [sub('s2', { kind: 'item', item_id: 'I9' })];
expect(subs.forItem('I9')).toBe('s2');
expect(subs.forItem('nope')).toBeNull();
});
});
+9 -3
View File
@@ -55,17 +55,23 @@
{#if auth.loaded && auth.user} {#if auth.loaded && auth.user}
<a <a
href="/lists" href="/lists"
class="tag {active('/lists') ? 'border-iris text-iris' : 'border-smoke text-mute hover:text-iris'}" class="tag {active('/lists')
? 'border-iris text-iris'
: 'border-smoke text-mute hover:text-iris'}"
aria-current={active('/lists') ? 'page' : undefined}>lists</a aria-current={active('/lists') ? 'page' : undefined}>lists</a
> >
<a <a
href="/subscriptions" href="/subscriptions"
class="tag {active('/subscriptions') ? 'border-iris text-iris' : 'border-smoke text-mute hover:text-iris'}" class="tag {active('/subscriptions')
? 'border-iris text-iris'
: 'border-smoke text-mute hover:text-iris'}"
aria-current={active('/subscriptions') ? 'page' : undefined}>following</a aria-current={active('/subscriptions') ? 'page' : undefined}>following</a
> >
<a <a
href="/settings" href="/settings"
class="tag max-w-[40vw] truncate sm:max-w-none {active('/settings') ? 'border-iris text-iris' : 'border-smoke text-mute hover:text-iris'}" class="tag max-w-[40vw] truncate sm:max-w-none {active('/settings')
? 'border-iris text-iris'
: 'border-smoke text-mute hover:text-iris'}"
aria-current={active('/settings') ? 'page' : undefined} aria-current={active('/settings') ? 'page' : undefined}
> >
<span class="sm:hidden"></span> <span class="sm:hidden"></span>
+3 -3
View File
@@ -25,9 +25,9 @@
WANT MORE. SPEND MORE. ACCUMULATE THE DEBT. WANT MORE. SPEND MORE. ACCUMULATE THE DEBT.
</h1> </h1>
<p class="max-w-xl text-lg text-mute"> <p class="max-w-xl text-lg text-mute">
A wishlist for your every craving. Paste a product link; we watch the price A wishlist for your every craving. Paste a product link; we watch the price and email you
and email you the moment it drops. No feed. No algorithm. Just you, your the moment it drops. No feed. No algorithm. Just you, your wants, and the gentle hum of
wants, and the gentle hum of impending debt. impending debt.
<span class="gospel">You deserve it.</span> <span class="gospel">You deserve it.</span>
</p> </p>
</div> </div>
+8 -1
View File
@@ -36,7 +36,14 @@
<form class="space-y-4" onsubmit={submit}> <form class="space-y-4" onsubmit={submit}>
<div> <div>
<label class="label" for="em">email</label> <label class="label" for="em">email</label>
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" /> <input
id="em"
class="field mt-1"
type="email"
bind:value={email}
required
autocomplete="email"
/>
</div> </div>
{#if error} {#if error}
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p> <p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
@@ -82,8 +82,8 @@
</div> </div>
</div> </div>
<p class="mt-4 text-sm text-mute"> <p class="mt-4 text-sm text-mute">
accepting lets you <span class="text-ink">{roleBlurb(preview.role)}</span> on this list. accepting lets you <span class="text-ink">{roleBlurb(preview.role)}</span> on this list. it'll
it'll appear under your lists. appear under your lists.
</p> </p>
{#if auth.loaded && auth.user} {#if auth.loaded && auth.user}
+30 -6
View File
@@ -68,18 +68,40 @@
<div class="grid gap-4 sm:grid-cols-[5rem_1fr]"> <div class="grid gap-4 sm:grid-cols-[5rem_1fr]">
<div> <div>
<label class="label" for="emoji">emoji</label> <label class="label" for="emoji">emoji</label>
<input id="emoji" class="field mt-1 text-center" bind:value={emoji} maxlength="4" placeholder="🛍" /> <input
id="emoji"
class="field mt-1 text-center"
bind:value={emoji}
maxlength="4"
placeholder="🛍"
/>
</div> </div>
<div> <div>
<label class="label" for="name">name</label> <label class="label" for="name">name</label>
<input id="name" class="field mt-1" bind:value={name} maxlength="80" placeholder="clothes, gear, indulgences…" /> <input
id="name"
class="field mt-1"
bind:value={name}
maxlength="80"
placeholder="clothes, gear, indulgences…"
/>
</div> </div>
</div> </div>
<div> <div>
<label class="label" for="desc">description <span class="text-mute">(optional)</span></label> <label class="label" for="desc"
<input id="desc" class="field mt-1" bind:value={description} maxlength="500" placeholder="what you tell yourself you need" /> >description <span class="text-mute">(optional)</span></label
>
<input
id="desc"
class="field mt-1"
bind:value={description}
maxlength="500"
placeholder="what you tell yourself you need"
/>
</div> </div>
{#if error}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>{/if} {#if error}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">
{error}
</p>{/if}
<button class="btn btn-acid" disabled={busy}>{busy ? 'creating…' : 'create it'}</button> <button class="btn btn-acid" disabled={busy}>{busy ? 'creating…' : 'create it'}</button>
</form> </form>
{/if} {/if}
@@ -94,7 +116,9 @@
{:else} {:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each lists.items as l (l.id)} {#each lists.items as l (l.id)}
<div class="panel group relative flex flex-col p-5 transition-transform hover:-translate-y-0.5"> <div
class="panel group relative flex flex-col p-5 transition-transform hover:-translate-y-0.5"
>
<a href="/lists/{l.id}" class="flex-1"> <a href="/lists/{l.id}" class="flex-1">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="text-3xl leading-none">{l.emoji ?? '✦'}</span> <span class="text-3xl leading-none">{l.emoji ?? '✦'}</span>
+143 -33
View File
@@ -422,12 +422,17 @@
<button <button
class="tag border-mint text-mint" class="tag border-mint text-mint"
title="this list is shared — manage below" title="this list is shared — manage below"
onclick={() => document.getElementById('share-box')?.scrollIntoView({ behavior: 'smooth' })} onclick={() =>
document.getElementById('share-box')?.scrollIntoView({ behavior: 'smooth' })}
> >
◈ shared ◈ shared
</button> </button>
{:else} {:else}
<button class="tag border-smoke text-mute hover:text-iris" disabled={sharing} onclick={share}> <button
class="tag border-smoke text-mute hover:text-iris"
disabled={sharing}
onclick={share}
>
{sharing ? '…' : '◈ share'} {sharing ? '…' : '◈ share'}
</button> </button>
{/if} {/if}
@@ -446,12 +451,30 @@
{#if list?.share_token} {#if list?.share_token}
<div id="share-box" class="panel mt-4 flex flex-wrap items-center gap-2 p-3 text-sm"> <div id="share-box" class="panel mt-4 flex flex-wrap items-center gap-2 p-3 text-sm">
<span class="label shrink-0">public link</span> <span class="label shrink-0">public link</span>
<input class="field flex-1 text-xs" readonly value={shareUrl} onclick={(e) => e.currentTarget.select()} /> <input
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris" onclick={copyShare}> class="field flex-1 text-xs"
readonly
value={shareUrl}
onclick={(e) => e.currentTarget.select()}
/>
<button
class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris"
onclick={copyShare}
>
{copied ? '✓ copied' : 'copy'} {copied ? '✓ copied' : 'copy'}
</button> </button>
<a href={shareUrl} target="_blank" rel="noopener noreferrer" class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris">open ↗</a> <a
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-rose hover:text-rose" disabled={sharing} onclick={unshare}> href={shareUrl}
target="_blank"
rel="noopener noreferrer"
class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris"
>open ↗</a
>
<button
class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-rose hover:text-rose"
disabled={sharing}
onclick={unshare}
>
{sharing ? '…' : 'unshare'} {sharing ? '…' : 'unshare'}
</button> </button>
</div> </div>
@@ -461,7 +484,9 @@
<div class="panel mt-4 space-y-5 p-4 text-sm"> <div class="panel mt-4 space-y-5 p-4 text-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="label">list settings</p> <p class="label">list settings</p>
<button class="text-xs text-mute hover:text-iris" onclick={() => (showSettings = false)}>close</button> <button class="text-xs text-mute hover:text-iris" onclick={() => (showSettings = false)}
>close</button
>
</div> </div>
<!-- Guest cross-off --> <!-- Guest cross-off -->
@@ -470,11 +495,15 @@
<p class="font-bold">let anyone with the link cross items off</p> <p class="font-bold">let anyone with the link cross items off</p>
<p class="mt-0.5 text-xs text-mute"> <p class="mt-0.5 text-xs text-mute">
for gift/birthday lists — visitors don't need an account to mark something as taken. for gift/birthday lists — visitors don't need an account to mark something as taken.
{#if !list?.share_token}<span class="text-rose/80">share the list first to use this.</span>{/if} {#if !list?.share_token}<span class="text-rose/80"
>share the list first to use this.</span
>{/if}
</p> </p>
</div> </div>
<button <button
class="tag shrink-0 {list?.allow_guest_crossoff ? 'border-mint text-mint' : 'border-smoke text-mute hover:text-iris'}" class="tag shrink-0 {list?.allow_guest_crossoff
? 'border-mint text-mint'
: 'border-smoke text-mute hover:text-iris'}"
disabled={settingsBusy || !list?.share_token} disabled={settingsBusy || !list?.share_token}
onclick={toggleGuestCrossoff} onclick={toggleGuestCrossoff}
> >
@@ -489,10 +518,18 @@
create a link, then share it. whoever opens it (and logs in) joins with that role. create a link, then share it. whoever opens it (and logs in) joins with that role.
</p> </p>
<div class="mt-3 flex flex-wrap gap-2"> <div class="mt-3 flex flex-wrap gap-2">
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris" disabled={settingsBusy} onclick={() => makeInvite('editor')}> <button
class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris"
disabled={settingsBusy}
onclick={() => makeInvite('editor')}
>
+ editor link <span class="text-mute/70">(add & edit items)</span> + editor link <span class="text-mute/70">(add & edit items)</span>
</button> </button>
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris" disabled={settingsBusy} onclick={() => makeInvite('crosser')}> <button
class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris"
disabled={settingsBusy}
onclick={() => makeInvite('crosser')}
>
+ cross-off link <span class="text-mute/70">(only tick items off)</span> + cross-off link <span class="text-mute/70">(only tick items off)</span>
</button> </button>
</div> </div>
@@ -501,12 +538,27 @@
<ul class="mt-3 space-y-2"> <ul class="mt-3 space-y-2">
{#each invites as inv (inv.id)} {#each invites as inv (inv.id)}
<li class="flex flex-wrap items-center gap-2 rounded border border-smoke p-2"> <li class="flex flex-wrap items-center gap-2 rounded border border-smoke p-2">
<span class="tag shrink-0 {inv.role === 'editor' ? 'border-iris text-iris' : 'border-mint text-mint'}">{inv.role}</span> <span
<input class="field flex-1 text-xs" readonly value={inviteUrl(inv.token)} onclick={(e) => e.currentTarget.select()} /> class="tag shrink-0 {inv.role === 'editor'
<button class="rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-iris hover:text-iris" onclick={() => copyInvite(inv.token)}> ? 'border-iris text-iris'
: 'border-mint text-mint'}">{inv.role}</span
>
<input
class="field flex-1 text-xs"
readonly
value={inviteUrl(inv.token)}
onclick={(e) => e.currentTarget.select()}
/>
<button
class="rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-iris hover:text-iris"
onclick={() => copyInvite(inv.token)}
>
{copiedInvite === inv.token ? '✓' : 'copy'} {copiedInvite === inv.token ? '✓' : 'copy'}
</button> </button>
<button class="rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-rose hover:text-rose" onclick={() => dropInvite(inv.id)}>revoke</button> <button
class="rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-rose hover:text-rose"
onclick={() => dropInvite(inv.id)}>revoke</button
>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -519,12 +571,21 @@
<p class="font-bold">collaborators</p> <p class="font-bold">collaborators</p>
<ul class="mt-2 space-y-2"> <ul class="mt-2 space-y-2">
{#each collaborators as c (c.user_id)} {#each collaborators as c (c.user_id)}
<li class="flex items-center justify-between gap-2 rounded border border-smoke p-2"> <li
class="flex items-center justify-between gap-2 rounded border border-smoke p-2"
>
<span class="min-w-0 truncate"> <span class="min-w-0 truncate">
{c.display_name ?? c.email} {c.display_name ?? c.email}
<span class="tag ml-1 {c.role === 'editor' ? 'border-iris text-iris' : 'border-mint text-mint'}">{c.role}</span> <span
class="tag ml-1 {c.role === 'editor'
? 'border-iris text-iris'
: 'border-mint text-mint'}">{c.role}</span
>
</span> </span>
<button class="shrink-0 rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-rose hover:text-rose" onclick={() => dropCollaborator(c.user_id)}>remove</button> <button
class="shrink-0 rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-rose hover:text-rose"
onclick={() => dropCollaborator(c.user_id)}>remove</button
>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -538,8 +599,15 @@
{#if canEdit} {#if canEdit}
<form class="panel space-y-3 p-4" onsubmit={addItem}> <form class="panel space-y-3 p-4" onsubmit={addItem}>
<div class="flex gap-2"> <div class="flex gap-2">
<input class="field" bind:value={title} maxlength="200" placeholder="add an item — what do you want?" /> <input
<button class="btn btn-acid shrink-0" disabled={busy || !title.trim()}>{busy ? '…' : 'add +'}</button> class="field"
bind:value={title}
maxlength="200"
placeholder="add an item — what do you want?"
/>
<button class="btn btn-acid shrink-0" disabled={busy || !title.trim()}
>{busy ? '…' : 'add +'}</button
>
</div> </div>
<button <button
type="button" type="button"
@@ -551,13 +619,29 @@
{#if showDetails} {#if showDetails}
<div class="space-y-3 border-t border-smoke pt-3"> <div class="space-y-3 border-t border-smoke pt-3">
<div class="grid gap-3 sm:grid-cols-[1fr_8rem]"> <div class="grid gap-3 sm:grid-cols-[1fr_8rem]">
<input class="field" bind:value={url} placeholder="product link (we'll track the price)" /> <input
<input class="field" bind:value={targetPrice} inputmode="decimal" placeholder="target price" /> class="field"
bind:value={url}
placeholder="product link (we'll track the price)"
/>
<input
class="field"
bind:value={targetPrice}
inputmode="decimal"
placeholder="target price"
/>
</div> </div>
<input class="field" bind:value={note} maxlength="1000" placeholder="note to self (optional)" /> <input
class="field"
bind:value={note}
maxlength="1000"
placeholder="note to self (optional)"
/>
</div> </div>
{/if} {/if}
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>{/if} {#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">
{formError}
</p>{/if}
</form> </form>
{:else if formError} {:else if formError}
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p> <p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>
@@ -602,7 +686,11 @@
</div> </div>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute"> <div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute">
{#if money(item.current_price, item.currency)} {#if money(item.current_price, item.currency)}
<span class="text-sm font-bold" class:text-mint={onSale(item)} class:text-ink={!onSale(item)}> <span
class="text-sm font-bold"
class:text-mint={onSale(item)}
class:text-ink={!onSale(item)}
>
{money(item.current_price, item.currency)} {money(item.current_price, item.currency)}
</span> </span>
{/if} {/if}
@@ -610,19 +698,28 @@
<span>target {money(item.target_price, item.currency)}</span> <span>target {money(item.target_price, item.currency)}</span>
{/if} {/if}
{#if item.url} {#if item.url}
<a href={item.url} target="_blank" rel="noopener noreferrer" class="hover:text-iris" title="open product page">visit ↗</a> <a
href={item.url}
target="_blank"
rel="noopener noreferrer"
class="hover:text-iris"
title="open product page">visit ↗</a
>
{/if} {/if}
{#if item.note}<span class="italic">{item.note}</span>{/if} {#if item.note}<span class="italic">{item.note}</span>{/if}
{#if !item.url && item.current_price == null} {#if !item.url && item.current_price == null}
<span class="text-mute">not tracked</span> <span class="text-mute">not tracked</span>
{/if} {/if}
{#if item.checked_at} {#if item.checked_at}
<span class="text-mute" title="last price check">checked {fmtDate(item.checked_at)}</span> <span class="text-mute" title="last price check"
>checked {fmtDate(item.checked_at)}</span
>
{/if} {/if}
</div> </div>
{#if item.claimed_at} {#if item.claimed_at}
<p class="mt-1 text-xs font-bold text-mint"> <p class="mt-1 text-xs font-bold text-mint">
☑ taken{#if item.claimed_by_name} by {item.claimed_by_name}{/if} ☑ taken{#if item.claimed_by_name}
by {item.claimed_by_name}{/if}
</p> </p>
{/if} {/if}
</div> </div>
@@ -630,8 +727,12 @@
<!-- Right controls: cross-off (everyone) + status (editors). --> <!-- Right controls: cross-off (everyone) + status (editors). -->
<div class="flex shrink-0 flex-col items-end gap-2"> <div class="flex shrink-0 flex-col items-end gap-2">
<button <button
class="tag cursor-pointer transition hover:brightness-125 {item.claimed_at ? 'border-mint bg-mint/10 text-mint' : 'border-smoke text-mute hover:text-iris'}" class="tag cursor-pointer transition hover:brightness-125 {item.claimed_at
title={item.claimed_at ? 'crossed off — click to undo' : 'cross off / claim this item'} ? 'border-mint bg-mint/10 text-mint'
: 'border-smoke text-mute hover:text-iris'}"
title={item.claimed_at
? 'crossed off — click to undo'
: 'cross off / claim this item'}
disabled={claimBusy === item.id} disabled={claimBusy === item.id}
onclick={() => toggleClaim(item)} onclick={() => toggleClaim(item)}
> >
@@ -639,7 +740,9 @@
</button> </button>
{#if canEdit} {#if canEdit}
<button <button
class="tag cursor-pointer transition hover:brightness-125 {STATUS_STYLE[item.status]}" class="tag cursor-pointer transition hover:brightness-125 {STATUS_STYLE[
item.status
]}"
title="click to cycle: want → bought → skip" title="click to cycle: want → bought → skip"
onclick={() => cycleStatus(item)} onclick={() => cycleStatus(item)}
> >
@@ -710,8 +813,15 @@
<input class="field" bind:value={edit.note} maxlength="1000" placeholder="note" /> <input class="field" bind:value={edit.note} maxlength="1000" placeholder="note" />
{#if editError}<p class="text-xs text-rose">{editError}</p>{/if} {#if editError}<p class="text-xs text-rose">{editError}</p>{/if}
<div class="flex justify-end gap-2 text-xs"> <div class="flex justify-end gap-2 text-xs">
<button class="rounded border border-smoke px-3 py-1 text-mute hover:text-ink" onclick={cancelEdit}>cancel</button> <button
<button class="btn btn-acid px-3 py-1" disabled={editBusy} onclick={() => saveEdit(item)}> class="rounded border border-smoke px-3 py-1 text-mute hover:text-ink"
onclick={cancelEdit}>cancel</button
>
<button
class="btn btn-acid px-3 py-1"
disabled={editBusy}
onclick={() => saveEdit(item)}
>
{editBusy ? 'saving…' : 'save'} {editBusy ? 'saving…' : 'save'}
</button> </button>
</div> </div>
+16 -2
View File
@@ -44,11 +44,25 @@
<form class="space-y-4" onsubmit={submit}> <form class="space-y-4" onsubmit={submit}>
<div> <div>
<label class="label" for="em">email</label> <label class="label" for="em">email</label>
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" /> <input
id="em"
class="field mt-1"
type="email"
bind:value={email}
required
autocomplete="email"
/>
</div> </div>
<div> <div>
<label class="label" for="pw">password</label> <label class="label" for="pw">password</label>
<input id="pw" class="field mt-1" type="password" bind:value={password} required autocomplete="current-password" /> <input
id="pw"
class="field mt-1"
type="password"
bind:value={password}
required
autocomplete="current-password"
/>
</div> </div>
{#if error} {#if error}
+24 -3
View File
@@ -47,15 +47,36 @@
<form class="space-y-4" onsubmit={submit}> <form class="space-y-4" onsubmit={submit}>
<div> <div>
<label class="label" for="dn">display name <span class="text-mute">(optional)</span></label> <label class="label" for="dn">display name <span class="text-mute">(optional)</span></label>
<input id="dn" class="field mt-1" bind:value={displayName} maxlength="80" autocomplete="nickname" /> <input
id="dn"
class="field mt-1"
bind:value={displayName}
maxlength="80"
autocomplete="nickname"
/>
</div> </div>
<div> <div>
<label class="label" for="em">email</label> <label class="label" for="em">email</label>
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" /> <input
id="em"
class="field mt-1"
type="email"
bind:value={email}
required
autocomplete="email"
/>
</div> </div>
<div> <div>
<label class="label" for="pw">password <span class="text-mute">(min 10)</span></label> <label class="label" for="pw">password <span class="text-mute">(min 10)</span></label>
<input id="pw" class="field mt-1" type="password" bind:value={password} required minlength="10" autocomplete="new-password" /> <input
id="pw"
class="field mt-1"
type="password"
bind:value={password}
required
minlength="10"
autocomplete="new-password"
/>
</div> </div>
{#if error} {#if error}
+12 -2
View File
@@ -46,12 +46,22 @@
<form class="space-y-4" onsubmit={submit}> <form class="space-y-4" onsubmit={submit}>
<div> <div>
<label class="label" for="pw">new password <span class="text-mute">(min 10)</span></label> <label class="label" for="pw">new password <span class="text-mute">(min 10)</span></label>
<input id="pw" class="field mt-1" type="password" bind:value={password} required minlength="10" autocomplete="new-password" /> <input
id="pw"
class="field mt-1"
type="password"
bind:value={password}
required
minlength="10"
autocomplete="new-password"
/>
</div> </div>
{#if error} {#if error}
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p> <p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
{/if} {/if}
<button class="btn btn-acid w-full" disabled={busy}>{busy ? 'saving…' : 'set password'}</button> <button class="btn btn-acid w-full" disabled={busy}
>{busy ? 'saving…' : 'set password'}</button
>
</form> </form>
{/if} {/if}
</div> </div>
+12 -2
View File
@@ -4,7 +4,12 @@
import { auth, type Settings } from '$lib/auth.svelte'; import { auth, type Settings } from '$lib/auth.svelte';
let displayName = $state(''); let displayName = $state('');
let settings = $state<Settings>({ locale: 'de', currency: 'EUR', theme: 'ethereal', notify_email: true }); let settings = $state<Settings>({
locale: 'de',
currency: 'EUR',
theme: 'ethereal',
notify_email: true
});
let msg = $state(''); let msg = $state('');
let error = $state(''); let error = $state('');
@@ -96,7 +101,12 @@
</div> </div>
<div> <div>
<label class="label" for="cur">currency</label> <label class="label" for="cur">currency</label>
<input id="cur" class="field mt-1 uppercase" bind:value={settings.currency} maxlength="3" /> <input
id="cur"
class="field mt-1 uppercase"
bind:value={settings.currency}
maxlength="3"
/>
</div> </div>
</div> </div>
@@ -172,7 +172,10 @@
{#if list.description}<p class="gospel mt-1 text-lg">{list.description}</p>{/if} {#if list.description}<p class="gospel mt-1 text-lg">{list.description}</p>{/if}
</div> </div>
{#if !auth.user} {#if !auth.user}
<button class="tag shrink-0 border-iris text-iris hover:brightness-125" onclick={toggleList}> <button
class="tag shrink-0 border-iris text-iris hover:brightness-125"
onclick={toggleList}
>
☆ subscribe ☆ subscribe
</button> </button>
{:else if subs.forList(list.id)} {:else if subs.forList(list.id)}
@@ -204,9 +207,16 @@
<div class="panel flex flex-wrap items-center gap-2 p-3 text-sm"> <div class="panel flex flex-wrap items-center gap-2 p-3 text-sm">
<span class="label shrink-0">cross items off</span> <span class="label shrink-0">cross items off</span>
{#if auth.user} {#if auth.user}
<span class="text-mute">you can tick items as <span class="text-mint">taken</span> below.</span> <span class="text-mute"
>you can tick items as <span class="text-mint">taken</span> below.</span
>
{:else} {:else}
<input class="field flex-1" bind:value={guestName} maxlength="80" placeholder="your name (so others don't double-buy)" /> <input
class="field flex-1"
bind:value={guestName}
maxlength="80"
placeholder="your name (so others don't double-buy)"
/>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -230,7 +240,10 @@
{/if} {/if}
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h3 class="truncate font-display font-bold" class:line-through={!!item.claimed_at}> <h3
class="truncate font-display font-bold"
class:line-through={!!item.claimed_at}
>
{item.title_fetched ?? item.title} {item.title_fetched ?? item.title}
</h3> </h3>
{#if item.in_stock === true} {#if item.in_stock === true}
@@ -241,7 +254,11 @@
</div> </div>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute"> <div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute">
{#if money(item.current_price, item.currency)} {#if money(item.current_price, item.currency)}
<span class="text-sm font-bold" class:text-mint={onSale(item)} class:text-ink={!onSale(item)}> <span
class="text-sm font-bold"
class:text-mint={onSale(item)}
class:text-ink={!onSale(item)}
>
{money(item.current_price, item.currency)} {money(item.current_price, item.currency)}
</span> </span>
{/if} {/if}
@@ -249,7 +266,12 @@
<span>target {money(item.target_price, item.currency)}</span> <span>target {money(item.target_price, item.currency)}</span>
{/if} {/if}
{#if item.url} {#if item.url}
<a href={item.url} target="_blank" rel="noopener noreferrer" class="hover:text-iris">visit ↗</a> <a
href={item.url}
target="_blank"
rel="noopener noreferrer"
class="hover:text-iris">visit ↗</a
>
{/if} {/if}
{#if item.note}<span class="italic">{item.note}</span>{/if} {#if item.note}<span class="italic">{item.note}</span>{/if}
{#if item.url} {#if item.url}
@@ -260,7 +282,8 @@
</div> </div>
{#if item.claimed_at} {#if item.claimed_at}
<p class="mt-1 text-xs font-bold text-mint"> <p class="mt-1 text-xs font-bold text-mint">
☑ taken{#if item.claimed_by_name} by {item.claimed_by_name}{/if} ☑ taken{#if item.claimed_by_name}
by {item.claimed_by_name}{/if}
</p> </p>
{/if} {/if}
{#if onSale(item)} {#if onSale(item)}
@@ -271,8 +294,12 @@
<div class="flex shrink-0 flex-col items-end gap-2"> <div class="flex shrink-0 flex-col items-end gap-2">
{#if canGuestCross} {#if canGuestCross}
<button <button
class="tag cursor-pointer transition hover:brightness-125 {item.claimed_at ? 'border-mint bg-mint/10 text-mint' : 'border-smoke text-mute hover:text-iris'}" class="tag cursor-pointer transition hover:brightness-125 {item.claimed_at
title={item.claimed_at ? 'crossed off — click to undo' : 'cross off / claim this item'} ? 'border-mint bg-mint/10 text-mint'
: 'border-smoke text-mute hover:text-iris'}"
title={item.claimed_at
? 'crossed off — click to undo'
: 'cross off / claim this item'}
disabled={claimBusy === item.id} disabled={claimBusy === item.id}
onclick={() => toggleClaim(item)} onclick={() => toggleClaim(item)}
> >
@@ -84,7 +84,11 @@
</div> </div>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute"> <div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute">
{#if money(s.current_price, s.currency)} {#if money(s.current_price, s.currency)}
<span class="text-sm font-bold" class:text-mint={onSale(s)} class:text-ink={!onSale(s)}> <span
class="text-sm font-bold"
class:text-mint={onSale(s)}
class:text-ink={!onSale(s)}
>
{money(s.current_price, s.currency)} {money(s.current_price, s.currency)}
</span> </span>
{/if} {/if}
@@ -92,7 +96,9 @@
<span>target {money(s.target_price, s.currency)}</span> <span>target {money(s.target_price, s.currency)}</span>
{/if} {/if}
{#if s.url} {#if s.url}
<a href={s.url} target="_blank" rel="noopener noreferrer" class="hover:text-iris">visit ↗</a> <a href={s.url} target="_blank" rel="noopener noreferrer" class="hover:text-iris"
>visit ↗</a
>
{/if} {/if}
{#if s.share_token} {#if s.share_token}
<a href="/shared/{s.share_token}" class="hover:text-iris">view list →</a> <a href="/shared/{s.share_token}" class="hover:text-iris">view list →</a>
@@ -0,0 +1,3 @@
// Stand-in for SvelteKit's `$env/dynamic/public` virtual module under Vitest,
// which otherwise reads a runtime env object that doesn't exist in tests.
export const env: Record<string, string> = {};
+20 -4
View File
@@ -1,7 +1,23 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite'; import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig(({ mode }) => ({
plugins: [tailwindcss(), sveltekit()] plugins: [tailwindcss(), sveltekit()],
}); // Under Vitest, resolve Svelte's browser (client) build so components can
// `mount` in jsdom instead of hitting the SSR build.
resolve: mode === 'test' ? { conditions: ['browser'] } : {},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest-setup.ts'],
include: ['src/**/*.{test,spec}.{js,ts}'],
alias: {
// SvelteKit's $env virtual module isn't available outside the dev server.
'$env/dynamic/public': fileURLToPath(
new URL('./src/test-mocks/env-dynamic-public.ts', import.meta.url)
)
}
}
}));
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
#
# Full project check: formatting, linting, type-checking, building and tests for
# both the Rust backend and the SvelteKit frontend. Run by the pre-push hook
# (.githooks/pre-push) and usable by hand or in CI.
#
# Usage:
# scripts/check.sh # check everything
# scripts/check.sh backend # backend only
# scripts/check.sh frontend # frontend only
#
# Backend integration tests (tests/api.rs) need a throwaway Postgres database.
# Point TEST_DATABASE_URL at one to run them; otherwise they skip:
# TEST_DATABASE_URL=postgres://shoplist:devpass@localhost:5432/shoplist_test \
# scripts/check.sh backend
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TARGET="${1:-all}"
# Pretty section headers.
section() { printf '\n\033[1;35m▸ %s\033[0m\n' "$1"; }
ok() { printf '\033[1;32m✓ %s\033[0m\n' "$1"; }
check_backend() {
section "Backend (Rust)"
cd "$ROOT/backend"
echo "• cargo fmt --check"
cargo fmt --all -- --check
echo "• cargo clippy (-D warnings)"
cargo clippy --all-targets --all-features -- -D warnings
echo "• cargo build"
cargo build --all-targets
if [ -n "${TEST_DATABASE_URL:-}" ]; then
echo "• cargo test (with integration DB)"
else
echo "• cargo test (unit only — set TEST_DATABASE_URL for integration)"
fi
cargo test --all-targets
ok "backend"
}
check_frontend() {
section "Frontend (SvelteKit)"
cd "$ROOT/frontend"
# Make sure deps are present (lockfile-faithful) before checking.
if [ ! -d node_modules ]; then
echo "• pnpm install"
pnpm install --frozen-lockfile
fi
echo "• prettier --check"
pnpm run format:check
echo "• eslint"
pnpm run lint
echo "• svelte-check"
pnpm run check
echo "• vitest"
pnpm test
echo "• vite build"
pnpm run build
ok "frontend"
}
case "$TARGET" in
backend) check_backend ;;
frontend) check_frontend ;;
all) check_backend; check_frontend ;;
*) echo "unknown target: $TARGET (use: all | backend | frontend)" >&2; exit 2 ;;
esac
printf '\n\033[1;32m✓ all checks passed\033[0m\n'