diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 08ee996..3aa7006 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -5,7 +5,7 @@ use crate::domain::roasters::RoasterSortKey; use crate::domain::roasters::{Roaster, UpdateRoaster}; use crate::domain::roasts::RoastSortKey; use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast}; -use crate::domain::timeline::TimelineEvent; +use crate::domain::timeline::{TimelineEvent, TimelineSortKey}; use async_trait::async_trait; #[async_trait] @@ -63,5 +63,16 @@ pub trait RoastRepository: Send + Sync { #[async_trait] pub trait TimelineEventRepository: Send + Sync { - async fn list_all(&self) -> Result, RepositoryError>; + async fn list( + &self, + request: &ListRequest, + ) -> Result, RepositoryError>; + + async fn list_all(&self) -> Result, RepositoryError> { + let sort_key = ::default(); + let request = + ListRequest::::show_all(sort_key, sort_key.default_direction()); + let page = self.list(&request).await?; + Ok(page.items) + } } diff --git a/src/domain/timeline.rs b/src/domain/timeline.rs index 312c836..3e58c93 100644 --- a/src/domain/timeline.rs +++ b/src/domain/timeline.rs @@ -1,6 +1,8 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use crate::domain::listing::{SortDirection, SortKey}; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimelineEventDetail { pub label: String, @@ -27,3 +29,33 @@ pub struct NewTimelineEvent { pub details: Vec, pub tasting_notes: Vec, } + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum TimelineSortKey { + OccurredAt, +} + +impl SortKey for TimelineSortKey { + fn default() -> Self { + TimelineSortKey::OccurredAt + } + + fn from_query(value: &str) -> Option { + match value { + "occurred-at" => Some(TimelineSortKey::OccurredAt), + _ => None, + } + } + + fn query_value(self) -> &'static str { + match self { + TimelineSortKey::OccurredAt => "occurred-at", + } + } + + fn default_direction(self) -> SortDirection { + match self { + TimelineSortKey::OccurredAt => SortDirection::Desc, + } + } +} diff --git a/src/infrastructure/repositories/timeline_events.rs b/src/infrastructure/repositories/timeline_events.rs index 27214a3..1e61ec8 100644 --- a/src/infrastructure/repositories/timeline_events.rs +++ b/src/infrastructure/repositories/timeline_events.rs @@ -1,11 +1,12 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use serde_json::from_str; -use sqlx::query_as; +use sqlx::{query_as, query_scalar}; use crate::domain::RepositoryError; +use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; use crate::domain::repositories::TimelineEventRepository; -use crate::domain::timeline::{TimelineEvent, TimelineEventDetail}; +use crate::domain::timeline::{TimelineEvent, TimelineEventDetail, TimelineSortKey}; use crate::infrastructure::database::DatabasePool; #[derive(Clone)] @@ -21,20 +22,83 @@ impl SqlTimelineEventRepository { #[async_trait] impl TimelineEventRepository for SqlTimelineEventRepository { - async fn list_all(&self) -> Result, RepositoryError> { - let records = query_as::<_, TimelineEventRecord>( - "SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json FROM timeline_events ORDER BY occurred_at DESC", - ) - .fetch_all(&self.pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + async fn list( + &self, + request: &ListRequest, + ) -> Result, RepositoryError> { + let direction_sql = match request.sort_direction() { + SortDirection::Asc => "ASC", + SortDirection::Desc => "DESC", + }; - let mut events = Vec::with_capacity(records.len()); - for record in records { - events.push(record.into_domain()?); + let order_clause = format!("occurred_at {direction_sql}, id DESC"); + + match request.page_size() { + PageSize::All => { + let query = format!( + "SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json \ + FROM timeline_events \ + ORDER BY {order_clause}" + ); + + let records = query_as::<_, TimelineEventRecord>(&query) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let mut events = Vec::with_capacity(records.len()); + for record in records { + events.push(record.into_domain()?); + } + + let total = events.len() as u64; + let page_size = total.min(u64::from(u32::MAX)) as u32; + Ok(Page::new(events, 1, page_size.max(1), total, true)) + } + PageSize::Limited(page_size) => { + let limit = page_size as i64; + let mut page = request.page(); + let offset = ((page - 1) as i64).saturating_mul(limit); + + let query = format!( + "SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json \ + FROM timeline_events \ + ORDER BY {order_clause} \ + LIMIT ? OFFSET ?" + ); + + let mut records = query_as::<_, TimelineEventRecord>(&query) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let total: i64 = query_scalar("SELECT COUNT(*) FROM timeline_events") + .fetch_one(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + if page > 1 && records.is_empty() && total > 0 { + let last_page = ((total + limit - 1) / limit) as u32; + page = last_page.max(1); + let offset = ((page - 1) as i64).saturating_mul(limit); + records = query_as::<_, TimelineEventRecord>(&query) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + } + + let mut events = Vec::with_capacity(records.len()); + for record in records { + events.push(record.into_domain()?); + } + + Ok(Page::new(events, page, page_size, total as u64, false)) + } } - - Ok(events) } } diff --git a/src/presentation/templates.rs b/src/presentation/templates.rs index 53b7d01..3926297 100644 --- a/src/presentation/templates.rs +++ b/src/presentation/templates.rs @@ -1,10 +1,12 @@ use askama::Template; use super::views::{ - ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineMonthView, + ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineEventView, + TimelineMonthView, }; use crate::domain::roasters::RoasterSortKey; use crate::domain::roasts::RoastSortKey; +use crate::domain::timeline::TimelineSortKey; #[derive(Template)] #[template(path = "roasters.html")] @@ -56,6 +58,16 @@ pub struct RoastListTemplate { #[template(path = "timeline.html")] pub struct TimelineTemplate { pub nav_active: &'static str, + pub events: Paginated, + pub navigator: ListNavigator, + pub months: Vec, +} + +#[derive(Template)] +#[template(path = "partials/timeline_chunk.html")] +pub struct TimelineChunkTemplate { + pub events: Paginated, + pub navigator: ListNavigator, pub months: Vec, } diff --git a/src/presentation/views.rs b/src/presentation/views.rs index ca2f42b..23581c7 100644 --- a/src/presentation/views.rs +++ b/src/presentation/views.rs @@ -1,6 +1,7 @@ use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; use crate::domain::roasters::Roaster; use crate::domain::roasts::{Roast, RoastWithRoaster}; +use crate::domain::timeline::TimelineEvent; pub struct Paginated { pub items: Vec, @@ -404,11 +405,13 @@ impl RoastView { } } +#[derive(Clone)] pub struct TimelineEventDetailView { pub label: String, pub value: String, } +#[derive(Clone)] pub struct TimelineEventView { pub id: String, pub kind_label: &'static str, @@ -431,3 +434,77 @@ pub struct TimelineMonthView { pub heading: String, pub events: Vec, } + +impl TimelineEventView { + pub fn from_domain(event: TimelineEvent) -> Self { + let TimelineEvent { + id, + entity_type, + entity_id, + occurred_at, + title, + details, + tasting_notes, + } = event; + + let kind_label = match entity_type.as_str() { + "roaster" => "Roaster Added", + "roast" => "Roast Added", + _ => "Event", + }; + + let link = match entity_type.as_str() { + "roaster" => format!("/roasters/{entity_id}"), + "roast" => format!("/roasts/{entity_id}"), + _ => String::from("#"), + }; + + let mut mapped_details = Vec::new(); + let mut external_link = None; + for detail in details { + if detail.label.eq_ignore_ascii_case("homepage") { + let trimmed = detail.value.trim(); + if !trimmed.is_empty() && trimmed != "—" { + external_link = Some(trimmed.to_string()); + } + } else { + mapped_details.push(TimelineEventDetailView { + label: detail.label, + value: detail.value, + }); + } + } + + let tasting_notes = if entity_type == "roast" { + let notes = tasting_notes + .into_iter() + .flat_map(|note| { + note.split(|ch| ch == ',' || ch == '\n') + .map(|segment| segment.trim().to_string()) + .filter(|segment| !segment.is_empty()) + .collect::>() + }) + .collect::>(); + Some(notes) + } else { + None + }; + + Self { + id, + kind_label, + badge_class: "bg-amber-200 text-amber-800", + accent_class: "bg-amber-600", + card_border_class: "border-amber-200 bg-amber-50/80", + title_class: "text-amber-800", + date_label: occurred_at.format("%B %d, %Y").to_string(), + time_label: Some(occurred_at.format("%H:%M UTC").to_string()), + iso_timestamp: occurred_at.to_rfc3339(), + title, + link, + external_link, + details: mapped_details, + tasting_notes, + } + } +} diff --git a/src/server/routes/support.rs b/src/server/routes/support.rs index 11e6d34..b6c8095 100644 --- a/src/server/routes/support.rs +++ b/src/server/routes/support.rs @@ -45,11 +45,15 @@ enum PageSizeParam { impl ListQuery { pub fn into_request(self) -> ListRequest { + self.into_request_with_default::(DEFAULT_PAGE_SIZE) + } + + pub fn into_request_with_default(self, default_page_size: u32) -> ListRequest { let page = self.page.unwrap_or(1); let page_size = match self.page_size { Some(PageSizeParam::Number(value)) => PageSize::limited(value), Some(PageSizeParam::Text(text)) => page_size_from_text(&text), - None => PageSize::limited(DEFAULT_PAGE_SIZE), + None => PageSize::limited(default_page_size.max(1)), }; let sort_key = self diff --git a/src/server/routes/timeline.rs b/src/server/routes/timeline.rs index 2965ab8..3fc2a08 100644 --- a/src/server/routes/timeline.rs +++ b/src/server/routes/timeline.rs @@ -1,118 +1,153 @@ -use axum::extract::State; +use axum::extract::{Query, State}; +use axum::http::HeaderMap; use axum::http::StatusCode; -use axum::response::Html; +use axum::response::{Html, IntoResponse, Response}; -use crate::presentation::templates::TimelineTemplate; -use crate::presentation::views::{TimelineEventDetailView, TimelineEventView, TimelineMonthView}; +use crate::domain::listing::ListRequest; +use crate::domain::timeline::{TimelineEvent, TimelineSortKey}; +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, set_datastar_patch_headers, +}; use crate::server::server::AppState; +const TIMELINE_PAGE_PATH: &str = "/timeline"; +const TIMELINE_FRAGMENT_PATH: &str = "/timeline"; +const TIMELINE_DEFAULT_PAGE_SIZE: u32 = 5; + pub(crate) async fn timeline_page( State(state): State, -) -> Result, StatusCode> { - let events = state - .timeline_repo - .list_all() - .await - .map_err(|err| map_app_error(AppError::from(err)))?; + headers: HeaderMap, + Query(query): Query, +) -> Result { + let request = query.into_request_with_default::(TIMELINE_DEFAULT_PAGE_SIZE); + if is_datastar_request(&headers) { + return render_timeline_chunk(state, request) + .await + .map_err(|err| map_app_error(err)); + } + let data = load_timeline_page(&state, request) + .await + .map_err(|err| map_app_error(err))?; + + let template = TimelineTemplate { + nav_active: "timeline", + events: data.events, + navigator: data.navigator, + months: data.months, + }; + + render_html(template).map(IntoResponse::into_response) +} + +struct TimelinePreparedEvent { + anchor: String, + heading: String, + view: TimelineEventView, +} + +async fn render_timeline_chunk( + state: AppState, + request: ListRequest, +) -> Result { + let data = load_timeline_page(&state, request).await?; + let template = TimelineChunkTemplate { + events: data.events, + navigator: data.navigator, + months: data.months, + }; + + let html = crate::presentation::templates::render_template(template) + .map_err(|err| AppError::unexpected(format!("failed to render timeline chunk: {err}")))?; + + let mut response = Html(html).into_response(); + set_datastar_patch_headers(response.headers_mut(), "#timeline-loader"); + Ok(response) +} + +struct TimelinePageData { + events: Paginated, + navigator: ListNavigator, + months: Vec, +} + +async fn load_timeline_page( + state: &AppState, + request: ListRequest, +) -> Result { + let page = state + .timeline_repo + .list(&request) + .await + .map_err(AppError::from)?; + + let normalized_request = normalize_request(request, &page); + + let prepared_events = page + .items + .into_iter() + .map(prepare_event) + .collect::>(); + + let views = prepared_events + .iter() + .map(|prepared| prepared.view.clone()) + .collect::>(); + + let events = Paginated::new( + views, + page.page, + page.page_size, + page.total, + page.showing_all, + ); + let months = build_months(prepared_events); + let navigator = ListNavigator::new( + TIMELINE_PAGE_PATH, + TIMELINE_FRAGMENT_PATH, + normalized_request, + ); + + Ok(TimelinePageData { + events, + navigator, + months, + }) +} + +fn prepare_event(event: TimelineEvent) -> TimelinePreparedEvent { + let anchor = event.occurred_at.format("%Y-%m").to_string(); + let heading = event.occurred_at.format("%B %Y").to_string(); + let view = TimelineEventView::from_domain(event); + + TimelinePreparedEvent { + anchor, + heading, + view, + } +} + +fn build_months(prepared_events: Vec) -> Vec { let mut months: Vec = Vec::new(); - for event in events { - let occurred_at = event.occurred_at; - let anchor = occurred_at.format("%Y-%m").to_string(); - let heading = occurred_at.format("%B %Y").to_string(); - - let entity_id = event.entity_id.clone(); - let kind_label = match event.entity_type.as_str() { - "roaster" => "Roaster Added", - "roast" => "Roast Added", - _ => "Event", - }; - let link = match event.entity_type.as_str() { - "roaster" => format!("/roasters/{entity_id}"), - "roast" => format!("/roasts/{entity_id}"), - _ => String::from("#"), - }; - - let mut details: Vec = event - .details - .into_iter() - .map(|detail| TimelineEventDetailView { - label: detail.label, - value: detail.value, - }) - .collect(); - - let external_link = if let Some(index) = details - .iter() - .position(|detail| detail.label.eq_ignore_ascii_case("homepage")) - { - let value = details.remove(index).value; - let trimmed = value.trim(); - if trimmed.is_empty() || trimmed == "—" { - None - } else { - Some(trimmed.to_string()) - } - } else { - None - }; - - let tasting_notes = if event.entity_type == "roast" { - let notes = event - .tasting_notes - .clone() - .into_iter() - .flat_map(|note| { - note.split(|ch| ch == ',' || ch == '\n') - .map(|segment| segment.trim()) - .filter(|segment| !segment.is_empty()) - .map(|segment| segment.to_string()) - .collect::>() - }) - .collect::>(); - Some(notes) - } else { - None - }; - - let view = TimelineEventView { - id: event.id, - kind_label, - badge_class: "bg-amber-200 text-amber-800", - accent_class: "bg-amber-600", - card_border_class: "border-amber-200 bg-amber-50/80", - title_class: "text-amber-800", - date_label: occurred_at.format("%B %d, %Y").to_string(), - time_label: Some(occurred_at.format("%H:%M UTC").to_string()), - iso_timestamp: occurred_at.to_rfc3339(), - title: event.title, - link, - external_link, - details, - tasting_notes, - }; - + for prepared in prepared_events { if let Some(last) = months.last_mut() { - if last.anchor == anchor { - last.events.push(view); + if last.anchor == prepared.anchor { + last.events.push(prepared.view); continue; } } months.push(TimelineMonthView { - anchor, - heading, - events: vec![view], + anchor: prepared.anchor, + heading: prepared.heading, + events: vec![prepared.view], }); } - let template = TimelineTemplate { - nav_active: "timeline", - months, - }; - - render_html(template) + months } diff --git a/templates/partials/timeline_chunk.html b/templates/partials/timeline_chunk.html new file mode 100644 index 0000000..e5536d5 --- /dev/null +++ b/templates/partials/timeline_chunk.html @@ -0,0 +1,20 @@ +
+
+ {% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %} +
+ +
diff --git a/templates/partials/timeline_month.html b/templates/partials/timeline_month.html new file mode 100644 index 0000000..647f57e --- /dev/null +++ b/templates/partials/timeline_month.html @@ -0,0 +1,74 @@ +
+

