//! HTTP request builders and response extraction helpers. //! //! The stock axum `Request::builder()…oneshot()` chain is verbose; //! centralising the common shapes here turns ~8 repetitive lines per //! call site into one function call, and localises URL/header shape //! changes to this file. use axum::body::Body; use axum::http::{Request, header}; use axum::response::Response; use doctate_common::{ API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT, }; /// Plain form-urlencoded POST with a session cookie. `body` is passed /// verbatim; CSRF-protected calls should use [`csrf_form_post`] to add /// the token automatically. pub fn form_post>(uri: U, cookie: &str, body: impl Into) -> Request { Request::builder() .method("POST") .uri(uri.as_ref()) .header(header::COOKIE, cookie) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(body.into())) .unwrap() } /// Form-urlencoded POST that appends `csrf_token={csrf}` automatically. /// Pass an empty `extra_body` when the form has no other fields /// (e.g. POST /cases/{id}/close). pub fn csrf_form_post>( uri: U, cookie: &str, csrf: &str, extra_body: &str, ) -> Request { let body = if extra_body.is_empty() { format!("csrf_token={csrf}") } else { format!("{extra_body}&csrf_token={csrf}") }; form_post(uri, cookie, body) } /// GET with a session cookie — authenticated `/*` pages. pub fn get_with_cookie>(uri: U, cookie: &str) -> Request { Request::builder() .method("GET") .uri(uri.as_ref()) .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap() } /// GET with `X-API-Key` — the public `/api/*` surface used by the /// watch/desktop clients. pub fn get_with_api_key>(uri: U, api_key: &str) -> Request { Request::builder() .method("GET") .uri(uri.as_ref()) .header(API_KEY_HEADER, api_key) .body(Body::empty()) .unwrap() } /// GET with `X-API-Key` and a conditional `If-None-Match` header. Used /// by the oneliners ETag tests and the upload watermark-invalidation /// tests. pub fn get_with_api_key_if_none_match>( uri: U, api_key: &str, if_none_match: &str, ) -> Request { Request::builder() .method("GET") .uri(uri.as_ref()) .header(API_KEY_HEADER, api_key) .header(header::IF_NONE_MATCH, if_none_match) .body(Body::empty()) .unwrap() } /// Consume a response body into raw bytes. pub async fn body_bytes(response: Response) -> Vec { axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap() .to_vec() } /// Consume a response body into a UTF-8 string. Panics on non-UTF-8 — /// which is correct for the HTML/JSON responses these tests assert on. pub async fn body_string(response: Response) -> String { String::from_utf8(body_bytes(response).await).unwrap() } /// Consume a response body into a `serde_json::Value`. Panics on /// malformed JSON, which the test should want to surface loudly. pub async fn body_json(response: Response) -> serde_json::Value { let bytes = body_bytes(response).await; serde_json::from_slice(&bytes).unwrap() } /// Borrow a header value as a `&str`. Returns `None` if the header is /// absent or not valid UTF-8. pub fn header_opt<'a>(response: &'a Response, name: &str) -> Option<&'a str> { response.headers().get(name).and_then(|v| v.to_str().ok()) } /// Header value as an owned `String`, `""` if missing. Matches the /// existing style in `oneliners_api_test.rs`'s `header_str` helper. pub fn header_str(response: &Response, name: &str) -> String { header_opt(response, name).unwrap_or("").to_owned() } /// Build a multipart body for `POST /api/upload`. Returns /// `(content_type_header_value, body_bytes)` — pass the first as the /// `Content-Type` header verbatim. /// /// Uses the server's canonical field names (`case_id`, `recorded_at`, /// `audio`) from `doctate_common::constants`, so a server-side rename /// propagates here for free. pub fn multipart_upload_body(case_id: &str, recorded_at: &str, audio: &[u8]) -> (String, Vec) { let boundary = "----testboundary"; let mut body = Vec::new(); // case_id body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); body.extend_from_slice( format!("Content-Disposition: form-data; name=\"{FIELD_CASE_ID}\"\r\n\r\n").as_bytes(), ); body.extend_from_slice(case_id.as_bytes()); body.extend_from_slice(b"\r\n"); // recorded_at body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); body.extend_from_slice( format!("Content-Disposition: form-data; name=\"{FIELD_RECORDED_AT}\"\r\n\r\n").as_bytes(), ); body.extend_from_slice(recorded_at.as_bytes()); body.extend_from_slice(b"\r\n"); // audio body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); body.extend_from_slice( format!( "Content-Disposition: form-data; name=\"{FIELD_AUDIO}\"; filename=\"test.m4a\"\r\n" ) .as_bytes(), ); body.extend_from_slice(format!("Content-Type: {CONTENT_TYPE_AUDIO_MP4}\r\n\r\n").as_bytes()); body.extend_from_slice(audio); body.extend_from_slice(b"\r\n"); // end body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); (format!("multipart/form-data; boundary={boundary}"), body) }