Skip to main content

akribes_sdk/
lib.rs

1//! Akribes SDK for Rust — typed client for the Akribes workflow platform.
2//!
3//! # Quick start
4//!
5//! ```no_run
6//! use akribes_sdk::AkribesClient;
7//!
8//! # async fn example() -> akribes_sdk::Result<()> {
9//! let client = AkribesClient::builder("http://localhost:3001")
10//!     .project_id(1)
11//!     .token("akribes_tk_my_api_key")
12//!     .build();
13//!
14//! // Run a script and wait for the result
15//! let (id, output) = client.project(1).executions().run("my-script")
16//!     .channel("production")
17//!     .execute_and_await(None).await?;
18//!
19//! println!("Result: {:?}", output.result);
20//! # Ok(())
21//! # }
22//! ```
23
24mod client;
25pub mod error;
26pub mod events;
27pub mod models;
28pub mod runtime;
29pub mod sub;
30pub mod suspend;
31pub mod task_end;
32pub mod token_safety;
33
34// Re-export the main types at the crate root for convenience.
35pub use client::{AkribesClient, AkribesClientBuilder};
36pub use error::{AkribesError, InputValidationEntry, Result, parse_input_validation_errors};
37pub use events::{EnvelopeDecodeError, EventCategory, WorkflowEvent};
38pub use models::*;
39pub use runtime::{
40    RuntimeEndPayload, RuntimeErrorKind, RuntimeErrorPayload, RuntimeEvent, RuntimeStartPayload,
41    RuntimeStderrPayload, RuntimeStdoutPayload,
42};
43pub use suspend::{SuspendTrigger, UnableRecord, ValidationErrorWire};
44pub use task_end::TaskEndVariant;
45
46// Re-export sub-clients for direct use.
47pub use sub::bench::{BenchClient, BenchRunsClient};
48pub use sub::channels::ChannelsClient;
49pub use sub::clients::RegisteredClientsClient;
50pub use sub::convert::ConvertClient;
51pub use sub::documents::DocumentsClient;
52pub use sub::drafts::DraftsClient;
53pub use sub::events::EventsClient;
54pub use sub::executions::ExecutionsClient;
55pub use sub::projects::ProjectsClient;
56pub use sub::run_stream::{
57    EngineErrorPayload, RunStream, RunSummary, RunSummaryCost, RunSummaryDuration, RunSummaryTasks,
58    SuspendPayload, TaskEndPayload,
59};
60pub use sub::tokens::TokensClient;
61
62/// Test-only helpers, public so integration tests in `tests/` can reach
63/// crate-internal constructors. Not a stable API — may change or disappear
64/// without notice.
65#[doc(hidden)]
66pub mod _test {
67    use tokio::sync::mpsc;
68    use tokio::task::JoinHandle;
69
70    /// Build a [`crate::RunStream`] from a raw receiver and join handle.
71    /// Used by `tests/run_stream.rs` to exercise terminal detection without
72    /// spinning up a mock SSE server.
73    pub fn make_run_stream(
74        execution_id: String,
75        rx: mpsc::UnboundedReceiver<crate::error::Result<crate::events::WorkflowEvent>>,
76        handle: JoinHandle<()>,
77    ) -> crate::RunStream {
78        let sub = crate::sub::events::EventSubscription::from_handle(handle);
79        crate::sub::run_stream::RunStream::new(execution_id, rx, sub)
80    }
81}
82pub use sub::scripts::ScriptsClient;
83pub use sub::versions::{PublishBuilder, VersionsClient};
84
85// Compile-time guarantees.
86#[allow(dead_code)]
87const _: () = {
88    fn assert_send_sync_clone<T: Send + Sync + Clone>() {}
89    fn checks() {
90        assert_send_sync_clone::<AkribesClient>();
91    }
92};
93
94// ── Tests ────────────────────────────────────────────────────────────────────
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use mockito::Server;
100
101    #[test]
102    fn client_is_send_sync() {
103        fn assert_send_sync<T: Send + Sync>() {}
104        assert_send_sync::<AkribesClient>();
105        assert_send_sync::<crate::sub::executions::ExecutionsClient>();
106        assert_send_sync::<crate::sub::scripts::ScriptsClient>();
107        assert_send_sync::<crate::sub::tokens::TokensClient>();
108    }
109
110    fn make_client(server: &Server) -> AkribesClient {
111        AkribesClient::builder(server.url())
112            .project_id(1)
113            .name("test-app")
114            .id("test-id")
115            .build()
116    }
117
118    fn make_authed_client(server: &Server) -> AkribesClient {
119        AkribesClient::builder(server.url())
120            .project_id(1)
121            .name("test-app")
122            .id("test-id")
123            .token("test-token-123")
124            .build()
125    }
126
127    // ── helpers for common JSON fixtures ─────────────────────────────────────
128
129    fn project_json() -> &'static str {
130        r#"{"id":10,"name":"My Project","created_at":"2024-01-01T00:00:00Z"}"#
131    }
132
133    fn script_json() -> &'static str {
134        r#"{"id":5,"project_id":1,"name":"my_script","created_at":"2024-01-01T00:00:00Z"}"#
135    }
136
137    fn version_json() -> &'static str {
138        r#"{"id":3,"script_id":5,"source":"workflow main {}","label":null,"published_by":null,"created_at":"2024-01-01T00:00:00Z"}"#
139    }
140
141    fn channel_json() -> &'static str {
142        r#"{"id":1,"script_id":5,"name":"production","version_id":3,"updated_at":"2024-01-01T00:00:00Z"}"#
143    }
144
145    // ── auth ──────────────────────────────────────────────────────────────────
146
147    #[tokio::test]
148    async fn test_auth_header_sent() {
149        let mut server = Server::new_async().await;
150        let _m = server
151            .mock("GET", "/projects")
152            .match_header("authorization", "Bearer test-token-123")
153            .with_status(200)
154            .with_header("content-type", "application/json")
155            .with_body("[]")
156            .create_async()
157            .await;
158
159        let client = make_authed_client(&server);
160        client.projects().list().await.unwrap();
161    }
162
163    #[tokio::test]
164    async fn test_no_auth_header_without_token() {
165        let mut server = Server::new_async().await;
166        let _m = server
167            .mock("GET", "/projects")
168            .with_status(200)
169            .with_header("content-type", "application/json")
170            .with_body("[]")
171            .create_async()
172            .await;
173
174        let client = make_client(&server);
175        client.projects().list().await.unwrap();
176    }
177
178    #[tokio::test]
179    async fn test_set_token_updates_header() {
180        let mut server = Server::new_async().await;
181        let _m = server
182            .mock("GET", "/projects")
183            .match_header("authorization", "Bearer new-token")
184            .with_status(200)
185            .with_header("content-type", "application/json")
186            .with_body("[]")
187            .create_async()
188            .await;
189
190        let client = make_client(&server);
191        client.set_token(Some("new-token".to_string())).await;
192        client.projects().list().await.unwrap();
193    }
194
195    #[tokio::test]
196    async fn test_on_behalf_of_header_via_builder() {
197        // Builder-set on_behalf_of must land on outbound requests as
198        // X-Akribes-User. Mockito only matches the request when the header is
199        // present with the expected value.
200        let mut server = Server::new_async().await;
201        let _m = server
202            .mock("GET", "/projects")
203            .match_header("x-akribes-user", "alice@acme.com")
204            .with_status(200)
205            .with_header("content-type", "application/json")
206            .with_body("[]")
207            .create_async()
208            .await;
209
210        let client = AkribesClient::builder(server.url())
211            .project_id(1)
212            .name("test-app")
213            .id("test-id")
214            .on_behalf_of("alice@acme.com")
215            .build();
216        client.projects().list().await.unwrap();
217    }
218
219    #[tokio::test]
220    async fn test_set_on_behalf_of_updates_header_at_runtime() {
221        let mut server = Server::new_async().await;
222        let _m = server
223            .mock("GET", "/projects")
224            .match_header("x-akribes-user", "bob@acme.com")
225            .with_status(200)
226            .with_header("content-type", "application/json")
227            .with_body("[]")
228            .create_async()
229            .await;
230
231        let client = make_client(&server);
232        client
233            .set_on_behalf_of(Some("bob@acme.com".to_string()))
234            .await;
235        client.projects().list().await.unwrap();
236    }
237
238    #[tokio::test]
239    async fn test_set_on_behalf_of_none_clears_header() {
240        // Clearing the value via `set_on_behalf_of(None)` must not emit the
241        // header on subsequent requests. mockito's `match_header` only fires
242        // when the header is missing, so the request will only match if the
243        // SDK truly omits it.
244        let mut server = Server::new_async().await;
245        let _m = server
246            .mock("GET", "/projects")
247            .match_header("x-akribes-user", mockito::Matcher::Missing)
248            .with_status(200)
249            .with_header("content-type", "application/json")
250            .with_body("[]")
251            .create_async()
252            .await;
253
254        let client = AkribesClient::builder(server.url())
255            .project_id(1)
256            .on_behalf_of("alice@acme.com")
257            .build();
258        client.set_on_behalf_of(None).await;
259        client.projects().list().await.unwrap();
260    }
261
262    // ── builder ───────────────────────────────────────────────────────────────
263
264    #[test]
265    fn test_builder_defaults() {
266        let client = AkribesClient::builder("http://localhost:3001/")
267            .project_id(42)
268            .build();
269        assert_eq!(client.inner.base_url, "http://localhost:3001");
270        assert_eq!(client.project_id(), Some(42));
271        assert_eq!(client.inner.name, "rust-sdk");
272        assert!(!client.inner.id.is_empty()); // auto UUID
273    }
274
275    #[test]
276    fn test_builder_custom() {
277        let client = AkribesClient::builder("http://localhost:3001")
278            .project_id(1)
279            .name("my-svc")
280            .id("custom-id")
281            .token("tok")
282            .build();
283        assert_eq!(client.inner.name, "my-svc");
284        assert_eq!(client.inner.id, "custom-id");
285    }
286
287    #[test]
288    fn test_builder_no_project_id() {
289        let client = AkribesClient::builder("http://localhost:3001").build();
290        assert_eq!(client.project_id(), None);
291    }
292
293    #[test]
294    fn test_client_is_clone() {
295        let client = AkribesClient::builder("http://localhost:3001")
296            .project_id(1)
297            .name("test")
298            .id("test")
299            .build();
300        let clone = client.clone();
301        assert_eq!(clone.project_id(), Some(1));
302    }
303
304    // ── lifecycle ─────────────────────────────────────────────────────────────
305
306    #[tokio::test]
307    async fn test_init() {
308        let mut server = Server::new_async().await;
309        let _m = server
310            .mock("POST", "/projects/1/clients")
311            .with_status(200)
312            .with_body(r#"{"status":"ok"}"#)
313            .create_async()
314            .await;
315
316        let client = make_client(&server);
317        assert!(
318            client
319                .project(1)
320                .registered_clients()
321                .init(vec![])
322                .await
323                .is_ok()
324        );
325        client.project(1).registered_clients().destroy();
326    }
327
328    #[tokio::test]
329    async fn test_init_registers_heartbeat_and_destroy_cancels() {
330        let mut server = Server::new_async().await;
331        let _m = server
332            .mock("POST", "/projects/1/clients")
333            .with_status(200)
334            .with_body("{}")
335            .create_async()
336            .await;
337
338        let client = make_client(&server);
339        client
340            .project(1)
341            .registered_clients()
342            .init(vec![])
343            .await
344            .unwrap();
345        assert!(client.inner.heartbeat_handle.lock().unwrap().is_some());
346        client.project(1).registered_clients().destroy();
347        assert!(client.inner.heartbeat_handle.lock().unwrap().is_none());
348    }
349
350    // ── projects ──────────────────────────────────────────────────────────────
351
352    #[tokio::test]
353    async fn test_list_projects() {
354        let mut server = Server::new_async().await;
355        let body = format!("[{}]", project_json());
356        let _m = server
357            .mock("GET", "/projects")
358            .with_status(200)
359            .with_header("content-type", "application/json")
360            .with_body(&body)
361            .create_async()
362            .await;
363
364        let client = make_client(&server);
365        let projects = client.projects().list().await.unwrap();
366        assert_eq!(projects.len(), 1);
367        assert_eq!(projects[0].id, 10);
368        assert_eq!(projects[0].name, "My Project");
369    }
370
371    #[tokio::test]
372    async fn test_get_project() {
373        let mut server = Server::new_async().await;
374        let _m = server
375            .mock("GET", "/projects/10")
376            .with_status(200)
377            .with_header("content-type", "application/json")
378            .with_body(project_json())
379            .create_async()
380            .await;
381
382        let client = make_client(&server);
383        let proj = client.projects().get(10).await.unwrap().unwrap();
384        assert_eq!(proj.id, 10);
385    }
386
387    #[tokio::test]
388    async fn test_get_project_not_found() {
389        let mut server = Server::new_async().await;
390        let _m = server
391            .mock("GET", "/projects/999")
392            .with_status(404)
393            .create_async()
394            .await;
395
396        let client = make_client(&server);
397        assert!(client.projects().get(999).await.unwrap().is_none());
398    }
399
400    #[tokio::test]
401    async fn test_create_project() {
402        let mut server = Server::new_async().await;
403        let _m = server
404            .mock("POST", "/projects")
405            .with_status(201)
406            .with_header("content-type", "application/json")
407            .with_body(project_json())
408            .create_async()
409            .await;
410
411        let client = make_client(&server);
412        let proj = client.projects().create("My Project").await.unwrap();
413        assert_eq!(proj.id, 10);
414    }
415
416    #[tokio::test]
417    async fn test_update_project() {
418        let mut server = Server::new_async().await;
419        let _m = server
420            .mock("PATCH", "/projects/10")
421            .with_status(200)
422            .with_header("content-type", "application/json")
423            .with_body(project_json())
424            .create_async()
425            .await;
426
427        let client = make_client(&server);
428        let proj = client.projects().update(10, "My Project").await.unwrap();
429        assert_eq!(proj.id, 10);
430    }
431
432    #[tokio::test]
433    async fn test_delete_project() {
434        let mut server = Server::new_async().await;
435        let _m = server
436            .mock("DELETE", "/projects/10")
437            .with_status(204)
438            .create_async()
439            .await;
440
441        let client = make_client(&server);
442        assert!(client.projects().delete(10).await.is_ok());
443    }
444
445    #[tokio::test]
446    async fn test_duplicate_project() {
447        let mut server = Server::new_async().await;
448        let _m = server
449            .mock("POST", "/projects/10/duplicate")
450            .with_status(200)
451            .with_header("content-type", "application/json")
452            .with_body(project_json())
453            .create_async()
454            .await;
455
456        let client = make_client(&server);
457        let proj = client.projects().duplicate(10).await.unwrap();
458        assert_eq!(proj.id, 10);
459    }
460
461    #[tokio::test]
462    async fn test_reorder_projects() {
463        let mut server = Server::new_async().await;
464        let _m = server
465            .mock("PUT", "/projects/reorder")
466            .match_body(r#"{"order":[3,1,2]}"#)
467            .with_status(204)
468            .create_async()
469            .await;
470
471        let client = make_client(&server);
472        client.projects().reorder(vec![3, 1, 2]).await.unwrap();
473    }
474
475    // ── scripts ───────────────────────────────────────────────────────────────
476
477    #[tokio::test]
478    async fn test_list_scripts() {
479        let mut server = Server::new_async().await;
480        let body = format!("[{}]", script_json());
481        let _m = server
482            .mock("GET", "/projects/1/scripts")
483            .with_status(200)
484            .with_header("content-type", "application/json")
485            .with_body(&body)
486            .create_async()
487            .await;
488
489        let client = make_client(&server);
490        let scripts = client.project(1).scripts().list().await.unwrap();
491        assert_eq!(scripts.len(), 1);
492        assert_eq!(scripts[0].name, "my_script");
493    }
494
495    #[tokio::test]
496    async fn test_create_script() {
497        let mut server = Server::new_async().await;
498        let _m = server
499            .mock("POST", "/projects/1/scripts?name=my_script")
500            .with_status(201)
501            .with_header("content-type", "application/json")
502            .with_body(script_json())
503            .create_async()
504            .await;
505
506        let client = make_client(&server);
507        let s = client
508            .project(1)
509            .scripts()
510            .create("my_script", "workflow main {}")
511            .await
512            .unwrap();
513        assert_eq!(s.name, "my_script");
514    }
515
516    #[tokio::test]
517    async fn test_get_script() {
518        let mut server = Server::new_async().await;
519        let _m = server
520            .mock("GET", "/projects/1/scripts/my_script")
521            .with_status(200)
522            .with_header("content-type", "application/json")
523            .with_body(script_json())
524            .create_async()
525            .await;
526
527        let client = make_client(&server);
528        let s = client.project(1).scripts().get("my_script").await.unwrap();
529        assert!(s.is_some());
530        assert_eq!(s.unwrap().id, 5);
531    }
532
533    #[tokio::test]
534    async fn test_get_script_not_found() {
535        let mut server = Server::new_async().await;
536        let _m = server
537            .mock("GET", "/projects/1/scripts/missing")
538            .with_status(404)
539            .create_async()
540            .await;
541
542        let client = make_client(&server);
543        let s = client.project(1).scripts().get("missing").await.unwrap();
544        assert!(s.is_none());
545    }
546
547    #[tokio::test]
548    async fn test_rename_script() {
549        let mut server = Server::new_async().await;
550        let _m = server
551            .mock("PATCH", "/projects/1/scripts/old")
552            .with_status(204)
553            .create_async()
554            .await;
555
556        let client = make_client(&server);
557        assert!(
558            client
559                .project(1)
560                .scripts()
561                .rename("old", "new")
562                .await
563                .is_ok()
564        );
565    }
566
567    #[tokio::test]
568    async fn test_delete_script() {
569        let mut server = Server::new_async().await;
570        let _m = server
571            .mock("DELETE", "/projects/1/scripts/my_script")
572            .with_status(204)
573            .create_async()
574            .await;
575
576        let client = make_client(&server);
577        assert!(
578            client
579                .project(1)
580                .scripts()
581                .delete("my_script")
582                .await
583                .is_ok()
584        );
585    }
586
587    #[tokio::test]
588    async fn test_duplicate_script() {
589        let mut server = Server::new_async().await;
590        let _m = server
591            .mock("POST", "/projects/1/scripts/foo/duplicate")
592            .with_status(200)
593            .with_header("content-type", "application/json")
594            .with_body(script_json())
595            .create_async()
596            .await;
597
598        let client = make_client(&server);
599        let s = client.project(1).scripts().duplicate("foo").await.unwrap();
600        assert_eq!(s.name, "my_script");
601    }
602
603    #[tokio::test]
604    async fn test_move_script() {
605        let mut server = Server::new_async().await;
606        let _m = server
607            .mock("POST", "/projects/1/scripts/foo/move")
608            .match_body(r#"{"target_project_id":9}"#)
609            .with_status(200)
610            .with_header("content-type", "application/json")
611            .with_body(script_json())
612            .create_async()
613            .await;
614
615        let client = make_client(&server);
616        let s = client.project(1).scripts().move_to("foo", 9).await.unwrap();
617        assert_eq!(s.id, 5);
618    }
619
620    #[tokio::test]
621    async fn test_reorder_scripts() {
622        let mut server = Server::new_async().await;
623        let _m = server
624            .mock("PUT", "/projects/1/scripts/reorder")
625            .match_body(r#"{"order":[5,4,3]}"#)
626            .with_status(204)
627            .create_async()
628            .await;
629
630        let client = make_client(&server);
631        client
632            .project(1)
633            .scripts()
634            .reorder(vec![5, 4, 3])
635            .await
636            .unwrap();
637    }
638
639    // ── drafts ────────────────────────────────────────────────────────────────
640
641    #[tokio::test]
642    async fn test_get_draft_legacy_tuple_form() {
643        // Pre-0.11.x servers (and simpler mocks) send inputs as 2-tuples.
644        // Kept for forward-compat: the SDK must keep accepting this shape.
645        let mut server = Server::new_async().await;
646        let _m = server
647            .mock("GET", "/projects/1/scripts/my_script/draft")
648            .with_status(200)
649            .with_header("content-type", "application/json")
650            .with_body(r#"{"source":"workflow main {}","inputs":[["doc","string"]]}"#)
651            .create_async()
652            .await;
653
654        let client = make_client(&server);
655        let draft = client
656            .project(1)
657            .drafts()
658            .get("my_script")
659            .await
660            .unwrap()
661            .unwrap();
662        assert_eq!(draft.source, "workflow main {}");
663        assert_eq!(draft.inputs.len(), 1);
664        assert_eq!(draft.inputs[0], ("doc".to_string(), "string".to_string()));
665    }
666
667    #[tokio::test]
668    async fn test_get_draft_server_object_form() {
669        // Regression test for issue #277: 0.11.x servers send inputs as
670        // `[{name, ty: TypeRef, docs}]` and add a `type_defs` field. Large
671        // scripts with non-empty inputs used to fail with
672        // "error decoding response body" because the SDK's Draft model
673        // expected the legacy `[["name","type"]]` tuple shape.
674        let body = r#"{
675            "source": "input doc: string",
676            "inputs": [
677                {
678                    "name": "doc",
679                    "ty": {"name": "string", "inner": null, "choices": null, "variants": null},
680                    "docs": null
681                },
682                {
683                    "name": "items",
684                    "ty": {
685                        "name": "list",
686                        "inner": {"name": "string", "inner": null, "choices": null, "variants": null},
687                        "choices": null,
688                        "variants": null
689                    },
690                    "docs": "a list of strings"
691                }
692            ],
693            "type_defs": {}
694        }"#;
695        let mut server = Server::new_async().await;
696        let _m = server
697            .mock("GET", "/projects/1/scripts/my_script/draft")
698            .with_status(200)
699            .with_header("content-type", "application/json")
700            .with_body(body)
701            .create_async()
702            .await;
703
704        let client = make_client(&server);
705        let draft = client
706            .project(1)
707            .drafts()
708            .get("my_script")
709            .await
710            .unwrap()
711            .unwrap();
712        assert_eq!(draft.inputs.len(), 2);
713        assert_eq!(draft.inputs[0], ("doc".to_string(), "string".to_string()));
714        assert_eq!(
715            draft.inputs[1],
716            ("items".to_string(), "list[string]".to_string())
717        );
718    }
719
720    #[tokio::test]
721    async fn test_get_draft_not_found() {
722        let mut server = Server::new_async().await;
723        let _m = server
724            .mock("GET", "/projects/1/scripts/my_script/draft")
725            .with_status(404)
726            .create_async()
727            .await;
728
729        let client = make_client(&server);
730        assert!(
731            client
732                .project(1)
733                .drafts()
734                .get("my_script")
735                .await
736                .unwrap()
737                .is_none()
738        );
739    }
740
741    #[tokio::test]
742    async fn test_save_draft() {
743        let mut server = Server::new_async().await;
744        let _m = server
745            .mock("PUT", "/projects/1/scripts/my_script/draft")
746            .with_status(200)
747            .with_header("content-type", "application/json")
748            .with_body(r#"{"schema_warnings":[]}"#)
749            .create_async()
750            .await;
751
752        let client = make_client(&server);
753        let resp = client
754            .project(1)
755            .drafts()
756            .save("my_script", "workflow main {}")
757            .await
758            .unwrap();
759        assert!(resp.schema_warnings.is_empty());
760    }
761
762    // ── versions ──────────────────────────────────────────────────────────────
763
764    #[tokio::test]
765    async fn test_list_versions() {
766        let mut server = Server::new_async().await;
767        let body = format!("[{}]", version_json());
768        let _m = server
769            .mock("GET", "/projects/1/scripts/my_script/versions")
770            .with_status(200)
771            .with_header("content-type", "application/json")
772            .with_body(&body)
773            .create_async()
774            .await;
775
776        let client = make_client(&server);
777        let versions = client
778            .project(1)
779            .versions()
780            .list("my_script")
781            .await
782            .unwrap();
783        assert_eq!(versions.len(), 1);
784        assert_eq!(versions[0].id, 3);
785    }
786
787    #[tokio::test]
788    async fn test_get_version() {
789        let mut server = Server::new_async().await;
790        let _m = server
791            .mock("GET", "/projects/1/scripts/my_script/versions/3")
792            .with_status(200)
793            .with_header("content-type", "application/json")
794            .with_body(version_json())
795            .create_async()
796            .await;
797
798        let client = make_client(&server);
799        let v = client
800            .project(1)
801            .versions()
802            .get("my_script", 3)
803            .await
804            .unwrap()
805            .unwrap();
806        assert_eq!(v.id, 3);
807    }
808
809    #[tokio::test]
810    async fn test_get_version_not_found() {
811        let mut server = Server::new_async().await;
812        let _m = server
813            .mock("GET", "/projects/1/scripts/my_script/versions/99")
814            .with_status(404)
815            .create_async()
816            .await;
817
818        let client = make_client(&server);
819        assert!(
820            client
821                .project(1)
822                .versions()
823                .get("my_script", 99)
824                .await
825                .unwrap()
826                .is_none()
827        );
828    }
829
830    #[tokio::test]
831    async fn test_get_latest_version() {
832        let mut server = Server::new_async().await;
833        let _m = server
834            .mock("GET", "/projects/1/scripts/my_script/latest")
835            .with_status(200)
836            .with_header("content-type", "application/json")
837            .with_body(r#"{"id":3,"script_id":5,"source":"workflow main {}","label":null,"published_by":null,"created_at":"2024-01-01T00:00:00Z","inputs":[]}"#)
838            .create_async()
839            .await;
840
841        let client = make_client(&server);
842        let v = client
843            .project(1)
844            .versions()
845            .get_latest("my_script")
846            .await
847            .unwrap();
848        assert!(v.is_some());
849    }
850
851    #[tokio::test]
852    async fn test_publish_version() {
853        let mut server = Server::new_async().await;
854        let _m = server
855            .mock("POST", "/projects/1/scripts/my_script/publish")
856            .with_status(201)
857            .with_header("content-type", "application/json")
858            .with_body(format!(r#"{{"version":{}}}"#, version_json()))
859            .create_async()
860            .await;
861
862        let client = make_client(&server);
863        let v = client
864            .project(1)
865            .versions()
866            .publish_version("my_script", vec!["production".into()], Some("v1"), None)
867            .await
868            .unwrap();
869        assert_eq!(v.id, 3);
870    }
871
872    // ── channels ──────────────────────────────────────────────────────────────
873
874    #[tokio::test]
875    async fn test_list_channels() {
876        let mut server = Server::new_async().await;
877        let body = format!("[{}]", channel_json());
878        let _m = server
879            .mock("GET", "/projects/1/scripts/my_script/channels")
880            .with_status(200)
881            .with_header("content-type", "application/json")
882            .with_body(&body)
883            .create_async()
884            .await;
885
886        let client = make_client(&server);
887        let channels = client
888            .project(1)
889            .channels()
890            .list("my_script")
891            .await
892            .unwrap();
893        assert_eq!(channels.len(), 1);
894        assert_eq!(channels[0].name, "production");
895    }
896
897    #[tokio::test]
898    async fn test_create_channel() {
899        let mut server = Server::new_async().await;
900        let _m = server
901            .mock("POST", "/projects/1/scripts/my_script/channels")
902            .with_status(201)
903            .with_header("content-type", "application/json")
904            .with_body(channel_json())
905            .create_async()
906            .await;
907
908        let client = make_client(&server);
909        let ch = client
910            .project(1)
911            .channels()
912            .create("my_script", "production")
913            .await
914            .unwrap();
915        assert_eq!(ch.name, "production");
916    }
917
918    #[tokio::test]
919    async fn test_delete_channel() {
920        let mut server = Server::new_async().await;
921        let _m = server
922            .mock("DELETE", "/projects/1/scripts/my_script/channels/beta")
923            .with_status(204)
924            .create_async()
925            .await;
926
927        let client = make_client(&server);
928        assert!(
929            client
930                .project(1)
931                .channels()
932                .delete("my_script", "beta")
933                .await
934                .is_ok()
935        );
936    }
937
938    #[tokio::test]
939    async fn test_move_channel() {
940        let mut server = Server::new_async().await;
941        let _m = server
942            .mock("PATCH", "/projects/1/scripts/my_script/channels/staging")
943            .with_status(204)
944            .create_async()
945            .await;
946
947        let client = make_client(&server);
948        assert!(
949            client
950                .project(1)
951                .channels()
952                .move_to("my_script", "staging", 3, false)
953                .await
954                .is_ok()
955        );
956    }
957
958    // ── execution (builder) ──────────────────────────────────────────────────
959
960    #[tokio::test]
961    async fn test_run_builder() {
962        let mut server = Server::new_async().await;
963        let _m = server
964            .mock(
965                "POST",
966                "/projects/1/scripts/my_script/run?channel=production",
967            )
968            .with_status(200)
969            .with_header("content-type", "application/json")
970            .with_body(r#"{"execution_id":"abc-123"}"#)
971            .create_async()
972            .await;
973
974        let client = make_client(&server);
975        let result = client
976            .project(1)
977            .executions()
978            .run("my_script")
979            .execute()
980            .await
981            .unwrap();
982        assert_eq!(result.execution_id, "abc-123");
983    }
984
985    #[tokio::test]
986    async fn test_run_404_surfaces_as_http_status() {
987        // Regression test for issue #277: a 404 on POST paths used to be
988        // silently passed through `send()` and then hit `res.json()` against
989        // the error body, producing the generic
990        // "error decoding response body" message. It must now surface as
991        // HttpStatus{404, ...} with the server's error string readable.
992        let mut server = Server::new_async().await;
993        let _m = server
994            .mock(
995                "POST",
996                "/projects/1/scripts/my_script/run?channel=nonexistent",
997            )
998            .with_status(404)
999            .with_header("content-type", "application/json")
1000            .with_body(r#"{"error":"channel 'nonexistent' not found"}"#)
1001            .create_async()
1002            .await;
1003
1004        let client = make_client(&server);
1005        let err = client
1006            .project(1)
1007            .executions()
1008            .run("my_script")
1009            .channel("nonexistent")
1010            .execute()
1011            .await
1012            .unwrap_err();
1013
1014        match err {
1015            AkribesError::HttpStatus { status, message } => {
1016                assert_eq!(status, 404);
1017                assert!(
1018                    message.contains("channel 'nonexistent' not found"),
1019                    "expected server body in message, got: {message}"
1020                );
1021            }
1022            other => panic!("expected HttpStatus, got {other:?}"),
1023        }
1024    }
1025
1026    #[tokio::test]
1027    async fn test_decode_error_includes_body_snippet() {
1028        // Regression test for issue #277: when a response body doesn't
1029        // match the expected model, the error must include a snippet of
1030        // the actual body (not just "error decoding response body").
1031        let mut server = Server::new_async().await;
1032        let _m = server
1033            .mock(
1034                "POST",
1035                "/projects/1/scripts/my_script/run?channel=production",
1036            )
1037            .with_status(200)
1038            .with_header("content-type", "application/json")
1039            // Wrong shape — missing `execution_id`.
1040            .with_body(r#"{"surprise":"not what you wanted"}"#)
1041            .create_async()
1042            .await;
1043
1044        let client = make_client(&server);
1045        let err = client
1046            .project(1)
1047            .executions()
1048            .run("my_script")
1049            .execute()
1050            .await
1051            .unwrap_err();
1052
1053        let msg = err.to_string();
1054        assert!(
1055            msg.contains("failed to decode")
1056                && msg.contains("RunResult")
1057                && msg.contains("surprise"),
1058            "decode error should name the target type and include body; got: {msg}"
1059        );
1060    }
1061
1062    #[tokio::test]
1063    async fn test_run_builder_typed_inputs_body() {
1064        let mut server = Server::new_async().await;
1065        // Asserts the wire format: multiple `.input(...)` calls plus the
1066        // `.document(...)` / `.documents(...)` shortcuts all merge into a
1067        // single `inputs` map.
1068        let _m = server
1069            .mock(
1070                "POST",
1071                "/projects/1/scripts/my_script/run?channel=production",
1072            )
1073            .match_body(mockito::Matcher::PartialJson(serde_json::json!({
1074                "inputs": {
1075                    "age": 25,
1076                    "name": "alice",
1077                    "resume": "doc_00000000-0000-0000-0000-000000000001",
1078                    "attachments": [
1079                        "doc_00000000-0000-0000-0000-000000000002",
1080                        "doc_00000000-0000-0000-0000-000000000003",
1081                    ],
1082                }
1083            })))
1084            .with_status(200)
1085            .with_header("content-type", "application/json")
1086            .with_body(r#"{"execution_id":"abc-777"}"#)
1087            .create_async()
1088            .await;
1089
1090        let client = make_client(&server);
1091        let result = client
1092            .project(1)
1093            .executions()
1094            .run("my_script")
1095            .input("age", 25)
1096            .input("name", "alice")
1097            .document("resume", "doc_00000000-0000-0000-0000-000000000001")
1098            .documents(
1099                "attachments",
1100                [
1101                    "doc_00000000-0000-0000-0000-000000000002",
1102                    "doc_00000000-0000-0000-0000-000000000003",
1103                ],
1104            )
1105            .execute()
1106            .await
1107            .unwrap();
1108        assert_eq!(result.execution_id, "abc-777");
1109    }
1110
1111    #[tokio::test]
1112    async fn test_run_builder_custom_channel() {
1113        let mut server = Server::new_async().await;
1114        let _m = server
1115            .mock("POST", "/projects/1/scripts/my_script/run?channel=staging")
1116            .with_status(200)
1117            .with_header("content-type", "application/json")
1118            .with_body(r#"{"execution_id":"abc-456"}"#)
1119            .create_async()
1120            .await;
1121
1122        let client = make_client(&server);
1123        let result = client
1124            .project(1)
1125            .executions()
1126            .run("my_script")
1127            .channel("staging")
1128            .execute()
1129            .await
1130            .unwrap();
1131        assert_eq!(result.execution_id, "abc-456");
1132    }
1133
1134    #[tokio::test]
1135    async fn test_run_and_await_builder() {
1136        let mut server = Server::new_async().await;
1137        let _run = server
1138            .mock(
1139                "POST",
1140                "/projects/1/scripts/my_script/run?channel=production",
1141            )
1142            .with_status(200)
1143            .with_header("content-type", "application/json")
1144            .with_body(r#"{"execution_id":"eid-1"}"#)
1145            .create_async()
1146            .await;
1147        let _out = server
1148            .mock("GET", "/executions/eid-1/output")
1149            .with_status(200)
1150            .with_header("content-type", "application/json")
1151            .with_body(r#"{"status":"completed","error":null,"error_kind":null,"result":null}"#)
1152            .create_async()
1153            .await;
1154
1155        let client = make_client(&server);
1156        let (eid, out) = client
1157            .project(1)
1158            .executions()
1159            .run("my_script")
1160            .execute_and_await(None)
1161            .await
1162            .unwrap();
1163        assert_eq!(eid, "eid-1");
1164        assert_eq!(out.status, "completed");
1165    }
1166
1167    // ── execution (direct) ───────────────────────────────────────────────────
1168
1169    #[tokio::test]
1170    async fn test_cancel_run() {
1171        let mut server = Server::new_async().await;
1172        let _m = server
1173            .mock("DELETE", "/projects/1/scripts/my_script/run")
1174            .with_status(200)
1175            .create_async()
1176            .await;
1177
1178        let client = make_client(&server);
1179        assert!(
1180            client
1181                .project(1)
1182                .executions()
1183                .cancel_run("my_script")
1184                .await
1185                .unwrap()
1186        );
1187    }
1188
1189    #[tokio::test]
1190    async fn test_cancel_run_not_found() {
1191        let mut server = Server::new_async().await;
1192        let _m = server
1193            .mock("DELETE", "/projects/1/scripts/my_script/run")
1194            .with_status(404)
1195            .create_async()
1196            .await;
1197
1198        let client = make_client(&server);
1199        assert!(
1200            !client
1201                .project(1)
1202                .executions()
1203                .cancel_run("my_script")
1204                .await
1205                .unwrap()
1206        );
1207    }
1208
1209    #[tokio::test]
1210    async fn test_cancel_execution() {
1211        let mut server = Server::new_async().await;
1212        let _m = server
1213            .mock("DELETE", "/executions/abc-123")
1214            .with_status(200)
1215            .create_async()
1216            .await;
1217
1218        let client = make_client(&server);
1219        assert!(client.executions().cancel("abc-123").await.unwrap());
1220    }
1221
1222    #[tokio::test]
1223    async fn test_get_execution() {
1224        let mut server = Server::new_async().await;
1225        let body = r#"{"id":"abc-123","project_id":1,"script_name":"my_script","status":"completed","started_at":null,"finished_at":null,"version_id":null,"channel":null,"error":null,"error_kind":null,"result":null,"documents":null,"triggered_by":null}"#;
1226        let _m = server
1227            .mock("GET", "/executions/abc-123")
1228            .with_status(200)
1229            .with_header("content-type", "application/json")
1230            .with_body(body)
1231            .create_async()
1232            .await;
1233
1234        let client = make_client(&server);
1235        let status = client.executions().get("abc-123").await.unwrap().unwrap();
1236        assert_eq!(status.id, "abc-123");
1237        assert_eq!(status.status, "completed");
1238    }
1239
1240    #[tokio::test]
1241    async fn test_get_execution_not_found() {
1242        let mut server = Server::new_async().await;
1243        let _m = server
1244            .mock("GET", "/executions/missing")
1245            .with_status(404)
1246            .create_async()
1247            .await;
1248
1249        let client = make_client(&server);
1250        assert!(client.executions().get("missing").await.unwrap().is_none());
1251    }
1252
1253    #[tokio::test]
1254    async fn test_get_execution_output() {
1255        let mut server = Server::new_async().await;
1256        let body =
1257            r#"{"status":"completed","error":null,"error_kind":null,"result":{"answer":42}}"#;
1258        let _m = server
1259            .mock("GET", "/executions/abc-123/output")
1260            .with_status(200)
1261            .with_header("content-type", "application/json")
1262            .with_body(body)
1263            .create_async()
1264            .await;
1265
1266        let client = make_client(&server);
1267        let out = client
1268            .executions()
1269            .get_output("abc-123")
1270            .await
1271            .unwrap()
1272            .unwrap();
1273        assert_eq!(out.status, "completed");
1274        assert_eq!(out.result.unwrap()["answer"], 42);
1275    }
1276
1277    #[tokio::test]
1278    async fn test_await_execution_immediate_completion() {
1279        let mut server = Server::new_async().await;
1280        let body = r#"{"status":"completed","error":null,"error_kind":null,"result":{"ok":true}}"#;
1281        let _m = server
1282            .mock("GET", "/executions/abc-123/output")
1283            .with_status(200)
1284            .with_header("content-type", "application/json")
1285            .with_body(body)
1286            .create_async()
1287            .await;
1288
1289        let client = make_client(&server);
1290        let out = client
1291            .executions()
1292            .await_execution("abc-123", None, Some(0))
1293            .await
1294            .unwrap();
1295        assert_eq!(out.status, "completed");
1296    }
1297
1298    #[tokio::test]
1299    async fn test_await_execution_script_error() {
1300        let mut server = Server::new_async().await;
1301        let body = r#"{"status":"failed","error":"boom","error_kind":"ScriptError","result":null}"#;
1302        let _m = server
1303            .mock("GET", "/executions/abc-123/output")
1304            .with_status(200)
1305            .with_header("content-type", "application/json")
1306            .with_body(body)
1307            .create_async()
1308            .await;
1309
1310        let client = make_client(&server);
1311        let err = client
1312            .executions()
1313            .await_execution("abc-123", None, Some(0))
1314            .await
1315            .unwrap_err();
1316        assert!(matches!(err, AkribesError::Script { .. }));
1317    }
1318
1319    #[tokio::test]
1320    async fn test_await_execution_fatal_error() {
1321        let mut server = Server::new_async().await;
1322        let body =
1323            r#"{"status":"failed","error":"unauthorized","error_kind":"AuthError","result":null}"#;
1324        let _m = server
1325            .mock("GET", "/executions/abc-123/output")
1326            .with_status(200)
1327            .with_header("content-type", "application/json")
1328            .with_body(body)
1329            .create_async()
1330            .await;
1331
1332        let client = make_client(&server);
1333        let err = client
1334            .executions()
1335            .await_execution("abc-123", None, Some(0))
1336            .await
1337            .unwrap_err();
1338        assert!(matches!(err, AkribesError::Fatal { .. }));
1339    }
1340
1341    #[tokio::test]
1342    async fn test_await_execution_transient_error() {
1343        let mut server = Server::new_async().await;
1344        let body =
1345            r#"{"status":"failed","error":"rate limited","error_kind":"RateLimit","result":null}"#;
1346        let _m = server
1347            .mock("GET", "/executions/abc-123/output")
1348            .with_status(200)
1349            .with_header("content-type", "application/json")
1350            .with_body(body)
1351            .create_async()
1352            .await;
1353
1354        let client = make_client(&server);
1355        let err = client
1356            .executions()
1357            .await_execution("abc-123", None, Some(0))
1358            .await
1359            .unwrap_err();
1360        assert!(matches!(err, AkribesError::Transient { .. }));
1361    }
1362
1363    #[tokio::test]
1364    async fn test_await_execution_timeout() {
1365        let mut server = Server::new_async().await;
1366        let body = r#"{"status":"running","error":null,"error_kind":null,"result":null}"#;
1367        let _m = server
1368            .mock("GET", "/executions/timeout-id/output")
1369            .with_status(200)
1370            .with_header("content-type", "application/json")
1371            .with_body(body)
1372            .expect_at_least(1)
1373            .create_async()
1374            .await;
1375
1376        let client = make_client(&server);
1377        let err = client
1378            .executions()
1379            .await_execution("timeout-id", Some(50), Some(10))
1380            .await
1381            .unwrap_err();
1382        assert!(matches!(err, AkribesError::Timeout { .. }));
1383    }
1384
1385    // ── list executions (builder) ─────────────────────────────────────────────
1386
1387    fn exec_status_json() -> &'static str {
1388        r#"{"id":"exec-1","project_id":1,"script_name":"my_script","status":"completed","started_at":"2026-04-02","finished_at":"2026-04-02","version_id":5,"channel":"production","error":null,"error_kind":null,"result":null,"documents":{"doc":"content"},"triggered_by":"studio"}"#
1389    }
1390
1391    #[tokio::test]
1392    async fn test_list_executions() {
1393        let mut server = Server::new_async().await;
1394        let body = format!("[{}]", exec_status_json());
1395        let _m = server
1396            .mock("GET", "/projects/1/scripts/my_script/executions")
1397            .with_status(200)
1398            .with_header("content-type", "application/json")
1399            .with_body(&body)
1400            .create_async()
1401            .await;
1402
1403        let client = make_client(&server);
1404        let execs = client
1405            .project(1)
1406            .executions()
1407            .list("my_script")
1408            .fetch()
1409            .await
1410            .unwrap();
1411        assert_eq!(execs.len(), 1);
1412        assert_eq!(execs[0].id, "exec-1");
1413        assert_eq!(execs[0].triggered_by.as_deref(), Some("studio"));
1414        assert!(execs[0].documents.is_some());
1415    }
1416
1417    #[tokio::test]
1418    async fn test_list_executions_with_filters() {
1419        let mut server = Server::new_async().await;
1420        let _m = server
1421            .mock(
1422                "GET",
1423                "/projects/1/scripts/my_script/executions?status=failed&channel=draft&limit=10&offset=5",
1424            )
1425            .with_status(200)
1426            .with_header("content-type", "application/json")
1427            .with_body("[]")
1428            .create_async()
1429            .await;
1430
1431        let client = make_client(&server);
1432        let execs = client
1433            .project(1)
1434            .executions()
1435            .list("my_script")
1436            .status("failed")
1437            .channel("draft")
1438            .limit(10)
1439            .offset(5)
1440            .fetch()
1441            .await
1442            .unwrap();
1443        assert!(execs.is_empty());
1444    }
1445
1446    // ── execution events ──────────────────────────────────────────────────────
1447
1448    fn mock_events_json() -> serde_json::Value {
1449        serde_json::json!([
1450            {"type": "WorkflowStart", "payload": 2},
1451            {"type": "TaskStart", "payload": ["summarise", null]},
1452            {"type": "AgentOutput", "payload": {
1453                "task_name": "summarise",
1454                "agent_name": null,
1455                "task_id": "t1",
1456                "schema_type": null,
1457                "chunk": "hello "
1458            }},
1459            {"type": "AgentOutput", "payload": {
1460                "task_name": "summarise",
1461                "agent_name": null,
1462                "task_id": "t1",
1463                "schema_type": null,
1464                "chunk": "world"
1465            }},
1466            // `WorkflowEnd(Value)` on the wire emits the clean form spec'd
1467            // in `docs/src/content/docs/reference/engine-events.mdx` —
1468            // a string output is the bare JSON string `"done"`, not the
1469            // engine's internal tagged-`Value` envelope `{"String":"done"}`.
1470            {"type": "WorkflowEnd", "payload": "done"},
1471        ])
1472    }
1473
1474    #[tokio::test]
1475    async fn test_get_execution_events_completed() {
1476        let mut server = Server::new_async().await;
1477        let body = serde_json::json!({
1478            "execution_id": "exec-1",
1479            "status": "completed",
1480            "complete": true,
1481            "events": mock_events_json(),
1482        });
1483        let _m = server
1484            .mock("GET", "/executions/exec-1/events")
1485            .with_status(200)
1486            .with_header("content-type", "application/json")
1487            .with_body(body.to_string())
1488            .create_async()
1489            .await;
1490
1491        let client = make_client(&server);
1492        let result = client
1493            .executions()
1494            .get_events("exec-1", None, None, None)
1495            .await
1496            .unwrap()
1497            .unwrap();
1498        assert_eq!(result.execution_id, "exec-1");
1499        assert_eq!(result.status, "completed");
1500        assert!(result.complete);
1501        assert_eq!(result.events.len(), 5);
1502        assert!(matches!(
1503            result.events[0],
1504            crate::models::EngineEvent::WorkflowStart(2)
1505        ));
1506        match &result.events[2] {
1507            crate::models::EngineEvent::AgentOutput { chunk, .. } => {
1508                assert_eq!(chunk, "hello ");
1509            }
1510            other => panic!("expected AgentOutput, got {other:?}"),
1511        }
1512    }
1513
1514    #[tokio::test]
1515    async fn test_get_execution_events_running_partial() {
1516        let mut server = Server::new_async().await;
1517        let body = serde_json::json!({
1518            "execution_id": "exec-2",
1519            "status": "running",
1520            "complete": false,
1521            "events": [
1522                {"type": "WorkflowStart", "payload": 2},
1523                {"type": "TaskStart", "payload": ["summarise", null]},
1524            ],
1525        });
1526        let _m = server
1527            .mock("GET", "/executions/exec-2/events")
1528            .with_status(200)
1529            .with_header("content-type", "application/json")
1530            .with_body(body.to_string())
1531            .create_async()
1532            .await;
1533
1534        let client = make_client(&server);
1535        let result = client
1536            .executions()
1537            .get_events("exec-2", None, None, None)
1538            .await
1539            .unwrap()
1540            .unwrap();
1541        assert!(!result.complete);
1542        assert_eq!(result.status, "running");
1543        assert_eq!(result.events.len(), 2);
1544    }
1545
1546    #[tokio::test]
1547    async fn test_get_execution_events_empty() {
1548        let mut server = Server::new_async().await;
1549        let body = serde_json::json!({
1550            "execution_id": "exec-3",
1551            "status": "running",
1552            "complete": false,
1553            "events": [],
1554        });
1555        let _m = server
1556            .mock("GET", "/executions/exec-3/events")
1557            .with_status(200)
1558            .with_header("content-type", "application/json")
1559            .with_body(body.to_string())
1560            .create_async()
1561            .await;
1562
1563        let client = make_client(&server);
1564        let result = client
1565            .executions()
1566            .get_events("exec-3", None, None, None)
1567            .await
1568            .unwrap()
1569            .unwrap();
1570        assert!(result.events.is_empty());
1571    }
1572
1573    #[tokio::test]
1574    async fn test_get_execution_events_not_found() {
1575        let mut server = Server::new_async().await;
1576        let _m = server
1577            .mock("GET", "/executions/missing/events")
1578            .with_status(404)
1579            .create_async()
1580            .await;
1581
1582        let client = make_client(&server);
1583        assert!(
1584            client
1585                .executions()
1586                .get_events("missing", None, None, None)
1587                .await
1588                .unwrap()
1589                .is_none()
1590        );
1591    }
1592
1593    // ── clients & state ───────────────────────────────────────────────────────
1594
1595    #[tokio::test]
1596    async fn test_list_clients() {
1597        let mut server = Server::new_async().await;
1598        let _m = server
1599            .mock("GET", "/projects/1/clients")
1600            .with_status(200)
1601            .with_header("content-type", "application/json")
1602            .with_body(r#"[{"id":"c1","name":"sdk","last_seen":"2024-01-01T00:00:00Z","scripts":["my-script"]}]"#)
1603            .create_async()
1604            .await;
1605
1606        let client = make_client(&server);
1607        let clients = client.project(1).registered_clients().list().await.unwrap();
1608        assert_eq!(clients.len(), 1);
1609    }
1610
1611    #[tokio::test]
1612    async fn test_delete_client() {
1613        let mut server = Server::new_async().await;
1614        let _m = server
1615            .mock("DELETE", "/clients/c1")
1616            .with_status(204)
1617            .create_async()
1618            .await;
1619
1620        let client = make_client(&server);
1621        assert!(
1622            client
1623                .project(1)
1624                .registered_clients()
1625                .delete("c1")
1626                .await
1627                .is_ok()
1628        );
1629    }
1630
1631    #[tokio::test]
1632    async fn test_get_state() {
1633        let mut server = Server::new_async().await;
1634        let _m = server
1635            .mock("GET", "/state")
1636            .with_status(200)
1637            .with_header("content-type", "application/json")
1638            .with_body(r#"{"env":{}}"#)
1639            .create_async()
1640            .await;
1641
1642        let client = make_client(&server);
1643        let state = client.get_state().await.unwrap();
1644        assert!(state.get("env").is_some());
1645    }
1646
1647    // ── tokens ────────────────────────────────────────────────────────────────
1648
1649    #[tokio::test]
1650    async fn test_list_tokens() {
1651        let mut server = Server::new_async().await;
1652        let _m = server
1653            .mock("GET", "/tokens")
1654            .with_status(200)
1655            .with_header("content-type", "application/json")
1656            .with_body(r#"[{"id":"tok_1","label":"test","user_email":null,"scopes":{"projects":"*","role":"admin"},"minted_by":"studio","expires_at":"2026-02-01T00:00:00Z","revoked":false,"created_at":"2026-01-01T00:00:00Z","last_used_at":null}]"#)
1657            .create_async()
1658            .await;
1659
1660        let client = make_client(&server);
1661        let tokens = client.tokens().list().await.unwrap();
1662        assert_eq!(tokens.len(), 1);
1663        assert_eq!(tokens[0].label, "test");
1664    }
1665
1666    #[tokio::test]
1667    async fn test_mint_token() {
1668        let mut server = Server::new_async().await;
1669        let _m = server
1670            .mock("POST", "/tokens")
1671            .with_status(201)
1672            .with_header("content-type", "application/json")
1673            .with_body(r#"{"token":"aura_tk_abc123","token_id":"tok_1","expires_at":"2026-02-01T00:00:00Z"}"#)
1674            .create_async()
1675            .await;
1676
1677        let client = make_client(&server);
1678        let req = MintTokenRequest {
1679            user_email: None,
1680            scopes: TokenScopes {
1681                projects: ProjectScope::Wildcard(WildcardMarker),
1682                role: TokenRole::Admin,
1683                scripts: None,
1684                executions: None,
1685                can_mint: false,
1686                features: vec![],
1687                org_id: None,
1688            },
1689            expires_in: 3600,
1690            label: "test".to_string(),
1691        };
1692        let res = client.tokens().mint(&req).await.unwrap();
1693        assert_eq!(res.token, "aura_tk_abc123");
1694        assert_eq!(res.token_id, "tok_1");
1695    }
1696
1697    #[tokio::test]
1698    async fn test_revoke_token() {
1699        let mut server = Server::new_async().await;
1700        let _m = server
1701            .mock("DELETE", "/tokens/tok_1")
1702            .with_status(204)
1703            .create_async()
1704            .await;
1705
1706        let client = make_client(&server);
1707        assert!(client.tokens().revoke("tok_1").await.is_ok());
1708    }
1709
1710    // ── ad-hoc execution ──────────────────────────────────────────────────────
1711
1712    #[tokio::test]
1713    async fn test_get_sandbox_project_id() {
1714        let mut server = Server::new_async().await;
1715        let _m = server
1716            .mock("GET", "/me/sandbox")
1717            .with_status(200)
1718            .with_header("content-type", "application/json")
1719            .with_body(r#"{"project_id":42}"#)
1720            .create_async()
1721            .await;
1722
1723        let client = make_client(&server);
1724        let pid = client.get_sandbox_project_id().await.unwrap();
1725        assert_eq!(pid, 42);
1726    }
1727
1728    #[tokio::test]
1729    async fn test_run_adhoc() {
1730        let mut server = Server::new_async().await;
1731        let _m = server
1732            .mock("POST", "/execute")
1733            .with_status(200)
1734            .with_header("content-type", "application/json")
1735            .match_body(r#"{"source":"workflow main {}"}"#)
1736            .with_body(r#"{"execution_id":"exec-1","project_id":42}"#)
1737            .create_async()
1738            .await;
1739
1740        let client = make_client(&server);
1741        let res = client
1742            .run_adhoc("workflow main {}", None, None)
1743            .await
1744            .unwrap();
1745        assert_eq!(res.execution_id, "exec-1");
1746        assert_eq!(res.project_id, 42);
1747    }
1748
1749    #[tokio::test]
1750    async fn test_run_adhoc_with_inputs() {
1751        let mut server = Server::new_async().await;
1752        let _m = server
1753            .mock("POST", "/execute")
1754            .with_status(200)
1755            .with_header("content-type", "application/json")
1756            .match_body(r#"{"source":"workflow main {}","inputs":{"doc":"hello"}}"#)
1757            .with_body(r#"{"execution_id":"exec-2","project_id":42}"#)
1758            .create_async()
1759            .await;
1760
1761        let client = make_client(&server);
1762        let mut inputs = std::collections::HashMap::new();
1763        inputs.insert(
1764            "doc".to_string(),
1765            serde_json::Value::String("hello".to_string()),
1766        );
1767        let res = client
1768            .run_adhoc("workflow main {}", Some(inputs), None)
1769            .await
1770            .unwrap();
1771        assert_eq!(res.execution_id, "exec-2");
1772    }
1773
1774    // ── error classification ──────────────────────────────────────────────────
1775
1776    #[tokio::test]
1777    async fn test_fatal_error_on_401() {
1778        let mut server = Server::new_async().await;
1779        let _m = server
1780            .mock("GET", "/projects")
1781            .with_status(401)
1782            .with_body("Unauthorized")
1783            .create_async()
1784            .await;
1785
1786        let client = make_client(&server);
1787        let err = client.projects().list().await.unwrap_err();
1788        assert!(matches!(err, AkribesError::Fatal { .. }));
1789    }
1790
1791    #[tokio::test]
1792    async fn test_fatal_error_on_403() {
1793        let mut server = Server::new_async().await;
1794        let _m = server
1795            .mock("GET", "/projects")
1796            .with_status(403)
1797            .with_body("Forbidden")
1798            .create_async()
1799            .await;
1800
1801        let client = make_client(&server);
1802        let err = client.projects().list().await.unwrap_err();
1803        assert!(matches!(err, AkribesError::Fatal { .. }));
1804    }
1805
1806    #[tokio::test]
1807    async fn test_transient_error_on_429() {
1808        let mut server = Server::new_async().await;
1809        let _m = server
1810            .mock("GET", "/projects")
1811            .with_status(429)
1812            .with_body("Too Many Requests")
1813            .create_async()
1814            .await;
1815
1816        let client = make_client(&server);
1817        let err = client.projects().list().await.unwrap_err();
1818        assert!(matches!(err, AkribesError::Transient { .. }));
1819    }
1820
1821    #[tokio::test]
1822    async fn test_transient_error_on_503() {
1823        let mut server = Server::new_async().await;
1824        let _m = server
1825            .mock("GET", "/projects")
1826            .with_status(503)
1827            .with_body("Service Unavailable")
1828            .create_async()
1829            .await;
1830
1831        let client = make_client(&server);
1832        let err = client.projects().list().await.unwrap_err();
1833        assert!(matches!(err, AkribesError::Transient { .. }));
1834    }
1835
1836    /// Retry-After header is parsed into `Transient.retry_after` (#1009).
1837    #[tokio::test]
1838    async fn test_retry_after_populated_on_429() {
1839        let mut server = Server::new_async().await;
1840        let _m = server
1841            .mock("GET", "/projects")
1842            .with_status(429)
1843            .with_header("Retry-After", "7")
1844            .with_body("Too Many Requests")
1845            .create_async()
1846            .await;
1847
1848        let client = make_client(&server);
1849        let err = client.projects().list().await.unwrap_err();
1850        match err {
1851            AkribesError::Transient {
1852                retry_after: Some(d),
1853                ..
1854            } => {
1855                assert_eq!(d.as_secs(), 7);
1856            }
1857            other => panic!("expected Transient with retry_after=Some(7s), got {other:?}"),
1858        }
1859    }
1860
1861    /// Missing Retry-After yields `None` (not an error).
1862    #[tokio::test]
1863    async fn test_retry_after_none_when_header_absent() {
1864        let mut server = Server::new_async().await;
1865        let _m = server
1866            .mock("GET", "/projects")
1867            .with_status(503)
1868            .with_body("down")
1869            .create_async()
1870            .await;
1871
1872        let client = make_client(&server);
1873        let err = client.projects().list().await.unwrap_err();
1874        match err {
1875            AkribesError::Transient {
1876                retry_after: None, ..
1877            } => {}
1878            other => panic!("expected Transient with retry_after=None, got {other:?}"),
1879        }
1880    }
1881
1882    /// HTTP-date form is ignored (matches Python's behavior).
1883    #[tokio::test]
1884    async fn test_retry_after_ignored_on_http_date() {
1885        let mut server = Server::new_async().await;
1886        let _m = server
1887            .mock("GET", "/projects")
1888            .with_status(429)
1889            .with_header("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT")
1890            .with_body("rate-limited")
1891            .create_async()
1892            .await;
1893
1894        let client = make_client(&server);
1895        let err = client.projects().list().await.unwrap_err();
1896        match err {
1897            AkribesError::Transient {
1898                retry_after: None, ..
1899            } => {}
1900            other => {
1901                panic!("expected Transient with retry_after=None for HTTP-date, got {other:?}")
1902            }
1903        }
1904    }
1905
1906    #[tokio::test]
1907    async fn test_transient_on_500_with_status() {
1908        // #1296: HTTP 500 routes to `AkribesError::Transient` with the
1909        // status code captured so callers can pick the right base backoff
1910        // via `AkribesError::recommended_backoff_ms(500)`. Previously this
1911        // fell through to the catch-all `HttpStatus` path.
1912        let mut server = Server::new_async().await;
1913        let _m = server
1914            .mock("GET", "/projects")
1915            .with_status(500)
1916            .with_body("Internal Server Error")
1917            .create_async()
1918            .await;
1919
1920        let client = make_client(&server);
1921        let err = client.projects().list().await.unwrap_err();
1922        assert!(
1923            matches!(
1924                err,
1925                AkribesError::Transient {
1926                    status: Some(500),
1927                    ..
1928                }
1929            ),
1930            "expected Transient with status=500, got {err:?}",
1931        );
1932    }
1933
1934    #[tokio::test]
1935    async fn test_transient_on_504_with_status() {
1936        // #1296: HTTP 504 routes to `Transient` with status=504 captured.
1937        // `AkribesError::recommended_backoff_ms(504) > recommended_backoff_ms(500)`
1938        // expresses the per-status retry-policy split.
1939        let mut server = Server::new_async().await;
1940        let _m = server
1941            .mock("GET", "/projects")
1942            .with_status(504)
1943            .with_body("Gateway Timeout")
1944            .create_async()
1945            .await;
1946
1947        let client = make_client(&server);
1948        let err = client.projects().list().await.unwrap_err();
1949        assert!(
1950            matches!(
1951                err,
1952                AkribesError::Transient {
1953                    status: Some(504),
1954                    ..
1955                }
1956            ),
1957            "expected Transient with status=504, got {err:?}",
1958        );
1959        // Spot-check the per-status retry-policy table: 504 must back off
1960        // longer than 500/502 (slow upstream).
1961        let b504 = AkribesError::recommended_backoff_ms(504).unwrap();
1962        let b500 = AkribesError::recommended_backoff_ms(500).unwrap();
1963        let b502 = AkribesError::recommended_backoff_ms(502).unwrap();
1964        let b503 = AkribesError::recommended_backoff_ms(503).unwrap();
1965        assert!(b504 > b500);
1966        assert!(b504 > b502);
1967        // 503 mirrors 429.
1968        assert_eq!(b503, AkribesError::recommended_backoff_ms(429).unwrap());
1969        // 500 and 502 share the same short base.
1970        assert_eq!(b500, b502);
1971        assert_eq!(err.transient_status(), Some(504));
1972    }
1973
1974    // ── missing project_id ───────────────────────────────────────────────────
1975
1976    #[test]
1977    fn test_scoped_shim_without_project_id() {
1978        // A client built without project_id should fail the `.scoped()` shim
1979        // (used by consumers that pre-bind a project at construction time).
1980        // `.project(id)` remains infallible — it supplies the id directly.
1981        let client = AkribesClient::builder("http://localhost:3001")
1982            .name("test-app")
1983            .id("test-id")
1984            .build();
1985
1986        assert!(matches!(
1987            client.scoped(),
1988            Err(AkribesError::MissingProjectId)
1989        ));
1990        // .project(id) is infallible and returns a ProjectScope directly.
1991        let _ = client.project(1).scripts();
1992    }
1993
1994    // ── document helpers ────────────────────────────────────────────────────
1995
1996    #[tokio::test]
1997    async fn test_get_document() {
1998        let mut server = Server::new_async().await;
1999        let body = r#"{"id":"doc_abc","filename":"report.pdf","content_type":"application/pdf","size_bytes":1024,"content_hash":"abc123","conversion_status":"ready","conversion_error":null,"created_at":"2026-04-12T00:00:00Z"}"#;
2000        let _m = server
2001            .mock("GET", "/documents/doc_abc")
2002            .with_status(200)
2003            .with_header("content-type", "application/json")
2004            .with_body(body)
2005            .create_async()
2006            .await;
2007
2008        let client = make_authed_client(&server);
2009        let doc = client.executions().get_document("doc_abc").await.unwrap();
2010        let doc = doc.expect("should return Some");
2011        assert_eq!(doc.id, "doc_abc");
2012        assert_eq!(doc.filename, "report.pdf");
2013        assert_eq!(doc.conversion_status, "ready");
2014    }
2015
2016    #[tokio::test]
2017    async fn test_get_document_not_found() {
2018        let mut server = Server::new_async().await;
2019        let _m = server
2020            .mock("GET", "/documents/doc_missing")
2021            .with_status(404)
2022            .create_async()
2023            .await;
2024
2025        let client = make_authed_client(&server);
2026        let doc = client
2027            .executions()
2028            .get_document("doc_missing")
2029            .await
2030            .unwrap();
2031        assert!(doc.is_none());
2032    }
2033
2034    #[tokio::test]
2035    async fn test_get_document_markdown() {
2036        let mut server = Server::new_async().await;
2037        let _m = server
2038            .mock("GET", "/documents/doc_abc/markdown")
2039            .with_status(200)
2040            .with_header("content-type", "application/json")
2041            .with_body(r##"{"markdown":"# Hello World"}"##)
2042            .create_async()
2043            .await;
2044
2045        let client = make_authed_client(&server);
2046        let md = client
2047            .executions()
2048            .get_document_markdown("doc_abc")
2049            .await
2050            .unwrap();
2051        assert_eq!(md, "# Hello World");
2052    }
2053
2054    #[tokio::test]
2055    async fn test_get_document_url() {
2056        let mut server = Server::new_async().await;
2057        let presigned = "https://s3.example.com/documents/doc_abc/report.pdf?token=xyz";
2058        let _m = server
2059            .mock("GET", "/documents/doc_abc/content")
2060            .with_status(200)
2061            .with_header("location", presigned)
2062            .create_async()
2063            .await;
2064
2065        let client = make_authed_client(&server);
2066        let url = client
2067            .executions()
2068            .get_document_url("doc_abc")
2069            .await
2070            .unwrap();
2071        assert_eq!(url, presigned);
2072    }
2073
2074    #[tokio::test]
2075    async fn test_reconvert_document() {
2076        let mut server = Server::new_async().await;
2077        let _m = server
2078            .mock("POST", "/documents/doc_abc/convert")
2079            .with_status(200)
2080            .with_header("content-type", "application/json")
2081            .with_body(r#"{"status":"ready"}"#)
2082            .create_async()
2083            .await;
2084
2085        let client = make_authed_client(&server);
2086        let resp = client
2087            .executions()
2088            .reconvert_document("doc_abc")
2089            .await
2090            .unwrap();
2091        assert_eq!(resp["status"], "ready");
2092    }
2093
2094    // ── Flat ProjectsClient cross-project script ops ──────────────────────────
2095
2096    #[tokio::test]
2097    async fn test_projects_list_scripts_flat() {
2098        let mut server = Server::new_async().await;
2099        let body = format!("[{}]", script_json());
2100        let _m = server
2101            .mock("GET", "/projects/7/scripts")
2102            .with_status(200)
2103            .with_header("content-type", "application/json")
2104            .with_body(&body)
2105            .create_async()
2106            .await;
2107
2108        let client = make_client(&server);
2109        let scripts = client.projects().list_scripts(7).await.unwrap();
2110        assert_eq!(scripts.len(), 1);
2111        assert_eq!(scripts[0].name, "my_script");
2112    }
2113
2114    #[tokio::test]
2115    async fn test_projects_move_script_flat() {
2116        let mut server = Server::new_async().await;
2117        let _m = server
2118            .mock("POST", "/projects/3/scripts/foo/move")
2119            .match_body(r#"{"target_project_id":9}"#)
2120            .with_status(200)
2121            .with_header("content-type", "application/json")
2122            .with_body(script_json())
2123            .create_async()
2124            .await;
2125
2126        let client = make_client(&server);
2127        let s = client.projects().move_script(3, "foo", 9).await.unwrap();
2128        assert_eq!(s.id, 5);
2129    }
2130
2131    #[tokio::test]
2132    async fn test_projects_rename_script_flat() {
2133        let mut server = Server::new_async().await;
2134        let _m = server
2135            .mock("PATCH", "/projects/3/scripts/old")
2136            .match_body(r#"{"new_name":"new"}"#)
2137            .with_status(204)
2138            .create_async()
2139            .await;
2140
2141        let client = make_client(&server);
2142        client
2143            .projects()
2144            .rename_script(3, "old", "new")
2145            .await
2146            .unwrap();
2147    }
2148
2149    #[tokio::test]
2150    async fn test_projects_delete_script_flat() {
2151        let mut server = Server::new_async().await;
2152        let _m = server
2153            .mock("DELETE", "/projects/3/scripts/foo")
2154            .with_status(204)
2155            .create_async()
2156            .await;
2157
2158        let client = make_client(&server);
2159        client.projects().delete_script(3, "foo").await.unwrap();
2160    }
2161
2162    #[tokio::test]
2163    async fn test_projects_duplicate_script_flat() {
2164        let mut server = Server::new_async().await;
2165        let _m = server
2166            .mock("POST", "/projects/3/scripts/foo/duplicate")
2167            .with_status(200)
2168            .with_header("content-type", "application/json")
2169            .with_body(script_json())
2170            .create_async()
2171            .await;
2172
2173        let client = make_client(&server);
2174        let s = client
2175            .projects()
2176            .duplicate_script(3, "foo", None)
2177            .await
2178            .unwrap();
2179        assert_eq!(s.name, "my_script");
2180    }
2181
2182    // ── Flat lock helpers on RegisteredClientsClient ──────────────────────────
2183
2184    fn lock_json() -> &'static str {
2185        r#"{
2186            "id":42,
2187            "client_id":"c1",
2188            "client_name":"sdk",
2189            "script_name":"my_script",
2190            "channel":"production",
2191            "bound_version_id":3,
2192            "lifetime":"persistent",
2193            "drifted":false,
2194            "created_by":null,
2195            "created_at":"2026-04-01T00:00:00Z",
2196            "input_schema":"{}"
2197        }"#
2198    }
2199
2200    #[tokio::test]
2201    async fn test_list_locks_for_flat() {
2202        let mut server = Server::new_async().await;
2203        let body = format!("[{}]", lock_json());
2204        let _m = server
2205            .mock("GET", "/projects/7/scripts/my_script/locks")
2206            .with_status(200)
2207            .with_header("content-type", "application/json")
2208            .with_body(&body)
2209            .create_async()
2210            .await;
2211
2212        let client = make_client(&server);
2213        // The implicit project_id on the registered_clients client is `1`, but
2214        // `list_locks_for(7, ...)` must hit project 7 — that's the whole point
2215        // of the flat helper.
2216        let locks = client
2217            .project(1)
2218            .registered_clients()
2219            .list_locks_for(7, "my_script")
2220            .await
2221            .unwrap();
2222        assert_eq!(locks.len(), 1);
2223        assert_eq!(locks[0].id, 42);
2224    }
2225
2226    #[tokio::test]
2227    async fn test_delete_lock_flat() {
2228        let mut server = Server::new_async().await;
2229        let _m = server
2230            .mock("DELETE", "/projects/7/scripts/my_script/locks/42")
2231            .with_status(204)
2232            .create_async()
2233            .await;
2234
2235        let client = make_client(&server);
2236        client
2237            .project(1)
2238            .registered_clients()
2239            .delete_lock(7, "my_script", 42)
2240            .await
2241            .unwrap();
2242    }
2243
2244    #[tokio::test]
2245    async fn test_update_lock_flat() {
2246        let mut server = Server::new_async().await;
2247        let _m = server
2248            .mock("PATCH", "/projects/7/scripts/my_script/locks/42/rebind")
2249            .match_body(r#"{"version_id":11}"#)
2250            .with_status(200)
2251            .with_header("content-type", "application/json")
2252            .with_body(lock_json())
2253            .create_async()
2254            .await;
2255
2256        let client = make_client(&server);
2257        let lock = client
2258            .project(1)
2259            .registered_clients()
2260            .update_lock(7, "my_script", 42, Some(11))
2261            .await
2262            .unwrap();
2263        assert_eq!(lock.id, 42);
2264        assert_eq!(lock.bound_version_id, Some(3));
2265    }
2266}