{{ month.heading }}

+
    + {% for event in month.events %} +
  1. + +
    +
    + {{ event.kind_label }} + +
    +

    + {{ event.title }} + {% if let Some(url) = event.external_link %} + + + Open external link + + {% endif %} +

    + {% if event.details.len() > 0 %} +
    + {% for detail in event.details %} +
    +
    {{ detail.label }}
    +
    {{ detail.value }}
    +
    + {% endfor %} +
    + {% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %} +

    No tasting notes yet.

    + {% else %} +
      + {% for note in notes %} +
    • + {{ note }} +
    • + {% endfor %} +
    + {% endif %} {% endif %} +
    +
  2. + {% endfor %} +
+
diff --git a/templates/timeline.html b/templates/timeline.html index 3fa1fa0..eca0370 100644 --- a/templates/timeline.html +++ b/templates/timeline.html @@ -7,94 +7,65 @@
-
+
{% if months.is_empty() %}

No events yet. Create roasters or roasts to populate the timeline.

- {% else %} {% for month in months %} -
-

{{ month.heading }}

-
    - {% for event in month.events %} -
  1. - -
    -
    - {{ event.kind_label }} - -
    -

    - {{ event.title }} - {% if let Some(url) = event.external_link %} - - - Open external link - - {% endif %} -

    - {% if event.details.len() > 0 %} -
    - {% for detail in event.details %} -
    -
    {{ detail.label }}
    -
    {{ detail.value }}
    -
    - {% endfor %} -
    - {% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %} -

    No tasting notes yet.

    - {% else %} -
      - {% for note in notes %} -
    • - {{ note }} -
    • - {% endfor %} -
    - {% endif %} {% endif %} -
    -
  2. - {% endfor %} -
