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:
parent
0090c4ba43
commit
00477bdbc0
5 changed files with 137 additions and 5 deletions
|
|
@ -41,7 +41,9 @@ pub fn app_router(state: AppState) -> axum::Router {
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/roasts/:id",
|
"/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("/bags", get(bags::list_bags).post(bags::create_bag))
|
||||||
.route(
|
.route(
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ use crate::domain::bags::{BagFilter, BagSortKey};
|
||||||
use crate::domain::ids::{RoastId, RoasterId};
|
use crate::domain::ids::{RoastId, RoasterId};
|
||||||
use crate::domain::listing::{ListRequest, SortDirection};
|
use crate::domain::listing::{ListRequest, SortDirection};
|
||||||
use crate::domain::roasters::RoasterSortKey;
|
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::{
|
use crate::presentation::web::templates::{
|
||||||
RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate,
|
RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate,
|
||||||
};
|
};
|
||||||
|
|
@ -227,6 +227,40 @@ define_delete_handler!(
|
||||||
render_roast_list_fragment
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RoastsQuery {
|
pub struct RoastsQuery {
|
||||||
pub roaster_id: Option<String>,
|
pub roaster_id: Option<String>,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use anyhow::{Context, Result};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
use crate::domain::ids::{RoastId, RoasterId};
|
use crate::domain::ids::{RoastId, RoasterId};
|
||||||
use crate::domain::roasts::{NewRoast, RoastWithRoaster};
|
use crate::domain::roasts::{NewRoast, RoastWithRoaster, UpdateRoast};
|
||||||
|
|
||||||
use super::BrewlogClient;
|
use super::BrewlogClient;
|
||||||
|
|
||||||
|
|
@ -57,6 +57,19 @@ impl<'a> RoastsClient<'a> {
|
||||||
self.inner.handle_response(response).await
|
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<()> {
|
pub async fn delete(&self, id: RoastId) -> Result<()> {
|
||||||
let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?;
|
let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?;
|
||||||
let response = self
|
let response = self
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use clap::{Args, Subcommand};
|
||||||
use super::macros::{define_delete_command, define_get_command};
|
use super::macros::{define_delete_command, define_get_command};
|
||||||
use super::print_json;
|
use super::print_json;
|
||||||
use crate::domain::ids::{RoastId, RoasterId};
|
use crate::domain::ids::{RoastId, RoasterId};
|
||||||
use crate::domain::roasts::NewRoast;
|
use crate::domain::roasts::{NewRoast, UpdateRoast};
|
||||||
use crate::infrastructure::client::BrewlogClient;
|
use crate::infrastructure::client::BrewlogClient;
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
|
|
@ -15,6 +15,8 @@ pub enum RoastCommands {
|
||||||
List(ListRoastsCommand),
|
List(ListRoastsCommand),
|
||||||
/// Get a roast by ID
|
/// Get a roast by ID
|
||||||
Get(GetRoastCommand),
|
Get(GetRoastCommand),
|
||||||
|
/// Update a roast
|
||||||
|
Update(UpdateRoastCommand),
|
||||||
/// Delete a roast
|
/// Delete a roast
|
||||||
Delete(DeleteRoastCommand),
|
Delete(DeleteRoastCommand),
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +26,7 @@ pub async fn run(client: &BrewlogClient, cmd: RoastCommands) -> Result<()> {
|
||||||
RoastCommands::Add(c) => add_roast(client, c).await,
|
RoastCommands::Add(c) => add_roast(client, c).await,
|
||||||
RoastCommands::List(c) => list_roasts(client, c).await,
|
RoastCommands::List(c) => list_roasts(client, c).await,
|
||||||
RoastCommands::Get(c) => get_roast(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,
|
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);
|
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");
|
define_delete_command!(DeleteRoastCommand, delete_roast, RoastId, roasts, "roast");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
use serde_json::Value;
|
||||||
|
|
||||||
#[test]
|
#[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]
|
#[test]
|
||||||
fn test_delete_roast_requires_authentication() {
|
fn test_delete_roast_requires_authentication() {
|
||||||
let _ = server_info();
|
let _ = server_info();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue