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.
This commit is contained in:
Jon Seager 2026-02-02 17:09:32 +00:00
parent 0090c4ba43
commit 00477bdbc0
No known key found for this signature in database
5 changed files with 137 additions and 5 deletions

View file

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

View file

@ -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<AppState>,
_auth_user: AuthenticatedUser,
Path(id): Path<RoastId>,
Json(payload): Json<UpdateRoast>,
) -> Result<Json<RoastWithRoaster>, 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<String>,

View file

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

View file

@ -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<i64>,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub origin: Option<String>,
#[arg(long)]
pub region: Option<String>,
#[arg(long)]
pub producer: Option<String>,
#[arg(long)]
pub process: Option<String>,
#[arg(long = "tasting-notes")]
pub tasting_notes: Option<Vec<String>>,
}
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");

View file

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