+ {% else %} {% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %} + {% endif %} + +
+ + +

+ No more events. +

+
- {% endfor %} {% endif %} +
+ + {% endblock %} diff --git a/tests/server/timeline.rs b/tests/server/timeline.rs index bd7ac91..b86cc07 100644 --- a/tests/server/timeline.rs +++ b/tests/server/timeline.rs @@ -26,20 +26,49 @@ async fn create_roast(app: &crate::helpers::TestApp, roaster_id: &str, name: &st assert_eq!(response.status(), 201); } +async fn seed_timeline_with_roasts( + app: &crate::helpers::TestApp, + roast_count: usize, +) -> (String, Vec) { + let roaster_name = "Timeline Seed Roasters"; + let roaster = create_roaster_with_payload( + app, + NewRoaster { + name: roaster_name.to_string(), + country: "UK".to_string(), + city: Some("Bristol".to_string()), + homepage: Some("https://example.com".to_string()), + notes: None, + }, + ) + .await; + + // Ensure the roaster event predates the roast events. + sleep(Duration::from_millis(5)).await; + + let mut roast_names = Vec::new(); + for index in 0..roast_count { + let roast_name = format!("Seed Roast {index:02}"); + create_roast(app, &roaster.id, &roast_name).await; + roast_names.push(roast_name); + // Space out timestamps to keep ordering deterministic. + sleep(Duration::from_millis(2)).await; + } + + (roaster_name.to_string(), roast_names) +} + #[tokio::test] async fn timeline_page_returns_a_200_with_empty_state() { - // Arrange let app = spawn_app().await; let client = Client::new(); - // Act let response = client .get(format!("{}/timeline", app.address)) .send() .await .expect("failed to fetch timeline"); - // Assert assert_eq!(response.status(), 200); let body = response.text().await.expect("failed to read response body"); @@ -51,7 +80,6 @@ async fn timeline_page_returns_a_200_with_empty_state() { #[tokio::test] async fn creating_a_roaster_surfaces_on_the_timeline() { - // Arrange let app = spawn_app().await; let client = Client::new(); @@ -69,17 +97,14 @@ async fn creating_a_roaster_surfaces_on_the_timeline() { .await; let roaster_id = roaster.id.clone(); - // Give the database a brief moment to commit timestamps sleep(Duration::from_millis(10)).await; - // Act let response = client .get(format!("{}/timeline", app.address)) .send() .await .expect("failed to fetch timeline"); - // Assert assert_eq!(response.status(), 200); let body = response.text().await.expect("failed to read response body"); @@ -99,7 +124,6 @@ async fn creating_a_roaster_surfaces_on_the_timeline() { #[tokio::test] async fn creating_a_roast_surfaces_on_the_timeline() { - // Arrange let app = spawn_app().await; let client = Client::new(); @@ -115,19 +139,17 @@ async fn creating_a_roast_surfaces_on_the_timeline() { ) .await .id; - // Ensure the roast event occurs after the roaster event to make ordering deterministic + sleep(Duration::from_millis(5)).await; let roast_name = "Timeline Natural"; create_roast(&app, &roaster_id, roast_name).await; - // Act let response = client .get(format!("{}/timeline", app.address)) .send() .await .expect("failed to fetch timeline"); - // Assert assert_eq!(response.status(), 200); let body = response.text().await.expect("failed to read response body"); @@ -144,3 +166,89 @@ async fn creating_a_roast_surfaces_on_the_timeline() { "Expected tasting notes to appear in timeline HTML, got: {body}" ); } + +#[tokio::test] +async fn timeline_page_signals_more_results_when_over_page_size() { + let app = spawn_app().await; + let (_, roast_names) = seed_timeline_with_roasts(&app, 6).await; + assert_eq!(roast_names.len(), 6); + + let client = Client::new(); + + let response = client + .get(format!("{}/timeline", app.address)) + .send() + .await + .expect("failed to fetch timeline"); + + assert_eq!(response.status(), 200); + let body = response.text().await.expect("failed to read response body"); + + assert!( + body.contains( + "data-next-url=\"/timeline?page=2&page_size=5&sort=occurred-at&dir=desc\"" + ), + "Expected loader next-page URL missing from timeline HTML:\n{}", + body + ); + assert!( + body.contains("data-has-more=\"true\""), + "Expected loader to signal additional pages" + ); + + let latest_roast = roast_names.last().unwrap(); + assert!( + body.contains(latest_roast), + "Expected most recent roast '{latest_roast}' to appear in first page HTML" + ); + + let event_occurrences = body.matches("data-timeline-event").count(); + assert_eq!( + event_occurrences, 5, + "Expected exactly 5 events on first page" + ); +} + +#[tokio::test] +async fn timeline_chunk_endpoint_serves_remaining_events() { + let app = spawn_app().await; + let (roaster_name, roast_names) = seed_timeline_with_roasts(&app, 6).await; + let oldest_roast = roast_names + .first() + .expect("missing seeded roast name") + .clone(); + let client = Client::new(); + + let chunk_url = format!( + "{}/timeline?page=2&page_size=5&sort=occurred-at&dir=desc", + app.address + ); + + let response = client + .get(chunk_url) + .header("datastar-request", "true") + .send() + .await + .expect("failed to fetch timeline chunk"); + + assert_eq!(response.status(), 200); + let body = response.text().await.expect("failed to read response body"); + + assert!( + body.contains(&oldest_roast), + "Expected chunk payload to include oldest roast '{oldest_roast}':\n{}", + body + ); + assert!( + body.contains(&roaster_name), + "Expected chunk payload to include the roaster event: {body}" + ); + assert!( + body.contains("data-has-more=\"false\""), + "Expected chunk to disable further pagination" + ); + assert!( + body.contains("data-next-url=\"\""), + "Expected chunk to clear next URL once exhausted" + ); +}