The service's routes on the in-memory backend: the wire the tablet and the app depend on, byte for byte, and the decisions the service makes (the thinking window, the voice cap) on the ports.

4use std::sync::atomic::Ordering;
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;

An embedder that puts a text on an axis by what it is about, so the shortlist is predictable: pumpkins and 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    }
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}

What is served follows what the adapter has: a service is given the routes whose capabilities its adapter implements (the compiler refuses any other), and a route it was not given is a 404, as an unknown path is. 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};

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    }
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    }

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    ];
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}