1//! The service's routes on the in-memory backend: the wire the tablet and the app depend on, byte for 2//! byte, and the decisions the service makes (the thinking window, the voice cap) on the ports. 3 4use std::sync::atomic::Ordering; 5 6use whiskers_conformance::MemBackend; 7use whiskers_conformance::suite::build::{chat, fact, household, snapshot}; 8use whiskers_core::{Verdict, wire::EmbedReply}; 9use whiskers_ports::{ 10 Diagnostic, EmbedError, HasChat, HasHousehold, Hub, JudgeError, Reply, SecretName, SpeakError, ThinkError, run_ready, 11}; 12use whiskers_service::routes::Routes; 13use whiskers_service::{Body, Complete, Content, MAX_BODY, Method, Request, Response, Service}; 14 15type Full = Service<MemBackend, Complete>; 16 17fn service() -> Full { 18 Service::complete(MemBackend::at(86_400_000 * 100)) 19} 20 21fn call<C, R: Routes<C>>(svc: &Service<C, R>, path: &str, body: &str) -> Response { 22 run_ready(svc.handle(Request::post(path, body))) 23} 24 25fn json_of(r: &Response) -> serde_json::Value { 26 assert_eq!(r.content, Content::Json, "{}", r.text_body()); 27 serde_json::from_slice(&r.body).unwrap() 28} 29 30fn set_household(svc: &Full, tokens: u64, voice_chars: u32) { 31 run_ready(svc.backend().household().merge(household(10, tokens, voice_chars, 1, &[]))).unwrap(); 32} 33 34mod transport { 35 use super::*; 36 37 #[test] 38 fn only_posts_are_answered_and_unknown_routes_are_404() { 39 let svc = service(); 40 let r = run_ready(svc.handle(Request { method: Method::Other("GET".into()), path: "/check".into(), body: Body::Text(String::new()) })); 41 assert_eq!(r, Response::empty(405)); 42 assert_eq!(call(&svc, "/nope", "{}"), Response::empty(404)); 43 } 44 45 #[test] 46 fn a_body_that_could_not_be_read_or_is_too_big_is_a_400() { 47 let svc = service(); 48 let r = run_ready(svc.handle(Request { method: Method::Post, path: "/check".into(), body: Body::Unreadable(Diagnostic::new("eof")) })); 49 assert_eq!(r, Response::empty(400)); 50 assert_eq!(call(&svc, "/check", &"x".repeat(MAX_BODY + 1)), Response::empty(400)); 51 } 52} 53 54mod jev { 55 use super::*; 56 57 #[test] 58 fn check_answers_the_judges_verdict() { 59 let svc = service(); 60 let r = call(&svc, "/check", r#"{"direction":"FromChild","age":6,"text":"hi"}"#); 61 assert_eq!((r.status, r.text_body()), (200, r#"{"Verdict":"Allow"}"#.to_owned())); 62 *svc.backend().judge.verdict.lock().unwrap() = Ok(Verdict::Refuse { reason: "no".into(), kind: whiskers_core::RefusalKind::OffLimits }); 63 let r = call(&svc, "/check", r#"{"direction":"ToChild","text":"hi"}"#); 64 assert!(r.text_body().contains("OffLimits"), "{}", r.text_body()); 65 } 66 67 #[test] 68 fn check_without_a_decision_is_unavailable_never_allow() { 69 let svc = service(); 70 *svc.backend().judge.verdict.lock().unwrap() = Err(JudgeError::NotConfigured(SecretName::JevKey)); 71 let r = call(&svc, "/check", r#"{"direction":"FromChild","age":6,"text":"hi"}"#); 72 assert_eq!((r.status, r.text_body()), (200, r#"{"Unavailable":"no Jev client"}"#.to_owned())); 73 let r = call(&svc, "/check", "not json"); 74 assert_eq!(r.text_body(), r#"{"Unavailable":"bad request: expected ident at line 1 column 2"}"#); 75 let r = call(&svc, "/check", r#"{"direction":"FromChild","age":2,"text":"hi"}"#); 76 assert!(r.text_body().starts_with(r#"{"Unavailable":"bad request:"#), "an age outside the range is refused"); 77 } 78 79 #[test] 80 fn embed_returns_vectors_and_refuses_a_bad_batch_before_asking() { 81 let svc = service(); 82 *svc.backend().embedder.answer.lock().unwrap() = Ok(vec![vec![1.0, 2.0], vec![3.0, 4.0]]); 83 let r = call(&svc, "/embed", r#"{"texts":["a","b"]}"#); 84 assert_eq!(r.text_body(), r#"{"Vectors":[[1.0,2.0],[3.0,4.0]]}"#); 85 let r = call(&svc, "/embed", r#"{"texts":[]}"#); 86 assert_eq!(r.text_body(), r#"{"Unavailable":"bad request: 1 to 64 texts, not 0"}"#); 87 assert_eq!(svc.backend().embedder.asked.load(Ordering::SeqCst), 1, "the empty batch never reached the model"); 88 let long = serde_json::json!({ "texts": ["x".repeat(2001)] }).to_string(); 89 assert_eq!(call(&svc, "/embed", &long).text_body(), r#"{"Unavailable":"bad request: a text is longer than 2000 characters"}"#); 90 *svc.backend().embedder.answer.lock().unwrap() = Err(EmbedError::WrongCount { asked: 2, got: 1 }); 91 let reply: EmbedReply = serde_json::from_slice(&call(&svc, "/embed", r#"{"texts":["a","b"]}"#).body).unwrap(); 92 assert_eq!(reply, EmbedReply::Unavailable("embedding server: asked for 2 vectors, got 1".into())); 93 } 94 95 #[test] 96 fn rerank_returns_one_probability_each_or_says_why_not() { 97 let svc = service(); 98 let r = call(&svc, "/rerank", r#"{"query":"q","candidates":["x","y"]}"#); 99 assert_eq!(r.text_body(), r#"{"Probabilities":[0.5,0.5]}"#); 100 *svc.backend().judge.ranking.lock().unwrap() = Err(JudgeError::CannotRank { candidates: 99, why: Diagnostic::new("too many") }); 101 let r = call(&svc, "/rerank", r#"{"query":"q","candidates":["x"]}"#); 102 assert_eq!(r.text_body(), r#"{"Unavailable":"cannot rank 99 candidates: too many"}"#); 103 assert!(call(&svc, "/rerank", "[").text_body().starts_with(r#"{"Unavailable":"bad request: "#)); 104 } 105} 106 107mod icon { 108 use std::sync::Arc; 109 110 use super::*; 111 use whiskers_core::IconId; 112 113 /// An embedder that puts a text on an axis by what it is about, so the shortlist is predictable: pumpkins and 114 /// rabbits are far apart, and everything else is in between. 115 fn by_topic(svc: &Full) { 116 let f: whiskers_conformance::memory::EmbedWith = Arc::new(|texts: &[String]| { 117 texts 118 .iter() 119 .map(|t| if t.contains("pumpkin") { vec![1.0, 0.0] } else if t.contains("rabbit") || t.contains("bunny") { vec![0.0, 1.0] } else { vec![0.2, 0.2] }) 120 .collect() 121 }); 122 *svc.backend().embedder.with.lock().unwrap() = Some(f); 123 } 124 125 #[test] 126 fn the_judge_chooses_among_the_closest_twenty_and_its_choice_is_the_reply() { 127 let svc = service(); 128 by_topic(&svc); 129 *svc.backend().judge.icon.lock().unwrap() = Ok(Some(IconId::new("pumpkin").unwrap())); 130 let r = call(&svc, "/icon", r#"{"text":"loves pumpkin pie"}"#); 131 assert_eq!(r.text_body(), r#"{"Icon":"pumpkin"}"#); 132 let options = svc.backend().judge.icon_options.lock().unwrap().clone(); 133 assert_eq!(options.len(), whiskers_ports::SHORTLIST); 134 assert_eq!(options[0].as_str(), "pumpkin", "the closest by meaning is first"); 135 assert!(options.iter().all(IconId::depicts_a_thing), "a fact is never pictured by an interface icon"); 136 } 137 138 #[test] 139 fn none_fits_is_an_answer_and_a_judge_that_cannot_be_asked_is_not() { 140 let svc = service(); 141 by_topic(&svc); 142 assert_eq!(call(&svc, "/icon", r#"{"text":"loves pumpkins"}"#).text_body(), r#""NoneFits""#); 143 *svc.backend().judge.icon.lock().unwrap() = Err(JudgeError::NoAnswer); 144 let r = call(&svc, "/icon", r#"{"text":"loves pumpkins"}"#).text_body(); 145 assert_eq!(r, r#"{"Unavailable":"choosing: no answer from Jev"}"#); 146 } 147 148 #[test] 149 fn a_judge_that_names_a_picture_outside_the_shortlist_gets_no_picture() { 150 let svc = service(); 151 by_topic(&svc); 152 let r = call(&svc, "/icon", r#"{"text":"loves pumpkins"}"#).text_body(); 153 let options = svc.backend().judge.icon_options.lock().unwrap().clone(); 154 assert!(options.iter().all(|o| o.as_str() != "whale"), "the other names tie, so the list's order fills the shortlist: a whale is far down it"); 155 assert_eq!(r, r#""NoneFits""#, "a choice outside the options is no picture, never a wrong one"); 156 } 157 158 #[test] 159 fn the_names_are_embedded_once_and_an_embedder_that_is_down_is_unavailable_never_a_picture() { 160 let svc = service(); 161 by_topic(&svc); 162 call(&svc, "/icon", r#"{"text":"a"}"#); 163 let after_first = svc.backend().embedder.asked.load(Ordering::SeqCst); 164 call(&svc, "/icon", r#"{"text":"b"}"#); 165 assert_eq!(svc.backend().embedder.asked.load(Ordering::SeqCst), after_first + 1, "only the fact is embedded the second time"); 166 let down = service(); 167 let r = call(&down, "/icon", r#"{"text":"loves pumpkins"}"#).text_body(); 168 assert!(r.starts_with(r#"{"Unavailable":"embedding: "#), "{r}"); 169 assert_eq!(down.backend().judge.asked.load(Ordering::SeqCst), 0, "Jev is not asked without a shortlist"); 170 } 171 172 #[test] 173 fn an_empty_or_unreadable_request_is_unavailable() { 174 let svc = service(); 175 by_topic(&svc); 176 assert!(call(&svc, "/icon", r#"{"text":" "}"#).text_body().starts_with(r#"{"Unavailable""#)); 177 assert!(call(&svc, "/icon", "[").text_body().starts_with(r#"{"Unavailable":"bad request: "#)); 178 } 179} 180 181mod sync { 182 use super::*; 183 184 #[test] 185 fn memory_sync_returns_the_converged_memory_and_a_bad_body_is_a_400() { 186 let svc = service(); 187 let first = serde_json::to_string(&snapshot(vec![fact("g1", "Biscuit is a bunny"), fact("g2", "she loves purple")], &[])).unwrap(); 188 let r = call(&svc, "/memory/sync", &first); 189 let v = json_of(&r); 190 assert_eq!(v["facts"].as_array().unwrap().len(), 2); 191 let second = serde_json::to_string(&snapshot(vec![fact("g3", "BISCUIT is a bunny")], &["g2"])).unwrap(); 192 let v = json_of(&call(&svc, "/memory/sync", &second)); 193 let gids: Vec<&str> = v["facts"].as_array().unwrap().iter().map(|f| f["gid"].as_str().unwrap()).collect(); 194 assert_eq!(gids, ["g1"]); 195 assert_eq!(v["forgotten"], serde_json::json!(["g2", "g3"]), "a forgotten fact and a duplicate are both forgotten for good"); 196 assert_eq!(call(&svc, "/memory/sync", "x"), Response::empty(400)); 197 } 198 199 #[test] 200 fn memory_sync_carries_a_hide_with_the_exact_bytes_and_leaves_an_untouched_fact_as_it_was() { 201 let svc = service(); 202 let mut hidden = fact("g1", "Biscuit is a bunny"); 203 hidden.visibility = whiskers_core::Visibility::default().hidden_after(40); 204 let first = serde_json::to_string(&snapshot(vec![hidden, fact("g2", "she loves purple")], &[])).unwrap(); 205 let v = json_of(&call(&svc, "/memory/sync", &first)); 206 assert_eq!(v["facts"][0]["visibility"], serde_json::json!({"HiddenByChild": {"at_ms": 40}})); 207 assert!(v["facts"][1].get("visibility").is_none(), "an untouched fact has no new field on the wire"); 208 assert_eq!(v["forgotten"], serde_json::json!([]), "hiding is not forgetting"); 209 } 210 211 #[test] 212 fn household_sync_converges_and_the_later_choice_wins() { 213 let svc = service(); 214 let a = serde_json::to_string(&household(10, 150, 3, 1, &[("a", 1000)])).unwrap(); 215 let r = call(&svc, "/household/sync", &a); 216 assert_eq!(serde_json::from_slice::<whiskers_core::Household>(&r.body).unwrap(), household(10, 150, 3, 1, &[("a", 1000)])); 217 let older = serde_json::to_string(&household(5, 999, 9, 1, &[])).unwrap(); 218 let r = call(&svc, "/household/sync", &older); 219 assert_eq!(serde_json::from_slice::<whiskers_core::Household>(&r.body).unwrap().config().voice_daily_chars, 3); 220 assert_eq!(call(&svc, "/household/sync", r#"{"x""#), Response::empty(400)); 221 } 222 223 #[test] 224 fn chat_sync_keeps_the_newer_copy_whole() { 225 let svc = service(); 226 let two = serde_json::to_string(&chat(2, 5, "s")).unwrap(); 227 assert_eq!(call(&svc, "/chat/sync", &two).text_body(), r#"{"summary":"s","turns":[],"last_active_ms":5,"version":2}"#); 228 let old = serde_json::to_string(&chat(1, 99, "old")).unwrap(); 229 assert_eq!(call(&svc, "/chat/sync", &old).text_body(), r#"{"summary":"s","turns":[],"last_active_ms":5,"version":2}"#); 230 assert_eq!(call(&svc, "/chat/sync", "!"), Response::empty(400)); 231 } 232} 233 234mod pictures { 235 use super::*; 236 237 #[test] 238 fn a_picture_goes_up_is_not_missing_and_comes_back() { 239 let svc = service(); 240 let missing = |ids: &str| call(&svc, "/picture/missing", &format!(r#"{{"ids":{ids}}}"#)).text_body(); 241 assert_eq!(missing(r#"["0000000000000000-0000.jpg","../etc/passwd","a.jpg",5]"#), r#"{"missing":["0000000000000000-0000.jpg","a.jpg"]}"#); 242 let r = call(&svc, "/picture/put", r#"{"id":"0000000000000000-0000.jpg","data_base64":"/9gBAgM="}"#); 243 assert_eq!(r.text_body(), r#"{"ok":true}"#); 244 assert_eq!(call(&svc, "/picture/put", r#"{"id":"0000000000000000-0000.jpg","data_base64":"AAAA"}"#).text_body(), r#"{"ok":true}"#, "never replaced, and not an error"); 245 assert_eq!(missing(r#"["0000000000000000-0000.jpg","c.png"]"#), r#"{"missing":["c.png"]}"#); 246 assert_eq!(call(&svc, "/picture/get", r#"{"id":"0000000000000000-0000.jpg"}"#).text_body(), r#"{"data_base64":"/9gBAgM="}"#, "the first one is the picture"); 247 } 248 249 #[test] 250 fn what_cannot_be_a_picture_is_refused_with_the_reason() { 251 let svc = service(); 252 let cases = [ 253 ("/picture/put", r#"{"id":"../x.jpg","data_base64":"AAAA"}"#, 400, r#"{"error":"not a picture name"}"#), 254 ("/picture/put", r#"{"id":"b.jpg","data_base64":"!!!"}"#, 400, r#"{"error":"bad base64"}"#), 255 ("/picture/put", r#"{"id":"b.jpg"}"#, 400, r#"{"error":"id and data_base64"}"#), 256 ("/picture/put", "nope", 400, r#"{"error":"bad json"}"#), 257 ("/picture/get", r#"{"id":"c.png"}"#, 404, r#"{"error":"no such picture"}"#), 258 ("/picture/get", r#"{"id":"../c.png"}"#, 400, r#"{"error":"not a picture name"}"#), 259 ("/picture/get", r#"{}"#, 400, r#"{"error":"not a picture name"}"#), 260 ]; 261 for (path, body, status, reply) in cases { 262 let r = call(&svc, path, body); 263 assert_eq!((r.status, r.text_body().as_str()), (status, reply), "{path} {body}"); 264 } 265 } 266} 267 268mod journal { 269 use super::*; 270 271 fn push(svc: &Full, body: &str) -> Response { 272 call(svc, "/journal/push", body) 273 } 274 275 #[test] 276 fn a_device_pushes_where_it_left_off_and_everyone_pulls_one_log() { 277 let svc = service(); 278 assert_eq!(push(&svc, r#"{"device":"phone1","from":0,"lines":[]}"#).text_body(), r#"{"have":0}"#); 279 let r = push(&svc, r#"{"device":"phone1","from":0,"lines":["{\"b\":1,\"a\":{\"z\":1,\"y\":2}}","{\"at_ms\":3}"]}"#); 280 assert_eq!(r.text_body(), r#"{"have":2}"#); 281 assert_eq!(push(&svc, r#"{"device":"phone1","from":0,"lines":["{\"x\":1}"]}"#).text_body(), r#"{"have":2}"#, "behind: nothing taken, told the truth"); 282 assert_eq!(push(&svc, r#"{"device":"phone2","from":0,"lines":["{\"x\":2}"]}"#).text_body(), r#"{"have":1}"#); 283 let pulled = call(&svc, "/journal/pull", r#"{"have":0}"#).text_body(); 284 assert_eq!( 285 pulled, 286 r#"{"from":0,"total":3,"lines":["{\"device\":\"phone1\",\"entry\":{\"b\":1,\"a\":{\"z\":1,\"y\":2}}}","{\"device\":\"phone1\",\"entry\":{\"at_ms\":3}}","{\"device\":\"phone2\",\"entry\":{\"x\":2}}"]}"# 287 ); 288 assert_eq!(call(&svc, "/journal/pull", r#"{"have":2}"#).text_body(), r#"{"from":2,"total":3,"lines":["{\"device\":\"phone2\",\"entry\":{\"x\":2}}"]}"#); 289 assert_eq!(call(&svc, "/journal/pull", r#"{"have":99}"#).text_body(), r#"{"from":3,"total":3,"lines":[]}"#, "a copy longer than the log is told where the log ends"); 290 assert_eq!(call(&svc, "/journal/pull", "{}").text_body(), pulled, "no cursor is the start"); 291 } 292 293 #[test] 294 fn what_is_not_a_plain_device_or_a_single_json_object_is_refused() { 295 let svc = service(); 296 let refused = |body: &str, why: &str| { 297 let r = push(&svc, body); 298 assert_eq!((r.status, r.text_body().as_str()), (400, why), "{body}"); 299 }; 300 refused(r#"{"device":"bad name","from":0,"lines":[]}"#, r#"{"error":"device"}"#); 301 refused(r#"{"from":0,"lines":[]}"#, r#"{"error":"device"}"#); 302 refused(r#"{"device":"phone2","from":0,"lines":["[1]"]}"#, r#"{"error":"lines must be single JSON objects"}"#); 303 refused(r#"{"device":"phone2","from":0,"lines":["3","{\"k\":1}"]}"#, r#"{"error":"lines must be single JSON objects"}"#); 304 refused("nope", r#"{"error":"bad json"}"#); 305 assert_eq!(call(&svc, "/journal/pull", "}").text_body(), r#"{"error":"bad json"}"#); 306 assert_eq!(push(&svc, r#"{"device":"phone2","from":0,"lines":[]}"#).text_body(), r#"{"have":0}"#, "and a refused push took nothing at all"); 307 } 308 309 #[test] 310 fn a_push_that_will_not_be_taken_is_answered_with_the_count_whatever_it_carries() { 311 let svc = service(); 312 push(&svc, r#"{"device":"phone1","from":0,"lines":["{\"x\":1}"]}"#); 313 assert_eq!(push(&svc, r#"{"device":"phone1","from":5,"lines":["not even json"]}"#).text_body(), r#"{"have":1}"#); 314 assert_eq!(push(&svc, r#"{"device":"phone1","lines":[{"o":1},"{\"w\":1}"]}"#).text_body(), r#"{"have":1}"#, "a missing cursor is zero, and non-text lines are dropped"); 315 } 316} 317 318mod messages { 319 use super::*; 320 321 const GOOD: &str = r#"{"model":"m","max_tokens":300,"system":"s","messages":[{"role":"user","content":"hi"}]}"#; 322 323 #[test] 324 fn a_whiskers_request_is_forwarded_and_the_answer_comes_back_whole() { 325 let svc = service(); 326 *svc.backend().thinker.answer.lock().unwrap() = Ok(Reply { status: 200, body: r#"{"content":[]}"#.into() }); 327 let r = call(&svc, "/v1/messages", GOOD); 328 assert_eq!((r.status, r.content, r.text_body()), (200, Content::Json, r#"{"content":[]}"#.to_owned())); 329 } 330 331 #[test] 332 fn once_the_allowance_is_spent_nothing_reaches_the_gateway_and_the_reply_says_budget() { 333 let svc = service(); 334 set_household(&svc, 10, 1000); 335 run_ready(whiskers_ports::Allowance::charge_thinking(&svc.backend().allowance, svc.backend().now(), whiskers_ports::Tokens::new(50))).unwrap(); 336 let r = call(&svc, "/v1/messages", GOOD); 337 assert_eq!((r.status, r.content, r.text_body()), (429, Content::Text, "{\"error\":\"budget\"}".to_owned())); 338 assert_eq!(svc.backend().thinker.asked.load(Ordering::SeqCst), 0); 339 } 340 341 #[test] 342 fn a_reply_is_charged_for_its_tokens_and_room_returns_as_the_window_rolls() { 343 let svc = service(); 344 set_household(&svc, 150, 1000); 345 call(&svc, "/v1/messages", GOOD); 346 let usage = json_of(&call(&svc, "/usage", "{}")); 347 assert_eq!(usage["tokens"]["used"], 100, "70 in and 30 out"); 348 call(&svc, "/v1/messages", GOOD); 349 assert_eq!(call(&svc, "/v1/messages", GOOD).status, 429, "200 of 150: asked first, charged after, so the second overshoots and the third is refused"); 350 svc.backend().clock.advance(5 * 3_600_000 + 1); 351 assert_eq!(call(&svc, "/v1/messages", GOOD).status, 200, "the window rolled"); 352 } 353 354 #[test] 355 fn the_gateways_own_errors_pass_through_with_their_status_and_are_not_charged() { 356 let svc = service(); 357 *svc.backend().thinker.answer.lock().unwrap() = Ok(Reply { status: 500, body: "boom".into() }); 358 let r = call(&svc, "/v1/messages", GOOD); 359 assert_eq!((r.status, r.text_body()), (500, "boom".to_owned())); 360 assert_eq!(json_of(&call(&svc, "/usage", "{}"))["tokens"]["used"], 0); 361 } 362 363 #[test] 364 fn anything_else_is_refused_before_it_reaches_the_gateway() { 365 let svc = service(); 366 let cases = [ 367 ("nonsense", "bad request: expected ident at line 1 column 2"), 368 (r#"{"model":"x","max_tokens":1,"messages":[]}"#, "bad request: model must be m, not Some(\"x\")"), 369 (r#"{"model":"m","max_tokens":5000,"messages":[]}"#, "bad request: max_tokens must be 1 to 1000"), 370 (r#"{"model":"m","max_tokens":5,"stream":true,"messages":[]}"#, "bad request: streaming is not supported"), 371 (r#"{"model":"m","max_tokens":5,"tools":[],"messages":[]}"#, "bad request: tools are not allowed"), 372 ]; 373 for (bad, why) in cases { 374 let r = call(&svc, "/v1/messages", bad); 375 assert_eq!((r.status, r.content, r.text_body().as_str()), (400, Content::Text, why), "{bad}"); 376 } 377 assert_eq!(svc.backend().thinker.asked.load(Ordering::SeqCst), 0); 378 } 379 380 #[test] 381 fn an_unreachable_gateway_is_a_502_that_says_so() { 382 let svc = service(); 383 *svc.backend().thinker.answer.lock().unwrap() = Err(ThinkError::Unreachable(Diagnostic::new("gateway: connection refused"))); 384 let r = call(&svc, "/v1/messages", GOOD); 385 assert_eq!((r.status, r.text_body()), (502, "gateway: connection refused".to_owned())); 386 } 387} 388 389mod voice { 390 use super::*; 391 392 #[test] 393 fn a_line_is_spoken_and_stays_spent() { 394 let svc = service(); 395 set_household(&svc, 1000, 20); 396 let r = call(&svc, "/speak", r#"{"text":"hello there"}"#); 397 assert_eq!((r.status, r.content, r.body), (200, Content::Mpeg, vec![0xFF, 0xFB, 1, 2])); 398 let r = call(&svc, "/speak/v2", r#"{"text":"hi"}"#); 399 assert_eq!((r.status, r.content), (200, Content::Json)); 400 assert_eq!(json_of(&call(&svc, "/usage", "{}"))["voice"]["daily_spent"], 13); 401 let r = call(&svc, "/speak", r#"{"text":"hello there"}"#); 402 assert_eq!((r.status, r.content, r.text_body()), (429, Content::Text, "today's character allowance is spent".to_owned())); 403 assert_eq!(svc.backend().voice.asked.load(Ordering::SeqCst), 2, "the refused line never reached the voice"); 404 } 405 406 const MP3_BASE64: &str = "//sBAg=="; 407 408 #[test] 409 fn a_timed_line_is_whiskers_own_shape_on_the_new_route() { 410 let svc = service(); 411 let r = call(&svc, "/speak/v2", r#"{"text":"hi"}"#); 412 assert_eq!( 413 (r.status, r.content, r.text_body()), 414 (200, Content::Json, format!(r#"{{"audio_base64":"{MP3_BASE64}","text":"hi","spans_ms":[[0,100],[100,200]]}}"#)), 415 ); 416 // A character is a Unicode scalar value: one span for the cat, not two. 417 let r = call(&svc, "/speak/v2", "{\"text\":\"h\u{e9}\u{1F408}\"}"); 418 assert_eq!(json_of(&r)["spans_ms"], serde_json::json!([[0, 100], [100, 200], [200, 300]])); 419 assert_eq!(json_of(&call(&svc, "/usage", "{}"))["voice"]["daily_spent"], 5); 420 } 421 422 #[test] 423 fn both_routes_check_in_the_same_order_and_answer_the_same_failures() { 424 for route in ["/speak", "/speak/v2"] { 425 let svc = service(); 426 set_household(&svc, 1000, 10); 427 let long = format!(r#"{{"text":"{}"}}"#, "x".repeat(501)); 428 let status = |svc: &Full, body: &str| { 429 let r = call(svc, route, body); 430 (r.status, r.text_body()) 431 }; 432 assert_eq!(status(&svc, "!"), (400, "bad request: expected value at line 1 column 1".to_owned()), "{route}"); 433 assert_eq!(status(&svc, r#"{"text":" "}"#), (400, "bad request: empty text".to_owned()), "{route}"); 434 assert_eq!(status(&svc, &long), (413, "line longer than 500 characters".to_owned()), "{route}"); 435 *svc.backend().voice.configured.lock().unwrap() = false; 436 assert_eq!(status(&svc, &long).0, 503, "{route}: unconfigured before too long"); 437 *svc.backend().voice.configured.lock().unwrap() = true; 438 assert_eq!(status(&svc, r#"{"text":"hello there!"}"#), (429, "today's character allowance is spent".to_owned()), "{route}"); 439 *svc.backend().voice.speech.lock().unwrap() = Err(SpeakError::Unreachable(Diagnostic::new("ElevenLabs: down"))); 440 assert_eq!(status(&svc, r#"{"text":"hello"}"#), (502, "ElevenLabs: down".to_owned()), "{route}"); 441 assert_eq!(json_of(&call(&svc, "/usage", "{}"))["voice"]["daily_spent"], 0, "{route}: a line that did not come out costs nothing"); 442 } 443 } 444 445 #[test] 446 fn a_timing_that_is_not_of_the_line_is_not_spoken_and_costs_nothing() { 447 // The adapter's own refusal (see `timed_from_vendor`) arrives as Unreadable, which is a 502 like any 448 // reply the vendor sent that cannot be used. 449 let svc = service(); 450 *svc.backend().voice.speech.lock().unwrap() = Err(SpeakError::Unreadable(Diagnostic::new("ElevenLabs: the timing is of other text than the line that was spoken"))); 451 let r = call(&svc, "/speak/v2", r#"{"text":"hi"}"#); 452 assert_eq!((r.status, r.text_body().as_str()), (502, "ElevenLabs: the timing is of other text than the line that was spoken")); 453 assert_eq!(json_of(&call(&svc, "/usage", "{}"))["voice"]["daily_spent"], 0); 454 } 455 456 #[test] 457 fn a_line_that_does_not_come_out_costs_nothing_and_the_parents_can_see_why() { 458 let svc = service(); 459 set_household(&svc, 1000, 20); 460 *svc.backend().voice.speech.lock().unwrap() = Err(SpeakError::Refused { status: 401, said: Diagnostic::new("ElevenLabs: 401 Unauthorized: nope") }); 461 let r = call(&svc, "/speak", r#"{"text":"hello there"}"#); 462 assert_eq!((r.status, r.text_body()), (502, "ElevenLabs: 401 Unauthorized: nope".to_owned())); 463 let v = json_of(&call(&svc, "/usage", "{}")); 464 assert_eq!(v["voice"]["daily_spent"], 0); 465 assert_eq!(v["voice"]["last_error"], "ElevenLabs: 401 Unauthorized: nope"); 466 *svc.backend().voice.speech.lock().unwrap() = Ok(vec![1]); 467 assert_eq!(call(&svc, "/speak", r#"{"text":"hello there"}"#).status, 200); 468 assert_eq!(json_of(&call(&svc, "/usage", "{}"))["voice"]["last_error"], serde_json::Value::Null, "one that comes out clears it"); 469 } 470 471 #[test] 472 fn the_checks_come_in_the_order_the_wire_has_always_had() { 473 let svc = service(); 474 set_household(&svc, 1000, 3); 475 let long = serde_json::json!({ "text": "x".repeat(501) }).to_string(); 476 let at = |body: &str| { 477 let r = call(&svc, "/speak", body); 478 (r.status, r.text_body()) 479 }; 480 assert_eq!(at("nope"), (400, "bad request: expected ident at line 1 column 2".to_owned())); 481 assert_eq!(at(r#"{"text":" "}"#), (400, "bad request: empty text".to_owned())); 482 assert_eq!(at(&long), (413, "line longer than 500 characters".to_owned())); 483 *svc.backend().voice.configured.lock().unwrap() = false; 484 assert_eq!(at(&long), (503, "ELEVENLABS_API_KEY or ELEVENLABS_VOICE_ID is not set".to_owned()), "not configured beats too long"); 485 assert_eq!(at(r#"{"text":"hi"}"#).0, 503); 486 assert_eq!(at(r#"{"text":" "}"#).0, 400, "and an empty line is a bad request even then"); 487 assert_eq!(svc.backend().voice.asked.load(Ordering::SeqCst), 0); 488 } 489 490 #[test] 491 fn usage_shows_both_allowances_in_the_shape_the_app_reads() { 492 let svc = service(); 493 set_household(&svc, 150, 3); 494 let r = call(&svc, "/usage", "{}"); 495 assert_eq!( 496 r.text_body(), 497 r#"{"voice":{"configured":true,"daily_cap":3,"daily_spent":0,"last_error":null,"tier":"free","used":10,"limit":10000,"resets_at_unix":1800000000,"plan_error":null},"tokens":{"used":0,"limit":150,"window_hours":5,"frees_up_at_ms":null}}"# 498 ); 499 *svc.backend().voice.credits.lock().unwrap() = Err(whiskers_ports::CreditsError::Refused { status: 401, said: Diagnostic::new("ElevenLabs answered 401 Unauthorized") }); 500 let v = json_of(&call(&svc, "/usage", "{}")); 501 assert_eq!(v["voice"]["plan_error"], "ElevenLabs answered 401 Unauthorized"); 502 *svc.backend().voice.configured.lock().unwrap() = false; 503 let v = json_of(&call(&svc, "/usage", "{}")); 504 assert_eq!((v["voice"]["configured"].clone(), v["voice"]["plan_error"].clone()), (false.into(), serde_json::Value::Null), "no lookup when it is not set up"); 505 } 506} 507 508mod storage_failure { 509 use super::*; 510 511 #[test] 512 fn a_chat_that_cannot_be_kept_is_a_500_like_every_other_route_and_the_hub_still_holds_what_it_saved() { 513 let svc = service(); 514 let kept = serde_json::to_string(&chat(2, 5, "kept")).unwrap(); 515 assert_eq!(call(&svc, "/chat/sync", &kept).status, 200); 516 517 svc.backend().chat.refuse_writes(true); 518 let r = call(&svc, "/chat/sync", &serde_json::to_string(&chat(9, 9, "theirs")).unwrap()); 519 assert_eq!(r, Response::empty(500), "the device is told, not handed the hub's copy as if its own had been older"); 520 assert_eq!(run_ready(svc.backend().chat().current()).unwrap(), chat(2, 5, "kept"), "and the hub holds exactly what it saved"); 521 522 // A copy the hub already has something newer than needs no write, so it is still answered. 523 let r = call(&svc, "/chat/sync", &serde_json::to_string(&chat(1, 99, "old")).unwrap()); 524 assert_eq!((r.status, r.text_body()), (200, r#"{"summary":"kept","turns":[],"last_active_ms":5,"version":2}"#.to_owned())); 525 526 // The device sends its copy again at the next sync, and now it is kept. 527 svc.backend().chat.refuse_writes(false); 528 let r = call(&svc, "/chat/sync", &serde_json::to_string(&chat(9, 9, "theirs")).unwrap()); 529 assert_eq!((r.status, r.text_body()), (200, r#"{"summary":"theirs","turns":[],"last_active_ms":9,"version":9}"#.to_owned())); 530 } 531} 532 533/// What is served follows what the adapter has: a service is given the routes whose capabilities its adapter 534/// implements (the compiler refuses any other), and a route it was not given is a 404, as an unknown path is. 535/// That a route cannot be given to an adapter without its capabilities is a `compile_fail` doctest on `Service`. 536mod capabilities { 537 use super::*; 538 use whiskers_conformance::memory::{MemChatHub, MemHouseholdHub, MemMemoryHub}; 539 use whiskers_ports::HasMemory; 540 use whiskers_service::routes::{ChatSync, HouseholdSync, MemorySync}; 541 542 /// An adapter that is only the three hubs, as a Worker serving only them is. 543 #[derive(Default)] 544 struct OnlyHubs { 545 memory: MemMemoryHub, 546 household: MemHouseholdHub, 547 chat: MemChatHub, 548 } 549 550 impl HasMemory for OnlyHubs { 551 type Memory = MemMemoryHub; 552 fn memory(&self) -> &MemMemoryHub { 553 &self.memory 554 } 555 } 556 impl HasHousehold for OnlyHubs { 557 type Household = MemHouseholdHub; 558 fn household(&self) -> &MemHouseholdHub { 559 &self.household 560 } 561 } 562 impl HasChat for OnlyHubs { 563 type Chat = MemChatHub; 564 fn chat(&self) -> &MemChatHub { 565 &self.chat 566 } 567 } 568 569 /// Every path the service has ever answered, and the capability-free way to ask each. 570 const EVERY_ROUTE: &[&str] = &[ 571 "/check", "/embed", "/rerank", "/icon", "/memory/sync", "/household/sync", "/chat/sync", "/journal/push", "/journal/pull", "/picture/missing", 572 "/picture/put", "/picture/get", "/usage", "/speak", "/speak/v2", "/v1/messages", 573 ]; 574 575 #[test] 576 fn the_complete_service_answers_every_route() { 577 let svc = service(); 578 for path in EVERY_ROUTE { 579 assert_ne!(call(&svc, path, "{}").status, 404, "{path} is not served by the complete service"); 580 } 581 } 582 583 #[test] 584 fn a_service_given_only_the_hubs_answers_exactly_the_hubs() { 585 let svc = Service::new(OnlyHubs::default()).serve(MemorySync).serve(HouseholdSync).serve(ChatSync); 586 let documents = [ 587 ("/memory/sync", serde_json::to_string(&snapshot(vec![], &[])).unwrap()), 588 ("/household/sync", serde_json::to_string(&household(0, 0, 0, 0, &[])).unwrap()), 589 ("/chat/sync", serde_json::to_string(&chat(0, 0, "")).unwrap()), 590 ]; 591 for (path, body) in &documents { 592 assert_eq!(call(&svc, path, body).status, 200, "{path}"); 593 } 594 for path in EVERY_ROUTE.iter().filter(|p| !p.ends_with("/sync")) { 595 assert_eq!(call(&svc, path, "{}"), Response::empty(404), "{path} must not exist without its capabilities"); 596 } 597 } 598 599 #[test] 600 fn the_refusals_every_route_shares_are_the_same_with_or_without_the_other_routes() { 601 let svc = Service::new(OnlyHubs::default()).serve(MemorySync); 602 let get = Request { method: Method::Other("GET".into()), path: "/memory/sync".into(), body: Body::Text(String::new()) }; 603 assert_eq!(run_ready(svc.handle(get)), Response::empty(405)); 604 assert_eq!(call(&svc, "/memory/sync", &"x".repeat(MAX_BODY + 1)), Response::empty(400)); 605 assert_eq!(call(&svc, "/memory/sync", "not json"), Response::empty(400)); 606 // A hub that was not given is as absent as an unknown path, even though the adapter has it. 607 assert_eq!(call(&svc, "/chat/sync", "{}"), Response::empty(404)); 608 } 609}