Skip to main content

ocre/
testing.rs

1//! Test helpers for Ocre apps, like Rails' `ActionDispatch::IntegrationTest`
2//! and `ActiveSupport::Testing`: a request client for the running app, the
3//! test database, the server log, time travel and assertions.
4//!
5//! Feature `testing`, native builds only: a generated app lists
6//! `ocre = { ..., features = ["testing"] }` under `[dev-dependencies]`, so the
7//! module is in `cargo test` and never in the WebAssembly build.
8//!
9//! # How request tests run
10//!
11//! Handlers need workerd (D1, KV, Queues... exist only there), so request
12//! tests talk HTTP to the real runtime. `ocre test --e2e`:
13//!
14//! 1. creates a fresh local D1 database in `.wrangler/test-state` (the
15//!    development data in `.wrangler/state` is untouched), applies the
16//!    migrations and loads the fixtures of `tests/fixtures/`,
17//! 2. starts one `cf dev` on that state for the whole run, logging to
18//!    `.wrangler/test-state/dev.log`,
19//! 3. runs `cargo test -- --ignored` with [`TEST_URL`], [`TEST_STATE`] and
20//!    [`TEST_LOG`] set. In an Ocre app, `#[ignore]` marks the tests that need
21//!    the runtime: plain `cargo test` (and `ocre test`) skips them.
22//!
23//! ```no_run
24//! // tests/posts.rs
25//! use ocre::testing::Client;
26//!
27//! #[test]
28//! #[ignore = "request test: run with `ocre test --e2e`"]
29//! fn creates_a_post() {
30//!     let mut client = Client::new();
31//!     let created = client.post("/posts", &[("title", "Hello"), ("body", "First post")]);
32//!     created.assert_redirect_to("/posts/1");
33//!     assert_eq!(client.flash("notice").as_deref(), Some("Post was successfully created."));
34//!     client.follow_redirect(&created).assert_status(200).assert_contains("Hello");
35//! }
36//! ```
37//!
38//! There are no per-test transactions (Rails' transactional tests): the
39//! database belongs to the `cf dev` process, which the test process reaches
40//! only over HTTP or through wrangler. The database is fresh for each run;
41//! tests running in parallel share it, so they create their own records
42//! (factories give unique values) and assert on them rather than on global
43//! counts, or run with `-- --test-threads=1`.
44
45use std::{
46    collections::BTreeMap,
47    fmt::Debug,
48    path::{Path, PathBuf},
49    process::Command,
50    time::{Duration, Instant},
51};
52
53use cookie::{Cookie, CookieJar, Key};
54use serde::{Serialize, de::DeserializeOwned};
55use serde_json::{Map, Value};
56
57use crate::{SESSION_COOKIE, mail::Email};
58
59/// Environment variable holding the base URL of the server `ocre test --e2e` started.
60///
61/// # Examples
62///
63/// ```
64/// assert_eq!(ocre::testing::TEST_URL, "OCRE_TEST_URL");
65/// ```
66pub const TEST_URL: &str = "OCRE_TEST_URL";
67
68/// Environment variable holding the local state directory of the test run (`.wrangler/test-state`).
69///
70/// # Examples
71///
72/// ```
73/// assert_eq!(ocre::testing::TEST_STATE, "OCRE_TEST_STATE");
74/// ```
75pub const TEST_STATE: &str = "OCRE_TEST_STATE";
76
77/// Environment variable holding the path of the test server's log.
78///
79/// # Examples
80///
81/// ```
82/// assert_eq!(ocre::testing::TEST_LOG, "OCRE_TEST_LOG");
83/// ```
84pub const TEST_LOG: &str = "OCRE_TEST_LOG";
85
86/// How long [`eventually`] and [`Log::wait_for`] wait.
87const WAIT: Duration = Duration::from_secs(30);
88
89/// An HTTP client for request tests, with a cookie jar, like a browser tab
90/// (Rails' integration session).
91///
92/// Every request carries `Sec-Fetch-Site: same-origin`, as a browser's
93/// same-site form does, so Ocre's cross-site request check passes; use
94/// [`Client::cross_site`] to test the check. Cookies from `Set-Cookie`
95/// are sent back on the next requests, so a sign-in, the session and flash
96/// messages carry over. Redirects are not followed automatically: assert on
97/// them, then call [`Client::follow_redirect`]. Two clients are two
98/// independent visitors (Rails' `open_session`).
99///
100/// # Examples
101///
102/// ```no_run
103/// use ocre::testing::Client;
104///
105/// let mut client = Client::new();
106/// client.get("/up").assert_status(200).assert_contains("OK");
107/// ```
108#[derive(Debug)]
109pub struct Client {
110    agent: ureq::Agent,
111    base: String,
112    headers: Vec<(String, String)>,
113    /// `name` -> value as the server sent it (percent-encoded).
114    cookies: BTreeMap<String, String>,
115}
116
117impl Client {
118    /// A client for the server `ocre test --e2e` started ([`TEST_URL`]).
119    ///
120    /// # Panics
121    ///
122    /// When [`TEST_URL`] is not set: the test was run with plain `cargo test -- --ignored`.
123    ///
124    /// # Examples
125    ///
126    /// ```no_run
127    /// let mut client = ocre::testing::Client::new();
128    /// client.get("/").assert_success();
129    /// ```
130    #[allow(clippy::new_without_default)]
131    pub fn new() -> Self {
132        let base = std::env::var(TEST_URL).unwrap_or_else(|_| {
133            panic!("{TEST_URL} is not set. Fix: run request tests with `ocre test --e2e`, which starts the server")
134        });
135        Self::with_base_url(&base)
136    }
137
138    /// A client for the app at `base` (`http://localhost:8787`), e.g. a running `ocre dev`.
139    ///
140    /// # Examples
141    ///
142    /// ```no_run
143    /// let mut client = ocre::testing::Client::with_base_url("http://localhost:8787");
144    /// client.get("/up").assert_status(200);
145    /// ```
146    pub fn with_base_url(base: &str) -> Self {
147        let agent = ureq::Agent::config_builder()
148            .http_status_as_error(false)
149            .max_redirects(0)
150            .allow_non_standard_methods(true)
151            .timeout_global(Some(Duration::from_secs(60)))
152            .build()
153            .into();
154        Self {
155            agent,
156            base: base.trim_end_matches('/').to_owned(),
157            headers: vec![("sec-fetch-site".to_owned(), "same-origin".to_owned())],
158            cookies: BTreeMap::new(),
159        }
160    }
161
162    /// Sends `name: value` with every request (Rails' `headers:`); replaces
163    /// an earlier value of the same header.
164    ///
165    /// # Examples
166    ///
167    /// ```no_run
168    /// let mut client = ocre::testing::Client::new().header("Accept-Language", "fr");
169    /// client.get("/").assert_contains("Bienvenue");
170    /// ```
171    pub fn header(mut self, name: &str, value: &str) -> Self {
172        let name = name.to_ascii_lowercase();
173        self.headers.retain(|(existing, _)| *existing != name);
174        self.headers.push((name, value.to_owned()));
175        self
176    }
177
178    /// Requests as a form posted from another site would (`Sec-Fetch-Site: cross-site`):
179    /// Ocre's CSRF protection answers 403 to unsafe methods.
180    ///
181    /// # Examples
182    ///
183    /// ```no_run
184    /// let mut client = ocre::testing::Client::new().cross_site();
185    /// client.post("/posts", &[("title", "x")]).assert_status(403);
186    /// ```
187    pub fn cross_site(self) -> Self {
188        self.header("Sec-Fetch-Site", "cross-site")
189    }
190
191    /// Requests as htmx does (`HX-Request: true`), Rails' `xhr: true`.
192    ///
193    /// # Examples
194    ///
195    /// ```no_run
196    /// let mut client = ocre::testing::Client::new().htmx();
197    /// client.get("/posts").assert_not_contains("<html");
198    /// ```
199    pub fn htmx(self) -> Self {
200        self.header("HX-Request", "true")
201    }
202
203    /// `GET path`.
204    ///
205    /// # Examples
206    ///
207    /// ```no_run
208    /// ocre::testing::Client::new().get("/posts?page=2").assert_status(200);
209    /// ```
210    pub fn get(&mut self, path: &str) -> Response {
211        self.request("GET", path, None)
212    }
213
214    /// `POST path` with an `application/x-www-form-urlencoded` body, as an HTML form sends it.
215    ///
216    /// `form` is anything serde_urlencoded takes: a slice of `(name, value)`
217    /// pairs, a factory's `form()`, a struct deriving `Serialize`, or `&()` for an empty body.
218    ///
219    /// # Panics
220    ///
221    /// When `form` is not a flat list of fields.
222    ///
223    /// # Examples
224    ///
225    /// ```no_run
226    /// let mut client = ocre::testing::Client::new();
227    /// client.post("/posts", &[("title", "Hello"), ("body", "World")]).assert_status(303);
228    /// client.post("/posts/1/delete", &()).assert_redirect_to("/posts");
229    /// ```
230    pub fn post(&mut self, path: &str, form: &impl Serialize) -> Response {
231        let body = serde_urlencoded::to_string(form).expect("form fields encode");
232        self.request("POST", path, Some(("application/x-www-form-urlencoded", body.into_bytes())))
233    }
234
235    /// `POST path` with `value` as a JSON body.
236    ///
237    /// # Examples
238    ///
239    /// ```no_run
240    /// let mut client = ocre::testing::Client::new();
241    /// let created = client.post_json("/api/posts", &ocre::serde_json::json!({"title": "Hello"}));
242    /// assert_eq!(created.assert_status(201).json::<ocre::serde_json::Value>()["title"], "Hello");
243    /// ```
244    pub fn post_json(&mut self, path: &str, value: &impl Serialize) -> Response {
245        self.json("POST", path, value)
246    }
247
248    /// `PATCH path` with `value` as a JSON body.
249    ///
250    /// # Examples
251    ///
252    /// ```no_run
253    /// let mut client = ocre::testing::Client::new();
254    /// client.patch_json("/api/posts/1", &ocre::serde_json::json!({"title": "New"})).assert_status(200);
255    /// ```
256    pub fn patch_json(&mut self, path: &str, value: &impl Serialize) -> Response {
257        self.json("PATCH", path, value)
258    }
259
260    /// `PUT path` with `value` as a JSON body.
261    ///
262    /// # Examples
263    ///
264    /// ```no_run
265    /// let mut client = ocre::testing::Client::new();
266    /// client.put_json("/api/settings", &ocre::serde_json::json!({"theme": "dark"})).assert_success();
267    /// ```
268    pub fn put_json(&mut self, path: &str, value: &impl Serialize) -> Response {
269        self.json("PUT", path, value)
270    }
271
272    /// `DELETE path`.
273    ///
274    /// # Examples
275    ///
276    /// ```no_run
277    /// ocre::testing::Client::new().delete("/api/posts/1").assert_status(204);
278    /// ```
279    pub fn delete(&mut self, path: &str) -> Response {
280        self.request("DELETE", path, None)
281    }
282
283    fn json(&mut self, method: &str, path: &str, value: &impl Serialize) -> Response {
284        let body = serde_json::to_vec(value).expect("the value serializes to JSON");
285        self.request(method, path, Some(("application/json", body)))
286    }
287
288    /// Sends `method path` with an optional `(content type, body)`: any
289    /// method, any body (multipart uploads, raw bytes).
290    ///
291    /// # Panics
292    ///
293    /// When the server cannot be reached.
294    ///
295    /// # Examples
296    ///
297    /// ```no_run
298    /// let mut client = ocre::testing::Client::new();
299    /// client.request("HEAD", "/up", None).assert_status(200);
300    /// client.request("POST", "/api/raw", Some(("text/plain", b"bytes".to_vec()))).assert_success();
301    /// ```
302    pub fn request(&mut self, method: &str, path: &str, body: Option<(&str, Vec<u8>)>) -> Response {
303        let url = format!("{}{path}", self.base);
304        let mut builder = ureq::http::Request::builder().method(method).uri(&url);
305        for (name, value) in &self.headers {
306            builder = builder.header(name, value);
307        }
308        if !self.cookies.is_empty() {
309            let cookies: Vec<String> = self.cookies.iter().map(|(name, value)| format!("{name}={value}")).collect();
310            builder = builder.header("cookie", cookies.join("; "));
311        }
312        let result = match body {
313            Some((content_type, bytes)) => {
314                self.agent.run(builder.header("content-type", content_type).body(bytes).expect("valid request"))
315            }
316            None => self.agent.run(builder.body(()).expect("valid request")),
317        };
318        let mut response = result.unwrap_or_else(|err| panic!("{method} {url} failed: {err}"));
319        let headers: Vec<(String, String)> = response
320            .headers()
321            .iter()
322            .map(|(name, value)| (name.as_str().to_owned(), String::from_utf8_lossy(value.as_bytes()).into_owned()))
323            .collect();
324        for (name, value) in &headers {
325            if name == "set-cookie" {
326                self.store_cookie(value);
327            }
328        }
329        let status = response.status().as_u16();
330        let body = if method == "HEAD" || status == 204 || status == 304 {
331            String::new()
332        } else {
333            let bytes = body_bytes(response.body_mut().read_to_vec(), method, &url);
334            String::from_utf8_lossy(&bytes).into_owned()
335        };
336        Response { status, headers, body }
337    }
338
339    fn store_cookie(&mut self, set_cookie: &str) {
340        let Ok(cookie) = Cookie::parse(set_cookie) else { return };
341        let removed = cookie.value().is_empty() || cookie.max_age().is_some_and(|max_age| max_age.whole_seconds() <= 0);
342        if removed {
343            self.cookies.remove(cookie.name());
344        } else {
345            self.cookies.insert(cookie.name().to_owned(), cookie.value().to_owned());
346        }
347    }
348
349    /// Follows the redirect `response` answered: `GET` of its `Location` (Rails' `follow_redirect!`).
350    ///
351    /// # Panics
352    ///
353    /// When `response` has no `Location` header.
354    ///
355    /// # Examples
356    ///
357    /// ```no_run
358    /// let mut client = ocre::testing::Client::new();
359    /// let created = client.post("/posts", &[("title", "Hello"), ("body", "World")]);
360    /// client.follow_redirect(&created).assert_contains("Post was successfully created.");
361    /// ```
362    pub fn follow_redirect(&mut self, response: &Response) -> Response {
363        let location = response.location().unwrap_or_else(|| {
364            panic!("expected a redirect, got {} without a Location header:\n{}", response.status, response.excerpt())
365        });
366        let path = location.strip_prefix(&self.base).unwrap_or(location).to_owned();
367        self.get(&path)
368    }
369
370    /// The cookie `name` as the server set it (percent-encoded), if the jar has it.
371    ///
372    /// # Examples
373    ///
374    /// ```no_run
375    /// let mut client = ocre::testing::Client::new();
376    /// client.get("/");
377    /// assert!(client.cookie("_ocre_session").is_none());
378    /// ```
379    pub fn cookie(&self, name: &str) -> Option<&str> {
380        self.cookies.get(name).map(String::as_str)
381    }
382
383    /// Sets a cookie sent with the next requests, as if the server had set it.
384    ///
385    /// # Examples
386    ///
387    /// ```no_run
388    /// let mut client = ocre::testing::Client::new();
389    /// client.set_cookie("locale", "fr");
390    /// ```
391    pub fn set_cookie(&mut self, name: &str, value: &str) {
392        self.cookies.insert(name.to_owned(), value.to_owned());
393    }
394
395    /// The session data (Rails' `session` in tests), decrypted with the
396    /// app's `SECRET_KEY_BASE` (the environment variable, else `.dev.vars`):
397    /// empty without a session cookie. Flash messages set for the next
398    /// request are under `_flash`: [`Client::flash`] reads them.
399    ///
400    /// # Panics
401    ///
402    /// When there is a session cookie but no `SECRET_KEY_BASE`, or the
403    /// cookie does not decrypt with it.
404    ///
405    /// # Examples
406    ///
407    /// ```no_run
408    /// let mut client = ocre::testing::Client::new();
409    /// client.post("/session", &[("email", "ada@example.com"), ("password", "secret")]);
410    /// assert!(client.session().contains_key("user_id"));
411    /// ```
412    pub fn session(&self) -> Map<String, Value> {
413        let Some(value) = self.cookies.get(SESSION_COOKIE) else { return Map::new() };
414        let secret = var("SECRET_KEY_BASE")
415            .expect("SECRET_KEY_BASE is not set (environment or .dev.vars): cannot read the session");
416        decrypt_session(value, &secret).expect("the session cookie does not decrypt with SECRET_KEY_BASE")
417    }
418
419    /// The flash message of `kind` (`notice`, `alert`...) set by the last
420    /// request for the next page (Rails' `flash[:notice]` after an action).
421    ///
422    /// # Examples
423    ///
424    /// ```no_run
425    /// let mut client = ocre::testing::Client::new();
426    /// client.post("/posts", &[("title", "Hello"), ("body", "World")]);
427    /// assert_eq!(client.flash("notice").as_deref(), Some("Post was successfully created."));
428    /// ```
429    pub fn flash(&self, kind: &str) -> Option<String> {
430        self.session().get("_flash")?.get(kind)?.as_str().map(str::to_owned)
431    }
432
433    /// Emails the app sent with `MAIL_ADAPTER = "log"` (Rails'
434    /// `ActionMailer::Base.deliveries`), oldest first: the last 20, kept by
435    /// the Worker instance. Reads `GET /ocre/dev/mailers/sent.json`, which
436    /// exists when `routes()` merges `ocre::mail::dev_routes` (`ocre g mailer` adds it).
437    ///
438    /// # Panics
439    ///
440    /// When the endpoint does not answer a JSON list.
441    ///
442    /// # Examples
443    ///
444    /// ```no_run
445    /// let mut client = ocre::testing::Client::new();
446    /// let before = client.deliveries().len();
447    /// client.post("/passwords", &[("email", "ada@example.com")]);
448    /// let sent = client.deliveries();
449    /// assert_eq!(sent.len(), before + 1);
450    /// assert_eq!(sent.last().unwrap().to, ["ada@example.com"]);
451    /// ```
452    pub fn deliveries(&mut self) -> Vec<Email> {
453        let response = self.get("/ocre/dev/mailers/sent.json");
454        response.assert_status(200);
455        let sent: Vec<Value> = response.json();
456        sent.into_iter()
457            .map(|entry| serde_json::from_value(entry["email"].clone()).expect("a captured email"))
458            .collect()
459    }
460
461    /// Messages the app broadcast to realtime channels (Rails'
462    /// `assert_broadcasts`), oldest first: the last 50, kept by the Worker
463    /// instance. Reads `GET /ocre/dev/realtime/sent.json`, which exists when
464    /// `routes()` merges `ocre::realtime::dev_routes()` (`ocre g scaffold ... --realtime` adds it).
465    ///
466    /// # Panics
467    ///
468    /// When the endpoint does not answer a JSON list.
469    ///
470    /// # Examples
471    ///
472    /// ```no_run
473    /// let mut client = ocre::testing::Client::new();
474    /// client.post("/posts", &[("title", "Live"), ("body", "b")]);
475    /// let last = client.broadcasts().pop().unwrap();
476    /// assert_eq!(last.channel, "posts");
477    /// assert!(last.message.contains("Live"));
478    /// ```
479    pub fn broadcasts(&mut self) -> Vec<Broadcast> {
480        let response = self.get("/ocre/dev/realtime/sent.json");
481        response.assert_status(200);
482        response.json()
483    }
484
485    /// Jobs the app enqueued and ran (Rails' `assert_enqueued_with`,
486    /// `assert_performed_jobs`): the last 50 of each, oldest first, kept by
487    /// the Worker instance. Reads `GET /ocre/dev/jobs.json`, which the first
488    /// `ocre g job` merges into `routes()` (`ocre::jobs::dev_routes()`).
489    /// Local queues deliver within a second or so: wait for a run with
490    /// [`eventually`].
491    ///
492    /// # Panics
493    ///
494    /// When the endpoint does not answer the expected JSON.
495    ///
496    /// # Examples
497    ///
498    /// ```no_run
499    /// use ocre::testing::{Client, eventually};
500    ///
501    /// let mut client = Client::new();
502    /// client.post("/signups", &[("email", "ada@example.com")]);
503    /// let jobs = client.jobs();
504    /// assert_eq!(jobs.enqueued.last().unwrap().name(), Some("send_welcome"));
505    /// eventually(|| client.jobs().performed.iter().any(|run| run.job == "send_welcome" && run.outcome == "done").then_some(()));
506    /// ```
507    pub fn jobs(&mut self) -> Jobs {
508        let response = self.get("/ocre/dev/jobs.json");
509        response.assert_status(200);
510        response.json()
511    }
512
513    /// Delivers an email to the app's mailbox (`ocre g mailbox`), as
514    /// Cloudflare Email Routing would (Rails' `receive_inbound_email_from_mail`):
515    /// a plain-text message posted to the local server's email endpoint
516    /// (`POST /cdn-cgi/local/email?from=&to=`), which runs the Worker's
517    /// `email` event. The response says whether the mailbox accepted it.
518    ///
519    /// # Examples
520    ///
521    /// ```no_run
522    /// let mut client = ocre::testing::Client::new();
523    /// client.receive_email("ada@example.com", "support@example.com", "Help", "My order is late.").assert_success();
524    /// ```
525    pub fn receive_email(&mut self, from: &str, to: &str, subject: &str, body: &str) -> Response {
526        let query = serde_urlencoded::to_string([("from", from), ("to", to)]).expect("encoding two strings");
527        let raw = raw_email(from, to, subject, body, sequence());
528        self.request("POST", &format!("/cdn-cgi/local/email?{query}"), Some(("message/rfc822", raw.into_bytes())))
529    }
530}
531
532/// A plain-text RFC 5322 message; a non-ASCII subject is RFC 2047 encoded.
533fn raw_email(from: &str, to: &str, subject: &str, body: &str, n: u64) -> String {
534    let printable = subject.bytes().all(|byte| (0x20..0x7f).contains(&byte));
535    let subject = if printable {
536        subject.to_owned()
537    } else {
538        use base64::Engine as _;
539        format!("=?UTF-8?B?{}?=", base64::engine::general_purpose::STANDARD.encode(subject))
540    };
541    let body = body.replace("\r\n", "\n").replace('\n', "\r\n");
542    format!(
543        "From: {from}\r\nTo: {to}\r\nSubject: {subject}\r\nMessage-ID: <{n}.{}@ocre.test>\r\nMIME-Version: 1.0\r\n\
544         Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n{body}",
545        crate::now()
546    )
547}
548
549/// A message broadcast to a realtime channel, from [`Client::broadcasts`].
550///
551/// # Examples
552///
553/// ```
554/// let broadcast: ocre::testing::Broadcast =
555///     ocre::serde_json::from_str(r#"{"id":1,"channel":"posts","message":"<li>Hi</li>"}"#).unwrap();
556/// assert_eq!(broadcast.channel, "posts");
557/// ```
558#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
559pub struct Broadcast {
560    /// Position in the capture, from 1.
561    pub id: u64,
562    /// The channel it went to (`posts`).
563    pub channel: String,
564    /// The message: HTML or JSON text.
565    pub message: String,
566}
567
568/// Jobs the app enqueued and ran, from [`Client::jobs`].
569///
570/// # Examples
571///
572/// ```
573/// let jobs: ocre::testing::Jobs = ocre::serde_json::from_str(
574///     r#"{"enqueued":[{"id":1,"queue":"default","job":{"send_welcome":{"user_id":7}}}],
575///         "performed":[{"id":2,"job":"send_welcome","outcome":"done"}]}"#,
576/// )
577/// .unwrap();
578/// assert_eq!(jobs.enqueued[0].name(), Some("send_welcome"));
579/// assert_eq!(jobs.performed[0].outcome, "done");
580/// ```
581#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize)]
582pub struct Jobs {
583    /// Jobs sent to a queue, oldest first.
584    pub enqueued: Vec<EnqueuedJob>,
585    /// Jobs the queue consumer ran, oldest first.
586    pub performed: Vec<PerformedJob>,
587}
588
589/// A job sent to a queue, from [`Client::jobs`].
590#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
591pub struct EnqueuedJob {
592    /// Position in the capture, from 1 (shared with runs).
593    pub id: u64,
594    /// The queue: `default`, or the name given to `ocre::jobs::queue`.
595    pub queue: String,
596    /// The job as the app serialized it: `{"send_welcome": {"user_id": 7}}`.
597    pub job: Value,
598}
599
600impl EnqueuedJob {
601    /// The job's name: the key of its JSON object (`send_welcome`), as [`PerformedJob::job`] names it.
602    ///
603    /// # Examples
604    ///
605    /// ```
606    /// let job: ocre::testing::EnqueuedJob =
607    ///     ocre::serde_json::from_str(r#"{"id":1,"queue":"default","job":"cleanup"}"#).unwrap();
608    /// assert_eq!(job.name(), Some("cleanup"));
609    /// ```
610    pub fn name(&self) -> Option<&str> {
611        match &self.job {
612            Value::Object(map) => map.keys().next().map(String::as_str),
613            Value::String(name) => Some(name),
614            _ => None,
615        }
616    }
617}
618
619/// A job run by the queue consumer, from [`Client::jobs`].
620#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
621pub struct PerformedJob {
622    /// Position in the capture, from 1 (shared with enqueued jobs).
623    pub id: u64,
624    /// The job's name (`send_welcome`), or `mail` for `deliver_later` emails.
625    pub job: String,
626    /// `done`, `discarded` (an error a retry cannot fix) or `retried`.
627    pub outcome: String,
628}
629
630/// Runs a future to completion on the test thread: `async` handlers,
631/// helpers and `ocre::password` in plain unit tests (no async runtime runs
632/// outside workerd). It is [`pollster::block_on`].
633///
634/// # Examples
635///
636/// ```
637/// async fn up() -> &'static str {
638///     "OK"
639/// }
640///
641/// assert_eq!(ocre::testing::block_on(up()), "OK");
642/// ```
643pub use pollster::block_on;
644
645/// A variable of the app under test: the environment variable `name`, else
646/// its value in `.dev.vars` (the file `ocre dev` loads), as the Worker sees
647/// it. Request tests use it for the secrets they sign with (a webhook's).
648///
649/// # Examples
650///
651/// ```no_run
652/// let secret = ocre::testing::var("PAYMENTS_WEBHOOK_SECRET").expect("in .dev.vars");
653/// # let _ = secret;
654/// ```
655pub fn var(name: &str) -> Option<String> {
656    dev_var(name, std::env::var(name).ok(), Path::new(".dev.vars"))
657}
658
659/// `name` from the environment (`env`), else from the `dotenv` file.
660fn dev_var(name: &str, env: Option<String>, dotenv: &Path) -> Option<String> {
661    if env.is_some() {
662        return env;
663    }
664    let text = std::fs::read_to_string(dotenv).ok()?;
665    text.lines().find_map(|line| {
666        let value = line.trim().strip_prefix(name)?.trim_start().strip_prefix('=')?.trim();
667        Some(value.trim_matches('"').to_owned())
668    })
669}
670
671/// The session data in the cookie `value` (percent-encoded), `None` when it does not decrypt.
672fn decrypt_session(value: &str, secret: &str) -> Option<Map<String, Value>> {
673    let cookie = Cookie::parse_encoded(format!("{SESSION_COOKIE}={value}")).ok()?;
674    let mut jar = CookieJar::new();
675    jar.add_original(cookie.into_owned());
676    let decrypted = jar.private(&Key::derive_from(secret.as_bytes())).get(SESSION_COOKIE)?;
677    serde_json::from_str(decrypted.value()).ok()
678}
679
680/// A response in a request test, with chainable assertions (Rails'
681/// `assert_response`, `assert_redirected_to`, `assert_match`).
682///
683/// Assertions panic with the status and the start of the body, and return
684/// the response so they chain.
685///
686/// # Examples
687///
688/// ```no_run
689/// let mut client = ocre::testing::Client::new();
690/// client.get("/posts").assert_status(200).assert_contains("<h1>Posts</h1>");
691/// ```
692#[derive(Debug, Clone)]
693pub struct Response {
694    /// HTTP status code.
695    pub status: u16,
696    /// Headers in the order received, names lowercase.
697    pub headers: Vec<(String, String)>,
698    /// Body as text (invalid UTF-8 replaced); empty for `HEAD`, 204 and 304.
699    pub body: String,
700}
701
702impl Response {
703    /// The first value of the header `name` (any case).
704    ///
705    /// # Examples
706    ///
707    /// ```
708    /// let response = ocre::testing::Response { status: 200, headers: vec![("content-type".into(), "text/html".into())], body: String::new() };
709    /// assert_eq!(response.header("Content-Type"), Some("text/html"));
710    /// ```
711    pub fn header(&self, name: &str) -> Option<&str> {
712        self.headers.iter().find(|(key, _)| key.eq_ignore_ascii_case(name)).map(|(_, value)| value.as_str())
713    }
714
715    /// The `Location` header of a redirect.
716    ///
717    /// # Examples
718    ///
719    /// ```
720    /// let response = ocre::testing::Response { status: 303, headers: vec![("location".into(), "/posts/1".into())], body: String::new() };
721    /// assert_eq!(response.location(), Some("/posts/1"));
722    /// ```
723    pub fn location(&self) -> Option<&str> {
724        self.header("location")
725    }
726
727    /// The body parsed as JSON (Rails' `response.parsed_body`).
728    ///
729    /// # Panics
730    ///
731    /// When the body is not JSON of type `T`.
732    ///
733    /// # Examples
734    ///
735    /// ```
736    /// let response = ocre::testing::Response { status: 200, headers: vec![], body: r#"{"id":1}"#.into() };
737    /// assert_eq!(response.json::<ocre::serde_json::Value>()["id"], 1);
738    /// ```
739    pub fn json<T: DeserializeOwned>(&self) -> T {
740        serde_json::from_str(&self.body)
741            .unwrap_or_else(|err| panic!("the body is not the expected JSON ({err}):\n{}", self.excerpt()))
742    }
743
744    /// Asserts the status code (Rails' `assert_response 201`).
745    ///
746    /// # Examples
747    ///
748    /// ```
749    /// let response = ocre::testing::Response { status: 404, headers: vec![], body: String::new() };
750    /// response.assert_status(404);
751    /// ```
752    #[track_caller]
753    pub fn assert_status(&self, status: u16) -> &Self {
754        assert!(self.status == status, "expected status {status}, got {}:\n{}", self.status, self.excerpt());
755        self
756    }
757
758    /// Asserts a 2xx status (Rails' `assert_response :success`).
759    ///
760    /// # Examples
761    ///
762    /// ```
763    /// let response = ocre::testing::Response { status: 204, headers: vec![], body: String::new() };
764    /// response.assert_success();
765    /// ```
766    #[track_caller]
767    pub fn assert_success(&self) -> &Self {
768        assert!((200..300).contains(&self.status), "expected a 2xx status, got {}:\n{}", self.status, self.excerpt());
769        self
770    }
771
772    /// Asserts a 3xx redirect to `location` (Rails' `assert_redirected_to`).
773    ///
774    /// # Examples
775    ///
776    /// ```
777    /// let response = ocre::testing::Response { status: 303, headers: vec![("location".into(), "/posts/1".into())], body: String::new() };
778    /// response.assert_redirect_to("/posts/1");
779    /// ```
780    #[track_caller]
781    pub fn assert_redirect_to(&self, location: &str) -> &Self {
782        assert!(
783            (300..400).contains(&self.status) && self.location() == Some(location),
784            "expected a redirect to {location}, got {} to {:?}:\n{}",
785            self.status,
786            self.location(),
787            self.excerpt()
788        );
789        self
790    }
791
792    /// Asserts the body contains `text` (HTML-escaped as templates escape it: `'` is `&#39;`).
793    ///
794    /// # Examples
795    ///
796    /// ```
797    /// let response = ocre::testing::Response { status: 200, headers: vec![], body: "<h1>Posts</h1>".into() };
798    /// response.assert_contains("<h1>Posts</h1>");
799    /// ```
800    #[track_caller]
801    pub fn assert_contains(&self, text: &str) -> &Self {
802        assert!(self.body.contains(text), "expected the body to contain {text:?}:\n{}", self.excerpt());
803        self
804    }
805
806    /// Asserts the body does not contain `text`.
807    ///
808    /// # Examples
809    ///
810    /// ```
811    /// let response = ocre::testing::Response { status: 200, headers: vec![], body: "<h1>Posts</h1>".into() };
812    /// response.assert_not_contains("Error");
813    /// ```
814    #[track_caller]
815    pub fn assert_not_contains(&self, text: &str) -> &Self {
816        assert!(!self.body.contains(text), "expected the body not to contain {text:?}:\n{}", self.excerpt());
817        self
818    }
819
820    /// Asserts the header `name` has `value`.
821    ///
822    /// # Examples
823    ///
824    /// ```
825    /// let response = ocre::testing::Response { status: 200, headers: vec![("content-type".into(), "application/json".into())], body: String::new() };
826    /// response.assert_header("Content-Type", "application/json");
827    /// ```
828    #[track_caller]
829    pub fn assert_header(&self, name: &str, value: &str) -> &Self {
830        assert!(
831            self.header(name) == Some(value),
832            "expected header {name}: {value}, got {:?}:\n{}",
833            self.header(name),
834            self.excerpt()
835        );
836        self
837    }
838
839    /// Status line and the first 2,000 characters of the body, for failure messages.
840    fn excerpt(&self) -> String {
841        let body: String = self.body.chars().take(2000).collect();
842        format!("HTTP {}\n{body}", self.status)
843    }
844}
845
846/// Rows returned by `query`, run on the test database (Rails' `ActiveRecord::Base.connection.select_all`).
847///
848/// It runs the app's wrangler (`node_modules/.bin/wrangler d1 execute DB
849/// --local`) on [`TEST_STATE`]: about a second per call, so keep it to
850/// setup and checks. Several statements may be separated by `;`; the rows
851/// of the last one are returned. Values are SQL literals: build them with
852/// [`quote`].
853///
854/// # Panics
855///
856/// When wrangler fails (the message has its output), e.g. on a SQL error.
857///
858/// # Examples
859///
860/// ```no_run
861/// use ocre::testing::{quote, sql};
862///
863/// let rows = sql(&format!("SELECT id FROM posts WHERE title = {}", quote("Hello")));
864/// assert_eq!(rows.len(), 1);
865/// ```
866pub fn sql(query: &str) -> Vec<Map<String, Value>> {
867    sql_in(&app_root(), &state_dir(), query)
868}
869
870thread_local! {
871    /// The app's directory: `cargo test` runs tests in the package's directory;
872    /// Ocre's own unit tests point it at a scratch app.
873    static APP_ROOT: std::cell::RefCell<PathBuf> = std::cell::RefCell::new(PathBuf::from("."));
874}
875
876fn app_root() -> PathBuf {
877    APP_ROOT.with(|root| root.borrow().clone())
878}
879
880fn state_dir() -> String {
881    std::env::var(TEST_STATE).unwrap_or_else(|_| ".wrangler/test-state".to_owned())
882}
883
884fn sql_in(root: &Path, state: &str, query: &str) -> Vec<Map<String, Value>> {
885    let output = Command::new(root.join("node_modules/.bin/wrangler"))
886        .args(["d1", "execute", "DB", "--local", "--json", "--command", query])
887        .args(["-c", ".wrangler/ocre-d1.json", "--persist-to", state])
888        .current_dir(root)
889        .output()
890        .unwrap_or_else(|err| panic!("could not run node_modules/.bin/wrangler ({err}). Fix: run `npm install`"));
891    let stdout = String::from_utf8_lossy(&output.stdout);
892    assert!(
893        output.status.success(),
894        "SQL failed: {query}\n{stdout}{}\nFix: run the tests with `ocre test --e2e`, which creates the test database",
895        String::from_utf8_lossy(&output.stderr)
896    );
897    let statements: Vec<Value> =
898        serde_json::from_str(&stdout).unwrap_or_else(|err| panic!("unexpected wrangler output ({err}):\n{stdout}"));
899    let rows = statements.last().and_then(|statement| statement["results"].as_array()).cloned().unwrap_or_default();
900    rows.into_iter().filter_map(|row| if let Value::Object(row) = row { Some(row) } else { None }).collect()
901}
902
903/// `value` as a SQL literal: strings quoted (`'` doubled), numbers as is,
904/// booleans as `1`/`0`, null as `NULL`, arrays and objects as JSON text.
905///
906/// # Examples
907///
908/// ```
909/// use ocre::{serde_json::json, testing::quote};
910///
911/// assert_eq!(quote("it's"), "'it''s'");
912/// assert_eq!(quote(42), "42");
913/// assert_eq!(quote(true), "1");
914/// assert_eq!(quote(json!({"a": 1})), r#"'{"a":1}'"#);
915/// assert_eq!(quote(None::<i64>), "NULL");
916/// ```
917pub fn quote(value: impl Into<Value>) -> String {
918    quote_value(&value.into())
919}
920
921fn quote_value(value: &Value) -> String {
922    match value {
923        Value::Null => "NULL".to_owned(),
924        Value::Bool(true) => "1".to_owned(),
925        Value::Bool(false) => "0".to_owned(),
926        Value::Number(number) => number.to_string(),
927        Value::String(text) => format!("'{}'", text.replace('\'', "''")),
928        other => format!("'{}'", other.to_string().replace('\'', "''")),
929    }
930}
931
932/// Inserts a row into `table` of the test database and returns its `id`:
933/// what generated factories (`tests/factories/`) call. One wrangler call (about a second).
934///
935/// # Panics
936///
937/// Like [`sql`], e.g. on a constraint violation.
938///
939/// # Examples
940///
941/// ```no_run
942/// use ocre::serde_json::json;
943///
944/// let id = ocre::testing::insert("posts", &[("title", json!("Hello")), ("published", json!(true))]);
945/// assert!(id > 0);
946/// ```
947pub fn insert(table: &str, values: &[(&str, Value)]) -> i64 {
948    sql(&insert_sql(table, values))
949        .first()
950        .and_then(|row| row.get("id"))
951        .and_then(Value::as_i64)
952        .expect("INSERT ... RETURNING id returns the id")
953}
954
955fn insert_sql(table: &str, values: &[(&str, Value)]) -> String {
956    let columns: Vec<&str> = values.iter().map(|(column, _)| *column).collect();
957    let literals: Vec<String> = values.iter().map(|(_, value)| quote_value(value)).collect();
958    format!("INSERT INTO {table} ({}) VALUES ({}) RETURNING id", columns.join(", "), literals.join(", "))
959}
960
961/// Number of rows of `table` in the test database, for [`assert_difference`].
962///
963/// # Examples
964///
965/// ```no_run
966/// let posts = ocre::testing::count("posts");
967/// ```
968pub fn count(table: &str) -> i64 {
969    sql(&format!("SELECT COUNT(*) AS count FROM {table}"))[0]["count"].as_i64().expect("COUNT(*) is an integer")
970}
971
972/// The `id` of the fixture labelled `label` (Rails' `users(:david).id`):
973/// the loader of `tests/fixtures/*.yml` gives a record without an explicit
974/// `id` the CRC-32 of its label modulo 2^30 - 1, like Rails.
975///
976/// # Examples
977///
978/// ```
979/// assert_eq!(ocre::testing::fixture_id("david"), 127326141);
980/// ```
981pub fn fixture_id(label: &str) -> i64 {
982    i64::from(crc32(label.as_bytes()) % ((1 << 30) - 1))
983}
984
985/// The row of `table` loaded from the fixture `label` (Rails' `posts(:first)`).
986///
987/// # Panics
988///
989/// When there is no such row.
990///
991/// # Examples
992///
993/// ```no_run
994/// let post = ocre::testing::fixture("posts", "first");
995/// assert_eq!(post["title"], "Hello");
996/// ```
997pub fn fixture(table: &str, label: &str) -> Map<String, Value> {
998    let rows = sql(&format!("SELECT * FROM {table} WHERE id = {}", fixture_id(label)));
999    rows.into_iter().next().unwrap_or_else(|| panic!("no fixture {label} in {table} (tests/fixtures/{table}.yml)"))
1000}
1001
1002/// CRC-32 (IEEE, as zlib), bit by bit: fixture labels are short.
1003fn crc32(bytes: &[u8]) -> u32 {
1004    let mut crc = !0u32;
1005    for byte in bytes {
1006        crc ^= u32::from(*byte);
1007        for _ in 0..8 {
1008            crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
1009        }
1010    }
1011    !crc
1012}
1013
1014/// A unique number per call in the test process, for unique test data
1015/// (FactoryBot's `sequence`): 1, 2, 3...
1016///
1017/// # Examples
1018///
1019/// ```
1020/// let (a, b) = (ocre::testing::sequence(), ocre::testing::sequence());
1021/// assert!(b > a);
1022/// ```
1023pub fn sequence() -> u64 {
1024    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1025    NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1026}
1027
1028/// The test server's log (`cf dev` output: `console.log`, job and cron
1029/// lines, errors), read from the position of [`Log::mark`] on.
1030///
1031/// Jobs and crons log `[ocre jobs] <name> done` and failures; errors
1032/// answered 500 log `[ocre] ...`. Waiting for those lines replaces Rails'
1033/// `assert_performed_jobs` and `assert_error_reported`: queues deliver
1034/// asynchronously, as in production.
1035///
1036/// # Examples
1037///
1038/// ```no_run
1039/// use ocre::testing::{Client, Log};
1040///
1041/// let log = Log::mark();
1042/// Client::new().post("/signups", &[("email", "ada@example.com")]);
1043/// log.wait_for("[ocre jobs] send_welcome done");
1044/// ```
1045#[derive(Debug, Clone)]
1046pub struct Log {
1047    path: PathBuf,
1048    start: usize,
1049}
1050
1051impl Log {
1052    /// The log from now on ([`TEST_LOG`], else `.wrangler/test-state/dev.log`).
1053    ///
1054    /// # Examples
1055    ///
1056    /// ```no_run
1057    /// let log = ocre::testing::Log::mark();
1058    /// assert!(log.text().is_empty());
1059    /// ```
1060    pub fn mark() -> Self {
1061        let path =
1062            std::env::var(TEST_LOG).map_or_else(|_| PathBuf::from(".wrangler/test-state/dev.log"), PathBuf::from);
1063        Self::mark_at(path)
1064    }
1065
1066    fn mark_at(path: PathBuf) -> Self {
1067        let start = std::fs::metadata(&path).map_or(0, |meta| usize::try_from(meta.len()).unwrap_or(usize::MAX));
1068        Self { path, start }
1069    }
1070
1071    /// What was logged since the mark.
1072    ///
1073    /// # Examples
1074    ///
1075    /// ```no_run
1076    /// let log = ocre::testing::Log::mark();
1077    /// ocre::testing::Client::new().get("/boom");
1078    /// assert!(log.text().contains("[ocre]"));
1079    /// ```
1080    pub fn text(&self) -> String {
1081        let bytes = std::fs::read(&self.path).unwrap_or_default();
1082        String::from_utf8_lossy(bytes.get(self.start..).unwrap_or_default()).into_owned()
1083    }
1084
1085    /// Waits up to 30 seconds for a line containing `needle` and returns it.
1086    ///
1087    /// # Panics
1088    ///
1089    /// When no such line arrives in time; the message has the log since the mark.
1090    ///
1091    /// # Examples
1092    ///
1093    /// ```no_run
1094    /// let log = ocre::testing::Log::mark();
1095    /// log.wait_for("[ocre jobs] send_welcome done");
1096    /// ```
1097    #[track_caller]
1098    pub fn wait_for(&self, needle: &str) -> String {
1099        self.wait_for_within(needle, WAIT)
1100    }
1101
1102    #[track_caller]
1103    fn wait_for_within(&self, needle: &str, timeout: Duration) -> String {
1104        let found = poll(timeout, || self.text().lines().find(|line| line.contains(needle)).map(str::to_owned));
1105        found.unwrap_or_else(|| panic!("no log line containing {needle:?} within {timeout:?}; log:\n{}", self.text()))
1106    }
1107}
1108
1109/// Retries `check` every 100 ms for up to 30 seconds until it returns
1110/// `Some`, for effects that happen later: a queued job, a cron, a
1111/// broadcast.
1112///
1113/// # Panics
1114///
1115/// When `check` still returns `None` after 30 seconds.
1116///
1117/// # Examples
1118///
1119/// ```
1120/// let mut tries = 0;
1121/// let value = ocre::testing::eventually(|| {
1122///     tries += 1;
1123///     (tries == 3).then_some("done")
1124/// });
1125/// assert_eq!(value, "done");
1126/// ```
1127#[track_caller]
1128pub fn eventually<T>(check: impl FnMut() -> Option<T>) -> T {
1129    eventually_within(WAIT, check)
1130}
1131
1132#[track_caller]
1133fn eventually_within<T>(timeout: Duration, check: impl FnMut() -> Option<T>) -> T {
1134    poll(timeout, check).unwrap_or_else(|| panic!("the condition did not hold within {timeout:?}"))
1135}
1136
1137/// The body of a response, or a panic naming the request.
1138fn body_bytes(bytes: Result<Vec<u8>, ureq::Error>, method: &str, url: &str) -> Vec<u8> {
1139    bytes.unwrap_or_else(|err| panic!("{method} {url}: body: {err}"))
1140}
1141
1142fn poll<T>(timeout: Duration, mut check: impl FnMut() -> Option<T>) -> Option<T> {
1143    let deadline = Instant::now() + timeout;
1144    loop {
1145        if let Some(value) = check() {
1146            return Some(value);
1147        }
1148        if Instant::now() >= deadline {
1149            return None;
1150        }
1151        std::thread::sleep(Duration::from_millis(100));
1152    }
1153}
1154
1155/// Asserts that `block` changes the number `expression` returns by
1156/// `difference` (Rails' `assert_difference`), and returns the block's value.
1157///
1158/// # Examples
1159///
1160/// ```
1161/// let count = std::cell::Cell::new(1);
1162/// ocre::testing::assert_difference(|| count.get(), 1, || count.set(2));
1163/// ```
1164#[track_caller]
1165pub fn assert_difference<R>(mut expression: impl FnMut() -> i64, difference: i64, block: impl FnOnce() -> R) -> R {
1166    let before = expression();
1167    let result = block();
1168    let after = expression();
1169    assert!(
1170        after - before == difference,
1171        "expected a difference of {difference}, got {} ({before} -> {after})",
1172        after - before
1173    );
1174    result
1175}
1176
1177/// Asserts that `block` leaves the number `expression` returns unchanged (Rails' `assert_no_difference`).
1178///
1179/// # Examples
1180///
1181/// ```
1182/// ocre::testing::assert_no_difference(|| 3, || ());
1183/// ```
1184#[track_caller]
1185pub fn assert_no_difference<R>(expression: impl FnMut() -> i64, block: impl FnOnce() -> R) -> R {
1186    assert_difference(expression, 0, block)
1187}
1188
1189/// Asserts that `block` changes what `expression` returns (Rails'
1190/// `assert_changes`), and returns `(before, after)`.
1191///
1192/// # Examples
1193///
1194/// ```
1195/// let title = std::cell::RefCell::new("Draft".to_owned());
1196/// let (before, after) = ocre::testing::assert_changes(|| title.borrow().clone(), || *title.borrow_mut() = "Final".into());
1197/// assert_eq!((before.as_str(), after.as_str()), ("Draft", "Final"));
1198/// ```
1199#[track_caller]
1200pub fn assert_changes<T: PartialEq + Debug>(mut expression: impl FnMut() -> T, block: impl FnOnce()) -> (T, T) {
1201    let before = expression();
1202    block();
1203    let after = expression();
1204    assert!(before != after, "expected a change, still {after:?}");
1205    (before, after)
1206}
1207
1208/// Asserts that `block` leaves what `expression` returns unchanged (Rails' `assert_no_changes`).
1209///
1210/// # Examples
1211///
1212/// ```
1213/// ocre::testing::assert_no_changes(|| "same", || ());
1214/// ```
1215#[track_caller]
1216pub fn assert_no_changes<T: PartialEq + Debug>(mut expression: impl FnMut() -> T, block: impl FnOnce()) {
1217    let before = expression();
1218    block();
1219    let after = expression();
1220    assert!(before == after, "expected no change, {before:?} became {after:?}");
1221}
1222
1223/// Makes [`crate::now`] return `unix` on this thread until [`travel_back`]
1224/// (Rails' `travel_to`, frozen). Each Rust test runs on its own thread, so
1225/// other tests keep the real clock. Affects code running in the test
1226/// process (models, helpers, JWT), not the `cf dev` server.
1227///
1228/// # Examples
1229///
1230/// ```
1231/// ocre::testing::travel_to(1_767_225_600); // 2026-01-01T00:00:00Z
1232/// assert_eq!(ocre::now(), 1_767_225_600);
1233/// ocre::testing::travel_back();
1234/// ```
1235pub fn travel_to(unix: i64) {
1236    crate::clock::set_frozen(Some(unix));
1237}
1238
1239/// Moves [`crate::now`] by `seconds` from its current value and freezes it there (Rails' `travel 1.day`).
1240///
1241/// # Examples
1242///
1243/// ```
1244/// ocre::testing::travel_to(1_000);
1245/// ocre::testing::travel(3_600);
1246/// assert_eq!(ocre::now(), 4_600);
1247/// ocre::testing::travel_back();
1248/// ```
1249pub fn travel(seconds: i64) {
1250    travel_to(crate::now() + seconds);
1251}
1252
1253/// Stops [`crate::now`] at the current second (Rails' `freeze_time`); returns it.
1254///
1255/// # Examples
1256///
1257/// ```
1258/// let frozen = ocre::testing::freeze_time();
1259/// assert_eq!(ocre::now(), frozen);
1260/// ocre::testing::travel_back();
1261/// ```
1262pub fn freeze_time() -> i64 {
1263    let now = crate::now();
1264    travel_to(now);
1265    now
1266}
1267
1268/// Returns [`crate::now`] to the real clock (Rails' `travel_back`).
1269///
1270/// # Examples
1271///
1272/// ```
1273/// ocre::testing::travel_to(0);
1274/// ocre::testing::travel_back();
1275/// assert!(ocre::now() > 0);
1276/// ```
1277pub fn travel_back() {
1278    crate::clock::set_frozen(None);
1279}
1280
1281/// Replaces values that change on every run by placeholders, so a snapshot
1282/// or an `assert_eq!` on a whole page or JSON body is stable (Loco's
1283/// `cleanup_*` filters): UUIDs become `<UUID>`, ISO 8601 dates and times
1284/// (`2026-01-01`, `2026-01-01T12:00:00Z`, `2026-01-01 12:00:00`) become
1285/// `<DATE>`, and runs of 32 or more hexadecimal or base64url characters
1286/// (tokens, digests) become `<TOKEN>`.
1287///
1288/// # Examples
1289///
1290/// ```
1291/// let body = r#"{"id":"123e4567-e89b-42d3-a456-426614174000","at":"2026-09-29T10:00:00Z"}"#;
1292/// assert_eq!(ocre::testing::redact(body), r#"{"id":"<UUID>","at":"<DATE>"}"#);
1293/// ```
1294pub fn redact(text: &str) -> String {
1295    let bytes = text.as_bytes();
1296    let mut out = String::with_capacity(text.len());
1297    let mut i = 0;
1298    while i < bytes.len() {
1299        let boundary = i == 0 || !is_token_byte(bytes[i - 1]);
1300        if boundary && let Some((len, placeholder)) = sensitive_at(&bytes[i..]) {
1301            out.push_str(placeholder);
1302            i += len;
1303            continue;
1304        }
1305        let ch = text[i..].chars().next().expect("i is on a char boundary");
1306        out.push(ch);
1307        i += ch.len_utf8();
1308    }
1309    out
1310}
1311
1312fn is_token_byte(byte: u8) -> bool {
1313    byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'
1314}
1315
1316/// Length and placeholder of the value starting `bytes`, if it is one to redact.
1317fn sensitive_at(bytes: &[u8]) -> Option<(usize, &'static str)> {
1318    if let Some(len) = uuid_len(bytes) {
1319        return Some((len, "<UUID>"));
1320    }
1321    if let Some(len) = date_len(bytes) {
1322        return Some((len, "<DATE>"));
1323    }
1324    let token = bytes.iter().take_while(|byte| is_token_byte(**byte)).count();
1325    let digits_or_letters =
1326        bytes[..token].iter().any(u8::is_ascii_digit) && bytes[..token].iter().any(u8::is_ascii_alphabetic);
1327    (token >= 32 && digits_or_letters).then_some((token, "<TOKEN>"))
1328}
1329
1330/// Matches `pattern` (`9` a digit, `x` a hex digit, others literal) at the start of `bytes`.
1331fn matches(bytes: &[u8], pattern: &[u8]) -> bool {
1332    bytes.len() >= pattern.len()
1333        && pattern.iter().zip(bytes).all(|(want, got)| match want {
1334            b'9' => got.is_ascii_digit(),
1335            b'x' => got.is_ascii_hexdigit(),
1336            literal => literal == got,
1337        })
1338}
1339
1340fn uuid_len(bytes: &[u8]) -> Option<usize> {
1341    const UUID: &[u8] = b"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
1342    (matches(bytes, UUID) && bytes.get(UUID.len()).is_none_or(|byte| !is_token_byte(*byte))).then_some(UUID.len())
1343}
1344
1345fn date_len(bytes: &[u8]) -> Option<usize> {
1346    if !matches(bytes, b"9999-99-99") {
1347        return None;
1348    }
1349    let mut len = 10;
1350    if matches(&bytes[len..], b"T99:99") || matches(&bytes[len..], b" 99:99") {
1351        len += 6;
1352        if matches(&bytes[len..], b":99") {
1353            len += 3;
1354        }
1355        if bytes.get(len) == Some(&b'.') {
1356            len += 1 + bytes[len + 1..].iter().take_while(|byte| byte.is_ascii_digit()).count();
1357        }
1358        if bytes.get(len) == Some(&b'Z') {
1359            len += 1;
1360        } else if matches(&bytes[len..], b"+99:99") || matches(&bytes[len..], b"-99:99") {
1361            len += 6;
1362        }
1363    }
1364    Some(len)
1365}
1366
1367#[cfg(test)]
1368#[path = "../tests/testing.rs"]
1369mod tests;