//! `GET` and `PUT /api/review-classifier/settings`: the runtime, model and //! effort every run uses, and how many requested reviews run at once, for the //! whole swarm. //! //! The settings are one document under one key ([`KEY`] in [`BUCKET`]), //! which only this daemon reads or writes, as the schedule definitions are. //! No document is [`Settings::default`]. A try-out reads them when it starts //! and a review when it gets its place, and each sends the runtime, model and //! effort in its run request, so a change applies to the next run with no //! restart. A `PUT` changes only the fields its body names. use std::future::Future; use std::ops::RangeInclusive; use anyhow::Context as _; use async_nats::jetstream::kv::Store; use axum::Json; use axum::extract::State; use axum::extract::rejection::JsonRejection; use axum::http::{HeaderMap, StatusCode}; use problem_details::ProblemDetails; use serde::{Deserialize, Serialize}; use swarm_queue_client::agent_inference::Effort; use swarm_queue_client::review_classifier::{Acp, Runtime}; use utoipa::ToSchema; use crate::schedules::{queue, read_with, refused, write}; use crate::{AppState, error_problem, wanted_error_status}; /// The bucket holding the settings. No other service holds a grant on it. pub const BUCKET: &str = "review-classifier-settings"; /// The one key in [`BUCKET`]. pub const KEY: &str = "all"; /// How many times a losing writer re-reads and re-applies, as in /// `wanted::MAX_ATTEMPTS`. const MAX_ATTEMPTS: usize = 5; /// What `max_concurrent_reviews` may be. Each running review is one `claude` /// at a time in the classifier's container, which has no bound of its own. pub(crate) const CONCURRENT_REVIEWS: RangeInclusive = 1..=8; /// The review classifier's settings: the value under [`KEY`], and what both /// routes answer. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Settings { /// The model every classification on the API key runtime runs on, as /// claude's `--model` takes it: one word, not starting with `-`. pub(crate) model: String, /// One of `low`, `medium`, `high`, `xhigh`, `max`; `null` runs at the /// runtime's own default. An ACP agent gets it only where it offers it. #[serde(default)] #[schema(value_type = Option)] pub(crate) effort: Option, /// How many requested reviews run at once, 1 to 8. Try-outs don't count. pub(crate) max_concurrent_reviews: usize, /// What every classification runs on. #[serde(default)] pub(crate) runtime: RuntimeKind, /// The provider the `acp` runtime runs on. Kept while the runtime is /// `api-key`, so switching back and forth needs no re-entry. #[serde(default)] pub(crate) acp: Option, } /// Which runtime the classifications run on. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "kebab-case")] pub(crate) enum RuntimeKind { /// `claude` on the classifier's Anthropic API key. #[default] ApiKey, /// An ACP agent on the classifier's ACP provider key, at [`Settings::acp`]. Acp, } impl Default for Settings { fn default() -> Self { Self { model: swarm_queue_client::review_classifier::DEFAULT_MODEL.to_owned(), effort: None, max_concurrent_reviews: 2, runtime: RuntimeKind::ApiKey, acp: None, } } } impl Settings { /// What is wrong with these settings, if anything. fn check(&self) -> Result<(), String> { crate::inference::check_model(&self.model, "model")?; if !CONCURRENT_REVIEWS.contains(&self.max_concurrent_reviews) { return Err(format!( "max_concurrent_reviews {} is not {} to {}", self.max_concurrent_reviews, CONCURRENT_REVIEWS.start(), CONCURRENT_REVIEWS.end() )); } if let Some(acp) = &self.acp { check_acp(acp)?; } self.runtime().map(|_| ()) } /// The runtime a run request carries. An `acp` runtime with no provider /// is an error rather than the API key runtime, which would send the PR /// to a provider nobody chose. pub(crate) fn runtime(&self) -> Result { match (self.runtime, &self.acp) { (RuntimeKind::ApiKey, _) => Ok(Runtime::ApiKey), (RuntimeKind::Acp, Some(acp)) => Ok(Runtime::Acp(acp.clone())), (RuntimeKind::Acp, None) => { Err("the runtime is `acp` but no `acp` provider is set".to_owned()) } } } } /// What is wrong with `acp`, if anything. fn check_acp(acp: &Acp) -> Result<(), String> { let url = url::Url::parse(&acp.base_url) .map_err(|e| format!("acp.base_url {:?} is not a URL: {e}", acp.base_url))?; if url.scheme() != "https" { return Err(format!("acp.base_url {:?} is not https://", acp.base_url)); } if acp.model.is_empty() || acp .model .chars() .any(|c| c.is_whitespace() || c.is_control()) { return Err(format!( "acp.model {:?} is empty or has whitespace", acp.model )); } let prices = &acp.prices; for (name, price) in [ ("input", Some(prices.input)), ("output", Some(prices.output)), ("cache_read", prices.cache_read), ("cache_write", prices.cache_write), ] { if price.is_some_and(|p| !p.is_finite() || p < 0.0) { return Err(format!("acp.prices.{name} is not a price of 0 or more")); } } Ok(()) } /// The body of a `PUT`: any of the fields of [`Settings`]. A field left out /// keeps its stored value; `null` sets `effort` or `acp` to none. #[derive(Debug, Clone, Default, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] pub(crate) struct SettingsUpdate { #[serde(default, deserialize_with = "present")] #[schema(value_type = Option)] model: Option, #[serde(default)] #[schema(value_type = Option, nullable)] effort: Nullable, #[serde(default, deserialize_with = "present")] #[schema(value_type = Option)] max_concurrent_reviews: Option, #[serde(default, deserialize_with = "present")] #[schema(value_type = Option)] runtime: Option, #[serde(default)] #[schema(value_type = Option, nullable)] acp: Nullable, } /// A field of a [`SettingsUpdate`] that may be `null`. #[derive(Debug, Clone, Default, PartialEq)] enum Nullable { /// Left out of the body. #[default] Absent, /// In the body, `None` for `null`. Present(Option), } impl<'de, T: Deserialize<'de>> Deserialize<'de> for Nullable { fn deserialize>(value: D) -> Result { Option::::deserialize(value).map(Self::Present) } } impl Nullable { /// The value in the body, else `stored`. fn or(self, stored: Option) -> Option { match self { Self::Absent => stored, Self::Present(value) => value, } } } /// A field that is in the body, as its own type allows it: `null` only where /// that type is an `Option`. fn present<'de, T: Deserialize<'de>, D: serde::Deserializer<'de>>( value: D, ) -> Result, D::Error> { T::deserialize(value).map(Some) } impl SettingsUpdate { /// `stored` with this update's fields in place of its own. fn apply(self, stored: Settings) -> Settings { Settings { model: self.model.unwrap_or(stored.model), effort: self.effort.or(stored.effort), max_concurrent_reviews: self .max_concurrent_reviews .unwrap_or(stored.max_concurrent_reviews), runtime: self.runtime.unwrap_or(stored.runtime), acp: self.acp.or(stored.acp), } } } /// Where a run reads the settings: the queue in production, a fake in the /// tests. pub(crate) trait SettingsSource { fn settings(&self) -> impl Future> + Send; } impl SettingsSource for async_nats::Client { async fn settings(&self) -> anyhow::Result { let (settings, _) = stored(&open(self).await?) .await .context("reading the review classifier's settings")?; Ok(settings) } } /// The stored settings, with the revision to write them back against. async fn stored(store: &Store) -> anyhow::Result<(Settings, Option)> { read_with(store, KEY, |bytes| Ok(decode(bytes))).await } /// Every field of [`Settings`]. `a_full_document_round_trips` fails when one /// is missing. const FIELDS: &[&str] = &[ "model", "effort", "max_concurrent_reviews", "runtime", "acp", ]; /// `bytes`, the stored document, as settings. Unlike a `PUT` body, a field /// not in [`FIELDS`], such as one a newer build added, is ignored; a document /// that still does not decode is [`Settings::default`], with a warning, /// until the next `PUT` replaces it. fn decode(bytes: &[u8]) -> Settings { let decoded = serde_json::from_slice(bytes).and_then(|mut value: serde_json::Value| { if let Some(fields) = value.as_object_mut() { fields.retain(|name, _| FIELDS.contains(&name.as_str())); } serde_json::from_value(value) }); decoded.unwrap_or_else(|e| { tracing::warn!( bucket = BUCKET, key = KEY, error = %e, "the review classifier settings are not decodable; using the defaults" ); Settings::default() }) } /// Open [`BUCKET`], creating it if nothing has yet. async fn open(client: &async_nats::Client) -> anyhow::Result { let js = async_nats::jetstream::new(client.clone()); if let Ok(store) = js.get_key_value(BUCKET).await { return Ok(store); } js.create_key_value(async_nats::jetstream::kv::Config { bucket: BUCKET.to_owned(), description: "The review classifier's swarm-wide settings".to_owned(), history: 1, ..Default::default() }) .await .with_context(|| format!("creating the {BUCKET} bucket")) } fn queue_problem(e: &anyhow::Error) -> ProblemDetails { tracing::warn!(error = %format!("{e:#}"), "review classifier settings: queue failure"); error_problem(wanted_error_status(e), &format!("{e:#}")) } /// Read the settings. #[utoipa::path( get, path = "/api/review-classifier/settings", responses( (status = 200, description = "the settings in force; with none stored, \ `{\"model\":\"haiku\",\"effort\":null,\"max_concurrent_reviews\":2,\ \"runtime\":\"api-key\",\"acp\":null}`", body = Settings), (status = 503, description = "no swarm queue is configured on this host, or it is \ not connected (problem+json)", body = String), (status = 500, description = "the settings could not be read (problem+json)", body = String), ), tag = "review-classifier" )] pub(crate) async fn get_settings( State(state): State, ) -> Result, ProblemDetails> { let client = queue(&state).map_err(refused)?; client .settings() .await .map(Json) .map_err(|e| queue_problem(&e)) } /// Change the settings. #[utoipa::path( put, path = "/api/review-classifier/settings", request_body = SettingsUpdate, responses( (status = 200, description = "the settings as stored: the body's fields over the \ stored ones, which apply from the next run", body = Settings), (status = 400, description = "not JSON or not this shape: an unknown field, a \ `null` other than for `effort` or `acp`, an effort other than the five \ levels, a negative or non-integer `max_concurrent_reviews`, a runtime other \ than `api-key` or `acp`, an `acp` without `base_url`, `model` or `prices`; or, \ once applied, a model that is empty, has whitespace or starts with `-`, \ `max_concurrent_reviews` outside 1 to 8, an `acp.base_url` that is not \ https://, an empty `acp.model` or one with whitespace, a negative price, or \ the `acp` runtime with no `acp` provider (problem+json)", body = String), (status = 401, description = "the request carries no `Remote-User`, so there is no \ operator to name (problem+json)", body = String), (status = 503, description = "no swarm queue is configured on this host, or it is \ not connected (problem+json)", body = String), (status = 500, description = "the settings could not be written (problem+json)", body = String), ), tag = "review-classifier" )] pub(crate) async fn put_settings( State(state): State, headers: HeaderMap, body: Result, JsonRejection>, ) -> Result, ProblemDetails> { let operator = super::operator(&headers).ok_or_else(|| { error_problem( StatusCode::UNAUTHORIZED, "no Remote-User header: the gateway names the operator, and this request did not \ come through it", ) })?; let Json(update) = body.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.body_text()))?; let client = queue(&state).map_err(refused)?; let store = open(&client).await.map_err(|e| queue_problem(&e))?; for _ in 0..MAX_ATTEMPTS { let (current, revision) = stored(&store).await.map_err(|e| queue_problem(&e))?; let value = update.clone().apply(current); value .check() .map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?; if write(&store, KEY, &value, revision) .await .map_err(|e| queue_problem(&e))? { state.reviews.resize(value.max_concurrent_reviews); tracing::info!(%operator, ?value, "set the review classifier's settings"); return Ok(Json(value)); } } Err(queue_problem(&anyhow::anyhow!( "gave up writing the review classifier's settings after {MAX_ATTEMPTS} conflicting \ writes" ))) } #[cfg(test)] mod tests { use axum::body::Body; use axum::http::Request; use tower::ServiceExt as _; use super::*; use crate::test_nats::TestNats; fn settings(json: &str) -> serde_json::Result { serde_json::from_str(json) } fn update(json: &str) -> serde_json::Result { serde_json::from_str(json) } /// A valid ACP provider with every price set. fn provider() -> Acp { Acp { base_url: "https://llm.example/v1".to_owned(), model: "Qwen/Qwen3-35B".to_owned(), prices: swarm_queue_client::review_classifier::Prices { input: 0.5, output: 2.0, cache_read: Some(0.05), cache_write: Some(0.6), }, } } #[test] fn no_document_is_todays_values() { assert_eq!( serde_json::to_value(Settings::default()).unwrap(), serde_json::json!({ "model": "haiku", "effort": null, "max_concurrent_reviews": 2, "runtime": "api-key", "acp": null, }) ); assert_eq!(Settings::default().runtime(), Ok(Runtime::ApiKey)); } /// Every field set, every `Option` `Some`: a stored document keeps all of /// them, so a field missing from [`FIELDS`] fails here. #[test] fn a_full_document_round_trips() { let full = Settings { model: "sonnet".to_owned(), effort: Some(Effort::High), max_concurrent_reviews: 5, runtime: RuntimeKind::Acp, acp: Some(provider()), }; let encoded = serde_json::to_value(&full).unwrap(); let mut keys: Vec<_> = encoded.as_object().unwrap().keys().collect(); let mut fields = FIELDS.to_vec(); keys.sort_unstable(); fields.sort_unstable(); assert_eq!(keys, fields); assert_eq!(decode(&serde_json::to_vec(&full).unwrap()), full); } #[test] fn bad_settings_are_refused() { for json in [ r#"{"model":"","max_concurrent_reviews":2}"#, r#"{"model":"opus 4","max_concurrent_reviews":2}"#, r#"{"model":"--print","max_concurrent_reviews":2}"#, r#"{"model":"haiku","max_concurrent_reviews":0}"#, r#"{"model":"haiku","max_concurrent_reviews":9}"#, ] { assert!(settings(json).unwrap().check().is_err(), "{json}"); } // Control: the bounds themselves, and a full model id. for json in [ r#"{"model":"claude-haiku-4-5","max_concurrent_reviews":1}"#, r#"{"model":"haiku","effort":"max","max_concurrent_reviews":8}"#, ] { assert!(settings(json).unwrap().check().is_ok(), "{json}"); } } #[test] fn a_bad_acp_provider_is_refused() { let with = |change: fn(&mut Acp)| { let mut acp = provider(); change(&mut acp); Settings { runtime: RuntimeKind::Acp, acp: Some(acp), ..Settings::default() } }; for (why, bad) in [ ( "http", with(|a| a.base_url = "http://llm.example/v1".to_owned()), ), ("no URL", with(|a| a.base_url = "llm.example".to_owned())), ( "ftp", with(|a| a.base_url = "ftp://llm.example/v1".to_owned()), ), ("empty model", with(|a| a.model = String::new())), ( "model with a space", with(|a| a.model = "qwen 3".to_owned()), ), ("negative input", with(|a| a.prices.input = -1.0)), ("negative output", with(|a| a.prices.output = -1.0)), ( "negative cache read", with(|a| a.prices.cache_read = Some(-0.1)), ), ( "negative cache write", with(|a| a.prices.cache_write = Some(-0.1)), ), ("infinite", with(|a| a.prices.input = f64::INFINITY)), ] { assert!(bad.check().is_err(), "{why}"); } // Control: the valid provider, and one with free tokens and no cache // prices. assert!(with(|_| ()).check().is_ok()); let free = with(|a| { a.prices.input = 0.0; a.prices.cache_read = None; a.prices.cache_write = None; }); assert!(free.check().is_ok()); assert_eq!(free.runtime(), Ok(Runtime::Acp(free.acp.clone().unwrap()))); } #[test] fn the_acp_runtime_without_a_provider_is_refused_not_run_on_the_api_key() { let missing = Settings { runtime: RuntimeKind::Acp, ..Settings::default() }; assert!(missing.runtime().is_err()); assert!(missing.check().is_err()); // Control: a provider kept while the runtime is the API key's is // fine, and runs on the API key. let kept = Settings { acp: Some(provider()), ..Settings::default() }; assert!(kept.check().is_ok()); assert_eq!(kept.runtime(), Ok(Runtime::ApiKey)); } #[test] fn a_bad_shape_does_not_decode() { for json in [ r#"{"model":null}"#, r#"{"max_concurrent_reviews":null}"#, r#"{"runtime":null}"#, r#"{"effort":"extreme"}"#, r#"{"effort":"HIGH"}"#, r#"{"max_concurrent_reviews":-1}"#, r#"{"max_concurrent_reviews":2.5}"#, r#"{"api_key":"x"}"#, r#"{"runtime":"subscription"}"#, r#"{"acp":{"base_url":"https://x","model":"m"}}"#, r#"{"acp":{"base_url":"https://x","model":"m","prices":{"input":1}}}"#, r#"{"acp":{"base_url":"https://x","model":"m","prices":{"input":1,"output":1},"api_key":"x"}}"#, ] { assert!(update(json).is_err(), "{json}"); } // Control: every field may be left out, and `effort` and `acp` may be // null. let empty = update("{}").unwrap(); assert_eq!(empty.apply(Settings::default()), Settings::default()); let cleared = update(r#"{"effort":null,"acp":null}"#).unwrap(); assert_eq!( (cleared.effort, cleared.acp), (Nullable::Present(None), Nullable::Present(None)), "null is a value, not a field left out" ); } #[test] fn an_update_changes_only_the_fields_it_names() { let stored = Settings { model: "sonnet".to_owned(), effort: Some(Effort::High), max_concurrent_reviews: 3, runtime: RuntimeKind::Acp, acp: Some(provider()), }; let model_only = update(r#"{"model":"opus"}"#).unwrap().apply(stored.clone()); assert_eq!( model_only, Settings { model: "opus".to_owned(), ..stored.clone() } ); let cleared = update(r#"{"effort":null,"runtime":"api-key","acp":null}"#) .unwrap() .apply(stored.clone()); assert_eq!( cleared, Settings { effort: None, runtime: RuntimeKind::ApiKey, acp: None, ..stored } ); } /// An app whose queue is `nats`. async fn app(nats: &TestNats) -> (axum::Router, AppState) { let (mut state, _) = crate::tests::state_with_roster(); state.status = Some(std::sync::Arc::new(crate::status::StatusReader::new( nats.client().await, std::time::Duration::from_mins(1), ))); (crate::build_app(state.clone()), state) } async fn send( app: &axum::Router, req: Request, ) -> (StatusCode, serde_json::Value, Option) { let res = app.clone().oneshot(req).await.expect("infallible"); let status = res.status(); let kind = res .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .map(str::to_owned); let bytes = axum::body::to_bytes(res.into_body(), usize::MAX) .await .expect("a body"); ( status, serde_json::from_slice(&bytes).unwrap_or_default(), kind, ) } fn get() -> Request { Request::get("/api/review-classifier/settings") .body(Body::empty()) .expect("a request") } fn put(body: &str) -> Request { Request::put("/api/review-classifier/settings") .header("content-type", "application/json") .header("remote-user", "alice") .body(Body::from(body.to_owned())) .expect("a request") } /// A stored field this build does not know is ignored, and a document /// that still does not decode reads as the defaults; either way the GET /// and a run's read keep working, and a PUT, even of the defaults, /// replaces it with one that decodes. #[tokio::test] async fn an_undecodable_document_reads_as_the_defaults_until_a_put() { let nats = TestNats::start(); let (app, _) = app(&nats).await; let client = nats.client().await; let store = open(&client).await.expect("the bucket"); let defaults = serde_json::to_value(Settings::default()).unwrap(); let newer = serde_json::json!({ "model": "sonnet", "effort": "high", "max_concurrent_reviews": 3, "later": true, }); store .put(KEY, newer.to_string().into()) .await .expect("written"); let (status, read, _) = send(&app, get()).await; assert_eq!(status, StatusCode::OK, "{read}"); assert_eq!( read, serde_json::json!({ "model": "sonnet", "effort": "high", "max_concurrent_reviews": 3, "runtime": "api-key", "acp": null, }) ); store .put( KEY, br#"{"model":"sonnet","effort":"later"}"#.to_vec().into(), ) .await .expect("written"); let (status, read, _) = send(&app, get()).await; assert_eq!(status, StatusCode::OK, "{read}"); assert_eq!(read, defaults); assert_eq!( client.settings().await.expect("readable"), Settings::default() ); let (status, stored, _) = send(&app, put(&defaults.to_string())).await; assert_eq!(status, StatusCode::OK, "{stored}"); let entry = store.get(KEY).await.expect("readable").expect("a document"); assert_eq!( serde_json::from_slice::(&entry).expect("decodes strictly"), Settings::default() ); } #[tokio::test] async fn a_put_is_what_the_get_and_the_next_run_read() { let nats = TestNats::start(); let (app, state) = app(&nats).await; let (status, before, _) = send(&app, get()).await; assert_eq!(status, StatusCode::OK, "{before}"); assert_eq!(before, serde_json::to_value(Settings::default()).unwrap()); let wanted = serde_json::json!({ "model": "sonnet", "effort": "high", "max_concurrent_reviews": 5, "runtime": "api-key", "acp": null, }); let (status, stored, _) = send(&app, put(&wanted.to_string())).await; assert_eq!(status, StatusCode::OK, "{stored}"); assert_eq!(stored, wanted); let (_, after, _) = send(&app, get()).await; assert_eq!(after, wanted); assert_eq!(state.reviews.limit(), 5); let client = nats.client().await; let read = client.settings().await.expect("readable"); assert_eq!( (read.model.as_str(), read.effort), ("sonnet", Some(Effort::High)) ); // The same value again is no conflict. let (status, _, _) = send(&app, put(&wanted.to_string())).await; assert_eq!(status, StatusCode::OK); } /// The swarm UI's settings form PUTs the model, effort and limit alone; /// that save keeps the runtime and the provider. #[tokio::test] async fn a_put_without_the_runtime_keeps_the_stored_one() { let nats = TestNats::start(); let (app, _) = app(&nats).await; let acp = serde_json::to_value(provider()).unwrap(); let switch = serde_json::json!({ "runtime": "acp", "acp": acp }); let (status, stored, _) = send(&app, put(&switch.to_string())).await; assert_eq!(status, StatusCode::OK, "{stored}"); assert_eq!(stored["model"], "haiku", "the rest kept its defaults"); let form = r#"{"model":"sonnet","effort":null,"max_concurrent_reviews":4}"#; let (status, stored, _) = send(&app, put(form)).await; assert_eq!(status, StatusCode::OK, "{stored}"); let (_, after, _) = send(&app, get()).await; assert_eq!( after, serde_json::json!({ "model": "sonnet", "effort": null, "max_concurrent_reviews": 4, "runtime": "acp", "acp": acp, }) ); let read = nats.client().await.settings().await.expect("readable"); assert_eq!(read.runtime(), Ok(Runtime::Acp(provider()))); } #[tokio::test] async fn a_refused_put_is_a_problem_and_changes_nothing() { let nats = TestNats::start(); let (app, state) = app(&nats).await; for bad in [ "not json", r#"{"model":null}"#, r#"{"model":"haiku","effort":"extreme","max_concurrent_reviews":2}"#, r#"{"model":"-x","max_concurrent_reviews":2}"#, r#"{"model":"haiku","max_concurrent_reviews":9}"#, r#"{"runtime":"acp"}"#, r#"{"acp":{"base_url":"http://x","model":"m","prices":{"input":1,"output":1}}}"#, ] { let (status, problem, kind) = send(&app, put(bad)).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{bad}: {problem}"); assert!(problem["detail"].is_string(), "{bad}: {problem}"); assert_eq!(kind.as_deref(), Some("application/problem+json"), "{bad}"); } let (_, after, _) = send(&app, get()).await; assert_eq!(after, serde_json::to_value(Settings::default()).unwrap()); assert_eq!(state.reviews.limit(), 2); } #[tokio::test] async fn a_put_without_an_operator_is_401() { let (state, _) = crate::tests::state_with_roster(); let req = Request::put("/api/review-classifier/settings") .header("content-type", "application/json") .body(Body::from( serde_json::to_string(&Settings::default()).unwrap(), )) .expect("a request"); let (status, _, _) = send(&crate::build_app(state), req).await; assert_eq!(status, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn without_a_queue_both_verbs_are_503() { let (state, _) = crate::tests::state_with_roster(); let app = crate::build_app(state); let (status, _, _) = send(&app, get()).await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); let body = serde_json::to_string(&Settings::default()).unwrap(); let (status, _, _) = send(&app, put(&body)).await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); } }