diff --git a/Cargo.lock b/Cargo.lock index bb24c5f..4dcb79c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,6 +334,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "slug", "sqlx", "tempfile", "thiserror 1.0.69", @@ -583,6 +584,12 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" @@ -2225,6 +2232,16 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + [[package]] name = "smallvec" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index a5c89d9..f9e81d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tower = "0.4" tower-cookies = "0.10" +slug = "0.1.6" [dev-dependencies] portpicker = "0.1" diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 5005e70..a5b4457 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -7,9 +7,12 @@ CREATE TABLE roasters ( city TEXT, homepage TEXT, notes TEXT, + slug TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); +CREATE UNIQUE INDEX idx_roasters_slug ON roasters(slug); + CREATE TABLE roasts ( id INTEGER PRIMARY KEY, roaster_id INTEGER NOT NULL REFERENCES roasters(id) ON DELETE CASCADE, @@ -19,10 +22,12 @@ CREATE TABLE roasts ( producer TEXT, process TEXT, tasting_notes TEXT, + slug TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX idx_roasts_roaster_id ON roasts(roaster_id); +CREATE UNIQUE INDEX idx_roasts_roaster_slug ON roasts(roaster_id, slug); CREATE TABLE timeline_events ( id INTEGER PRIMARY KEY, diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index e84a343..45f093f 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -16,6 +16,7 @@ use async_trait::async_trait; pub trait RoasterRepository: Send + Sync { async fn insert(&self, roaster: NewRoaster) -> Result; async fn get(&self, id: RoasterId) -> Result; + async fn get_by_slug(&self, slug: &str) -> Result; async fn list( &self, request: &ListRequest, @@ -50,6 +51,11 @@ pub trait RoasterRepository: Send + Sync { pub trait RoastRepository: Send + Sync { async fn insert(&self, roast: NewRoast) -> Result; async fn get(&self, id: RoastId) -> Result; + async fn get_by_slug( + &self, + roaster_id: RoasterId, + slug: &str, + ) -> Result; async fn list( &self, request: &ListRequest, diff --git a/src/domain/roasters.rs b/src/domain/roasters.rs index 131cbf4..d8abd35 100644 --- a/src/domain/roasters.rs +++ b/src/domain/roasters.rs @@ -8,6 +8,7 @@ use crate::domain::listing::{SortDirection, SortKey}; pub struct Roaster { pub id: RoasterId, pub name: String, + pub slug: String, pub country: String, pub city: Option, pub homepage: Option, @@ -33,6 +34,14 @@ impl NewRoaster { self.notes = normalize_optional_field(self.notes); self } + + pub fn slug(&self) -> String { + let base = match &self.city { + Some(city) => format!("{}-{}", self.name, city), + None => self.name.clone(), + }; + slug::slugify(base) + } } fn normalize_optional_field(value: Option) -> Option { diff --git a/src/domain/roasts.rs b/src/domain/roasts.rs index c034547..b2c3557 100644 --- a/src/domain/roasts.rs +++ b/src/domain/roasts.rs @@ -9,6 +9,7 @@ pub struct Roast { pub id: RoastId, pub roaster_id: RoasterId, pub name: String, + pub slug: String, pub origin: Option, pub region: Option, pub producer: Option, @@ -21,6 +22,7 @@ pub struct Roast { pub struct RoastWithRoaster { pub roast: Roast, pub roaster_name: String, + pub roaster_slug: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -34,6 +36,12 @@ pub struct NewRoast { pub process: String, } +impl NewRoast { + pub fn slug(&self) -> String { + slug::slugify(&self.name) + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct UpdateRoast { pub roaster_id: Option, diff --git a/src/domain/timeline.rs b/src/domain/timeline.rs index 7e01984..3512ef9 100644 --- a/src/domain/timeline.rs +++ b/src/domain/timeline.rs @@ -19,6 +19,8 @@ pub struct TimelineEvent { pub title: String, pub details: Vec, pub tasting_notes: Vec, + pub slug: Option, + pub roaster_slug: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/infrastructure/repositories/roasters.rs b/src/infrastructure/repositories/roasters.rs index a24a1b5..d217f5f 100644 --- a/src/infrastructure/repositories/roasters.rs +++ b/src/infrastructure/repositories/roasters.rs @@ -38,6 +38,7 @@ impl SqlRoasterRepository { let RoasterRecord { id, name, + slug, country, city, homepage, @@ -48,6 +49,7 @@ impl SqlRoasterRepository { Roaster { id: RoasterId::from(id), name, + slug, country, city, homepage, @@ -100,13 +102,15 @@ impl RoasterRepository for SqlRoasterRepository { .map_err(|err| RepositoryError::unexpected(err.to_string()))?; let new_roaster = new_roaster.normalize(); + let slug = new_roaster.slug(); let created_at = Utc::now(); let record = query_as::<_, RoasterRecord>( - "INSERT INTO roasters (name, country, city, homepage, notes, created_at) VALUES (?, ?, ?, ?, ?, ?)\ - RETURNING id, name, country, city, homepage, notes, created_at", + "INSERT INTO roasters (name, slug, country, city, homepage, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)\ + RETURNING id, name, slug, country, city, homepage, notes, created_at", ) .bind(&new_roaster.name) + .bind(&slug) .bind(&new_roaster.country) .bind(new_roaster.city.as_deref()) .bind(new_roaster.homepage.as_deref()) @@ -114,7 +118,13 @@ impl RoasterRepository for SqlRoasterRepository { .bind(created_at) .fetch_one(&mut *tx) .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + .map_err(|err| { + if err.to_string().contains("UNIQUE constraint failed") { + RepositoryError::Conflict("A roaster with this name and city already exists".to_string()) + } else { + RepositoryError::unexpected(err.to_string()) + } + })?; let roaster = Self::into_domain(record); let details_json = Self::details_for_roaster(&roaster)?; @@ -141,7 +151,7 @@ impl RoasterRepository for SqlRoasterRepository { async fn get(&self, id: RoasterId) -> Result { let record = query_as::<_, RoasterRecord>( - "SELECT id, name, country, city, homepage, notes, created_at FROM roasters WHERE id = ?", + "SELECT id, name, slug, country, city, homepage, notes, created_at FROM roasters WHERE id = ?", ) .bind(i64::from(id)) .fetch_optional(&self.pool) @@ -154,13 +164,28 @@ impl RoasterRepository for SqlRoasterRepository { } } + async fn get_by_slug(&self, slug: &str) -> Result { + let record = query_as::<_, RoasterRecord>( + "SELECT id, name, slug, country, city, homepage, notes, created_at FROM roasters WHERE slug = ?", + ) + .bind(slug) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + match record { + Some(record) => Ok(Self::into_domain(record)), + None => Err(RepositoryError::NotFound), + } + } + async fn list( &self, request: &ListRequest, ) -> Result, RepositoryError> { let order_clause = Self::sort_clause(request); let base_query = - "SELECT id, name, country, city, homepage, notes, created_at FROM roasters"; + "SELECT id, name, slug, country, city, homepage, notes, created_at FROM roasters"; let count_query = "SELECT COUNT(*) FROM roasters"; crate::infrastructure::repositories::pagination::paginate( @@ -264,6 +289,7 @@ impl RoasterRepository for SqlRoasterRepository { struct RoasterRecord { id: i64, name: String, + slug: String, country: String, city: Option, homepage: Option, diff --git a/src/infrastructure/repositories/roasts.rs b/src/infrastructure/repositories/roasts.rs index f1b1e93..266fb1a 100644 --- a/src/infrastructure/repositories/roasts.rs +++ b/src/infrastructure/repositories/roasts.rs @@ -60,6 +60,7 @@ impl RoastRepository for SqlRoastRepository { .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + let slug = new_roast.slug(); let NewRoast { roaster_id, name, @@ -95,11 +96,12 @@ impl RoastRepository for SqlRoastRepository { let notes_json = Self::encode_notes(&tasting_notes)?; let record = query_as::<_, RoastRecord>( - "INSERT INTO roasts (roaster_id, name, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\ - RETURNING id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at", + "INSERT INTO roasts (roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\ + RETURNING id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at", ) .bind(i64::from(roaster_id)) .bind(&name) + .bind(&slug) .bind(origin_value.as_deref()) .bind(region_value.as_deref()) .bind(producer_value.as_deref()) @@ -108,7 +110,13 @@ impl RoastRepository for SqlRoastRepository { .bind(created_at) .fetch_one(&mut *tx) .await - .map_err(|err| map_insert_error(err, "unknown roaster reference"))?; + .map_err(|err| { + if err.to_string().contains("UNIQUE constraint failed") { + RepositoryError::Conflict("A roast with this name already exists for this roaster".to_string()) + } else { + map_insert_error(err, "unknown roaster reference") + } + })?; let roast = record.into_roast()?; @@ -179,7 +187,7 @@ impl RoastRepository for SqlRoastRepository { async fn get(&self, id: RoastId) -> Result { query_as::<_, RoastRecord>( - "SELECT id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE id = ?", + "SELECT id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE id = ?", ) .bind(i64::from(id)) .fetch_optional(&self.pool) @@ -190,12 +198,30 @@ impl RoastRepository for SqlRoastRepository { .ok_or(RepositoryError::NotFound) } + async fn get_by_slug( + &self, + roaster_id: RoasterId, + slug: &str, + ) -> Result { + query_as::<_, RoastRecord>( + "SELECT id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE roaster_id = ? AND slug = ?", + ) + .bind(i64::from(roaster_id)) + .bind(slug) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .map(|record| record.into_roast()) + .transpose()? + .ok_or(RepositoryError::NotFound) + } + async fn list( &self, request: &ListRequest, ) -> Result, RepositoryError> { let order_clause = Self::order_clause(request); - let base_query = "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id"; + let base_query = "SELECT r.id, r.roaster_id, r.name, r.slug, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name, ro.slug AS roaster_slug \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id"; let count_query = "SELECT COUNT(*) FROM roasts"; crate::infrastructure::repositories::pagination::paginate( @@ -214,7 +240,7 @@ impl RoastRepository for SqlRoastRepository { roaster_id: RoasterId, ) -> Result, RepositoryError> { let records = query_as::<_, RoastWithRoasterRecord>( - "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id \n WHERE r.roaster_id = ? \n ORDER BY r.created_at DESC", + "SELECT r.id, r.roaster_id, r.name, r.slug, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name, ro.slug AS roaster_slug \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id \n WHERE r.roaster_id = ? \n ORDER BY r.created_at DESC", ) .bind(i64::from(roaster_id)) .fetch_all(&self.pool) @@ -361,6 +387,7 @@ struct RoastRecord { id: i64, roaster_id: i64, name: String, + slug: String, origin: Option, region: Option, producer: Option, @@ -371,35 +398,24 @@ struct RoastRecord { impl RoastRecord { fn into_roast(self) -> Result { - let RoastRecord { - id, - roaster_id, - name, - origin, - region, - producer, - process, - tasting_notes, - created_at, - } = self; - - let tasting_notes = match tasting_notes { - Some(raw) if !raw.is_empty() => from_str(&raw).map_err(|err| { + let tasting_notes = match self.tasting_notes { + Some(raw) => from_str::>(&raw).map_err(|err| { RepositoryError::unexpected(format!("failed to decode tasting notes: {err}")) })?, - _ => Vec::new(), + None => Vec::new(), }; Ok(Roast { - id: RoastId::from(id), - roaster_id: RoasterId::from(roaster_id), - name, - origin, - region, - producer, + id: RoastId::from(self.id), + roaster_id: RoasterId::from(self.roaster_id), + name: self.name, + slug: self.slug, + origin: self.origin, + region: self.region, + producer: self.producer, + process: self.process, tasting_notes, - process, - created_at, + created_at: self.created_at, }) } } @@ -409,6 +425,7 @@ struct RoastWithRoasterRecord { id: i64, roaster_id: i64, name: String, + slug: String, origin: Option, region: Option, producer: Option, @@ -416,27 +433,33 @@ struct RoastWithRoasterRecord { tasting_notes: Option, created_at: DateTime, roaster_name: String, + roaster_slug: String, } impl RoastWithRoasterRecord { fn into_with_roaster(self) -> Result { - let roaster_name = self.roaster_name.clone(); - let roast = RoastRecord { - id: self.id, - roaster_id: self.roaster_id, - name: self.name, - origin: self.origin, - region: self.region, - producer: self.producer, - process: self.process, - tasting_notes: self.tasting_notes, - created_at: self.created_at, - } - .into_roast()?; + let tasting_notes = match self.tasting_notes { + Some(raw) => from_str::>(&raw).map_err(|err| { + RepositoryError::unexpected(format!("failed to decode tasting notes: {err}")) + })?, + None => Vec::new(), + }; Ok(RoastWithRoaster { - roast, - roaster_name, + roast: Roast { + id: RoastId::from(self.id), + roaster_id: RoasterId::from(self.roaster_id), + name: self.name, + slug: self.slug, + origin: self.origin, + region: self.region, + producer: self.producer, + process: self.process, + tasting_notes, + created_at: self.created_at, + }, + roaster_name: self.roaster_name, + roaster_slug: self.roaster_slug, }) } } diff --git a/src/infrastructure/repositories/timeline_events.rs b/src/infrastructure/repositories/timeline_events.rs index 18b5385..9a525b8 100644 --- a/src/infrastructure/repositories/timeline_events.rs +++ b/src/infrastructure/repositories/timeline_events.rs @@ -1,12 +1,12 @@ -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use serde_json::from_str; use crate::domain::RepositoryError; use crate::domain::ids::TimelineEventId; use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::repositories::TimelineEventRepository; use crate::domain::timeline::{TimelineEvent, TimelineEventDetail, TimelineSortKey}; use crate::infrastructure::database::DatabasePool; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde_json::from_str; #[derive(Clone)] pub struct SqlTimelineEventRepository { @@ -30,9 +30,22 @@ impl TimelineEventRepository for SqlTimelineEventRepository { SortDirection::Desc => "DESC", }; - let order_clause = format!("occurred_at {direction_sql}, id DESC"); - let base_query = "SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json \ - FROM timeline_events"; + let order_clause = format!("t.occurred_at {direction_sql}, t.id DESC"); + let base_query = "SELECT + t.id, t.entity_type, t.entity_id, t.occurred_at, t.title, t.details_json, t.tasting_notes_json, + CASE + WHEN t.entity_type = 'roaster' THEN r.slug + WHEN t.entity_type = 'roast' THEN rst.slug + ELSE NULL + END as slug, + CASE + WHEN t.entity_type = 'roast' THEN rst_r.slug + ELSE NULL + END as roaster_slug + FROM timeline_events t + LEFT JOIN roasters r ON t.entity_type = 'roaster' AND t.entity_id = r.id + LEFT JOIN roasts rst ON t.entity_type = 'roast' AND t.entity_id = rst.id + LEFT JOIN roasters rst_r ON rst.roaster_id = rst_r.id"; let count_query = "SELECT COUNT(*) FROM timeline_events"; crate::infrastructure::repositories::pagination::paginate( @@ -56,6 +69,8 @@ struct TimelineEventRecord { title: String, details_json: Option, tasting_notes_json: Option, + slug: Option, + roaster_slug: Option, } impl TimelineEventRecord { @@ -88,6 +103,8 @@ impl TimelineEventRecord { title: self.title, details, tasting_notes, + slug: self.slug, + roaster_slug: self.roaster_slug, }) } } diff --git a/src/presentation/views.rs b/src/presentation/views.rs index fba8614..33e8634 100644 --- a/src/presentation/views.rs +++ b/src/presentation/views.rs @@ -291,6 +291,7 @@ impl From for RoasterView { fn from(roaster: Roaster) -> Self { let Roaster { id, + slug, name, country, city, @@ -301,7 +302,7 @@ impl From for RoasterView { let homepage = homepage.unwrap_or_default(); let has_homepage = !homepage.is_empty(); - let detail_path = format!("/roasters/{id}"); + let detail_path = format!("/roasters/{slug}"); let created_at_sort_key = created_at.timestamp(); let created_at_label = created_at.format("%Y-%m-%d").to_string(); @@ -338,23 +339,25 @@ pub struct RoastView { } impl RoastView { - pub fn from_domain(roast: Roast, roaster_name: &str) -> Self { - Self::from_parts(roast, roaster_name) + pub fn from_domain(roast: Roast, roaster_name: &str, roaster_slug: &str) -> Self { + Self::from_parts(roast, roaster_name, roaster_slug) } pub fn from_list_item(item: RoastWithRoaster) -> Self { let RoastWithRoaster { roast, roaster_name, + roaster_slug, } = item; - Self::from_parts(roast, &roaster_name) + Self::from_parts(roast, &roaster_name, &roaster_slug) } - fn from_parts(roast: Roast, roaster_name: &str) -> Self { + fn from_parts(roast: Roast, roaster_name: &str, roaster_slug: &str) -> Self { let Roast { id: roast_id, roaster_id: _, name, + slug, origin, region, producer, @@ -385,7 +388,7 @@ impl RoastView { }) .collect(); let created_at = created_at.format("%Y-%m-%d").to_string(); - let detail_path = format!("/roasts/{full_id}"); + let detail_path = format!("/roasters/{roaster_slug}/roasts/{slug}"); Self { id, @@ -444,6 +447,8 @@ impl TimelineEventView { title, details, tasting_notes, + slug, + roaster_slug, } = event; let kind_label = match entity_type.as_str() { @@ -452,9 +457,13 @@ impl TimelineEventView { _ => "Event", }; - let link = match entity_type.as_str() { - "roaster" => format!("/roasters/{entity_id}"), - "roast" => format!("/roasts/{entity_id}"), + let link = match (entity_type.as_str(), slug, roaster_slug) { + ("roaster", Some(slug), _) => format!("/roasters/{slug}"), + ("roast", Some(slug), Some(roaster_slug)) => { + format!("/roasters/{roaster_slug}/roasts/{slug}") + } + ("roaster", None, _) => format!("/roasters/{entity_id}"), + ("roast", None, _) => format!("/roasts/{entity_id}"), _ => String::from("#"), }; diff --git a/src/server/routes/mod.rs b/src/server/routes/mod.rs index 4db4f36..0ab0494 100644 --- a/src/server/routes/mod.rs +++ b/src/server/routes/mod.rs @@ -49,9 +49,12 @@ pub fn app_router(state: AppState) -> axum::Router { .route("/login", get(auth::login_page).post(auth::login_submit)) .route("/logout", post(auth::logout)) .route("/roasters", get(roasters::roasters_page)) - .route("/roasters/:id", get(roasters::roaster_page)) + .route("/roasters/:slug", get(roasters::roaster_page)) .route("/roasts", get(roasts::roasts_page)) - .route("/roasts/:id", get(roasts::roast_page)) + .route( + "/roasters/:roaster_slug/roasts/:roast_slug", + get(roasts::roast_page), + ) .route("/timeline", get(timeline::timeline_page)) .route("/styles.css", get(styles)) .route("/favicon.ico", get(favicon)) diff --git a/src/server/routes/roasters.rs b/src/server/routes/roasters.rs index 5c5026d..59ffdea 100644 --- a/src/server/routes/roasters.rs +++ b/src/server/routes/roasters.rs @@ -73,16 +73,16 @@ pub(crate) async fn roasters_page( pub(crate) async fn roaster_page( State(state): State, cookies: tower_cookies::Cookies, - Path(id): Path, + Path(slug): Path, ) -> Result, StatusCode> { let roaster = state .roaster_repo - .get(id) + .get_by_slug(&slug) .await .map_err(|err| map_app_error(AppError::from(err)))?; let roasts = state .roast_repo - .list_by_roaster(id) + .list_by_roaster(roaster.id) .await .map_err(|err| map_app_error(AppError::from(err)))?; diff --git a/src/server/routes/roasts.rs b/src/server/routes/roasts.rs index 2c81e39..16af669 100644 --- a/src/server/routes/roasts.rs +++ b/src/server/routes/roasts.rs @@ -82,16 +82,17 @@ pub(crate) async fn roasts_page( pub(crate) async fn roast_page( State(state): State, cookies: tower_cookies::Cookies, - Path(id): Path, + Path((roaster_slug, roast_slug)): Path<(String, String)>, ) -> Result, StatusCode> { - let roast = state - .roast_repo - .get(id) - .await - .map_err(|err| map_app_error(AppError::from(err)))?; let roaster = state .roaster_repo - .get(roast.roaster_id) + .get_by_slug(&roaster_slug) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + + let roast = state + .roast_repo + .get_by_slug(roaster.id, &roast_slug) .await .map_err(|err| map_app_error(AppError::from(err)))?; @@ -100,7 +101,7 @@ pub(crate) async fn roast_page( let template = RoastDetailTemplate { nav_active: "roasts", is_authenticated, - roast: RoastView::from_domain(roast, &roaster.name), + roast: RoastView::from_domain(roast, &roaster.name, &roaster.slug), }; render_html(template) diff --git a/src/server/routes/support.rs b/src/server/routes/support.rs index 9870b64..5906af1 100644 --- a/src/server/routes/support.rs +++ b/src/server/routes/support.rs @@ -1,8 +1,8 @@ +use askama::Template; use axum::async_trait; use axum::extract::{Form, FromRequest, Json as JsonPayload, Request}; use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE}; use axum::response::{Html, IntoResponse, Response}; -use askama::Template; use serde::Deserialize; use crate::domain::listing::{ diff --git a/src/server/routes/timeline.rs b/src/server/routes/timeline.rs index a1ab1f5..e5e2996 100644 --- a/src/server/routes/timeline.rs +++ b/src/server/routes/timeline.rs @@ -9,9 +9,7 @@ use crate::presentation::templates::{TimelineChunkTemplate, TimelineTemplate}; use crate::presentation::views::{ListNavigator, Paginated, TimelineEventView, TimelineMonthView}; use crate::server::errors::{AppError, map_app_error}; use crate::server::routes::render_html; -use crate::server::routes::support::{ - ListQuery, is_datastar_request, normalize_request, -}; +use crate::server::routes::support::{ListQuery, is_datastar_request, normalize_request}; use crate::server::server::AppState; const TIMELINE_PAGE_PATH: &str = "/timeline"; diff --git a/tests/cli/roasts_cli.rs b/tests/cli/roasts_cli.rs index 5a97ad1..1faccdf 100644 --- a/tests/cli/roasts_cli.rs +++ b/tests/cli/roasts_cli.rs @@ -36,7 +36,13 @@ fn test_add_roast_with_authentication() { // First create a roaster let roaster_output = run_brewlog( - &["add-roaster", "--name", "Test Roasters", "--country", "UK"], + &[ + "add-roaster", + "--name", + "Test Roasters Add", + "--country", + "UK", + ], &[("BREWLOG_TOKEN", &token)], ); @@ -108,7 +114,13 @@ fn test_list_roasts_shows_added_roast() { // First create a roaster let roaster_output = run_brewlog( - &["add-roaster", "--name", "Test Roasters", "--country", "UK"], + &[ + "add-roaster", + "--name", + "Test Roasters List", + "--country", + "UK", + ], &[("BREWLOG_TOKEN", &token)], ); diff --git a/tests/server/timeline.rs b/tests/server/timeline.rs index 61b1ea0..38d5841 100644 --- a/tests/server/timeline.rs +++ b/tests/server/timeline.rs @@ -97,7 +97,6 @@ async fn creating_a_roaster_surfaces_on_the_timeline() { }, ) .await; - let roaster_id = roaster.id; sleep(Duration::from_millis(10)).await; @@ -119,7 +118,7 @@ async fn creating_a_roaster_surfaces_on_the_timeline() { "Expected roaster name to appear in timeline HTML, got: {body}" ); assert!( - body.contains(&format!("/roasters/{}", roaster_id)), + body.contains(&format!("/roasters/{}", roaster.slug)), "Expected roaster detail link in timeline HTML, got: {body}" ); }