oxedyne/fe2o3/fe2o3_steel/src/srv/publish/dest.rs
20.8 KiB, 82 runs
created by r1870400018:14743, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Where a post goes beyond the site's own pages. |
| 2 | //! |
| 3 | //! The site's own pages are the *origin*: a local write to the store that either happens or does not, |
| 4 | //! and cannot half-succeed. Everywhere else -- a Mastodon server, a Bluesky PDS, an inbox -- is a |
| 5 | //! [`Delivery`]: an unreliable network reached over time, with its own state, its own retries and its |
| 6 | //! own returned address. So a post carries one [`PostState`](super::PostState) for itself and a |
| 7 | //! [`Delivery`] for each remote, and the two are not the same kind of thing. |
| 8 | //! |
| 9 | //! # A destination is described, not hard-coded into each caller |
| 10 | //! |
| 11 | //! Each [`Destination`] answers a [`Capability`]: how long a body it takes, whether it carries a link |
| 12 | //! without penalty, whether media can ride along, and what a post there costs. The composer reads the |
| 13 | //! capability to derive a default [`Rendition`] and to count length as the author types; the sender |
| 14 | //! reads it to know what it may send. A new destination is a new variant and a new capability, and |
| 15 | //! nothing that already works has to change -- which is the test the design is meant to pass. |
| 16 | //! |
| 17 | //! # This module is data, not delivery |
| 18 | //! |
| 19 | //! Everything here is a pure value: the enum, the capability, the rendition, the delivery state and |
| 20 | //! the retry policy. Nothing here opens a socket or reads a clock. The sender fills a |
| 21 | //! [`DeliveryState::Sent`] with the moment and the permalink the remote returned; the worker consults |
| 22 | //! [`DeliveryState::backoff_secs`] against a clock it owns. Keeping the model clock-free is what lets |
| 23 | //! it be tested without either. |
| 24 | //! |
| 25 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 26 | //! Anthropic Claude |
| 27 | |
| 28 | use oxedyne_fe2o3_core::prelude::*; |
| 29 | use oxedyne_fe2o3_jdat::prelude::*; |
| 30 | |
| 31 | |
| 32 | /// A place a post is delivered to, besides the site's own pages. |
| 33 | /// |
| 34 | /// The own site is not here: it is the origin, a store write, not a delivery over a network. A |
| 35 | /// `Destination` is always a remote, and always something that can be slow, refuse, or vanish. |
| 36 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 37 | pub enum Destination { |
| 38 | Email, // subscribers, through the site's own DKIM sender |
| 39 | Mastodon, // a static bearer token |
| 40 | Bluesky, // an app password exchanged for a session |
| 41 | // Per-post has no sanctioned API and rides an unofficial one; the supported path is a bulk feed |
| 42 | // import, so this is named but not wired. |
| 43 | Substack, |
| 44 | X, // pay-per-use, behind an OAuth 2.0 client |
| 45 | Threads, // behind Meta's OAuth and app review |
| 46 | } |
| 47 | |
| 48 | impl Destination { |
| 49 | |
| 50 | // Every destination the module names, in the order a picker shows them: the free and wired |
| 51 | // first, the costed and the unbuilt after. |
| 52 | pub const ALL: [Self; 6] = [ |
| 53 | Self::Email, |
| 54 | Self::Mastodon, |
| 55 | Self::Bluesky, |
| 56 | Self::Substack, |
| 57 | Self::X, |
| 58 | Self::Threads, |
| 59 | ]; |
| 60 | |
| 61 | /// The word a record stores. |
| 62 | pub fn as_str(&self) -> &'static str { |
| 63 | match self { |
| 64 | Self::Email => "email", |
| 65 | Self::Mastodon => "mastodon", |
| 66 | Self::Bluesky => "bluesky", |
| 67 | Self::Substack => "substack", |
| 68 | Self::X => "x", |
| 69 | Self::Threads => "threads", |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// The destination a word names, or nothing where the word is one this version does not know. |
| 74 | /// |
| 75 | /// Not a lenient default, and not an error either. A record naming a destination this version |
| 76 | /// cannot place is a record a later version wrote, and the safe reading is to drop *that delivery* |
| 77 | /// -- never to guess a different remote (which would post to the wrong place) and never to refuse |
| 78 | /// the whole post (which would make every new destination a migration). The caller skips a `None`. |
| 79 | pub fn of(s: &str) -> Option<Self> { |
| 80 | match s { |
| 81 | "email" => Some(Self::Email), |
| 82 | "mastodon" => Some(Self::Mastodon), |
| 83 | "bluesky" => Some(Self::Bluesky), |
| 84 | "substack" => Some(Self::Substack), |
| 85 | "x" => Some(Self::X), |
| 86 | "threads" => Some(Self::Threads), |
| 87 | _ => None, |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | pub fn capability(&self) -> Capability { |
| 92 | match self { |
| 93 | // The newsletter is the whole post, not a blurb, so it has no length worth enforcing here. |
| 94 | // `wired` stays false because email is not a per-remote queue delivery with a single |
| 95 | // permalink to return: it is a fan-out to the site's own subscriber list, sent from the |
| 96 | // subscribers page (`/manage/subscribers`) through the site's DKIM sender, not queued as a |
| 97 | // `Delivery` on the post the way Mastodon and Bluesky are. |
| 98 | Self::Email => Capability { |
| 99 | name: "Email", |
| 100 | max_chars: None, |
| 101 | links: true, |
| 102 | media: true, |
| 103 | cost_micros: 0, |
| 104 | link_micros: 0, |
| 105 | wired: false, |
| 106 | }, |
| 107 | Self::Mastodon => Capability { |
| 108 | name: "Mastodon", |
| 109 | max_chars: Some(500), |
| 110 | links: true, |
| 111 | media: true, |
| 112 | cost_micros: 0, |
| 113 | link_micros: 0, |
| 114 | wired: false, |
| 115 | }, |
| 116 | Self::Bluesky => Capability { |
| 117 | name: "Bluesky", |
| 118 | max_chars: Some(300), |
| 119 | links: true, |
| 120 | media: true, |
| 121 | cost_micros: 0, |
| 122 | link_micros: 0, |
| 123 | wired: false, |
| 124 | }, |
| 125 | // The bulk feed import carries the whole post and needs no per-post rendition; the per-post |
| 126 | // unofficial API is deferred, so nothing here is wired. |
| 127 | Self::Substack => Capability { |
| 128 | name: "Substack", |
| 129 | max_chars: None, |
| 130 | links: true, |
| 131 | media: true, |
| 132 | cost_micros: 0, |
| 133 | link_micros: 0, |
| 134 | wired: false, |
| 135 | }, |
| 136 | // $0.015 a post, and $0.20 when the post carries a link: the base is 15,000 micros and the |
| 137 | // link adds 185,000 more, to 200,000. |
| 138 | Self::X => Capability { |
| 139 | name: "X", |
| 140 | max_chars: Some(280), |
| 141 | links: true, |
| 142 | media: true, |
| 143 | cost_micros: 15_000, |
| 144 | link_micros: 185_000, |
| 145 | wired: false, |
| 146 | }, |
| 147 | Self::Threads => Capability { |
| 148 | name: "Threads", |
| 149 | max_chars: Some(500), |
| 150 | links: true, |
| 151 | media: true, |
| 152 | cost_micros: 0, |
| 153 | link_micros: 0, |
| 154 | wired: false, |
| 155 | }, |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | |
| 161 | /// What a destination will take, and what it costs. |
| 162 | /// |
| 163 | /// Read by the composer to derive a default rendition and to count length as an author types, and by |
| 164 | /// the sender to know what it may send. Every field is a fact about the remote, not a preference of the |
| 165 | /// site's. |
| 166 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 167 | pub struct Capability { |
| 168 | pub name: &'static str, // what a picker shows |
| 169 | // The longest body the remote accepts, in characters. Nothing where the limit is not worth |
| 170 | // enforcing here: an email carries the whole post. |
| 171 | pub max_chars: Option<usize>, |
| 172 | pub links: bool, // carried as written, not stripped or surcharged |
| 173 | pub media: bool, // an image can ride along with the words |
| 174 | pub cost_micros: u64, // millionths of a US dollar; zero for the free remotes |
| 175 | pub link_micros: u64, // extra millionths where the body carries a link |
| 176 | // Whether this module can actually deliver to the remote yet, or only names it. A picker greys |
| 177 | // out what is not wired rather than offering a post that will never go. |
| 178 | pub wired: bool, |
| 179 | } |
| 180 | |
| 181 | impl Capability { |
| 182 | |
| 183 | /// What a post costs here, given whether its body carries a link, in millionths of a US dollar. |
| 184 | pub fn cost_of(&self, has_link: bool) -> u64 { |
| 185 | if has_link { |
| 186 | self.cost_micros + self.link_micros |
| 187 | } else { |
| 188 | self.cost_micros |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | |
| 194 | /// The words a destination gets, as against the words the site's own page gets. |
| 195 | /// |
| 196 | /// A remote is not the site: a 280-character timeline cannot take a 2,000-word essay, and pushing one |
| 197 | /// there unedited reads as a machine, which costs the following the post was written to build. So each |
| 198 | /// delivery carries its own rendition -- derived by default so nothing has to be typed twice, editable |
| 199 | /// so nothing reads like a bot, and remembered once edited so a hand-written one is not overwritten by |
| 200 | /// the next automatic pass. |
| 201 | #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| 202 | pub struct Rendition { |
| 203 | pub text: String, // the body this destination is sent |
| 204 | // Whether this is still the derived default (`true`) or has been edited by hand (`false`). The |
| 205 | // distinction is the whole reason renditions are remembered: an automatic pass may replace an |
| 206 | // automatic rendition and must never replace a hand-written one. |
| 207 | pub auto: bool, |
| 208 | } |
| 209 | |
| 210 | impl Rendition { |
| 211 | |
| 212 | /// The default body for a length-limited social destination: the post's title and its canonical |
| 213 | /// link, trimmed to fit. |
| 214 | /// |
| 215 | /// The link is kept whole -- a truncated URL is a broken one -- and the title gives way to make |
| 216 | /// room. Where even the link alone will not fit, the link alone is sent, because a link that works |
| 217 | /// is worth more than a title that is cut off before it. |
| 218 | pub fn promo(title: &str, url: &str, max_chars: Option<usize>) -> Self { |
| 219 | let joined = |t: &str| -> String { |
| 220 | if t.is_empty() { |
| 221 | url.to_string() |
| 222 | } else { |
| 223 | fmt!("{}\n\n{}", t, url) |
| 224 | } |
| 225 | }; |
| 226 | let text = match max_chars { |
| 227 | None => joined(title), |
| 228 | Some(max) => { |
| 229 | let full = joined(title); |
| 230 | if full.chars().count() <= max { |
| 231 | full |
| 232 | } else { |
| 233 | // Reserve the link and the two newlines before it, and give the rest to the title. |
| 234 | let url_len = url.chars().count(); |
| 235 | let reserve = url_len + 2; |
| 236 | if reserve >= max { |
| 237 | // Not even the link fits with a title; send the link on its own. |
| 238 | url.to_string() |
| 239 | } else { |
| 240 | let room = max - reserve; |
| 241 | // A trimmed title ends on an ellipsis, so leave a place for it. |
| 242 | let keep = room.saturating_sub(1); |
| 243 | let cut = title.char_indices() |
| 244 | .take(keep) |
| 245 | .filter(|(_, c)| c.is_whitespace()) |
| 246 | .map(|(i, _)| i) |
| 247 | .last() |
| 248 | .unwrap_or_else(|| title.char_indices() |
| 249 | .nth(keep) |
| 250 | .map(|(i, _)| i) |
| 251 | .unwrap_or(title.len())); |
| 252 | let mut t = title[..cut].trim_end().to_string(); |
| 253 | t.push('…'); |
| 254 | joined(&t) |
| 255 | } |
| 256 | } |
| 257 | } |
| 258 | }; |
| 259 | Self { text, auto: true } |
| 260 | } |
| 261 | |
| 262 | pub fn to_dat(&self) -> Dat { |
| 263 | let mut m = DaticleMap::new(); |
| 264 | m.insert(dat!("text"), dat!(self.text.clone())); |
| 265 | m.insert(dat!("auto"), Dat::Bool(self.auto)); |
| 266 | Dat::Map(m) |
| 267 | } |
| 268 | |
| 269 | /// The rendition from a daticle. A missing or ill-typed field takes its default -- an absent body |
| 270 | /// is the empty one, and an absent `auto` reads as a default still (the safe reading: an automatic |
| 271 | /// pass may overwrite it, where treating an unknown as hand-written would freeze a bad default). |
| 272 | pub fn from_dat(d: &Dat) -> Self { |
| 273 | let m = match d { |
| 274 | Dat::Map(m) => m, |
| 275 | _ => return Self::default(), |
| 276 | }; |
| 277 | let text = match m.get(&dat!("text")) { |
| 278 | Some(Dat::Str(s)) => s.clone(), |
| 279 | _ => String::new(), |
| 280 | }; |
| 281 | let auto = match m.get(&dat!("auto")) { |
| 282 | Some(Dat::Bool(b)) => *b, |
| 283 | _ => true, |
| 284 | }; |
| 285 | Self { text, auto } |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | |
| 290 | // A remote that has refused this many times is not going to take the post because it was asked |
| 291 | // once more, and a queue that retries for ever is a queue that never drains. The number is |
| 292 | // arbitrary; having one, so the queue is bounded, is not. |
| 293 | pub const MAX_RETRIES: u32 = 5; |
| 294 | |
| 295 | // The wait after a failure doubles each time until it stops at the cap, so a remote briefly down |
| 296 | // is retried soon and a stubborn one hourly rather than never. |
| 297 | pub const BACKOFF_BASE_SECS: u64 = 60; |
| 298 | pub const BACKOFF_CAP_SECS: u64 = 3600; |
| 299 | |
| 300 | |
| 301 | /// Where one delivery to one remote has got to. |
| 302 | /// |
| 303 | /// The state of the post at the remote, as against [`PostState`](super::PostState), which is the state |
| 304 | /// of the post at home. A remote can have taken a post the site still calls a draft, or refused one it |
| 305 | /// calls live: the two states are about different places and do not track each other. |
| 306 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 307 | pub enum DeliveryState { |
| 308 | Queued, // waiting for the worker to try it, or to try it again |
| 309 | // Taken. `at` is the moment the remote confirmed it, `permalink` the address it gave back -- |
| 310 | // kept so the site can say "also on Mastodon" and link to where the post actually landed. |
| 311 | Sent { |
| 312 | at: String, |
| 313 | permalink: String, |
| 314 | }, |
| 315 | // Refused. `at` is when, `err` is what the remote or the wire said, `retries` how many attempts |
| 316 | // have failed. At MAX_RETRIES the worker stops trying and the failure stands. |
| 317 | Failed { |
| 318 | at: String, |
| 319 | err: String, |
| 320 | retries: u32, |
| 321 | }, |
| 322 | } |
| 323 | |
| 324 | impl DeliveryState { |
| 325 | |
| 326 | /// How long to wait before the next attempt, given how many have already failed. |
| 327 | /// |
| 328 | /// Exponential from [`BACKOFF_BASE_SECS`], doubling each failure, capped at [`BACKOFF_CAP_SECS`]: |
| 329 | /// a remote briefly down is retried soon, a remote long down is not hammered. Pure, so the worker |
| 330 | /// supplies the clock and this supplies only the interval. |
| 331 | pub fn backoff_secs(retries: u32) -> u64 { |
| 332 | let shifted = BACKOFF_BASE_SECS.checked_shl(retries).unwrap_or(u64::MAX); |
| 333 | shifted.min(BACKOFF_CAP_SECS) |
| 334 | } |
| 335 | |
| 336 | /// Whether the worker is done with this delivery -- it has been taken, or refused past retrying. |
| 337 | pub fn is_terminal(&self) -> bool { |
| 338 | match self { |
| 339 | Self::Queued => false, |
| 340 | Self::Sent { .. } => true, |
| 341 | Self::Failed { retries, .. } => *retries >= MAX_RETRIES, |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | fn tag(&self) -> &'static str { |
| 346 | match self { |
| 347 | Self::Queued => "queued", |
| 348 | Self::Sent { .. } => "sent", |
| 349 | Self::Failed { .. } => "failed", |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | pub fn to_dat(&self) -> Dat { |
| 354 | let mut m = DaticleMap::new(); |
| 355 | m.insert(dat!("state"), dat!(self.tag().to_string())); |
| 356 | match self { |
| 357 | Self::Queued => {}, |
| 358 | Self::Sent { at, permalink } => { |
| 359 | m.insert(dat!("at"), dat!(at.clone())); |
| 360 | m.insert(dat!("permalink"), dat!(permalink.clone())); |
| 361 | } |
| 362 | Self::Failed { at, err, retries } => { |
| 363 | m.insert(dat!("at"), dat!(at.clone())); |
| 364 | m.insert(dat!("err"), dat!(err.clone())); |
| 365 | m.insert(dat!("retries"), Dat::U32(*retries)); |
| 366 | } |
| 367 | } |
| 368 | Dat::Map(m) |
| 369 | } |
| 370 | |
| 371 | /// The state from a daticle. |
| 372 | /// |
| 373 | /// A state word this version cannot read is **not** taken as queued, because a queued delivery is |
| 374 | /// re-sent and a post this version cannot understand the state of might already have gone -- sending |
| 375 | /// it again would double-post. So an unreadable state is a spent failure: inert, retried by nobody, |
| 376 | /// and visible as a failure to whoever looks. |
| 377 | pub fn from_dat(d: &Dat) -> Self { |
| 378 | let m = match d { |
| 379 | Dat::Map(m) => m, |
| 380 | _ => return Self::spent("a delivery state that is not a map"), |
| 381 | }; |
| 382 | let tag = match m.get(&dat!("state")) { |
| 383 | Some(Dat::Str(s)) => s.clone(), |
| 384 | _ => return Self::spent("a delivery with no state"), |
| 385 | }; |
| 386 | let get_str = |key: &str| -> String { |
| 387 | match m.get(&dat!(key)) { |
| 388 | Some(Dat::Str(s)) => s.clone(), |
| 389 | _ => String::new(), |
| 390 | } |
| 391 | }; |
| 392 | match tag.as_str() { |
| 393 | "queued" => Self::Queued, |
| 394 | "sent" => Self::Sent { |
| 395 | at: get_str("at"), |
| 396 | permalink: get_str("permalink"), |
| 397 | }, |
| 398 | "failed" => Self::Failed { |
| 399 | at: get_str("at"), |
| 400 | err: get_str("err"), |
| 401 | retries: as_u32(m.get(&dat!("retries"))), |
| 402 | }, |
| 403 | other => Self::spent(&fmt!("a delivery state this version cannot read: '{}'", other)), |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | /// A failure that has used up its retries: a terminal state for a delivery nothing can safely act |
| 408 | /// on. |
| 409 | fn spent(why: &str) -> Self { |
| 410 | Self::Failed { |
| 411 | at: String::new(), |
| 412 | err: why.to_string(), |
| 413 | retries: MAX_RETRIES, |
| 414 | } |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | |
| 419 | /// One post's delivery to one remote: where it goes, in what words, and how far it has got. |
| 420 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 421 | pub struct Delivery { |
| 422 | pub dest: Destination, |
| 423 | pub rendition: Rendition, |
| 424 | pub state: DeliveryState, |
| 425 | } |
| 426 | |
| 427 | impl Delivery { |
| 428 | |
| 429 | pub fn new(dest: Destination, rendition: Rendition) -> Self { |
| 430 | Self { dest, rendition, state: DeliveryState::Queued } |
| 431 | } |
| 432 | |
| 433 | pub fn to_dat(&self) -> Dat { |
| 434 | let mut m = DaticleMap::new(); |
| 435 | m.insert(dat!("dest"), dat!(self.dest.as_str().to_string())); |
| 436 | m.insert(dat!("rendition"), self.rendition.to_dat()); |
| 437 | m.insert(dat!("state"), self.state.to_dat()); |
| 438 | Dat::Map(m) |
| 439 | } |
| 440 | |
| 441 | /// The delivery from a daticle, or nothing where its destination is one this version does not know |
| 442 | /// -- a delivery the caller drops rather than misroutes. |
| 443 | pub fn from_dat(d: &Dat) -> Option<Self> { |
| 444 | let m = match d { |
| 445 | Dat::Map(m) => m, |
| 446 | _ => return None, |
| 447 | }; |
| 448 | let dest = match m.get(&dat!("dest")) { |
| 449 | Some(Dat::Str(s)) => ok!(Destination::of(s)), |
| 450 | _ => return None, |
| 451 | }; |
| 452 | let rendition = match m.get(&dat!("rendition")) { |
| 453 | Some(d) => Rendition::from_dat(d), |
| 454 | None => Rendition::default(), |
| 455 | }; |
| 456 | let state = match m.get(&dat!("state")) { |
| 457 | Some(d) => DeliveryState::from_dat(d), |
| 458 | None => DeliveryState::Queued, |
| 459 | }; |
| 460 | Some(Self { dest, rendition, state }) |
| 461 | } |
| 462 | } |
| 463 | |
| 464 | |
| 465 | /// A `u32` out of a daticle that may hold one under any of the widths jdat writes an integer as, or |
| 466 | /// zero where there is no readable number. |
| 467 | fn as_u32(d: Option<&Dat>) -> u32 { |
| 468 | match d { |
| 469 | Some(Dat::U32(n)) => *n, |
| 470 | Some(Dat::U16(n)) => *n as u32, |
| 471 | Some(Dat::U8(n)) => *n as u32, |
| 472 | Some(Dat::U64(n)) => *n as u32, |
| 473 | _ => 0, |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | |
| 478 | #[cfg(test)] |
| 479 | mod tests { |
| 480 | use super::*; |
| 481 | |
| 482 | /// Every destination's word round-trips, and a word from outside the set is nobody's destination. |
| 483 | #[test] |
| 484 | fn test_a_destination_round_trips_by_word_00() -> Outcome<()> { |
| 485 | for d in Destination::ALL { |
| 486 | assert_eq!(Destination::of(d.as_str()), Some(d)); |
| 487 | } |
| 488 | assert_eq!(Destination::of("myspace"), None); |
| 489 | Ok(()) |
| 490 | } |
| 491 | |
| 492 | /// A capability's cost answers whether a link is in the body. X is the only costed one. |
| 493 | #[test] |
| 494 | fn test_x_costs_more_with_a_link_01() -> Outcome<()> { |
| 495 | let x = Destination::X.capability(); |
| 496 | assert_eq!(x.cost_of(false), 15_000); |
| 497 | assert_eq!(x.cost_of(true), 200_000); |
| 498 | let m = Destination::Mastodon.capability(); |
| 499 | assert_eq!(m.cost_of(true), 0); |
| 500 | Ok(()) |
| 501 | } |
| 502 | |
| 503 | /// A promo that fits is left whole; the link is always kept whole, and the title gives way. |
| 504 | #[test] |
| 505 | fn test_a_promo_keeps_the_link_whole_02() -> Outcome<()> { |
| 506 | let url = "https://example.com/asides/on-rent"; |
| 507 | // Fits: title and link, untouched. |
| 508 | let r = Rendition::promo("On rent", url, Some(280)); |
| 509 | assert!(r.text.contains("On rent")); |
| 510 | assert!(r.text.ends_with(url)); |
| 511 | assert!(r.auto); |
| 512 | // Does not fit: the whole URL still survives, the title is cut. |
| 513 | let long = "On rent and the long slow theft of the thing a person stands on and calls their own"; |
| 514 | let r = Rendition::promo(long, url, Some(60)); |
| 515 | assert!(r.text.ends_with(url), "the link must survive whole: {:?}", r.text); |
| 516 | assert!(r.text.chars().count() <= 60, "over the limit: {:?}", r.text); |
| 517 | assert!(r.text.contains('…'), "a cut title should show it was cut: {:?}", r.text); |
| 518 | Ok(()) |
| 519 | } |
| 520 | |
| 521 | /// Where not even the link fits with a title, the link goes on its own rather than truncated. |
| 522 | #[test] |
| 523 | fn test_a_promo_sends_the_link_alone_when_it_must_03() -> Outcome<()> { |
| 524 | let url = "https://example.com/asides/on-rent"; |
| 525 | let r = Rendition::promo("On rent", url, Some(url.chars().count() + 1)); |
| 526 | assert_eq!(r.text, url); |
| 527 | Ok(()) |
| 528 | } |
| 529 | |
| 530 | /// The backoff doubles from the base and stops at the cap. |
| 531 | #[test] |
| 532 | fn test_backoff_doubles_then_caps_04() -> Outcome<()> { |
| 533 | assert_eq!(DeliveryState::backoff_secs(0), 60); |
| 534 | assert_eq!(DeliveryState::backoff_secs(1), 120); |
| 535 | assert_eq!(DeliveryState::backoff_secs(2), 240); |
| 536 | // Well past the cap, and no overflow panic at a silly retry count. |
| 537 | assert_eq!(DeliveryState::backoff_secs(6), 3600); |
| 538 | assert_eq!(DeliveryState::backoff_secs(1000), 3600); |
| 539 | Ok(()) |
| 540 | } |
| 541 | |
| 542 | /// The worker is done with a delivery once it is sent, or once it has failed past retrying. |
| 543 | #[test] |
| 544 | fn test_a_delivery_is_terminal_when_done_05() -> Outcome<()> { |
| 545 | assert!(!DeliveryState::Queued.is_terminal()); |
| 546 | assert!(DeliveryState::Sent { |
| 547 | at: fmt!("t"), permalink: fmt!("p"), |
| 548 | }.is_terminal()); |
| 549 | assert!(!DeliveryState::Failed { |
| 550 | at: fmt!("t"), err: fmt!("e"), retries: 1, |
| 551 | }.is_terminal()); |
| 552 | assert!(DeliveryState::Failed { |
| 553 | at: fmt!("t"), err: fmt!("e"), retries: MAX_RETRIES, |
| 554 | }.is_terminal()); |
| 555 | Ok(()) |
| 556 | } |
| 557 | |
| 558 | /// A delivery survives the trip through a daticle, in each of its states. |
| 559 | #[test] |
| 560 | fn test_a_delivery_round_trips_06() -> Outcome<()> { |
| 561 | let cases = [ |
| 562 | DeliveryState::Queued, |
| 563 | DeliveryState::Sent { at: fmt!("2026-07-18T10:00:00Z"), permalink: fmt!("https://m/1") }, |
| 564 | DeliveryState::Failed { at: fmt!("2026-07-18T10:00:00Z"), err: fmt!("429"), retries: 2 }, |
| 565 | ]; |
| 566 | for state in cases { |
| 567 | let d = Delivery { |
| 568 | dest: Destination::Mastodon, |
| 569 | rendition: Rendition { text: fmt!("On rent https://x"), auto: false }, |
| 570 | state: state.clone(), |
| 571 | }; |
| 572 | let back = match Delivery::from_dat(&d.to_dat()) { |
| 573 | Some(b) => b, |
| 574 | None => return Err(err!("a delivery did not round-trip: {:?}", d; Test, Missing)), |
| 575 | }; |
| 576 | assert_eq!(back, d); |
| 577 | } |
| 578 | Ok(()) |
| 579 | } |
| 580 | |
| 581 | /// A delivery to a destination this version does not know is dropped, not misrouted or fatal. |
| 582 | #[test] |
| 583 | fn test_an_unknown_destination_is_dropped_07() -> Outcome<()> { |
| 584 | let mut m = DaticleMap::new(); |
| 585 | m.insert(dat!("dest"), dat!("myspace")); |
| 586 | m.insert(dat!("rendition"), Rendition::default().to_dat()); |
| 587 | m.insert(dat!("state"), DeliveryState::Queued.to_dat()); |
| 588 | assert_eq!(Delivery::from_dat(&Dat::Map(m)), None); |
| 589 | Ok(()) |
| 590 | } |
| 591 | |
| 592 | /// A delivery state this version cannot read is a spent failure, not a queued re-send: a post whose |
| 593 | /// state is unreadable might already have gone, and must not be sent twice. |
| 594 | #[test] |
| 595 | fn test_an_unreadable_state_will_not_resend_08() -> Outcome<()> { |
| 596 | let mut m = DaticleMap::new(); |
| 597 | m.insert(dat!("state"), dat!("halfway-out-the-door")); |
| 598 | let state = DeliveryState::from_dat(&Dat::Map(m)); |
| 599 | assert!(state.is_terminal(), "an unreadable state must not be retried: {:?}", state); |
| 600 | match state { |
| 601 | DeliveryState::Failed { retries, .. } => assert_eq!(retries, MAX_RETRIES), |
| 602 | other => return Err(err!("expected a spent failure, got {:?}", other; Test, Mismatch)), |
| 603 | } |
| 604 | Ok(()) |
| 605 | } |
| 606 | } |