1use axum::extract::State;
4use axum::routing::get;
5use axum::{Json, Router};
6use bhtune_cli::config::{
7 ConfigPolicyUpdate, ConfigStoreError, LoadedConfigStore, TuningConfig, TuningConfigSource,
8 resolve_retention_days, resolve_tuning_config, validate_tuning_config,
9};
10use serde::{Deserialize, Serialize};
11use utoipa::ToSchema;
12
13use crate::error::{ApiError, ErrorBody};
14use crate::state::AppState;
15
16#[derive(Debug, Clone, Serialize, ToSchema)]
17pub struct ConfigValues {
18 pub allow_uncertain_quality: bool,
19 #[schema(minimum = 1)]
20 pub retention_days: Option<u32>,
21 pub tuning: ConfigTuningValues,
22}
23
24#[derive(Debug, Clone, Serialize, ToSchema)]
25pub struct ConfigTuningValues {
26 pub mrft_delay_secs: u32,
27 pub poll_interval_ms: u64,
28 pub timeout_secs: u64,
29 pub op_timeout_secs: u64,
30 pub restore_timeout_secs: u64,
31}
32
33#[derive(Debug, Clone, Serialize, ToSchema)]
34pub struct ConfigSources {
35 pub allow_uncertain_quality: String,
36 pub retention_days: String,
37 pub tuning: ConfigTuningSources,
38}
39
40#[derive(Debug, Clone, Serialize, ToSchema)]
41pub struct ConfigTuningSources {
42 pub mrft_delay_secs: String,
43 pub poll_interval_ms: String,
44 pub timeout_secs: String,
45 pub op_timeout_secs: String,
46 pub restore_timeout_secs: String,
47}
48
49#[derive(Debug, Clone, Serialize, ToSchema)]
50pub struct ConfigTomlValues {
51 pub allow_uncertain_quality: Option<bool>,
52 #[schema(minimum = 1)]
53 pub retention_days: Option<u32>,
54 pub tuning: ConfigTuningTomlValues,
55}
56
57#[derive(Debug, Clone, Serialize, ToSchema)]
58pub struct ConfigTuningTomlValues {
59 #[schema(maximum = 3600)]
60 pub mrft_delay_secs: Option<u32>,
61 #[schema(minimum = 1)]
62 pub poll_interval_ms: Option<u64>,
63 #[schema(minimum = 1)]
64 pub timeout_secs: Option<u64>,
65 #[schema(minimum = 1)]
66 pub op_timeout_secs: Option<u64>,
67 #[schema(minimum = 1)]
68 pub restore_timeout_secs: Option<u64>,
69}
70
71#[derive(Debug, Clone, Serialize, ToSchema)]
72pub struct ConfigResponse {
73 pub revision: String,
74 pub config_path: String,
75 pub toml: ConfigTomlValues,
76 pub effective: ConfigValues,
77 pub source: ConfigSources,
78 pub backup_path: Option<String>,
79}
80
81#[derive(Debug, Clone, Deserialize, ToSchema)]
82pub struct UpdateConfigRequest {
83 pub revision: String,
84 pub allow_uncertain_quality: bool,
85 #[schema(minimum = 1)]
86 pub retention_days: Option<u32>,
87 pub tuning: Option<UpdateTuningRequest>,
94}
95
96#[derive(Debug, Clone, Deserialize, ToSchema)]
97pub struct UpdateTuningRequest {
98 #[schema(maximum = 3600)]
99 pub mrft_delay_secs: Option<u32>,
100 #[schema(minimum = 1)]
101 pub poll_interval_ms: Option<u64>,
102 #[schema(minimum = 1)]
103 pub timeout_secs: Option<u64>,
104 #[schema(minimum = 1)]
105 pub op_timeout_secs: Option<u64>,
106 #[schema(minimum = 1)]
107 pub restore_timeout_secs: Option<u64>,
108}
109
110impl From<UpdateTuningRequest> for TuningConfig {
111 fn from(request: UpdateTuningRequest) -> Self {
112 Self {
113 mrft_delay_secs: request.mrft_delay_secs,
114 poll_interval_ms: request.poll_interval_ms,
115 timeout_secs: request.timeout_secs,
116 op_timeout_secs: request.op_timeout_secs,
117 restore_timeout_secs: request.restore_timeout_secs,
118 }
119 }
120}
121
122fn tuning_source_label(source: TuningConfigSource) -> String {
123 match source {
124 TuningConfigSource::Toml => "config_file",
125 TuningConfigSource::BuiltInDefault => "default",
126 }
127 .to_string()
128}
129
130fn response_from_store(store: &LoadedConfigStore, backup_path: Option<String>) -> ConfigResponse {
131 let env_retention = std::env::var("BHTUNE_RETENTION_DAYS")
132 .ok()
133 .and_then(|value| value.parse().ok());
134 response_from_store_with_retention(store, backup_path, env_retention)
135}
136
137fn response_from_store_with_retention(
138 store: &LoadedConfigStore,
139 backup_path: Option<String>,
140 env_retention: Option<u32>,
141) -> ConfigResponse {
142 let effective_retention = resolve_retention_days(env_retention, &store.config);
143 let effective_tuning = resolve_tuning_config(&store.toml_tuning);
144 let sources = ConfigSources {
145 allow_uncertain_quality: if store.toml_allow_uncertain_quality.is_some() {
146 "config_file".to_string()
147 } else {
148 "default".to_string()
149 },
150 retention_days: if env_retention.is_some() {
151 "environment".to_string()
152 } else if store.config.retention_days.is_some() {
153 "config_file".to_string()
154 } else {
155 "default".to_string()
156 },
157 tuning: ConfigTuningSources {
158 mrft_delay_secs: tuning_source_label(store.tuning_sources.mrft_delay_secs),
159 poll_interval_ms: tuning_source_label(store.tuning_sources.poll_interval_ms),
160 timeout_secs: tuning_source_label(store.tuning_sources.timeout_secs),
161 op_timeout_secs: tuning_source_label(store.tuning_sources.op_timeout_secs),
162 restore_timeout_secs: tuning_source_label(store.tuning_sources.restore_timeout_secs),
163 },
164 };
165 let effective = ConfigValues {
166 allow_uncertain_quality: store.config.allow_uncertain_quality,
167 retention_days: effective_retention,
168 tuning: ConfigTuningValues {
169 mrft_delay_secs: effective_tuning.mrft_delay_secs,
170 poll_interval_ms: effective_tuning.poll_interval_ms,
171 timeout_secs: effective_tuning.timeout_secs,
172 op_timeout_secs: effective_tuning.op_timeout_secs,
173 restore_timeout_secs: effective_tuning.restore_timeout_secs,
174 },
175 };
176 ConfigResponse {
177 revision: store.revision.clone(),
178 config_path: store
179 .path
180 .as_deref()
181 .map(|path| path.display().to_string())
182 .unwrap_or_default(),
183 toml: ConfigTomlValues {
184 allow_uncertain_quality: store.toml_allow_uncertain_quality,
185 retention_days: store.config.retention_days,
186 tuning: ConfigTuningTomlValues {
187 mrft_delay_secs: store.toml_tuning.mrft_delay_secs,
188 poll_interval_ms: store.toml_tuning.poll_interval_ms,
189 timeout_secs: store.toml_tuning.timeout_secs,
190 op_timeout_secs: store.toml_tuning.op_timeout_secs,
191 restore_timeout_secs: store.toml_tuning.restore_timeout_secs,
192 },
193 },
194 source: sources,
195 effective,
196 backup_path,
197 }
198}
199
200fn map_store_error(error: ConfigStoreError) -> ApiError {
201 match error {
202 ConfigStoreError::Conflict { message, .. } => ApiError::Conflict(message),
203 other => ApiError::Internal(anyhow::anyhow!(other.to_string())),
204 }
205}
206
207#[utoipa::path(
208 get,
209 path = "/api/config",
210 tag = "config",
211 responses(
212 (status = 200, description = "The TOML and effective global configuration.", body = ConfigResponse),
213 (status = 500, description = "The configuration store could not be read.", body = ErrorBody),
214 ),
215)]
216pub(crate) async fn get_config(
217 State(state): State<AppState>,
218) -> Result<Json<ConfigResponse>, ApiError> {
219 let store = state
220 .config_store
221 .read()
222 .map_err(|_| ApiError::Internal(anyhow::anyhow!("configuration store lock is poisoned")))?;
223 Ok(Json(response_from_store(&store, None)))
224}
225
226#[utoipa::path(
227 put,
228 path = "/api/config",
229 tag = "config",
230 request_body = UpdateConfigRequest,
231 responses(
232 (status = 200, description = "The configuration was saved.", body = ConfigResponse),
233 (status = 400, description = "The request contains an invalid retention or tuning policy.", body = ErrorBody),
234 (status = 409, description = "The supplied revision is stale or the file changed on disk.", body = ErrorBody),
235 (status = 500, description = "The configuration could not be written.", body = ErrorBody),
236 ),
237)]
238pub(crate) async fn put_config(
239 State(state): State<AppState>,
240 Json(request): Json<UpdateConfigRequest>,
241) -> Result<Json<ConfigResponse>, ApiError> {
242 if request.retention_days == Some(0) {
243 return Err(ApiError::BadRequest(
244 "retention_days must be at least 1 or null".to_string(),
245 ));
246 }
247 let mut store = state
248 .config_store
249 .write()
250 .map_err(|_| ApiError::Internal(anyhow::anyhow!("configuration store lock is poisoned")))?;
251 let tuning = request
252 .tuning
253 .map(TuningConfig::from)
254 .unwrap_or(store.toml_tuning);
255 let effective_tuning = resolve_tuning_config(&tuning);
256 validate_tuning_config(&effective_tuning, false)
257 .map_err(|error| ApiError::BadRequest(error.to_string()))?;
258 let saved = bhtune_cli::config::save_config_store(
259 &store,
260 &request.revision,
261 &ConfigPolicyUpdate {
262 allow_uncertain_quality: request.allow_uncertain_quality,
263 retention_days: request.retention_days,
264 mrft_delay_secs: tuning.mrft_delay_secs,
265 poll_interval_ms: tuning.poll_interval_ms,
266 timeout_secs: tuning.timeout_secs,
267 op_timeout_secs: tuning.op_timeout_secs,
268 restore_timeout_secs: tuning.restore_timeout_secs,
269 },
270 )
271 .map_err(map_store_error)?;
272 let backup_path = saved
273 .backup_path
274 .as_deref()
275 .map(|path| path.display().to_string());
276 *store = saved.state;
277 Ok(Json(response_from_store(&store, backup_path)))
278}
279
280pub fn router() -> Router<AppState> {
281 Router::new().route("/api/config", get(get_config).put(put_config))
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use axum::body::{Body, to_bytes};
288 use axum::http::{Request, StatusCode};
289 use std::sync::{Arc, RwLock};
290 use tempfile::tempdir;
291 use tower::ServiceExt;
292
293 async fn state_for(path: &std::path::Path) -> AppState {
294 let mut state = crate::test_support::in_memory_state().await;
295 let loaded =
296 bhtune_cli::config::load_config_store_from(Some(path), None, None, None, false)
297 .unwrap();
298 state.config_store = Arc::new(RwLock::new(loaded));
299 state
300 }
301
302 async fn json_body(response: axum::http::Response<Body>) -> serde_json::Value {
303 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
304 serde_json::from_slice(&bytes).unwrap()
305 }
306
307 #[tokio::test]
308 async fn get_returns_default_policy_and_revision() {
309 let dir = tempdir().unwrap();
310 let path = dir.path().join("bhtune.toml");
311 std::fs::write(&path, "bridge_host = \"gateway:7600\"\n").unwrap();
312 let state = state_for(&path).await;
313 let revision = state.config_store.read().unwrap().revision.clone();
314 let response = crate::build_router(state)
315 .oneshot(Request::get("/api/config").body(Body::empty()).unwrap())
316 .await
317 .unwrap();
318
319 assert_eq!(response.status(), StatusCode::OK);
320 let body = json_body(response).await;
321 assert_eq!(body["revision"], revision);
322 assert_eq!(
323 body["toml"]["allow_uncertain_quality"],
324 serde_json::Value::Null
325 );
326 assert_eq!(body["effective"]["allow_uncertain_quality"], true);
327 assert_eq!(body["source"]["allow_uncertain_quality"], "default");
328 assert_eq!(
329 body["toml"]["tuning"]["mrft_delay_secs"],
330 serde_json::Value::Null
331 );
332 assert_eq!(body["effective"]["tuning"]["mrft_delay_secs"], 0);
333 assert_eq!(body["effective"]["tuning"]["poll_interval_ms"], 800);
334 assert_eq!(body["effective"]["tuning"]["timeout_secs"], 3600);
335 assert_eq!(body["effective"]["tuning"]["op_timeout_secs"], 30);
336 assert_eq!(body["effective"]["tuning"]["restore_timeout_secs"], 30);
337 assert_eq!(body["source"]["tuning"]["poll_interval_ms"], "default");
338 assert!(
339 body["config_path"]
340 .as_str()
341 .unwrap()
342 .ends_with("bhtune.toml")
343 );
344 }
345
346 #[tokio::test]
347 async fn put_patches_only_supported_keys_and_returns_new_revision() {
348 let dir = tempdir().unwrap();
349 let path = dir.path().join("bhtune.toml");
350 std::fs::write(
351 &path,
352 "# preserve\nbridge_host = \"gateway:7600\"\nunknown = \"keep\"\n",
353 )
354 .unwrap();
355 let state = state_for(&path).await;
356 let revision = state.config_store.read().unwrap().revision.clone();
357 let app = crate::build_router(state);
358 let response = app
359 .oneshot(
360 Request::put("/api/config")
361 .header("content-type", "application/json")
362 .body(Body::from(
363 serde_json::json!({
364 "revision": revision,
365 "allow_uncertain_quality": false,
366 "retention_days": 30
367 })
368 .to_string(),
369 ))
370 .unwrap(),
371 )
372 .await
373 .unwrap();
374
375 assert_eq!(response.status(), StatusCode::OK);
376 let body = json_body(response).await;
377 assert_ne!(body["revision"], revision);
378 assert_eq!(body["effective"]["allow_uncertain_quality"], false);
379 assert_eq!(body["effective"]["retention_days"], 30);
380 let saved = std::fs::read_to_string(path).unwrap();
381 assert!(saved.contains("# preserve"));
382 assert!(saved.contains("unknown = \"keep\""));
383 assert!(saved.contains("allow_uncertain_quality = false"));
384 assert!(saved.contains("retention_days = 30"));
385 }
386
387 #[tokio::test]
388 async fn put_persists_tuning_values_and_reports_effective_sources() {
389 let dir = tempdir().unwrap();
390 let path = dir.path().join("bhtune.toml");
391 std::fs::write(&path, "# preserve\n").unwrap();
392 let state = state_for(&path).await;
393 let revision = state.config_store.read().unwrap().revision.clone();
394 let response = crate::build_router(state)
395 .oneshot(
396 Request::put("/api/config")
397 .header("content-type", "application/json")
398 .body(Body::from(
399 serde_json::json!({
400 "revision": revision,
401 "allow_uncertain_quality": true,
402 "retention_days": null,
403 "tuning": {
404 "mrft_delay_secs": 12,
405 "poll_interval_ms": 250,
406 "timeout_secs": 900,
407 "op_timeout_secs": 45,
408 "restore_timeout_secs": 8
409 }
410 })
411 .to_string(),
412 ))
413 .unwrap(),
414 )
415 .await
416 .unwrap();
417
418 assert_eq!(response.status(), StatusCode::OK);
419 let body = json_body(response).await;
420 assert_eq!(body["toml"]["tuning"]["mrft_delay_secs"], 12);
421 assert_eq!(body["effective"]["tuning"]["poll_interval_ms"], 250);
422 assert_eq!(body["effective"]["tuning"]["timeout_secs"], 900);
423 assert_eq!(body["effective"]["tuning"]["op_timeout_secs"], 45);
424 assert_eq!(body["effective"]["tuning"]["restore_timeout_secs"], 8);
425 assert_eq!(body["source"]["tuning"]["mrft_delay_secs"], "config_file");
426 assert_eq!(
427 body["source"]["tuning"]["restore_timeout_secs"],
428 "config_file"
429 );
430 let saved = std::fs::read_to_string(path).unwrap();
431 assert!(saved.contains("[tuning]"));
432 assert!(saved.contains("mrft_delay_secs = 12"));
433 assert!(saved.contains("poll_interval_ms = 250"));
434 assert!(saved.contains("restore_timeout_secs = 8"));
435 }
436
437 #[tokio::test]
438 async fn put_omitting_tuning_preserves_existing_overrides() {
439 let dir = tempdir().unwrap();
440 let path = dir.path().join("bhtune.toml");
441 std::fs::write(
442 &path,
443 "[tuning]\nmrft_delay_secs = 12\npoll_interval_ms = 250\n",
444 )
445 .unwrap();
446 let state = state_for(&path).await;
447 let revision = state.config_store.read().unwrap().revision.clone();
448 let response = crate::build_router(state)
449 .oneshot(
450 Request::put("/api/config")
451 .header("content-type", "application/json")
452 .body(Body::from(
453 serde_json::json!({
454 "revision": revision,
455 "allow_uncertain_quality": false,
456 "retention_days": null
457 })
458 .to_string(),
459 ))
460 .unwrap(),
461 )
462 .await
463 .unwrap();
464
465 assert_eq!(response.status(), StatusCode::OK);
466 let body = json_body(response).await;
467 assert_eq!(body["toml"]["tuning"]["mrft_delay_secs"], 12);
468 assert_eq!(body["toml"]["tuning"]["poll_interval_ms"], 250);
469 assert_eq!(body["effective"]["tuning"]["mrft_delay_secs"], 12);
470 assert_eq!(body["effective"]["tuning"]["poll_interval_ms"], 250);
471 assert_eq!(body["effective"]["allow_uncertain_quality"], false);
472 }
473
474 #[tokio::test]
475 async fn put_supplied_partial_tuning_object_replaces_the_complete_block() {
476 let dir = tempdir().unwrap();
477 let path = dir.path().join("bhtune.toml");
478 std::fs::write(
479 &path,
480 "[tuning]\nmrft_delay_secs = 12\npoll_interval_ms = 250\ntimeout_secs = 900\nop_timeout_secs = 45\nrestore_timeout_secs = 8\n",
481 )
482 .unwrap();
483 let state = state_for(&path).await;
484 let revision = state.config_store.read().unwrap().revision.clone();
485 let response = crate::build_router(state)
486 .oneshot(
487 Request::put("/api/config")
488 .header("content-type", "application/json")
489 .body(Body::from(
490 serde_json::json!({
491 "revision": revision,
492 "allow_uncertain_quality": true,
493 "retention_days": null,
494 "tuning": {
495 "poll_interval_ms": 500
496 }
497 })
498 .to_string(),
499 ))
500 .unwrap(),
501 )
502 .await
503 .unwrap();
504
505 assert_eq!(response.status(), StatusCode::OK);
506 let body = json_body(response).await;
507 assert_eq!(
508 body["toml"]["tuning"]["mrft_delay_secs"],
509 serde_json::Value::Null
510 );
511 assert_eq!(body["toml"]["tuning"]["poll_interval_ms"], 500);
512 assert_eq!(
513 body["toml"]["tuning"]["timeout_secs"],
514 serde_json::Value::Null
515 );
516 assert_eq!(
517 body["toml"]["tuning"]["op_timeout_secs"],
518 serde_json::Value::Null
519 );
520 assert_eq!(
521 body["toml"]["tuning"]["restore_timeout_secs"],
522 serde_json::Value::Null
523 );
524 assert_eq!(body["effective"]["tuning"]["mrft_delay_secs"], 0);
525 assert_eq!(body["effective"]["tuning"]["poll_interval_ms"], 500);
526 assert_eq!(body["effective"]["tuning"]["timeout_secs"], 3600);
527 assert_eq!(body["effective"]["tuning"]["op_timeout_secs"], 30);
528 assert_eq!(body["effective"]["tuning"]["restore_timeout_secs"], 30);
529 let saved = std::fs::read_to_string(path).unwrap();
530 assert!(saved.contains("poll_interval_ms = 500"));
531 assert!(!saved.contains("mrft_delay_secs"));
532 assert!(!saved.contains("timeout_secs"));
533 assert!(!saved.contains("op_timeout_secs"));
534 assert!(!saved.contains("restore_timeout_secs"));
535 }
536
537 #[tokio::test]
538 async fn put_all_null_tuning_resets_to_built_in_defaults() {
539 let dir = tempdir().unwrap();
540 let path = dir.path().join("bhtune.toml");
541 std::fs::write(
542 &path,
543 "[tuning]\nmrft_delay_secs = 12\npoll_interval_ms = 250\ntimeout_secs = 900\nop_timeout_secs = 45\nrestore_timeout_secs = 8\n",
544 )
545 .unwrap();
546 let state = state_for(&path).await;
547 let revision = state.config_store.read().unwrap().revision.clone();
548 let response = crate::build_router(state)
549 .oneshot(
550 Request::put("/api/config")
551 .header("content-type", "application/json")
552 .body(Body::from(
553 serde_json::json!({
554 "revision": revision,
555 "allow_uncertain_quality": true,
556 "retention_days": null,
557 "tuning": {
558 "mrft_delay_secs": null,
559 "poll_interval_ms": null,
560 "timeout_secs": null,
561 "op_timeout_secs": null,
562 "restore_timeout_secs": null
563 }
564 })
565 .to_string(),
566 ))
567 .unwrap(),
568 )
569 .await
570 .unwrap();
571
572 assert_eq!(response.status(), StatusCode::OK);
573 let body = json_body(response).await;
574 assert_eq!(
575 body["toml"]["tuning"]["mrft_delay_secs"],
576 serde_json::Value::Null
577 );
578 assert_eq!(
579 body["toml"]["tuning"]["poll_interval_ms"],
580 serde_json::Value::Null
581 );
582 assert_eq!(body["effective"]["tuning"]["mrft_delay_secs"], 0);
583 assert_eq!(body["effective"]["tuning"]["poll_interval_ms"], 800);
584 assert_eq!(body["effective"]["tuning"]["timeout_secs"], 3600);
585 assert_eq!(body["source"]["tuning"]["restore_timeout_secs"], "default");
586 let saved = std::fs::read_to_string(path).unwrap();
587 assert!(!saved.contains("mrft_delay_secs"));
588 assert!(!saved.contains("restore_timeout_secs"));
589 }
590
591 #[tokio::test]
592 async fn put_rejects_invalid_tuning_values() {
593 let dir = tempdir().unwrap();
594 let path = dir.path().join("bhtune.toml");
595 std::fs::write(&path, "").unwrap();
596 let state = state_for(&path).await;
597 let revision = state.config_store.read().unwrap().revision.clone();
598 let response = crate::build_router(state)
599 .oneshot(
600 Request::put("/api/config")
601 .header("content-type", "application/json")
602 .body(Body::from(
603 serde_json::json!({
604 "revision": revision,
605 "allow_uncertain_quality": true,
606 "retention_days": null,
607 "tuning": {
608 "mrft_delay_secs": 3601,
609 "poll_interval_ms": 0,
610 "timeout_secs": 1,
611 "op_timeout_secs": 1,
612 "restore_timeout_secs": 1
613 }
614 })
615 .to_string(),
616 ))
617 .unwrap(),
618 )
619 .await
620 .unwrap();
621
622 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
623 assert!(
624 json_body(response).await["error"]
625 .as_str()
626 .unwrap()
627 .contains("mrft_delay_secs")
628 );
629 }
630
631 #[tokio::test]
632 async fn put_creates_an_auto_discovered_missing_config_file() {
633 let dir = tempdir().unwrap();
634 let path = dir.path().join("bhtune").join("bhtune.toml");
635 let loaded = bhtune_cli::config::load_config_store_from(
636 None,
637 Some(dir.path().to_str().unwrap()),
638 None,
639 None,
640 false,
641 )
642 .unwrap();
643 let revision = loaded.revision.clone();
644 let mut state = crate::test_support::in_memory_state().await;
645 state.config_store = Arc::new(RwLock::new(loaded));
646 let response = crate::build_router(state)
647 .oneshot(
648 Request::put("/api/config")
649 .header("content-type", "application/json")
650 .body(Body::from(
651 serde_json::json!({
652 "revision": revision,
653 "allow_uncertain_quality": true,
654 "retention_days": null
655 })
656 .to_string(),
657 ))
658 .unwrap(),
659 )
660 .await
661 .unwrap();
662
663 assert_eq!(response.status(), StatusCode::OK);
664 assert!(path.exists());
665 assert!(
666 std::fs::read_to_string(path)
667 .unwrap()
668 .contains("allow_uncertain_quality = true")
669 );
670 }
671
672 #[tokio::test]
673 async fn put_rejects_stale_revision_and_external_disk_changes() {
674 let dir = tempdir().unwrap();
675 let path = dir.path().join("bhtune.toml");
676 std::fs::write(&path, "bridge_host = \"gateway:7600\"\n").unwrap();
677 let state = state_for(&path).await;
678 let revision = state.config_store.read().unwrap().revision.clone();
679 std::fs::write(&path, "bridge_host = \"other:7600\"\n").unwrap();
680 let response = crate::build_router(state)
681 .oneshot(
682 Request::put("/api/config")
683 .header("content-type", "application/json")
684 .body(Body::from(
685 serde_json::json!({
686 "revision": revision,
687 "allow_uncertain_quality": false,
688 "retention_days": null
689 })
690 .to_string(),
691 ))
692 .unwrap(),
693 )
694 .await
695 .unwrap();
696 assert_eq!(response.status(), StatusCode::CONFLICT);
697 assert!(
698 json_body(response).await["error"]
699 .as_str()
700 .unwrap()
701 .contains("changed on disk")
702 );
703 }
704
705 #[test]
706 fn response_reports_environment_retention_and_unresolved_path() {
707 let store = LoadedConfigStore {
708 path: None,
709 missing_is_allowed: true,
710 original_raw: None,
711 config: bhtune_cli::config::BhtuneConfig {
712 retention_days: Some(30),
713 ..Default::default()
714 },
715 revision: "revision".to_string(),
716 toml_allow_uncertain_quality: None,
717 toml_tuning: Default::default(),
718 tuning_sources: bhtune_cli::config::tuning_config_sources(
719 &bhtune_cli::config::TuningConfig::default(),
720 ),
721 };
722
723 let response = response_from_store_with_retention(&store, None, Some(90));
724
725 assert_eq!(response.config_path, "");
726 assert_eq!(response.source.retention_days, "environment");
727 assert_eq!(response.effective.retention_days, Some(90));
728 assert_eq!(response.effective.tuning.mrft_delay_secs, 0);
729 assert_eq!(response.source.tuning.op_timeout_secs, "default");
730 }
731
732 #[test]
733 fn map_store_error_maps_non_conflicts_to_internal_errors() {
734 let error = map_store_error(ConfigStoreError::PathNotResolved);
735 assert!(matches!(error, ApiError::Internal(_)));
736
737 let error = map_store_error(ConfigStoreError::Conflict {
738 path: None,
739 message: "stale".to_string(),
740 });
741 assert!(matches!(error, ApiError::Conflict(message) if message == "stale"));
742 }
743
744 #[tokio::test]
745 async fn put_rejects_zero_retention_days() {
746 let dir = tempdir().unwrap();
747 let path = dir.path().join("bhtune.toml");
748 std::fs::write(&path, "").unwrap();
749 let state = state_for(&path).await;
750 let revision = state.config_store.read().unwrap().revision.clone();
751 let response = crate::build_router(state)
752 .oneshot(
753 Request::put("/api/config")
754 .header("content-type", "application/json")
755 .body(Body::from(
756 serde_json::json!({
757 "revision": revision,
758 "allow_uncertain_quality": true,
759 "retention_days": 0
760 })
761 .to_string(),
762 ))
763 .unwrap(),
764 )
765 .await
766 .unwrap();
767
768 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
769 assert_eq!(
770 json_body(response).await["error"],
771 "retention_days must be at least 1 or null"
772 );
773 }
774
775 #[tokio::test]
776 async fn get_returns_internal_error_when_config_store_lock_is_poisoned() {
777 let dir = tempdir().unwrap();
778 let path = dir.path().join("bhtune.toml");
779 std::fs::write(&path, "").unwrap();
780 let state = state_for(&path).await;
781 let store = Arc::clone(&state.config_store);
782 std::thread::spawn(move || {
783 let _guard = store.write().unwrap();
784 panic!("poison configuration store");
785 })
786 .join()
787 .unwrap_err();
788
789 let response = crate::build_router(state)
790 .oneshot(Request::get("/api/config").body(Body::empty()).unwrap())
791 .await
792 .unwrap();
793
794 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
795 }
796
797 #[tokio::test]
798 async fn put_returns_internal_error_when_config_store_lock_is_poisoned() {
799 let dir = tempdir().unwrap();
800 let path = dir.path().join("bhtune.toml");
801 std::fs::write(&path, "").unwrap();
802 let state = state_for(&path).await;
803 let revision = state.config_store.read().unwrap().revision.clone();
804 let store = Arc::clone(&state.config_store);
805 std::thread::spawn(move || {
806 let _guard = store.write().unwrap();
807 panic!("poison configuration store");
808 })
809 .join()
810 .unwrap_err();
811
812 let response = crate::build_router(state)
813 .oneshot(
814 Request::put("/api/config")
815 .header("content-type", "application/json")
816 .body(Body::from(
817 serde_json::json!({
818 "revision": revision,
819 "allow_uncertain_quality": true,
820 "retention_days": null
821 })
822 .to_string(),
823 ))
824 .unwrap(),
825 )
826 .await
827 .unwrap();
828
829 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
830 }
831}