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}