Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/tests/publish_store.rs

24.1 KiB, 28 runs

created by r1870400018:14429, 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//! The publish store against a real Ozone instance.
2//!
3//! The unit tests cover the record's encoding and the index's arithmetic. This covers the thing they
4//! cannot: that a post written to a database comes back out of it.
5//!
6//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
7//! Anthropic Claude
8
9use oxedyne_fe2o3_core::prelude::*;
10use oxedyne_fe2o3_steel::srv::{
11 console::union_admins,
12 publish::{
13 Markup,
14 PostState,
15 PublishConfig,
16 send::{
17 self,
18 BlueskyCreds,
19 DestCreds,
20 MailSender,
21 MastodonCreds,
22 SendReport,
23 },
24 store::{
25 self,
26 Record,
27 },
28 subscribe::{
29 self,
30 SubState,
31 },
32 },
33};
34
35mod common;
36
37#[test]
38fn a_post_survives_the_database() -> Outcome<()> {
39 let (db, uid, _tmp) = match common::test_db() {
40 Ok(t) => t,
41 Err(e) => {
42 println!("no test database available, skipping: {}", e);
43 return Ok(());
44 }
45 };
46 let handle = (db, uid);
47
48 // Nothing published is an empty list, not an error.
49 let posts = res!(store::list(&handle, "test"));
50 assert_eq!(posts.len(), 0, "a fresh store is not empty");
51
52 let rec = Record {
53 slug: fmt!("on-rent"),
54 author: String::new(),
55 categories: Vec::new(),
56 state: PostState::Live,
57 markup: Markup::Markdown,
58 date: Some(fmt!("2026-07-17")),
59 source: fmt!("# On rent\n\nAn opening sentence.\n"),
60 deliveries: Vec::new(),
61 tags: vec![fmt!("rust"), fmt!("web")],
62 ai_level: None,
63 };
64 res!(store::put(&handle, &rec, "test"));
65
66 // It comes back as the record it went in as, tags and all.
67 let back = res!(store::get(&handle, "on-rent"));
68 assert_eq!(back.as_ref(), Some(&rec));
69
70 // The tags travel through to the rendered post.
71 let posts = res!(store::list(&handle, "test"));
72 assert_eq!(posts[0].tags, vec![fmt!("rust"), fmt!("web")]);
73
74 // And the vocabulary is the sorted union across the store.
75 assert_eq!(res!(store::all_tags(&handle, "test")), vec![fmt!("rust"), fmt!("web")]);
76
77 // And as a rendered post, titled by its own heading.
78 let posts = res!(store::list(&handle, "test"));
79 assert_eq!(posts.len(), 1);
80 assert_eq!(posts[0].title, "On rent");
81 assert_eq!(posts[0].date, Some(fmt!("2026-07-17")));
82 assert!(posts[0].html.contains("<h1>On rent</h1>"), "got: {}", posts[0].html);
83 assert_eq!(posts[0].excerpt, "An opening sentence.");
84
85 // Writing the same slug twice is one post, not two: the index does not grow.
86 res!(store::put(&handle, &rec, "test"));
87 let posts = res!(store::list(&handle, "test"));
88 assert_eq!(posts.len(), 1, "the index grew on a rewrite");
89
90 // A draft is written, and served to nobody.
91 let draft = Record {
92 slug: fmt!("unfinished"),
93 state: PostState::Draft,
94 ..rec.clone()
95 };
96 res!(store::put(&handle, &draft, "test"));
97 assert!(res!(store::get(&handle, "unfinished")).is_some(), "the draft was not stored");
98 let posts = res!(store::list(&handle, "test"));
99 assert_eq!(posts.len(), 1, "a draft reached a reader");
100
101 // Newest first.
102 let older = Record {
103 slug: fmt!("older"),
104 date: Some(fmt!("2026-07-01")),
105 ..rec.clone()
106 };
107 res!(store::put(&handle, &older, "test"));
108 let posts = res!(store::list(&handle, "test"));
109 assert_eq!(posts.len(), 2);
110 assert_eq!(posts[0].slug, "on-rent", "not newest first");
111 assert_eq!(posts[1].slug, "older");
112
113 // Deleting takes it out of the index too, so a list does not name what is gone.
114 assert!(res!(store::delete(&handle, "older", "test")));
115 let posts = res!(store::list(&handle, "test"));
116 assert_eq!(posts.len(), 1);
117 assert_eq!(posts[0].slug, "on-rent");
118
119 // A deleted post is gone to a reader that asks for it by name.
120 assert!(res!(store::get(&handle, "older")).is_none(), "a deleted post still reads back");
121
122 // The index can be rebuilt from what the database holds, drafts included but not the deleted one.
123 //
124 // This is the assertion that matters. `Database::delete` "marks for deletion", and a scan still
125 // hands back the marked key -- so a rebuild that trusted the scan would resurrect every post ever
126 // deleted, silently, the next time anything repaired the index.
127 let n = res!(store::rebuild_index(&handle, "test"));
128 assert_eq!(n, 2, "rebuild found {} posts, expected the live one and the draft", n);
129 let posts = res!(store::list(&handle, "test"));
130 assert_eq!(posts.len(), 1, "the rebuilt index resurrected a deleted post");
131 assert_eq!(posts[0].slug, "on-rent");
132
133 Ok(())
134}
135
136/// Destination credentials written to the database come back out of it, and the effective set lays the
137/// stored ones over the config's.
138#[test]
139fn credentials_survive_the_database() -> Outcome<()> {
140 let (db, uid, _tmp) = match common::test_db() {
141 Ok(t) => t,
142 Err(e) => {
143 println!("no test database available, skipping: {}", e);
144 return Ok(());
145 }
146 };
147 let handle = (db, uid);
148
149 // Nothing set is an empty set, not an error.
150 let creds = res!(send::get_creds(&handle));
151 assert_eq!(creds, DestCreds::default());
152
153 // A Bluesky credential written and read back is the one that went in.
154 let stored = DestCreds {
155 mastodon: None,
156 bluesky: Some(BlueskyCreds {
157 host: fmt!("bsky.social"),
158 handle: fmt!("me.bsky.social"),
159 app_password: fmt!("app-secret"),
160 }),
161 };
162 res!(send::put_creds(&handle, &stored));
163 assert_eq!(res!(send::get_creds(&handle)), stored);
164
165 // The effective set lays the store over the config: the config's Mastodon survives, the store's
166 // Bluesky wins where both could name one.
167 let cfg = PublishConfig {
168 creds: DestCreds {
169 mastodon: Some(MastodonCreds { base_url: fmt!("https://m"), token: fmt!("cfg-tok") }),
170 bluesky: Some(BlueskyCreds {
171 host: fmt!("bsky.social"), handle: fmt!("cfg"), app_password: fmt!("cfg-pw"),
172 }),
173 },
174 ..Default::default()
175 };
176 let eff = res!(send::effective_creds(&handle, &cfg));
177 assert!(eff.mastodon.is_some(), "config Mastodon was lost");
178 match &eff.bluesky {
179 Some(b) => assert_eq!(b.handle, "me.bsky.social", "store Bluesky did not win"),
180 None => return Err(err!("effective Bluesky was lost"; Test, Missing)),
181 }
182
183 Ok(())
184}
185
186/// The database admin list bootstraps a site and manages it: empty to begin, a first admin claimed, a
187/// second granted, one revoked, and the union with config throughout deciding whether a claim is open.
188#[test]
189fn admins_bootstrap_and_management() -> Outcome<()> {
190 let (db, uid, _tmp) = match common::test_db() {
191 Ok(t) => t,
192 Err(e) => {
193 println!("no test database available, skipping: {}", e);
194 return Ok(());
195 }
196 };
197 let handle = (db, uid);
198
199 // Two well-formed member id-hashes: 64 lowercase hex characters, as a passphrase's SHA-256 renders.
200 let a = "0123456789abcdef".repeat(4);
201 let b = "fedcba9876543210".repeat(4);
202
203 // A fresh store has granted no admins, and no config pin either, so the effective set is empty --
204 // which is exactly the state a claim is open in.
205 assert!(res!(store::admins_get(&handle, "test")).is_empty(), "a fresh admin list is not empty");
206 assert!(union_admins(&[], &res!(store::admins_get(&handle, "test"))).is_empty(),
207 "an unclaimed site is not claimable");
208
209 // The first admin claims: the database list now holds them, and the site is no longer claimable.
210 res!(store::admins_add(&handle, "test", &a));
211 assert_eq!(res!(store::admins_get(&handle, "test")), vec![a.clone()]);
212 assert!(!union_admins(&[], &res!(store::admins_get(&handle, "test"))).is_empty(),
213 "a claimed site is still claimable");
214
215 // Granting the same admin again grants them once: the list does not grow.
216 res!(store::admins_add(&handle, "test", &a));
217 assert_eq!(res!(store::admins_get(&handle, "test")).len(), 1, "an idempotent add grew the list");
218
219 // A second admin is granted, alongside the first.
220 res!(store::admins_add(&handle, "test", &b));
221 assert_eq!(res!(store::admins_get(&handle, "test")), vec![a.clone(), b.clone()]);
222
223 // The first is revoked, leaving the second.
224 res!(store::admins_remove(&handle, "test", &a));
225 assert_eq!(res!(store::admins_get(&handle, "test")), vec![b.clone()]);
226
227 // Revoking one the list does not hold is a no-op, not an error.
228 res!(store::admins_remove(&handle, "test", &a));
229 assert_eq!(res!(store::admins_get(&handle, "test")), vec![b.clone()]);
230
231 // A config-pinned admin is always effective, whatever the database holds -- the union carries it
232 // even though the database list never named it.
233 let pinned = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string();
234 let eff = union_admins(&[pinned.clone()], &res!(store::admins_get(&handle, "test")));
235 assert!(eff.iter().any(|h| h == &pinned), "the config pin was lost from the union");
236 assert!(eff.iter().any(|h| h == &b), "the granted admin was lost from the union");
237
238 Ok(())
239}
240
241/// The subscriber store against a real database: a sign-up pends, a confirmation promotes, an
242/// unsubscribe demotes, only the confirmed are in the send set, re-subscribing a confirmed address is
243/// silent, and a bad token confirms nobody.
244#[test]
245fn subscribers_survive_the_database() -> Outcome<()> {
246 let (db, uid, _tmp) = match common::test_db() {
247 Ok(t) => t,
248 Err(e) => {
249 println!("no test database available, skipping: {}", e);
250 return Ok(());
251 }
252 };
253 let handle = (db, uid);
254
255 // A fresh store has no subscribers, and no confirmed send set.
256 assert_eq!(res!(subscribe::count(&handle, "test")), 0, "a fresh subscriber list is not empty");
257 assert!(res!(subscribe::confirmed(&handle, "test")).is_empty(), "a fresh send set is not empty");
258
259 // A sign-up records a pending subscriber and hands back its token to send a confirmation with.
260 let sub = match res!(subscribe::add_pending(&handle, " Me@Example.COM ")) {
261 Some(s) => s,
262 None => return Err(err!("a fresh address did not pend"; Test, Missing)),
263 };
264 assert_eq!(sub.email, "me@example.com", "the address was not normalised");
265 assert_eq!(sub.state, SubState::Pending);
266 let token = sub.token.clone();
267
268 // Pending is not confirmed: it is not in the send set.
269 assert!(res!(subscribe::confirmed(&handle, "test")).is_empty(), "a pending address reached the send set");
270
271 // A bad token confirms nobody, and does not disturb the pending one.
272 assert_eq!(res!(subscribe::confirm(&handle, "not-a-real-token", "test")),
273 subscribe::ConfirmOutcome::Unknown);
274 match res!(subscribe::get(&handle, "me@example.com")) {
275 Some(s) => assert_eq!(s.state, SubState::Pending, "a bad token changed a subscriber"),
276 None => return Err(err!("the pending subscriber vanished"; Test, Missing)),
277 }
278
279 // The real token promotes them, and now they are the send set.
280 assert_eq!(res!(subscribe::confirm(&handle, &token, "test")), subscribe::ConfirmOutcome::Confirmed);
281 let send_set = res!(subscribe::confirmed(&handle, "test"));
282 assert_eq!(send_set.len(), 1);
283 assert_eq!(send_set[0].email, "me@example.com");
284
285 // Confirming again is idempotent, not a second welcome.
286 assert_eq!(res!(subscribe::confirm(&handle, &token, "test")), subscribe::ConfirmOutcome::Already);
287
288 // Re-subscribing a confirmed address is silent: no confirmation to send, and the list does not grow.
289 assert!(res!(subscribe::add_pending(&handle, "me@example.com")).is_none(),
290 "re-subscribing a confirmed address asked for a second confirmation");
291 assert_eq!(res!(subscribe::count(&handle, "test")), 1, "the subscriber list grew on a re-subscribe");
292
293 // Unsubscribing by token takes them out of the send set but keeps the record.
294 assert_eq!(res!(subscribe::unsubscribe(&handle, &token, "test")), subscribe::UnsubOutcome::Done);
295 assert!(res!(subscribe::confirmed(&handle, "test")).is_empty(), "an unsubscribed address stayed in the send set");
296 assert_eq!(res!(subscribe::count(&handle, "test")), 1, "an unsubscribe deleted the record");
297
298 // A second subscriber, confirmed, so the export carries more than one row.
299 let sub2 = match res!(subscribe::add_pending(&handle, "other@example.net")) {
300 Some(s) => s,
301 None => return Err(err!("a second address did not pend"; Test, Missing)),
302 };
303 res!(subscribe::confirm(&handle, &sub2.token, "test"));
304
305 // The export is a CSV with a header and a row per subscriber.
306 let csv = res!(subscribe::export(&handle, "test"));
307 assert!(csv.starts_with("email,state,created\n"), "no CSV header: {}", csv);
308 assert!(csv.contains("me@example.com,unsubscribed,"), "the unsubscribed row is wrong: {}", csv);
309 assert!(csv.contains("other@example.net,confirmed,"), "the confirmed row is wrong: {}", csv);
310
311 Ok(())
312}
313
314/// Suppression: a bounced address leaves the send set and stays out of it, and a re-subscribe does not
315/// resurrect it -- unlike an unsubscribe, which a re-subscribe reopens as a fresh opt-in.
316#[test]
317fn suppression_keeps_a_bounced_address_off_the_list() -> Outcome<()> {
318 let (db, uid, _tmp) = match common::test_db() {
319 Ok(t) => t,
320 Err(e) => {
321 println!("no test database available, skipping: {}", e);
322 return Ok(());
323 }
324 };
325 let handle = (db, uid);
326
327 // A confirmed subscriber is in the send set.
328 let sub = match res!(subscribe::add_pending(&handle, "gone@example.com")) {
329 Some(s) => s,
330 None => return Err(err!("a fresh address did not pend"; Test, Missing)),
331 };
332 res!(subscribe::confirm(&handle, &sub.token, "test"));
333 assert_eq!(res!(subscribe::confirmed(&handle, "test")).len(), 1, "the confirmed address is not in the send set");
334
335 // A permanent delivery failure suppresses them: the mark that send_newsletter makes on a 5xx.
336 assert!(res!(subscribe::mark_bounced(&handle, "gone@example.com", "test")), "the address was not found to bounce");
337 match res!(subscribe::get(&handle, "gone@example.com")) {
338 Some(s) => assert_eq!(s.state, SubState::Bounced, "the address was not marked bounced"),
339 None => return Err(err!("the bounced subscriber vanished"; Test, Missing)),
340 }
341
342 // Bounced is not confirmed, so it is out of the send set.
343 assert!(res!(subscribe::confirmed(&handle, "test")).is_empty(), "a bounced address stayed in the send set");
344
345 // A re-subscribe does not resurrect it: the same suppressed answer as an address already on the list,
346 // and the record stays bounced.
347 assert!(res!(subscribe::add_pending(&handle, "gone@example.com")).is_none(),
348 "re-subscribing a bounced address asked for a confirmation");
349 match res!(subscribe::get(&handle, "gone@example.com")) {
350 Some(s) => assert_eq!(s.state, SubState::Bounced, "a re-subscribe resurrected a bounced address"),
351 None => return Err(err!("the bounced subscriber vanished on re-subscribe"; Test, Missing)),
352 }
353
354 // An unsubscribe, by contrast, IS reopened by a re-subscribe -- the difference between a choice and a
355 // bounce. A second, confirmed, then unsubscribed address proves it.
356 let sub2 = match res!(subscribe::add_pending(&handle, "back@example.net")) {
357 Some(s) => s,
358 None => return Err(err!("a second address did not pend"; Test, Missing)),
359 };
360 res!(subscribe::confirm(&handle, &sub2.token, "test"));
361 res!(subscribe::unsubscribe(&handle, &sub2.token, "test"));
362 assert!(res!(subscribe::confirmed(&handle, "test")).is_empty(), "an unsubscribed address stayed in the send set");
363 // Re-subscribing an unsubscribed address pends afresh: a confirmation is due.
364 assert!(res!(subscribe::add_pending(&handle, "back@example.net")).is_some(),
365 "re-subscribing an unsubscribed address did not reopen it");
366
367 Ok(())
368}
369
370/// Admin-side management: an admin unsubscribes an address by its name, keeping the record, and erases
371/// another outright, leaving nothing -- the two ways a subscriber leaves, told apart.
372#[test]
373fn admin_unsubscribe_and_remove_round_trip() -> Outcome<()> {
374 let (db, uid, _tmp) = match common::test_db() {
375 Ok(t) => t,
376 Err(e) => {
377 println!("no test database available, skipping: {}", e);
378 return Ok(());
379 }
380 };
381 let handle = (db, uid);
382
383 let a = match res!(subscribe::add_pending(&handle, "a@example.com")) {
384 Some(s) => s,
385 None => return Err(err!("a did not pend"; Test, Missing)),
386 };
387 res!(subscribe::confirm(&handle, &a.token, "test"));
388 let b = match res!(subscribe::add_pending(&handle, "b@example.com")) {
389 Some(s) => s,
390 None => return Err(err!("b did not pend"; Test, Missing)),
391 };
392 res!(subscribe::confirm(&handle, &b.token, "test"));
393 assert_eq!(res!(subscribe::count(&handle, "test")), 2);
394
395 // The admin unsubscribes a by name: the record is kept, out of the send set, still counted.
396 assert!(res!(subscribe::unsubscribe_email(&handle, "a@example.com", "test")), "a was not found to unsubscribe");
397 match res!(subscribe::get(&handle, "a@example.com")) {
398 Some(s) => assert_eq!(s.state, SubState::Unsubscribed, "a was not unsubscribed"),
399 None => return Err(err!("a vanished on unsubscribe"; Test, Missing)),
400 }
401 assert_eq!(res!(subscribe::count(&handle, "test")), 2, "an admin unsubscribe erased the record");
402 assert_eq!(res!(subscribe::confirmed(&handle, "test")).len(), 1, "a stayed in the send set");
403
404 // Unsubscribing an address the store does not hold is false, not an error.
405 assert!(!res!(subscribe::unsubscribe_email(&handle, "nobody@example.com", "test")),
406 "unsubscribing an unknown address claimed a subscriber");
407
408 // The admin erases b outright: gone from the store and the index both.
409 assert!(res!(subscribe::remove(&handle, "b@example.com", "test")), "b was not found to erase");
410 assert!(res!(subscribe::get(&handle, "b@example.com")).is_none(), "an erased subscriber still reads back");
411 assert_eq!(res!(subscribe::count(&handle, "test")), 1, "the erase did not shrink the list");
412 // Erasing one the store does not hold is false, not an error.
413 assert!(!res!(subscribe::remove(&handle, "b@example.com", "test")), "erasing a gone address claimed one");
414
415 Ok(())
416}
417
418/// Send history: a recorded send comes back with its counts, appended, and read most recent first.
419#[test]
420fn send_history_records_each_send() -> Outcome<()> {
421 let (db, uid, _tmp) = match common::test_db() {
422 Ok(t) => t,
423 Err(e) => {
424 println!("no test database available, skipping: {}", e);
425 return Ok(());
426 }
427 };
428 let handle = (db, uid);
429
430 // A fresh store has sent nothing.
431 assert!(res!(send::send_history(&handle)).is_empty(), "a fresh send history is not empty");
432
433 // One send recorded, with the report's counts intact.
434 let r1 = SendReport { attempted: 5, sent: 4, failed: 1, suppressed: 0 };
435 res!(send::record_send(&handle, &send::SendEntry::of("on-rent", "2026-07-18T10:00:00Z", &r1)));
436 let hist = res!(send::send_history(&handle));
437 assert_eq!(hist.len(), 1);
438 assert_eq!(hist[0].slug, "on-rent");
439 assert_eq!(hist[0].attempted, 5);
440 assert_eq!(hist[0].sent, 4);
441 assert_eq!(hist[0].failed, 1);
442 assert_eq!(hist[0].suppressed, 0);
443
444 // A second send appends, and the history reads newest first.
445 let r2 = SendReport { attempted: 6, sent: 5, failed: 0, suppressed: 1 };
446 res!(send::record_send(&handle, &send::SendEntry::of("on-work", "2026-07-19T09:00:00Z", &r2)));
447 let hist = res!(send::send_history(&handle));
448 assert_eq!(hist.len(), 2, "the second send did not append");
449 assert_eq!(hist[0].slug, "on-work", "the history is not most-recent-first");
450 assert_eq!(hist[0].suppressed, 1);
451 assert_eq!(hist[1].slug, "on-rent");
452
453 Ok(())
454}
455
456/// A test-send touches no subscriber state and writes no history: on the pre-network paths it exercises
457/// -- a malformed address, and a draft post -- nothing in the store moves.
458#[tokio::test]
459async fn a_test_send_touches_no_state_or_history() -> Outcome<()> {
460 let (db, uid, _tmp) = match common::test_db() {
461 Ok(t) => t,
462 Err(e) => {
463 println!("no test database available, skipping: {}", e);
464 return Ok(());
465 }
466 };
467 let handle = (db, uid);
468
469 // A confirmed subscriber and a draft post: the state a test must not disturb.
470 let sub = match res!(subscribe::add_pending(&handle, "reader@example.com")) {
471 Some(s) => s,
472 None => return Err(err!("the reader did not pend"; Test, Missing)),
473 };
474 res!(subscribe::confirm(&handle, &sub.token, "test"));
475 let draft = Record {
476 slug: fmt!("wip"),
477 author: String::new(),
478 categories: Vec::new(),
479 state: PostState::Draft,
480 markup: Markup::Markdown,
481 date: None,
482 source: fmt!("# WIP\n\nNot yet.\n"),
483 deliveries: Vec::new(),
484 tags: Vec::new(),
485 ai_level: None,
486 };
487 res!(store::put(&handle, &draft, "test"));
488
489 let sender = res!(MailSender::new("mail.x.test".to_string(), Vec::new(), "news@x.test".to_string()));
490 let cfg = PublishConfig { base_url: fmt!("https://x.test"), ..Default::default() };
491
492 // A malformed address is refused before anything is read or sent.
493 assert!(send::send_test(&sender, &handle, &cfg, "news@x.test", "wip", "not-an-address", "test").await.is_err(),
494 "a test to a malformed address did not error");
495 // A draft is refused before the network: a test sends the live post a subscriber would get.
496 assert!(send::send_test(&sender, &handle, &cfg, "news@x.test", "wip", "you@example.com", "test").await.is_err(),
497 "a test of a draft did not error");
498
499 // Neither path moved the subscriber, marked anything bounced, or wrote a line of history.
500 match res!(subscribe::get(&handle, "reader@example.com")) {
501 Some(s) => assert_eq!(s.state, SubState::Confirmed, "a test-send changed a subscriber's state"),
502 None => return Err(err!("the reader vanished during a test-send"; Test, Missing)),
503 }
504 assert_eq!(res!(subscribe::confirmed(&handle, "test")).len(), 1, "a test-send changed the send set");
505 assert!(res!(send::send_history(&handle)).is_empty(), "a test-send wrote to the history");
506
507 Ok(())
508}
509
510/// The read tallies against a real database: an uncounted post is nought rather than an error, a bump
511/// accumulates, posts are counted apart from one another, and the scan reads back what was written.
512#[test]
513fn read_tallies_survive_the_database() -> Outcome<()> {
514 let (db, uid, _tmp) = match common::test_db() {
515 Ok(t) => t,
516 Err(e) => {
517 println!("no test database available, skipping: {}", e);
518 return Ok(());
519 }
520 };
521 let handle = (db, uid);
522
523 // A post nobody has read is nought, not an error and not a missing key the caller must handle.
524 assert_eq!(res!(store::reads_get(&handle, "unread")), 0);
525 assert!(res!(store::reads_all(&handle, "test")).is_empty(), "a fresh store has tallies");
526
527 // A bump answers the new total, and it accumulates.
528 assert_eq!(res!(store::reads_bump(&handle, "popular")), 1);
529 assert_eq!(res!(store::reads_bump(&handle, "popular")), 2);
530 assert_eq!(res!(store::reads_bump(&handle, "popular")), 3);
531 assert_eq!(res!(store::reads_get(&handle, "popular")), 3);
532
533 // Two posts are counted apart: reading one does not touch the other.
534 assert_eq!(res!(store::reads_bump(&handle, "quiet")), 1);
535 assert_eq!(res!(store::reads_get(&handle, "popular")), 3, "one post's reads reached another");
536
537 // The scan reads back exactly what was written, and nothing that was not.
538 let all = res!(store::reads_all(&handle, "test"));
539 assert_eq!(all.len(), 2, "the scan found {:?}", all);
540 assert_eq!(all.get("popular").copied(), Some(3));
541 assert_eq!(all.get("quiet").copied(), Some(1));
542 assert_eq!(all.get("unread").copied(), None, "a post that was never read gained a key");
543
544 Ok(())
545}