tests
This commit is contained in:
Executable
+15
@@ -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
|
||||
Generated
+51
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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?))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! End-to-end HTTP tests against a live app + Postgres.
|
||||
//!
|
||||
//! Run with a throwaway database:
|
||||
//! TEST_DATABASE_URL=postgres://shoplist:shoplist@localhost:5432/shoplist_test \
|
||||
//! cargo test --test api
|
||||
//! Without `TEST_DATABASE_URL` every test skips (see `common::spawn`).
|
||||
|
||||
mod common;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Register a brand-new user and leave the client logged in. Returns the email.
|
||||
async fn register_user(app: &common::TestApp) -> String {
|
||||
let email = app.unique_email();
|
||||
let res = app
|
||||
.client
|
||||
.post(app.url("/auth/register"))
|
||||
.json(&json!({ "email": email, "password": "supersecret123" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201, "register should create the user");
|
||||
email
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_is_ok() {
|
||||
let app = test_app!();
|
||||
let res = app.client.get(app.url("/health")).send().await.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert_eq!(body["status"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_login_me_logout_flow() {
|
||||
let app = test_app!();
|
||||
let email = register_user(&app).await;
|
||||
|
||||
// Cookie from register authenticates /me.
|
||||
let me: Value = app
|
||||
.client
|
||||
.get(app.url("/auth/me"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(me["user"]["email"], email);
|
||||
assert_eq!(me["user"]["email_verified"], false);
|
||||
assert_eq!(me["settings"]["currency"], "EUR");
|
||||
|
||||
// Logout clears the session.
|
||||
let res = app
|
||||
.client
|
||||
.post(app.url("/auth/logout"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 204);
|
||||
let res = app.client.get(app.url("/auth/me")).send().await.unwrap();
|
||||
assert_eq!(res.status(), 401, "me must reject after logout");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_registration_conflicts() {
|
||||
let app = test_app!();
|
||||
let email = app.unique_email();
|
||||
let body = json!({ "email": email, "password": "supersecret123" });
|
||||
|
||||
let first = app
|
||||
.client
|
||||
.post(app.url("/auth/register"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), 201);
|
||||
|
||||
let second = app
|
||||
.client
|
||||
.post(app.url("/auth/register"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.status(), 409, "same email must conflict");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_validates_input() {
|
||||
let app = test_app!();
|
||||
// Bad email + too-short password => 422 Validation.
|
||||
let res = app
|
||||
.client
|
||||
.post(app.url("/auth/register"))
|
||||
.json(&json!({ "email": "nope", "password": "short" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 422);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_wrong_password() {
|
||||
let app = test_app!();
|
||||
let email = register_user(&app).await;
|
||||
let res = app
|
||||
.client
|
||||
.post(app.url("/auth/login"))
|
||||
.json(&json!({ "email": email, "password": "the-wrong-one" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 400);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protected_route_requires_auth() {
|
||||
let app = test_app!();
|
||||
// Fresh client, no cookies.
|
||||
let res = app.client.get(app.url("/lists")).send().await.unwrap();
|
||||
assert_eq!(res.status(), 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_and_items_crud() {
|
||||
let app = test_app!();
|
||||
register_user(&app).await;
|
||||
|
||||
// Create a list.
|
||||
let list: Value = app
|
||||
.client
|
||||
.post(app.url("/lists"))
|
||||
.json(&json!({ "name": "Gadgets", "emoji": "🛒" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list["name"], "Gadgets");
|
||||
assert_eq!(list["role"], "owner");
|
||||
let list_id = list["id"].as_str().unwrap().to_string();
|
||||
|
||||
// It shows up in the overview.
|
||||
let all: Value = app
|
||||
.client
|
||||
.get(app.url("/lists"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(all.as_array().unwrap().len(), 1);
|
||||
|
||||
// Add an item.
|
||||
let item: Value = app
|
||||
.client
|
||||
.post(app.url(&format!("/lists/{list_id}/items")))
|
||||
.json(&json!({ "title": "Mechanical keyboard", "target_price": 99.99 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(item["title"], "Mechanical keyboard");
|
||||
assert_eq!(item["status"], "coveted");
|
||||
let item_id = item["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Update its status.
|
||||
let updated: Value = app
|
||||
.client
|
||||
.patch(app.url(&format!("/items/{item_id}")))
|
||||
.json(&json!({ "status": "acquired" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated["status"], "acquired");
|
||||
|
||||
// Delete the item.
|
||||
let res = app
|
||||
.client
|
||||
.delete(app.url(&format!("/items/{item_id}")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let items: Value = app
|
||||
.client
|
||||
.get(app.url(&format!("/lists/{list_id}/items")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(items.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn share_link_exposes_public_read_only_view() {
|
||||
let app = test_app!();
|
||||
register_user(&app).await;
|
||||
|
||||
let list: Value = app
|
||||
.client
|
||||
.post(app.url("/lists"))
|
||||
.json(&json!({ "name": "Wishlist" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let list_id = list["id"].as_str().unwrap().to_string();
|
||||
|
||||
app.client
|
||||
.post(app.url(&format!("/lists/{list_id}/items")))
|
||||
.json(&json!({ "title": "Telescope" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Mint a share token.
|
||||
let shared: Value = app
|
||||
.client
|
||||
.post(app.url(&format!("/lists/{list_id}/share")))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let token = shared["share_token"].as_str().expect("share token minted");
|
||||
|
||||
// Anonymous client can read it.
|
||||
let anon = reqwest::Client::new();
|
||||
let view: Value = anon
|
||||
.get(app.url(&format!("/shared/{token}")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(view["list"]["name"], "Wishlist");
|
||||
assert_eq!(view["items"][0]["title"], "Telescope");
|
||||
|
||||
// A bogus token 404s.
|
||||
let res = anon
|
||||
.get(app.url("/shared/definitely-not-a-real-token"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 404);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Shared harness for the HTTP integration tests.
|
||||
//!
|
||||
//! Boots the real Axum app (via [`shoplist_backend::build_router`]) against a
|
||||
//! Postgres database named by the `TEST_DATABASE_URL` env var, on an ephemeral
|
||||
//! port, with a cookie-aware client so session auth works end to end.
|
||||
//!
|
||||
//! When `TEST_DATABASE_URL` is unset, [`spawn`] returns `None` and each test
|
||||
//! skips — so `cargo test` stays green without a database, and the pre-push
|
||||
//! hook runs the full suite only when one is available.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use shoplist_backend::config::{Config, SmtpConfig, SmtpSecurity};
|
||||
use shoplist_backend::mail::Mailer;
|
||||
use shoplist_backend::state::AppState;
|
||||
use shoplist_backend::{build_router, db, fetch};
|
||||
|
||||
pub struct TestApp {
|
||||
pub base: String,
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
/// Absolute URL for an `/api`-relative path.
|
||||
pub fn url(&self, path: &str) -> String {
|
||||
format!("{}/api{}", self.base, path)
|
||||
}
|
||||
|
||||
/// A fresh, never-before-used email (tests share one DB; avoid collisions).
|
||||
pub fn unique_email(&self) -> String {
|
||||
format!("u{}@example.test", uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot the app, or `None` if no test database is configured.
|
||||
pub async fn spawn() -> Option<TestApp> {
|
||||
let database_url = std::env::var("TEST_DATABASE_URL").ok()?;
|
||||
|
||||
let pool = db::connect(&database_url)
|
||||
.await
|
||||
.expect("connect + migrate test database");
|
||||
|
||||
let config = Config {
|
||||
database_url,
|
||||
host: "127.0.0.1".into(),
|
||||
port: 0,
|
||||
session_secret: "test-session-secret-at-least-32-chars!!".into(),
|
||||
public_app_url: "http://localhost:5173".into(),
|
||||
cors_origins: vec!["http://localhost:5173".into()],
|
||||
smtp: SmtpConfig {
|
||||
// Points nowhere; register() swallows send failures, other tests
|
||||
// never trigger mail.
|
||||
host: "localhost".into(),
|
||||
port: 1,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
from: "Test <test@localhost>".into(),
|
||||
security: SmtpSecurity::None,
|
||||
},
|
||||
refetch_interval_secs: 0, // no background worker in tests
|
||||
refetch_min_age_secs: 21_600,
|
||||
default_currency: "EUR".into(),
|
||||
cookie_secure: false, // plain HTTP in tests
|
||||
};
|
||||
|
||||
let mailer = Mailer::from_config(&config.smtp).expect("build test mailer");
|
||||
let state = AppState {
|
||||
pool,
|
||||
config: Arc::new(config),
|
||||
mailer,
|
||||
http: fetch::http_client(),
|
||||
};
|
||||
|
||||
let app = build_router(state).await.expect("build router");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral port");
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
.expect("build cookie client");
|
||||
|
||||
Some(TestApp {
|
||||
base: format!("http://{addr}"),
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
/// Skip-or-run boilerplate: `let app = test_app!();`.
|
||||
#[macro_export]
|
||||
macro_rules! test_app {
|
||||
() => {
|
||||
match common::spawn().await {
|
||||
Some(app) => app,
|
||||
None => {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
build/
|
||||
.svelte-kit/
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
static/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
@@ -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
@@ -7,17 +7,33 @@
|
||||
"dev": "vite dev --port 5173",
|
||||
"build": "vite build",
|
||||
"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": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.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-check": "^4.1.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1721
-32
File diff suppressed because it is too large
Load Diff
+11
-33
@@ -1,4 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Web fonts loaded via <link> in app.html (ethereal serif + clean sans + mono). */
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
--color-gold: #ffe6a3; /* divine gilt */
|
||||
--color-holo: #cbd6ff; /* holographic sheen */
|
||||
|
||||
--font-display: "Space Grotesk", system-ui, sans-serif;
|
||||
--font-gospel: "Fraunces", "Times New Roman", serif;
|
||||
--font-mono: "Space Mono", ui-monospace, monospace;
|
||||
--font-display: 'Space Grotesk', system-ui, sans-serif;
|
||||
--font-gospel: 'Fraunces', 'Times New Roman', serif;
|
||||
--font-mono: 'Space Mono', ui-monospace, monospace;
|
||||
|
||||
--radius-none: 0px;
|
||||
--radius-soft: 0.625rem; /* one radius language for panels/fields/buttons */
|
||||
@@ -48,32 +48,16 @@
|
||||
|
||||
/* Soft drifting aurora — celestial light pollution. */
|
||||
body::before {
|
||||
content: "";
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(
|
||||
40% 35% at 18% 12%,
|
||||
rgba(185, 167, 255, 0.18),
|
||||
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%
|
||||
);
|
||||
radial-gradient(40% 35% at 18% 12%, rgba(185, 167, 255, 0.18), 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);
|
||||
animation: drift 26s ease-in-out infinite alternate;
|
||||
}
|
||||
@@ -119,8 +103,7 @@
|
||||
position: relative;
|
||||
border-radius: var(--radius-soft);
|
||||
background:
|
||||
linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 60%),
|
||||
var(--color-panel);
|
||||
linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 60%), var(--color-panel);
|
||||
border: 1px solid var(--color-smoke);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(185, 167, 255, 0.06),
|
||||
@@ -146,12 +129,7 @@
|
||||
font-family: var(--font-gospel);
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
var(--color-gold),
|
||||
var(--color-rose),
|
||||
var(--color-iris)
|
||||
);
|
||||
background: linear-gradient(100deg, var(--color-gold), var(--color-rose), var(--color-iris));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
|
||||
const linePath = $derived(
|
||||
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.
|
||||
@@ -278,10 +280,32 @@
|
||||
stroke-width="1"
|
||||
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)})">
|
||||
<rect 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">
|
||||
<rect
|
||||
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)}
|
||||
</text>
|
||||
<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">
|
||||
<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="inline-block size-2 rounded-full" style="background:var(--color-rose)"></span>high {fmtMoney(coords[highIdx].price)}</span>
|
||||
<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="inline-block size-2 rounded-full" style="background:var(--color-rose)"
|
||||
></span>high {fmtMoney(coords[highIdx].price)}</span
|
||||
>
|
||||
{#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}
|
||||
</span>
|
||||
{#if latest}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
status: number;
|
||||
@@ -12,9 +12,9 @@ export class ApiError extends Error {
|
||||
|
||||
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${BASE}/api${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(opts.headers ?? {}) },
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts.headers ?? {}) },
|
||||
...opts
|
||||
});
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
@@ -32,13 +32,13 @@ export const api = {
|
||||
get: <T>(p: string) => request<T>(p),
|
||||
post: <T>(p: string, body?: unknown) =>
|
||||
request<T>(p, {
|
||||
method: "POST",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
}),
|
||||
patch: <T>(p: string, body?: unknown) =>
|
||||
request<T>(p, {
|
||||
method: "PATCH",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
method: 'PATCH',
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
}),
|
||||
del: <T>(p: string) => request<T>(p, { method: "DELETE" }),
|
||||
del: <T>(p: string) => request<T>(p, { method: 'DELETE' })
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from "./api";
|
||||
import { api } from './api';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
@@ -23,7 +23,7 @@ class AuthStore {
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const me = await api.get<Me>("/auth/me");
|
||||
const me = await api.get<Me>('/auth/me');
|
||||
this.user = me.user;
|
||||
this.settings = me.settings;
|
||||
} catch {
|
||||
@@ -42,7 +42,7 @@ class AuthStore {
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await api.post("/auth/logout");
|
||||
await api.post('/auth/logout');
|
||||
} finally {
|
||||
this.user = null;
|
||||
this.settings = null;
|
||||
|
||||
@@ -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 = {
|
||||
id: string;
|
||||
@@ -23,13 +23,13 @@ export type SharedView = { list: List; items: Item[] };
|
||||
export type Invite = {
|
||||
id: string;
|
||||
token: string;
|
||||
role: "editor" | "crosser";
|
||||
role: 'editor' | 'crosser';
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Collaborator = {
|
||||
user_id: string;
|
||||
role: "editor" | "crosser";
|
||||
role: 'editor' | 'crosser';
|
||||
display_name: string | null;
|
||||
email: string;
|
||||
created_at: string;
|
||||
@@ -39,12 +39,12 @@ export type InvitePreview = {
|
||||
list_id: string;
|
||||
list_name: string;
|
||||
emoji: string | null;
|
||||
role: "editor" | "crosser";
|
||||
role: 'editor' | 'crosser';
|
||||
};
|
||||
|
||||
export type Subscription = {
|
||||
id: string;
|
||||
kind: "list" | "item";
|
||||
kind: 'list' | 'item';
|
||||
created_at: string;
|
||||
list_id: string | null;
|
||||
item_id: string | null;
|
||||
@@ -109,11 +109,11 @@ export type NewItem = {
|
||||
// ---- Lists ----------------------------------------------------------------
|
||||
|
||||
export const listsApi = {
|
||||
all: () => api.get<List[]>("/lists"),
|
||||
create: (b: NewList) => api.post<List>("/lists", b),
|
||||
all: () => api.get<List[]>('/lists'),
|
||||
create: (b: NewList) => api.post<List>('/lists', b),
|
||||
update: (
|
||||
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),
|
||||
remove: (id: string) => api.del<{ deleted: string }>(`/lists/${id}`),
|
||||
share: (id: string) => api.post<List>(`/lists/${id}/share`, {}),
|
||||
@@ -123,8 +123,7 @@ export const listsApi = {
|
||||
api.get<PricePoint[]>(`/shared/${token}/items/${itemId}/history`),
|
||||
|
||||
// Cross off / claim (collaborator route).
|
||||
claim: (id: string, name?: string) =>
|
||||
api.post<Item>(`/items/${id}/claim`, name ? { name } : {}),
|
||||
claim: (id: string, name?: string) => api.post<Item>(`/items/${id}/claim`, name ? { name } : {}),
|
||||
unclaim: (id: string) => api.del<Item>(`/items/${id}/claim`),
|
||||
// Cross off via public share link (guest crossoff must be enabled).
|
||||
guestClaim: (token: string, itemId: string, name: string) =>
|
||||
@@ -134,34 +133,29 @@ export const listsApi = {
|
||||
|
||||
// Collaboration: invites + collaborators.
|
||||
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 }),
|
||||
revokeInvite: (listId: string, inviteId: string) =>
|
||||
api.del<{ deleted: string }>(`/lists/${listId}/invites/${inviteId}`),
|
||||
collaborators: (listId: string) =>
|
||||
api.get<Collaborator[]>(`/lists/${listId}/collaborators`),
|
||||
collaborators: (listId: string) => api.get<Collaborator[]>(`/lists/${listId}/collaborators`),
|
||||
removeCollaborator: (listId: string, userId: string) =>
|
||||
api.del<{ removed: string }>(`/lists/${listId}/collaborators/${userId}`),
|
||||
previewInvite: (token: string) =>
|
||||
api.get<InvitePreview>(`/invites/${token}`),
|
||||
previewInvite: (token: string) => api.get<InvitePreview>(`/invites/${token}`),
|
||||
acceptInvite: (token: string) =>
|
||||
api.post<{ list_id: string; role: string }>(`/invites/${token}/accept`, {}),
|
||||
|
||||
items: (listId: string) => api.get<Item[]>(`/lists/${listId}/items`),
|
||||
addItem: (listId: string, b: NewItem) =>
|
||||
api.post<Item>(`/lists/${listId}/items`, b),
|
||||
updateItem: (
|
||||
id: string,
|
||||
b: Partial<NewItem> & { status?: ItemStatus; position?: number },
|
||||
) => api.patch<Item>(`/items/${id}`, b),
|
||||
addItem: (listId: string, b: NewItem) => api.post<Item>(`/lists/${listId}/items`, b),
|
||||
updateItem: (id: string, b: Partial<NewItem> & { status?: ItemStatus; position?: number }) =>
|
||||
api.patch<Item>(`/items/${id}`, b),
|
||||
removeItem: (id: string) => api.del<{ deleted: string }>(`/items/${id}`),
|
||||
refetch: (id: string) => api.post<Item>(`/items/${id}/refetch`, {}),
|
||||
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 }) =>
|
||||
api.post<{ id: string }>("/subscriptions", b),
|
||||
unsubscribe: (id: string) => api.del<{ deleted: string }>(`/subscriptions/${id}`),
|
||||
api.post<{ id: string }>('/subscriptions', b),
|
||||
unsubscribe: (id: string) => api.del<{ deleted: string }>(`/subscriptions/${id}`)
|
||||
};
|
||||
|
||||
/** Reactive store for the user's lists. */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -55,17 +55,23 @@
|
||||
{#if auth.loaded && auth.user}
|
||||
<a
|
||||
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
|
||||
>
|
||||
<a
|
||||
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
|
||||
>
|
||||
<a
|
||||
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}
|
||||
>
|
||||
<span class="sm:hidden">⚙</span>
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
WANT MORE. SPEND MORE. ACCUMULATE THE DEBT.
|
||||
</h1>
|
||||
<p class="max-w-xl text-lg text-mute">
|
||||
A wishlist for your every craving. Paste a product link; we watch the price
|
||||
and email you the moment it drops. No feed. No algorithm. Just you, your
|
||||
wants, and the gentle hum of impending debt.
|
||||
A wishlist for your every craving. Paste a product link; we watch the price and email you
|
||||
the moment it drops. No feed. No algorithm. Just you, your wants, and the gentle hum of
|
||||
impending debt.
|
||||
<span class="gospel">You deserve it.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,14 @@
|
||||
<form class="space-y-4" onsubmit={submit}>
|
||||
<div>
|
||||
<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>
|
||||
{#if error}
|
||||
<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>
|
||||
<p class="mt-4 text-sm text-mute">
|
||||
accepting lets you <span class="text-ink">{roleBlurb(preview.role)}</span> on this list.
|
||||
it'll appear under your lists.
|
||||
accepting lets you <span class="text-ink">{roleBlurb(preview.role)}</span> on this list. it'll
|
||||
appear under your lists.
|
||||
</p>
|
||||
|
||||
{#if auth.loaded && auth.user}
|
||||
|
||||
@@ -68,18 +68,40 @@
|
||||
<div class="grid gap-4 sm:grid-cols-[5rem_1fr]">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<label class="label" for="desc">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" />
|
||||
<label class="label" for="desc"
|
||||
>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>
|
||||
{#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>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -94,7 +116,9 @@
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#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">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-3xl leading-none">{l.emoji ?? '✦'}</span>
|
||||
|
||||
@@ -422,12 +422,17 @@
|
||||
<button
|
||||
class="tag border-mint text-mint"
|
||||
title="this list is shared — manage below"
|
||||
onclick={() => document.getElementById('share-box')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
onclick={() =>
|
||||
document.getElementById('share-box')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
>
|
||||
◈ shared
|
||||
</button>
|
||||
{: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'}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -446,12 +451,30 @@
|
||||
{#if list?.share_token}
|
||||
<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>
|
||||
<input 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}>
|
||||
<input
|
||||
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'}
|
||||
</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>
|
||||
<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}>
|
||||
<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
|
||||
>
|
||||
<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'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -461,7 +484,9 @@
|
||||
<div class="panel mt-4 space-y-5 p-4 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
<!-- Guest cross-off -->
|
||||
@@ -470,11 +495,15 @@
|
||||
<p class="font-bold">let anyone with the link cross items off</p>
|
||||
<p class="mt-0.5 text-xs text-mute">
|
||||
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>
|
||||
</div>
|
||||
<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}
|
||||
onclick={toggleGuestCrossoff}
|
||||
>
|
||||
@@ -489,10 +518,18 @@
|
||||
create a link, then share it. whoever opens it (and logs in) joins with that role.
|
||||
</p>
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -501,12 +538,27 @@
|
||||
<ul class="mt-3 space-y-2">
|
||||
{#each invites as inv (inv.id)}
|
||||
<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>
|
||||
<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)}>
|
||||
<span
|
||||
class="tag shrink-0 {inv.role === 'editor'
|
||||
? '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'}
|
||||
</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>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -519,12 +571,21 @@
|
||||
<p class="font-bold">collaborators</p>
|
||||
<ul class="mt-2 space-y-2">
|
||||
{#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">
|
||||
{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>
|
||||
<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>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -536,29 +597,52 @@
|
||||
|
||||
<!-- Add item — compact quick-add; tracking details on demand -->
|
||||
{#if canEdit}
|
||||
<form class="panel space-y-3 p-4" onsubmit={addItem}>
|
||||
<div class="flex gap-2">
|
||||
<input 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>
|
||||
<button
|
||||
type="button"
|
||||
class="label transition hover:text-iris"
|
||||
onclick={() => (showDetails = !showDetails)}
|
||||
>
|
||||
{showDetails ? '− fewer' : '+ link, target price & note'}
|
||||
</button>
|
||||
{#if showDetails}
|
||||
<div class="space-y-3 border-t border-smoke pt-3">
|
||||
<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 class="field" bind:value={targetPrice} inputmode="decimal" placeholder="target price" />
|
||||
</div>
|
||||
<input class="field" bind:value={note} maxlength="1000" placeholder="note to self (optional)" />
|
||||
<form class="panel space-y-3 p-4" onsubmit={addItem}>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
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>
|
||||
{/if}
|
||||
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>{/if}
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
class="label transition hover:text-iris"
|
||||
onclick={() => (showDetails = !showDetails)}
|
||||
>
|
||||
{showDetails ? '− fewer' : '+ link, target price & note'}
|
||||
</button>
|
||||
{#if showDetails}
|
||||
<div class="space-y-3 border-t border-smoke pt-3">
|
||||
<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
|
||||
class="field"
|
||||
bind:value={targetPrice}
|
||||
inputmode="decimal"
|
||||
placeholder="target price"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
class="field"
|
||||
bind:value={note}
|
||||
maxlength="1000"
|
||||
placeholder="note to self (optional)"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">
|
||||
{formError}
|
||||
</p>{/if}
|
||||
</form>
|
||||
{:else if formError}
|
||||
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>
|
||||
{/if}
|
||||
@@ -602,7 +686,11 @@
|
||||
</div>
|
||||
<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)}
|
||||
<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)}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -610,19 +698,28 @@
|
||||
<span>target {money(item.target_price, item.currency)}</span>
|
||||
{/if}
|
||||
{#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 item.note}<span class="italic">“{item.note}”</span>{/if}
|
||||
{#if !item.url && item.current_price == null}
|
||||
<span class="text-mute">not tracked</span>
|
||||
{/if}
|
||||
{#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}
|
||||
</div>
|
||||
{#if item.claimed_at}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -630,8 +727,12 @@
|
||||
<!-- Right controls: cross-off (everyone) + status (editors). -->
|
||||
<div class="flex shrink-0 flex-col items-end gap-2">
|
||||
<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'}"
|
||||
title={item.claimed_at ? 'crossed off — click to undo' : 'cross off / claim this item'}
|
||||
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'}"
|
||||
title={item.claimed_at
|
||||
? 'crossed off — click to undo'
|
||||
: 'cross off / claim this item'}
|
||||
disabled={claimBusy === item.id}
|
||||
onclick={() => toggleClaim(item)}
|
||||
>
|
||||
@@ -639,7 +740,9 @@
|
||||
</button>
|
||||
{#if canEdit}
|
||||
<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"
|
||||
onclick={() => cycleStatus(item)}
|
||||
>
|
||||
@@ -710,8 +813,15 @@
|
||||
<input class="field" bind:value={edit.note} maxlength="1000" placeholder="note" />
|
||||
{#if editError}<p class="text-xs text-rose">{editError}</p>{/if}
|
||||
<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 class="btn btn-acid px-3 py-1" disabled={editBusy} onclick={() => saveEdit(item)}>
|
||||
<button
|
||||
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'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -44,11 +44,25 @@
|
||||
<form class="space-y-4" onsubmit={submit}>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{#if error}
|
||||
|
||||
@@ -47,15 +47,36 @@
|
||||
<form class="space-y-4" onsubmit={submit}>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{#if error}
|
||||
|
||||
@@ -46,12 +46,22 @@
|
||||
<form class="space-y-4" onsubmit={submit}>
|
||||
<div>
|
||||
<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>
|
||||
{#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 w-full" disabled={busy}>{busy ? 'saving…' : 'set password'}</button>
|
||||
<button class="btn btn-acid w-full" disabled={busy}
|
||||
>{busy ? 'saving…' : 'set password'}</button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
import { auth, type Settings } from '$lib/auth.svelte';
|
||||
|
||||
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 error = $state('');
|
||||
@@ -96,7 +101,12 @@
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -172,7 +172,10 @@
|
||||
{#if list.description}<p class="gospel mt-1 text-lg">{list.description}</p>{/if}
|
||||
</div>
|
||||
{#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
|
||||
</button>
|
||||
{:else if subs.forList(list.id)}
|
||||
@@ -204,9 +207,16 @@
|
||||
<div class="panel flex flex-wrap items-center gap-2 p-3 text-sm">
|
||||
<span class="label shrink-0">cross items off</span>
|
||||
{#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}
|
||||
<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}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -230,7 +240,10 @@
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<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}
|
||||
</h3>
|
||||
{#if item.in_stock === true}
|
||||
@@ -241,7 +254,11 @@
|
||||
</div>
|
||||
<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)}
|
||||
<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)}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -249,7 +266,12 @@
|
||||
<span>target {money(item.target_price, item.currency)}</span>
|
||||
{/if}
|
||||
{#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 item.note}<span class="italic">“{item.note}”</span>{/if}
|
||||
{#if item.url}
|
||||
@@ -260,7 +282,8 @@
|
||||
</div>
|
||||
{#if item.claimed_at}
|
||||
<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>
|
||||
{/if}
|
||||
{#if onSale(item)}
|
||||
@@ -271,8 +294,12 @@
|
||||
<div class="flex shrink-0 flex-col items-end gap-2">
|
||||
{#if canGuestCross}
|
||||
<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'}"
|
||||
title={item.claimed_at ? 'crossed off — click to undo' : 'cross off / claim this item'}
|
||||
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'}"
|
||||
title={item.claimed_at
|
||||
? 'crossed off — click to undo'
|
||||
: 'cross off / claim this item'}
|
||||
disabled={claimBusy === item.id}
|
||||
onclick={() => toggleClaim(item)}
|
||||
>
|
||||
|
||||
@@ -84,7 +84,11 @@
|
||||
</div>
|
||||
<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)}
|
||||
<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)}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -92,7 +96,9 @@
|
||||
<span>target {money(s.target_price, s.currency)}</span>
|
||||
{/if}
|
||||
{#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 s.share_token}
|
||||
<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
@@ -1,7 +1,23 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
export default defineConfig(({ mode }) => ({
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
Executable
+84
@@ -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'
|
||||
Reference in New Issue
Block a user