lmjtfy.git / packages / archive / src / event.rs
event.rsannotatedevent.rssource496 lines · 22.7 KB · raw
1//! What happened on the site, kept whole: one `Event` per request worth
2//! keeping, with everything the request said about itself and everything
3//! Cloudflare said about where it came from. The archive writes each one as
4//! a row of `events` (the owner, 2026-10-03: "anywhere in the app where we
5//! are dropping data we should plug", and of visitors, "Everything, linked").
6//!
7//! Nothing here is shown on the site. It is for the owner's admin backend,
8//! which reads the archive's tables.
9//!
10//! `COLUMNS` and `values` are the table's shape: the archive's migration
11//! spells the same columns out, and a test there holds the two together.
12
13use http::{HeaderMap, Method, Uri, header};
14use serde::{Deserialize, Serialize};
15
16/// The cookie that says when this browser was last counted as a visit, in
17/// Unix milliseconds.
18pub const VISIT: &str = "lmjtfy_visit";
19
20/// A visit ends after this long with no page viewed.
21const SESSION_MS: f64 = 30.0 * 60.0 * 1000.0;
22const DAY_MS: f64 = 24.0 * 60.0 * 60.0 * 1000.0;
23
24/// What Cloudflare says of where a request came from (`request.cf`), and
25/// the address it came from. All of it may be missing: a dev server has
26/// none.
27#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
28#[serde(default)]
29pub struct Origin {
30    pub ip: String,
31    pub country: String,
32    pub region: String,
33    pub city: String,
34    pub postcode: String,
35    pub timezone: String,
36    pub latitude: Option<f64>,
37    pub longitude: Option<f64>,
38    /// The network's number, and whose it is: the visitor's ISP.
39    pub asn: Option<u32>,
40    pub network: String,
41    /// The Cloudflare data centre that took the request.
42    pub colo: String,
43    pub protocol: String,
44    pub tls: String,
45}
46
47/// One thing that happened.
48#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
49#[serde(default)]
50pub struct Event {
51    /// `view`, `answer`, `gate`, `vote`, `more`, `card`, `fetch`, `moved`,
52    /// `live`, `left`, and from the page itself `read` (how long a page was
53    /// looked at, and how far down) and `out` (a link followed off the site).
54    pub what: String,
55    pub method: String,
56    pub host: String,
57    pub path: String,
58    /// The query as it came, the question in a shared link included.
59    pub query: String,
60    /// The question typed, asked or voted on, cleaned.
61    pub input: String,
62    /// What came of it: how an ask ended, which way a vote went, `clone` or
63    /// `pull`, `shared` for a page opened with a question in its link.
64    pub detail: String,
65    /// The response's status, or 0 when the event is not a response.
66    pub status: f64,
67    /// Requests sent to Jev or the LLM for it, and requests answered from
68    /// what was kept.
69    pub sent: f64,
70    pub kept: f64,
71    /// Whether an LLM was needed, 1 or 0.
72    pub llm: f64,
73    /// How long it took or lasted, in milliseconds.
74    pub took_ms: f64,
75    /// 1 on the first page this browser was ever given, its first of the UTC
76    /// day, and its first in half an hour.
77    pub first: f64,
78    pub daily: f64,
79    pub session: f64,
80    /// The page that linked here, whole.
81    pub referrer: String,
82    /// A campaign the link named (`utm_source` or `ref`).
83    pub source: String,
84    /// `browser`, `git`, `bot` or `other`, and for a browser its family,
85    /// operating system and `mobile` or `desktop`: read from `agent`, for
86    /// grouping.
87    pub client: String,
88    pub family: String,
89    pub os: String,
90    pub device: String,
91    /// `Accept-Language` and `User-Agent` as they came.
92    pub language: String,
93    pub agent: String,
94    /// The browser's id (the `lmjtfy_browser` cookie), which ties one
95    /// browser's events together.
96    pub browser: String,
97    #[serde(flatten)]
98    pub origin: Origin,
99    /// The link's `utm_medium` and `utm_campaign`.
100    pub medium: String,
101    pub campaign: String,
102    /// The screen and the window, `1920x1080`, as the page says them.
103    pub screen: String,
104    pub viewport: String,
105    /// How far down the page was seen, 0 to 1.
106    pub scroll: f64,
107}
108
109/// A value of one column.
110#[derive(Clone, Debug, PartialEq)]
111pub enum Cell {
112    Text(String),
113    Number(f64),
114    Null,
115}
116
117impl Event {
118    /// The columns of `events`, after `id` and `at_ms`, in the order of
119    /// `values`.
120    pub const COLUMNS: [&str; 41] = [
121        "what", "method", "host", "path", "query", "input", "detail", "status", "sent", "kept", "llm", "took_ms", "first", "daily", "session", "referrer",
122        "source", "client", "family", "os", "device", "language", "agent", "browser", "ip", "country", "region", "city", "postcode", "timezone", "latitude",
123        "longitude", "asn", "network", "colo", "protocol", "medium", "campaign", "screen", "viewport", "scroll",
124    ];
125
126    pub fn values(&self) -> Vec<Cell> {
127        let text = |value: &str| Cell::Text(value.to_owned());
128        let number = |value: Option<f64>| value.map_or(Cell::Null, Cell::Number);
129        let origin = &self.origin;
130        vec![
131            text(&self.what),
132            text(&self.method),
133            text(&self.host),
134            text(&self.path),
135            text(&self.query),
136            text(&self.input),
137            text(&self.detail),
138            Cell::Number(self.status),
139            Cell::Number(self.sent),
140            Cell::Number(self.kept),
141            Cell::Number(self.llm),
142            Cell::Number(self.took_ms),
143            Cell::Number(self.first),
144            Cell::Number(self.daily),
145            Cell::Number(self.session),
146            text(&self.referrer),
147            text(&self.source),
148            text(&self.client),
149            text(&self.family),
150            text(&self.os),
151            text(&self.device),
152            text(&self.language),
153            text(&self.agent),
154            text(&self.browser),
155            text(&origin.ip),
156            text(&origin.country),
157            text(&origin.region),
158            text(&origin.city),
159            text(&origin.postcode),
160            text(&origin.timezone),
161            number(origin.latitude),
162            number(origin.longitude),
163            number(origin.asn.map(f64::from)),
164            text(&origin.network),
165            text(&origin.colo),
166            text(&format!("{} {}", origin.protocol, origin.tls).trim().to_owned()),
167            text(&self.medium),
168            text(&self.campaign),
169            text(&self.screen),
170            text(&self.viewport),
171            Cell::Number(self.scroll),
172        ]
173    }
174
175    /// What a request says of itself, before it is known what came of it.
176    /// `browser` is the id in its cookie, if it has one.
177    pub fn from(method: &Method, uri: &Uri, headers: &HeaderMap, browser: Option<String>, origin: Origin) -> Event {
178        let agent = text(headers, header::USER_AGENT.as_str());
179        let lower = agent.to_ascii_lowercase();
180        let client = client(&lower, headers);
181        let person = client == "browser";
182        let query = uri.query().unwrap_or_default();
183        Event {
184            method: method.as_str().to_owned(),
185            host: cut(text(headers, header::HOST.as_str()), 200),
186            path: cut(uri.path(), 500),
187            query: cut(query, 2000),
188            referrer: cut(text(headers, header::REFERER.as_str()), 1000),
189            source: tag(query, "utm_source").or_else(|| tag(query, "ref")).unwrap_or_default(),
190            medium: tag(query, "utm_medium").unwrap_or_default(),
191            campaign: tag(query, "utm_campaign").unwrap_or_default(),
192            client: client.to_owned(),
193            family: if person { family(&lower) } else { "" }.to_owned(),
194            os: if person { os(&lower) } else { "" }.to_owned(),
195            device: if person { device(&lower, headers) } else { "" }.to_owned(),
196            language: cut(text(headers, header::ACCEPT_LANGUAGE.as_str()), 200),
197            agent: cut(agent, 500),
198            browser: browser.unwrap_or_default(),
199            origin,
200            ..Event::default()
201        }
202    }
203
204    /// What a `GET` is, if it is worth keeping: not the site's own scripts
205    /// and fonts, or git's first request. A `POST` is named by the handler
206    /// that knows what came of it.
207    pub fn got(mut self) -> Option<Event> {
208        if self.method != "GET" {
209            return None;
210        }
211        self.what = match self.path.as_str() {
212            "/datastar.js" | "/emoji.woff2" | "/live.js" | "/rules.svg" | "/favicon.ico" => return None,
213            // The socket's own event is written by the archive, which keeps
214            // it to say how long the page stayed.
215            "/live" => return None,
216            path if path.ends_with("/info/refs") || path.starts_with("/icons/") => return None,
217            "/feed" => "more",
218            "/card.png" => "card",
219            _ => "view",
220        }
221        .to_owned();
222        if self.what == "view" && self.query.split('&').any(|pair| pair.starts_with("q=") && pair.len() > 2) {
223            self.detail = "shared".into();
224        }
225        Some(self)
226    }
227
228    pub fn named(mut self, what: &str) -> Event {
229        self.what = what.to_owned();
230        self
231    }
232
233    pub fn with(mut self, detail: impl Into<String>) -> Event {
234        self.detail = detail.into();
235        self
236    }
237
238    /// The question it was about.
239    pub fn about(mut self, input: &str) -> Event {
240        self.input = cut(input, 2000);
241        self
242    }
243
244    /// Counts a browser's page view as a visit: whether it is the browser's
245    /// first page ever (it came with no cookie of the site's), its first
246    /// today, and its first in half an hour. Returns the cookie that says it
247    /// was counted now. Only a browser's page is a visit.
248    pub fn visit(&mut self, headers: &HeaderMap, now_ms: f64) -> Option<String> {
249        if self.what != "view" || self.client != "browser" {
250            return None;
251        }
252        let cookies = text(headers, header::COOKIE.as_str());
253        let last = cookies.split(';').filter_map(|pair| pair.trim().strip_prefix(VISIT)?.strip_prefix('=')?.parse::<f64>().ok()).find(|last| last.is_finite() && *last <= now_ms);
254        let flag = |is: bool| if is { 1.0 } else { 0.0 };
255        self.first = flag(cookies.trim().is_empty());
256        self.daily = flag(last.is_none_or(|last| (last / DAY_MS).floor() < (now_ms / DAY_MS).floor()));
257        self.session = flag(last.is_none_or(|last| now_ms - last >= SESSION_MS));
258        Some(format!("{VISIT}={now_ms:.0}; Path=/; Max-Age=31536000; Secure; HttpOnly; SameSite=Lax"))
259    }
260}
261
262fn text<'a>(headers: &'a HeaderMap, name: &str) -> &'a str {
263    headers.get(name).and_then(|value| value.to_str().ok()).unwrap_or_default()
264}
265
266fn cut(text: &str, most: usize) -> String {
267    text.chars().take(most).collect()
268}
269
270/// What kind of client asked, from what it says of itself.
271fn client(agent: &str, headers: &HeaderMap) -> &'static str {
272    const BOTS: [&str; 10] = ["bot", "crawler", "spider", "preview", "facebookexternalhit", "slurp", "curl/", "python", "wget", "headless"];
273    if agent.starts_with("git/") || agent.starts_with("cargo") {
274        "git"
275    } else if BOTS.iter().any(|mark| agent.contains(mark)) {
276        "bot"
277    } else if headers.contains_key("sec-fetch-mode") || agent.starts_with("mozilla/") {
278        "browser"
279    } else {
280        "other"
281    }
282}
283
284/// The browser's family. The order matters: Edge and Opera say Chrome too,
285/// and Chrome says Safari.
286fn family(agent: &str) -> &'static str {
287    [("edg", "Edge"), ("opr/", "Opera"), ("firefox", "Firefox"), ("fxios", "Firefox"), ("crios", "Chrome"), ("chrome", "Chrome"), ("safari", "Safari")]
288        .into_iter()
289        .find(|(mark, _)| agent.contains(mark))
290        .map_or("", |(_, name)| name)
291}
292
293/// The operating system. iPhones say "like Mac OS X" and Android says Linux,
294/// so they are looked for first.
295fn os(agent: &str) -> &'static str {
296    [("android", "Android"), ("iphone", "iOS"), ("ipad", "iOS"), ("windows", "Windows"), ("cros", "ChromeOS"), ("mac os x", "macOS"), ("linux", "Linux")]
297        .into_iter()
298        .find(|(mark, _)| agent.contains(mark))
299        .map_or("", |(_, name)| name)
300}
301
302fn device(agent: &str, headers: &HeaderMap) -> &'static str {
303    match text(headers, "sec-ch-ua-mobile") {
304        "?1" => "mobile",
305        "?0" => "desktop",
306        _ if ["mobile", "android", "iphone"].iter().any(|mark| agent.contains(mark)) => "mobile",
307        _ => "desktop",
308    }
309}
310
311/// One of a link's campaign tags (`utm_source`, `utm_medium`, ...), if it
312/// has it.
313fn tag(query: &str, name: &str) -> Option<String> {
314    query.split('&').find_map(|pair| pair.strip_prefix(name)?.strip_prefix('=')).filter(|value| !value.is_empty()).map(|value| cut(value, 100))
315}
316
317/// What the page says of itself when it is left or a link off the site is
318/// followed (`page.js`, posted to `/seen`). Every part is the page's word
319/// and is checked before it is kept.
320#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
321#[serde(default)]
322pub struct Seen {
323    /// The page's own path and query.
324    pub path: String,
325    pub query: String,
326    /// Milliseconds it was in view since it last said.
327    pub ms: f64,
328    pub scroll: f64,
329    pub screen: String,
330    pub viewport: String,
331    /// The address of a link followed off the site, when that is the news.
332    pub out: String,
333}
334
335impl Event {
336    /// The event of a page's report: `read`, or `out` when it names a link.
337    /// The request is the report's own (`POST /seen`); the page it is about
338    /// is in the report.
339    pub fn seen(mut self, seen: &Seen) -> Event {
340        // A size is two numbers and an `x`, and nothing else.
341        let size = |text: &str| {
342            let fits = text.len() <= 11 && text.split_once('x').is_some_and(|(wide, high)| [wide, high].iter().all(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())));
343            if fits { text.to_owned() } else { String::new() }
344        };
345        let within = |n: f64, most: f64| if n.is_finite() { n.clamp(0.0, most) } else { 0.0 };
346        self.what = if seen.out.is_empty() { "read" } else { "out" }.to_owned();
347        self.detail = cut(&seen.out, 500);
348        if seen.path.starts_with('/') {
349            self.path = cut(&seen.path, 500);
350            self.query = cut(seen.query.trim_start_matches('?'), 2000);
351        }
352        // A page cannot have been looked at for longer than a day.
353        self.took_ms = within(seen.ms, 86_400_000.0);
354        self.scroll = within(seen.scroll, 1.0);
355        self.screen = size(&seen.screen);
356        self.viewport = size(&seen.viewport);
357        self
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
366        let mut headers = HeaderMap::new();
367        for (name, value) in pairs {
368            headers.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), value.parse().unwrap());
369        }
370        headers
371    }
372
373    const CHROME: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
374
375    fn event(method: Method, uri: &str, from: &[(&str, &str)]) -> Event {
376        Event::from(&method, &uri.parse().unwrap(), &headers(from), None, Origin::default())
377    }
378
379    #[test]
380    fn a_get_is_named_for_what_it_is() {
381        let what = |uri: &str| event(Method::GET, uri, &[]).got().map(|event| event.what);
382        assert_eq!(what("/").as_deref(), Some("view"));
383        assert_eq!(what("/lmjtfy.git/packages/rules/").as_deref(), Some("view"));
384        assert_eq!(what("/feed?ms=1&q=x").as_deref(), Some("more"));
385        assert_eq!(what("/card.png?q=x").as_deref(), Some("card"));
386        // The site's own parts, the socket and git's first request are not
387        // named here.
388        assert_eq!(what("/live"), None);
389        assert_eq!(what("/emoji.woff2"), None);
390        assert_eq!(what("/icons/file-rust.png"), None);
391        assert_eq!(what("/lmjtfy.git/info/refs?service=git-upload-pack"), None);
392        // A POST is its handler's to name.
393        assert_eq!(event(Method::POST, "/ask", &[]).got(), None);
394    }
395
396    #[test]
397    fn nothing_the_request_said_is_dropped() {
398        let from = [
399            ("referer", "https://discord.com/channels/1/2?x=y"),
400            ("host", "lmjtfy.fun"),
401            ("user-agent", CHROME),
402            ("accept-language", "en-GB,en;q=0.9"),
403        ];
404        let uri: Uri = "/?q=Is+my+boss+a+lizard%3F&utm_source=discord&utm_medium=chat&utm_campaign=launch".parse().unwrap();
405        let origin = Origin { ip: "203.0.113.7".into(), country: "GB".into(), asn: Some(2856), network: "British Telecommunications PLC".into(), ..Origin::default() };
406        let event = Event::from(&Method::GET, &uri, &headers(&from), Some("0b5f2a1e".into()), origin).got().unwrap();
407        assert_eq!((event.what.as_str(), event.detail.as_str(), event.path.as_str()), ("view", "shared", "/"));
408        assert_eq!(event.query, "q=Is+my+boss+a+lizard%3F&utm_source=discord&utm_medium=chat&utm_campaign=launch");
409        assert_eq!(event.referrer, "https://discord.com/channels/1/2?x=y");
410        assert_eq!((event.source.as_str(), event.language.as_str(), event.agent.as_str()), ("discord", "en-GB,en;q=0.9", CHROME));
411        assert_eq!((event.medium.as_str(), event.campaign.as_str()), ("chat", "launch"));
412        assert_eq!((event.client.as_str(), event.family.as_str(), event.os.as_str(), event.device.as_str()), ("browser", "Chrome", "Windows", "desktop"));
413        assert_eq!((event.browser.as_str(), event.origin.ip.as_str(), event.origin.asn), ("0b5f2a1e", "203.0.113.7", Some(2856)));
414    }
415
416    #[test]
417    fn every_column_has_its_value_and_an_event_survives_the_wire() {
418        let event = Event { what: "answer".into(), origin: Origin { asn: Some(7), latitude: Some(1.5), ..Origin::default() }, ..Event::default() };
419        let values = event.values();
420        assert_eq!(values.len(), Event::COLUMNS.len());
421        let at = |name: &str| values[Event::COLUMNS.iter().position(|column| *column == name).unwrap()].clone();
422        assert_eq!(at("what"), Cell::Text("answer".into()));
423        assert_eq!(at("asn"), Cell::Number(7.0));
424        assert_eq!(at("latitude"), Cell::Number(1.5));
425        assert_eq!(at("longitude"), Cell::Null);
426        let wire = serde_json::to_string(&event).unwrap();
427        assert_eq!(serde_json::from_str::<Event>(&wire).unwrap(), event);
428    }
429
430    #[test]
431    fn a_pages_report_is_checked_before_it_is_kept() {
432        let report = event(Method::POST, "/seen", &[("user-agent", CHROME)]);
433        let read = report.clone().seen(&Seen { path: "/rules".into(), query: "?answerable=no".into(), ms: 4200.0, scroll: 0.5, screen: "1920x1080".into(), viewport: "1200x800".into(), out: String::new() });
434        assert_eq!((read.what.as_str(), read.path.as_str(), read.query.as_str()), ("read", "/rules", "answerable=no"));
435        assert_eq!((read.took_ms, read.scroll, read.screen.as_str(), read.viewport.as_str()), (4200.0, 0.5, "1920x1080", "1200x800"));
436        // A link followed off the site.
437        let out = report.clone().seen(&Seen { path: "/".into(), out: "https://docs.typesafe.ai/".into(), ..Seen::default() });
438        assert_eq!((out.what.as_str(), out.detail.as_str()), ("out", "https://docs.typesafe.ai/"));
439        // What a page makes up is cut down to what it could be.
440        let odd = report.seen(&Seen { path: "elsewhere".into(), ms: f64::INFINITY, scroll: 7.0, screen: "<b>".into(), viewport: "9999999x9999999".into(), ..Seen::default() });
441        assert_eq!((odd.path.as_str(), odd.took_ms, odd.scroll, odd.screen.as_str(), odd.viewport.as_str()), ("/seen", 0.0, 1.0, "", ""));
442        assert_eq!(serde_json::from_str::<Seen>("{\"ms\": 5}").unwrap().ms, 5.0);
443    }
444
445    #[test]
446    fn clients_are_classed() {
447        let class = |agent: &str| {
448            let event = event(Method::GET, "/", &[("user-agent", agent)]);
449            (event.client, event.family, event.os, event.device)
450        };
451        let is = |client: &str, family: &str, os: &str, device: &str| (client.to_owned(), family.to_owned(), os.to_owned(), device.to_owned());
452        assert_eq!(class("git/2.55.0"), is("git", "", "", ""));
453        assert_eq!(class("Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"), is("bot", "", "", ""));
454        assert_eq!(class("curl/8.22.0"), is("bot", "", "", ""));
455        assert_eq!(class(""), is("other", "", "", ""));
456        assert_eq!(class(CHROME), is("browser", "Chrome", "Windows", "desktop"));
457        assert_eq!(class("Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/140.0 Safari/537.36 Edg/140.0"), is("browser", "Edge", "Windows", "desktop"));
458        assert_eq!(class("Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 Version/19.0 Mobile/15E148 Safari/604.1"), is("browser", "Safari", "iOS", "mobile"));
459        assert_eq!(class("Mozilla/5.0 (Android 16; Mobile; rv:143.0) Gecko/143.0 Firefox/143.0"), is("browser", "Firefox", "Android", "mobile"));
460        assert_eq!(class("Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0"), is("browser", "Firefox", "Linux", "desktop"));
461    }
462
463    #[test]
464    fn a_visit_is_counted_from_when_the_browser_was_last_counted() {
465        let noon = 20_000.0 * DAY_MS + DAY_MS / 2.0;
466        let visit = |cookie: Option<String>, now: f64| {
467            let mut pairs = vec![("user-agent", CHROME.to_owned())];
468            pairs.extend(cookie.map(|cookie| ("cookie", cookie)));
469            let pairs: Vec<(&str, &str)> = pairs.iter().map(|(name, value)| (*name, value.as_str())).collect();
470            let mut event = event(Method::GET, "/", &pairs).named("view");
471            let cookie = event.visit(&headers(&pairs), now);
472            ((event.first, event.daily, event.session), cookie)
473        };
474        // No cookie at all: a new browser, today's first page, a new visit.
475        let (flags, cookie) = visit(None, noon);
476        assert_eq!(flags, (1.0, 1.0, 1.0));
477        let cookie = cookie.unwrap();
478        assert!(cookie.starts_with(&format!("lmjtfy_visit={noon:.0}; ")), "{cookie}");
479        let last = |ms: f64| Some(format!("lmjtfy_browser=x; lmjtfy_visit={ms:.0}"));
480        // A minute on: none of them. An hour on: a new visit, the same day.
481        assert_eq!(visit(last(noon), noon + 60_000.0).0, (0.0, 0.0, 0.0));
482        assert_eq!(visit(last(noon), noon + 3_600_000.0).0, (0.0, 0.0, 1.0));
483        // Past midnight UTC: a new day too.
484        assert_eq!(visit(last(noon), noon + DAY_MS).0, (0.0, 1.0, 1.0));
485        // A cookie from before this one existed: known, not yet counted.
486        assert_eq!(visit(Some("lmjtfy_browser=x".into()), noon).0, (0.0, 1.0, 1.0));
487        // A time the page made up, or one from the future, is no time.
488        assert_eq!(visit(Some("lmjtfy_visit=soon".into()), noon).0, (0.0, 1.0, 1.0));
489        assert_eq!(visit(last(noon + DAY_MS), noon).0, (0.0, 1.0, 1.0));
490        // Only a browser's page view is a visit.
491        let mut bot = event(Method::GET, "/", &[("user-agent", "curl/8")]).named("view");
492        assert_eq!(bot.visit(&HeaderMap::new(), noon), None);
493        let mut vote = event(Method::POST, "/rate", &[("user-agent", CHROME)]).named("vote");
494        assert_eq!(vote.visit(&HeaderMap::new(), noon), None);
495    }
496}