feat!: update to a more human friendly url structure for roasters/roasts

This commit is contained in:
Jon Seager 2025-11-25 21:34:08 +00:00
parent dd0f716437
commit f88a880d07
No known key found for this signature in database
18 changed files with 221 additions and 85 deletions

17
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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,

View file

@ -16,6 +16,7 @@ use async_trait::async_trait;
pub trait RoasterRepository: Send + Sync {
async fn insert(&self, roaster: NewRoaster) -> Result<Roaster, RepositoryError>;
async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Roaster, RepositoryError>;
async fn list(
&self,
request: &ListRequest<RoasterSortKey>,
@ -50,6 +51,11 @@ pub trait RoasterRepository: Send + Sync {
pub trait RoastRepository: Send + Sync {
async fn insert(&self, roast: NewRoast) -> Result<Roast, RepositoryError>;
async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError>;
async fn get_by_slug(
&self,
roaster_id: RoasterId,
slug: &str,
) -> Result<Roast, RepositoryError>;
async fn list(
&self,
request: &ListRequest<RoastSortKey>,

View file

@ -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<String>,
pub homepage: Option<String>,
@ -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<String>) -> Option<String> {

View file

@ -9,6 +9,7 @@ pub struct Roast {
pub id: RoastId,
pub roaster_id: RoasterId,
pub name: String,
pub slug: String,
pub origin: Option<String>,
pub region: Option<String>,
pub producer: Option<String>,
@ -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<RoasterId>,

View file

@ -19,6 +19,8 @@ pub struct TimelineEvent {
pub title: String,
pub details: Vec<TimelineEventDetail>,
pub tasting_notes: Vec<String>,
pub slug: Option<String>,
pub roaster_slug: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -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<Roaster, RepositoryError> {
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<Roaster, RepositoryError> {
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<RoasterSortKey>,
) -> Result<Page<Roaster>, 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<String>,
homepage: Option<String>,

View file

@ -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<Roast, RepositoryError> {
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<Roast, RepositoryError> {
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<RoastSortKey>,
) -> Result<Page<RoastWithRoaster>, 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<Vec<RoastWithRoaster>, 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<String>,
region: Option<String>,
producer: Option<String>,
@ -371,35 +398,24 @@ struct RoastRecord {
impl RoastRecord {
fn into_roast(self) -> Result<Roast, RepositoryError> {
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::<Vec<String>>(&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<String>,
region: Option<String>,
producer: Option<String>,
@ -416,27 +433,33 @@ struct RoastWithRoasterRecord {
tasting_notes: Option<String>,
created_at: DateTime<Utc>,
roaster_name: String,
roaster_slug: String,
}
impl RoastWithRoasterRecord {
fn into_with_roaster(self) -> Result<RoastWithRoaster, RepositoryError> {
let roaster_name = self.roaster_name.clone();
let roast = RoastRecord {
id: self.id,
roaster_id: self.roaster_id,
let tasting_notes = match self.tasting_notes {
Some(raw) => from_str::<Vec<String>>(&raw).map_err(|err| {
RepositoryError::unexpected(format!("failed to decode tasting notes: {err}"))
})?,
None => Vec::new(),
};
Ok(RoastWithRoaster {
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: self.tasting_notes,
tasting_notes,
created_at: self.created_at,
}
.into_roast()?;
Ok(RoastWithRoaster {
roast,
roaster_name,
},
roaster_name: self.roaster_name,
roaster_slug: self.roaster_slug,
})
}
}

View file

@ -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<String>,
tasting_notes_json: Option<String>,
slug: Option<String>,
roaster_slug: Option<String>,
}
impl TimelineEventRecord {
@ -88,6 +103,8 @@ impl TimelineEventRecord {
title: self.title,
details,
tasting_notes,
slug: self.slug,
roaster_slug: self.roaster_slug,
})
}
}

View file

@ -291,6 +291,7 @@ impl From<Roaster> for RoasterView {
fn from(roaster: Roaster) -> Self {
let Roaster {
id,
slug,
name,
country,
city,
@ -301,7 +302,7 @@ impl From<Roaster> 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("#"),
};

View file

@ -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))

View file

@ -73,16 +73,16 @@ pub(crate) async fn roasters_page(
pub(crate) async fn roaster_page(
State(state): State<AppState>,
cookies: tower_cookies::Cookies,
Path(id): Path<RoasterId>,
Path(slug): Path<String>,
) -> Result<Html<String>, 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)))?;

View file

@ -82,16 +82,17 @@ pub(crate) async fn roasts_page(
pub(crate) async fn roast_page(
State(state): State<AppState>,
cookies: tower_cookies::Cookies,
Path(id): Path<RoastId>,
Path((roaster_slug, roast_slug)): Path<(String, String)>,
) -> Result<Html<String>, 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)

View file

@ -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::{

View file

@ -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";

View file

@ -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)],
);

View file

@ -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}"
);
}