From 00477bdbc05f12dcc6346fcf382957c8c82068e1 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Mon, 2 Feb 2026 17:09:32 +0000 Subject: [PATCH] feat(roast): add update command for consistency with other entities - Add PUT /api/v1/roasts/:id route handler - Add update() method to roasts HTTP client - Add UpdateRoastCommand to CLI with optional fields - Add CLI tests for roast update authentication and functionality Brings roast entity in line with roaster, bag, and gear which all support add/list/get/update/delete operations. --- src/application/routes/mod.rs | 4 ++- src/application/routes/roasts.rs | 36 ++++++++++++++++++++++- src/infrastructure/client/roasts.rs | 15 +++++++++- src/presentation/cli/roasts.rs | 44 ++++++++++++++++++++++++++++- tests/cli/roasts_cli.rs | 43 +++++++++++++++++++++++++++- 5 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index 4a9c8cf..6c08a30 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -41,7 +41,9 @@ pub fn app_router(state: AppState) -> axum::Router { ) .route( "/roasts/:id", - get(roasts::get_roast).delete(roasts::delete_roast), + get(roasts::get_roast) + .put(roasts::update_roast) + .delete(roasts::delete_roast), ) .route("/bags", get(bags::list_bags).post(bags::create_bag)) .route( diff --git a/src/application/routes/roasts.rs b/src/application/routes/roasts.rs index 3d1066e..7d610b2 100644 --- a/src/application/routes/roasts.rs +++ b/src/application/routes/roasts.rs @@ -16,7 +16,7 @@ use crate::domain::bags::{BagFilter, BagSortKey}; use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::roasters::RoasterSortKey; -use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster}; +use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast}; use crate::presentation::web::templates::{ RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate, }; @@ -227,6 +227,40 @@ define_delete_handler!( render_roast_list_fragment ); +#[tracing::instrument(skip(state, _auth_user))] +pub(crate) async fn update_roast( + State(state): State, + _auth_user: AuthenticatedUser, + Path(id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let has_changes = payload.roaster_id.is_some() + || payload.name.is_some() + || payload.origin.is_some() + || payload.region.is_some() + || payload.producer.is_some() + || payload.tasting_notes.is_some() + || payload.process.is_some(); + + if !has_changes { + return Err(AppError::validation("no changes provided").into()); + } + + state + .roast_repo + .update(id, payload) + .await + .map_err(AppError::from)?; + + let enriched = state + .roast_repo + .get_with_roaster(id) + .await + .map_err(AppError::from)?; + + Ok(Json(enriched)) +} + #[derive(Debug, Deserialize)] pub struct RoastsQuery { pub roaster_id: Option, diff --git a/src/infrastructure/client/roasts.rs b/src/infrastructure/client/roasts.rs index b2f0010..b7aa3f6 100644 --- a/src/infrastructure/client/roasts.rs +++ b/src/infrastructure/client/roasts.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use reqwest::StatusCode; use crate::domain::ids::{RoastId, RoasterId}; -use crate::domain::roasts::{NewRoast, RoastWithRoaster}; +use crate::domain::roasts::{NewRoast, RoastWithRoaster, UpdateRoast}; use super::BrewlogClient; @@ -57,6 +57,19 @@ impl<'a> RoastsClient<'a> { self.inner.handle_response(response).await } + pub async fn update(&self, id: RoastId, payload: &UpdateRoast) -> Result { + let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?; + let response = self + .inner + .request(reqwest::Method::PUT, url) + .json(payload) + .send() + .await + .context("failed to issue update roast request")?; + + self.inner.handle_response(response).await + } + pub async fn delete(&self, id: RoastId) -> Result<()> { let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?; let response = self diff --git a/src/presentation/cli/roasts.rs b/src/presentation/cli/roasts.rs index a8390a6..9428189 100644 --- a/src/presentation/cli/roasts.rs +++ b/src/presentation/cli/roasts.rs @@ -4,7 +4,7 @@ use clap::{Args, Subcommand}; use super::macros::{define_delete_command, define_get_command}; use super::print_json; use crate::domain::ids::{RoastId, RoasterId}; -use crate::domain::roasts::NewRoast; +use crate::domain::roasts::{NewRoast, UpdateRoast}; use crate::infrastructure::client::BrewlogClient; #[derive(Debug, Subcommand)] @@ -15,6 +15,8 @@ pub enum RoastCommands { List(ListRoastsCommand), /// Get a roast by ID Get(GetRoastCommand), + /// Update a roast + Update(UpdateRoastCommand), /// Delete a roast Delete(DeleteRoastCommand), } @@ -24,6 +26,7 @@ pub async fn run(client: &BrewlogClient, cmd: RoastCommands) -> Result<()> { RoastCommands::Add(c) => add_roast(client, c).await, RoastCommands::List(c) => list_roasts(client, c).await, RoastCommands::Get(c) => get_roast(client, c).await, + RoastCommands::Update(c) => update_roast(client, c).await, RoastCommands::Delete(c) => delete_roast(client, c).await, } } @@ -76,4 +79,43 @@ pub async fn list_roasts(client: &BrewlogClient, command: ListRoastsCommand) -> } define_get_command!(GetRoastCommand, get_roast, RoastId, roasts); + +#[derive(Debug, Args)] +pub struct UpdateRoastCommand { + #[arg(long)] + pub id: i64, + #[arg(long)] + pub roaster_id: Option, + #[arg(long)] + pub name: Option, + #[arg(long)] + pub origin: Option, + #[arg(long)] + pub region: Option, + #[arg(long)] + pub producer: Option, + #[arg(long)] + pub process: Option, + #[arg(long = "tasting-notes")] + pub tasting_notes: Option>, +} + +pub async fn update_roast(client: &BrewlogClient, command: UpdateRoastCommand) -> Result<()> { + let payload = UpdateRoast { + roaster_id: command.roaster_id.map(RoasterId::new), + name: command.name, + origin: command.origin, + region: command.region, + producer: command.producer, + tasting_notes: command.tasting_notes, + process: command.process, + }; + + let roast = client + .roasts() + .update(RoastId::new(command.id), &payload) + .await?; + print_json(&roast) +} + define_delete_command!(DeleteRoastCommand, delete_roast, RoastId, roasts, "roast"); diff --git a/tests/cli/roasts_cli.rs b/tests/cli/roasts_cli.rs index cec0a52..607e783 100644 --- a/tests/cli/roasts_cli.rs +++ b/tests/cli/roasts_cli.rs @@ -1,4 +1,4 @@ -use crate::helpers::{create_roaster, create_token, run_brewlog, server_info}; +use crate::helpers::{create_roast, create_roaster, create_token, run_brewlog, server_info}; use serde_json::Value; #[test] @@ -156,6 +156,47 @@ fn test_list_roasts_shows_added_roast() { ); } +#[test] +fn test_update_roast_requires_authentication() { + let _ = server_info(); + + let output = run_brewlog( + &["roast", "update", "--id", "123", "--name", "Updated"], + &[], + ); + + assert!( + !output.status.success(), + "roast update without auth should fail" + ); +} + +#[test] +fn test_update_roast_with_authentication() { + let token = create_token("test-update-roast"); + + // Setup: create roaster and roast + let roaster_id = create_roaster("Update Roast Roaster", &token); + let roast_id = create_roast(&roaster_id, "Original Name", &token); + + // Update the roast + let output = run_brewlog( + &[ + "roast", + "update", + "--id", + &roast_id, + "--name", + "Updated Name", + ], + &[("BREWLOG_TOKEN", &token)], + ); + + assert!(output.status.success()); + let updated_roast: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(updated_roast["name"], "Updated Name"); +} + #[test] fn test_delete_roast_requires_authentication() { let _ = server_info();