Files
the-daily-epub/tests/e2e_pipeline.rs
T
thallada 9e30c1dcdf Initial commit: The Daily EPUB full implementation
Full implementation of a personalized daily newspaper delivered as an
EPUB.

Articles are pulled from a local self-hosted Miniflux instance, enriched
with comments, summarized and filtered by DeepSeek AI, and then
assembled into two EPUB editions: standard and optimized for the Xteink
X4 e-ink reader. Both are served by the local self-hosted BookOrbit OPDS
server in a separate library. Then the X4 edition is futher converted to
XTC format and served over a separate OPDS server hosted by the Rust
binary. Runs are tracked in a local SQLite database so runs are
idempotent per date.

Full documentation of the plan is in docs/plans and setup and install
instructions are in the README.md file.
2026-08-15 17:46:19 +00:00

623 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The capstone test: the whole pipeline, driven as a library, with no network
//! (spec §2, §5).
//!
//! ```text
//! synthetic entries → dedupe → extract (offline) → persist → prefilter
//! → select → editorial → issue → both EPUB editions → publish → OPDS + rows
//! ```
//!
//! Two passes over the same machinery:
//!
//! * [`skip_llm_pipeline_produces_a_published_issue`] takes the `--skip-llm`
//! route (prefilter order selects, feed excerpts stand in for summaries);
//! * [`llm_pipeline_runs_against_a_mock_backend`] takes the DeepSeek route with
//! [`MockBackend`] standing in for the API, so stages A, B and C are all
//! exercised — prompts, parsers, budget accounting and all — offline.
//!
//! `Extractor::offline` guarantees the extraction stage never opens a socket, and
//! no article in the fixtures carries an image, so the EPUB builder's image
//! downloader has nothing to fetch.
use std::path::Path;
use jiff::Timestamp;
use jiff::civil::Date;
use daily_epub::config::{Config, PublishConfig, ServerConfig, XtcConfig};
use daily_epub::curate::llm::{LlmClient, MockBackend, UsageMeter};
use daily_epub::curate::{Curator, editorial, prefilter};
use daily_epub::db::Db;
use daily_epub::extract::Extractor;
use daily_epub::types::{
Article, Colophon, Edition, Entry, Issue, Lineup, ScoredArticle, SourceKind, Vote,
};
use daily_epub::{auth, dedupe, epub, miniflux, pipeline, publish};
const SECRET: &str = "e2e-secret";
fn ts(s: &str) -> Timestamp {
s.parse().expect("timestamp")
}
fn date() -> Date {
"2026-08-15".parse().expect("date")
}
fn body(words: usize) -> String {
format!(
"<p>{}</p>",
"a sentence about database internals and page layout ".repeat(words / 8)
)
}
/// A config whose every writable path points inside `root`.
fn test_config(root: &Path) -> Config {
Config {
database_path: root.join("db").join("daily-epub.db"),
out_dir: root.join("out"),
target_article_count: 6,
prefilter_keep: 20,
world_briefing: false,
publish: PublishConfig {
bookorbit_dir: root.join("bookorbit"),
xtc_dir: root.join("xtc"),
},
xtc: XtcConfig {
// Never shell out to node in a test.
enabled: false,
..XtcConfig::default()
},
server: ServerConfig {
public_url: "https://daily.hallada.net".into(),
hmac_secret: Some(SECRET.into()),
..ServerConfig::default()
},
..Config::default()
}
}
/// One day of ingest: eight entries covering duplicates, an excerpt-only story,
/// and three things that are not articles at all.
fn ingested() -> Vec<Entry> {
let base = Entry {
id: 0,
feed_id: 0,
feed_title: None,
category: Some("Tech".into()),
title: String::new(),
url: String::new(),
canonical_url: None,
author: Some("Dana Author".into()),
published_at: Some(ts("2026-08-15T04:00:00Z")),
comments_url: None,
raw_content: String::new(),
fetched_at: ts("2026-08-15T05:30:00Z"),
};
vec![
Entry {
id: 101,
feed_id: 1,
feed_title: Some("Hacker News Front Page".into()),
title: "A Deep Dive Into B-Trees".into(),
url: "https://blog.dev/b-trees?utm_source=hnrss".into(),
comments_url: Some("https://news.ycombinator.com/item?id=41234567".into()),
raw_content: "<p>Discussion link only.</p>".into(),
..base.clone()
},
// Same story, richer body: the cluster keeps this one.
Entry {
id: 102,
feed_id: 2,
feed_title: Some("Scour: Databases".into()),
title: "A Deep Dive Into B-Trees".into(),
url: "https://blog.dev/b-trees#intro".into(),
raw_content: body(900),
..base.clone()
},
Entry {
id: 103,
feed_id: 3,
feed_title: Some("The Rust Blog".into()),
title: "Async Cancellation, Revisited".into(),
url: "https://rust.dev/cancellation".into(),
raw_content: body(1400),
..base.clone()
},
Entry {
id: 104,
feed_id: 4,
feed_title: Some("Astronomy Notes".into()),
title: "What Webb Saw in the Rings of Uranus".into(),
url: "https://space.dev/webb-uranus".into(),
raw_content: body(700),
..base.clone()
},
Entry {
id: 105,
feed_id: 5,
feed_title: Some("Boston Civic Tech".into()),
title: "The MBTA's New Signal Priority Pilot".into(),
url: "https://boston.dev/signal-priority".into(),
raw_content: body(600),
..base.clone()
},
// Excerpt only: penalized by the pre-filter but still eligible (§3.5).
Entry {
id: 106,
feed_id: 6,
feed_title: Some("Lobsters".into()),
title: "Notes on Writing a Toy Allocator".into(),
url: "https://other.dev/allocator".into(),
comments_url: Some("https://lobste.rs/s/abcdef/notes".into()),
raw_content: "<p>A teaser paragraph and nothing else.</p>".into(),
..base.clone()
},
// Non-articles: a video host and an empty title (§3.2).
Entry {
id: 107,
feed_id: 7,
feed_title: Some("Video Feed".into()),
title: "A conference talk".into(),
url: "https://www.youtube.com/watch?v=abc".into(),
raw_content: "<p>watch it</p>".into(),
..base.clone()
},
Entry {
id: 108,
feed_id: 8,
feed_title: Some("Broken Feed".into()),
title: " ".into(),
url: "https://broken.dev/x".into(),
raw_content: body(400),
..base.clone()
},
]
}
/// Stages 14: ingest → dedupe → extract → persist, exactly as `pipeline.rs`
/// orders them (extraction runs *before* the article rows are written).
async fn ingest_dedupe_extract_persist(db: &Db) -> Vec<Article> {
let entries = ingested();
db.upsert_entries(&entries).await.expect("persist entries");
let feeds = std::collections::HashMap::from([(
2,
miniflux::FeedMeta {
id: 2,
title: "Scour: Databases".into(),
site_url: "https://scour.ing".into(),
feed_url: "https://scour.ing/feed?interest=databases".into(),
category: Some("Interests".into()),
},
)]);
let (mut articles, stats) = dedupe::cluster_with_feeds(entries, &miniflux::feed_urls(&feeds));
assert_eq!(stats.entries_in, 8);
assert_eq!(stats.dropped_non_article, 2, "video host + empty title");
assert_eq!(stats.merged, 1, "the B-trees story arrived twice");
assert_eq!(articles.len(), 5);
let extractor = Extractor::offline(vec![]);
assert!(
!extractor.can_fetch(),
"the test must never hit the network"
);
let extracted = extractor.extract_all(&mut articles).await;
assert_eq!(extracted.excerpt_only, 1, "the allocator teaser");
for article in &mut articles {
article.id = db.upsert_article(article).await.expect("persist article");
assert!(article.id > 0);
}
let b_trees = articles
.iter()
.find(|a| a.canonical_url == "https://blog.dev/b-trees")
.expect("the merged cluster");
assert!(b_trees.came_via(SourceKind::Scour));
assert!(b_trees.came_via(SourceKind::HnFrontpage));
assert_eq!(b_trees.best_entry_id, 102, "the richest body won");
articles
}
/// Stages 1114: assemble, build both editions, publish, record.
async fn assemble_build_publish(
db: &Db,
cfg: &Config,
lineup: Lineup,
colophon: Colophon,
) -> Issue {
let editorial_doc = editorial::fallback_editorial(&lineup);
let mut lineup = lineup;
pipeline::apply_summaries(&mut lineup, &editorial_doc);
assert!(
lineup.picks.iter().all(|p| p.summary.is_some()),
"every pick carries a summary before the EPUB is built"
);
let issue_number = db.next_issue_number(date()).await.expect("issue number");
let issue = pipeline::build_issue(
date(),
issue_number,
ts("2026-08-15T09:30:00Z"),
lineup,
editorial_doc,
None,
colophon,
);
assert_eq!(issue.meta.display_date, "Saturday, August 15, 2026");
// --- EPUB: both editions (§3.10) ---
let (artifacts, images) = epub::build_all(&issue, cfg, &cfg.out_dir)
.await
.expect("both editions build");
assert_eq!(artifacts.len(), 2);
assert_eq!(images, 0, "the fixtures carry no images");
for artifact in &artifacts {
assert!(artifact.path.exists(), "{}", artifact.path.display());
assert!(artifact.bytes > 1000);
let zip = std::fs::read(&artifact.path).expect("read epub");
assert_eq!(&zip[0..4], b"PK\x03\x04", "is a zip");
assert_eq!(&zip[38..58], b"application/epub+zip");
}
assert!(
cfg.out_dir
.join("The Daily EPUB - 2026-08-15.epub")
.exists()
);
assert!(
cfg.out_dir
.join("The Daily EPUB - 2026-08-15 (X4).epub")
.exists()
);
// Rating links are signed with the configured secret and are what the
// running server verifies (§3.9). The chapters are deflated inside the zip,
// so assert on the rendered XHTML the builder just zipped.
let chapters = epub::build::render_all(
&issue,
Edition::Standard,
&[],
&cfg.server.public_url,
cfg.server.hmac_secret.as_deref(),
)
.expect("render chapters");
let first = issue.lineup.picks[0].article.id;
let expected = auth::rating_url(&cfg.server.public_url, SECRET, date(), first, Vote::Up);
let chapter = chapters
.iter()
.find(|c| c.id == format!("art-{}", issue.lineup.picks[0].article.best_entry_id))
.expect("the first article has a chapter");
assert!(
chapter.xhtml.contains(&expected),
"the article footer must carry {expected}\n{}",
chapter.xhtml
);
assert!(
daily_epub::server::verify_token(
SECRET,
date(),
first,
Vote::Up,
&auth::rating_token(SECRET, date(), first, Vote::Up)
),
"the server must accept the token the EPUB minted"
);
// --- Publish (§3.11) ---
let published = publish::publish_issue(db, cfg, &issue, &artifacts, None)
.await
.expect("publish");
assert_eq!(published.epubs.len(), 2);
for artifact in &published.epubs {
assert!(artifact.path.starts_with(&cfg.publish.bookorbit_dir));
assert!(artifact.path.exists(), "{}", artifact.path.display());
}
assert!(
cfg.publish
.bookorbit_dir
.join("The Daily EPUB - 2026-08-15.epub")
.exists()
);
assert!(
cfg.publish
.bookorbit_dir
.join("The Daily EPUB - 2026-08-15 (X4).epub")
.exists()
);
assert!(published.xtc.is_none(), "the converter is disabled here");
// The OPDS feed is regenerated on every publish, even with no XTC files yet.
let opds = published.opds.clone().expect("an OPDS feed was written");
assert_eq!(opds, cfg.publish.xtc_dir.join("xtc.xml"));
let feed = std::fs::read_to_string(&opds).expect("read the OPDS feed");
assert!(feed.starts_with("<?xml"), "{feed}");
assert!(feed.contains("<feed xmlns=\"http://www.w3.org/2005/Atom\""));
assert!(feed.contains(&cfg.server.public_url));
// --- Record (§3.13) ---
let epub_path = published
.epubs
.iter()
.find(|a| a.edition == Edition::Standard)
.map(|a| a.path.display().to_string());
let x4_path = published
.epubs
.iter()
.find(|a| a.edition == Edition::X4)
.map(|a| a.path.display().to_string());
db.upsert_issue(
date(),
issue.meta.issue_number,
issue.meta.generated_at,
epub_path.as_deref(),
x4_path.as_deref(),
None,
Some(&issue.editorial.front_page_html),
Some("{\"status\":\"ok\"}"),
)
.await
.expect("record the issue");
db.replace_issue_articles(date(), &issue.lineup.picks)
.await
.expect("record the lineup");
let reports = db.recent_reports(5).await.expect("recent reports");
assert_eq!(reports.len(), 1);
assert_eq!(reports[0].0, date());
assert_eq!(reports[0].1.as_deref(), Some("{\"status\":\"ok\"}"));
let published_ids = db
.previously_published_ids()
.await
.expect("issue_articles rows");
assert_eq!(published_ids.len(), issue.lineup.picks.len());
// Tomorrow's issue is No. 2 (days since the first issue, §3.10).
let tomorrow: Date = "2026-08-16".parse().unwrap();
assert_eq!(db.next_issue_number(tomorrow).await.unwrap(), 2);
issue
}
#[tokio::test]
async fn skip_llm_pipeline_produces_a_published_issue() {
let root = tempfile::tempdir().expect("tempdir");
let cfg = test_config(root.path());
let db = Db::open_and_migrate(&cfg.database_path)
.await
.expect("open db");
let articles = ingest_dedupe_extract_persist(&db).await;
// --- Stages 67 with no LLM at all (notes §6) ---
let curator = Curator::new(cfg.clone(), db.clone(), None);
let candidates = curator
.prefilter(articles, date())
.await
.expect("prefilter runs");
assert_eq!(candidates.len(), 5, "nothing is dropped at this volume");
assert!(
candidates
.windows(2)
.all(|w| w[0].prefilter_score >= w[1].prefilter_score),
"candidates come back in prefilter order"
);
// The excerpt-only story is penalized (§3.5).
let allocator = candidates
.iter()
.find(|c| c.article.excerpt_only)
.expect("the allocator teaser survived");
assert!(allocator.prefilter_score < candidates[0].prefilter_score);
let lineup = curator.select(candidates, date()).await.expect("select");
assert_eq!(lineup.picks.len(), 5, "target 6, only 5 candidates exist");
assert!(lineup.lead().is_some(), "a lead story is always chosen");
assert!(!lineup.section_order.is_empty());
assert!(
lineup
.picks
.iter()
.all(|p| lineup.section_order.contains(&p.section)),
"every pick sits in a listed section"
);
let colophon = Colophon {
model: "none (--skip-llm)".into(),
entries_fetched: 8,
feeds_seen: 8,
candidates: 5,
cost_usd: 0.0,
generator_version: format!("daily-epub {}", daily_epub::VERSION),
};
let issue = assemble_build_publish(&db, &cfg, lineup, colophon).await;
// Front page and summaries came from excerpts, not from a model.
assert!(!issue.editorial.front_page_html.is_empty());
assert_eq!(issue.editorial.summaries.len(), issue.lineup.picks.len());
// Re-running the same date replaces rather than duplicates (notes §12).
let republished = publish::publish_issue(
&db,
&cfg,
&issue,
&[
daily_epub::types::Artifact {
edition: Edition::Standard,
path: cfg.out_dir.join("The Daily EPUB - 2026-08-15.epub"),
bytes: 0,
},
daily_epub::types::Artifact {
edition: Edition::X4,
path: cfg.out_dir.join("The Daily EPUB - 2026-08-15 (X4).epub"),
bytes: 0,
},
],
None,
)
.await
.expect("republish");
assert_eq!(republished.epubs.len(), 2);
let files: Vec<String> = std::fs::read_dir(&cfg.publish.bookorbit_dir)
.expect("read bookorbit dir")
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.collect();
assert_eq!(files.len(), 2, "no duplicate files: {files:?}");
db.replace_issue_articles(date(), &issue.lineup.picks)
.await
.expect("replace lineup");
assert_eq!(
db.previously_published_ids().await.unwrap().len(),
issue.lineup.picks.len(),
"issue_articles was replaced, not appended"
);
}
#[tokio::test]
async fn llm_pipeline_runs_against_a_mock_backend() {
let root = tempfile::tempdir().expect("tempdir");
let cfg = test_config(root.path());
let db = Db::open_and_migrate(&cfg.database_path)
.await
.expect("open db");
let articles = ingest_dedupe_extract_persist(&db).await;
let ctx = prefilter::PrefilterContext::load(&db, date())
.await
.expect("prefilter context");
let candidates: Vec<ScoredArticle> = prefilter::run(articles, &ctx, &cfg);
let ids: Vec<i64> = candidates.iter().map(|c| c.article.id).collect();
assert_eq!(ids.len(), 5);
// --- Script DeepSeek: one stage-A batch, one stage-B call, five stage-C
// summaries and one front page (§3.6). ---
let backend = std::sync::Arc::new(MockBackend::new());
let usage = daily_epub::types::TokenUsage {
input_tokens: 1000,
cached_tokens: 500,
output_tokens: 200,
};
let scores: Vec<String> = ids
.iter()
.enumerate()
.map(|(i, id)| {
format!(
r#"{{"id": {id}, "score": {}, "category": "Tech & Engineering",
"rationale": "solid systems writeup", "is_paywalled_guess": false}}"#,
9 - i
)
})
.collect();
backend.push(format!("{{\"articles\": [{}]}}", scores.join(",")), usage);
let picks: Vec<String> = ids
.iter()
.enumerate()
.map(|(i, id)| {
format!(
r#"{{"id": {id}, "section": "{}", "position": {}, "lead_story": {}}}"#,
if i == 0 {
"Top Stories"
} else {
"Tech & Engineering"
},
i + 1,
i == 0
)
})
.collect();
backend.push(format!("{{\"picks\": [{}]}}", picks.join(",")), usage);
for id in &ids {
backend.push(
format!(r#"{{"summary": "A newspaper abstract for article {id}."}}"#),
usage,
);
}
backend.push(
r#"{"from_the_editor": "Today's issue leans on storage internals.\n\nRead on.",
"section_intros": {"Top Stories": "The day in one place."}}"#,
usage,
);
let meter = UsageMeter::new(&cfg.deepseek, cfg.max_daily_usd);
let llm = LlmClient::with_backend(
&cfg.deepseek.model,
"You are the editor of The Daily EPUB.".into(),
meter.clone(),
backend.clone(),
);
let curator = Curator::new(cfg.clone(), db.clone(), Some(llm));
let mut candidates = candidates;
curator
.score(&mut candidates, date())
.await
.expect("stage A");
assert!(
candidates.iter().all(|c| c.llm.is_some()),
"every candidate came back scored"
);
let lineup = curator.select(candidates, date()).await.expect("stage B");
assert_eq!(lineup.picks.len(), 5);
assert_eq!(
lineup.lead().map(|p| p.article.id),
Some(ids[0]),
"the model's lead choice is honored"
);
assert_eq!(
lineup.section_order.first().map(String::as_str),
Some("Top Stories")
);
let editorial_doc = curator.editorial(&lineup).await.expect("stage C");
assert_eq!(editorial_doc.summaries.len(), 5);
assert!(
editorial_doc
.summaries
.values()
.all(|s| s.contains("newspaper abstract")),
"the model's summaries were used, not excerpts"
);
assert!(editorial_doc.front_page_html.contains("storage internals"));
assert_eq!(
editorial_doc
.section_intros
.get("Top Stories")
.map(String::as_str),
Some("The day in one place.")
);
// Every scripted response was consumed, and the meter priced them (§3.6).
assert_eq!(backend.calls(), 1 + 1 + 5 + 1);
let total = meter.total();
assert_eq!(total.input_tokens, 8 * usage.input_tokens);
assert!(meter.cost_usd() > 0.0 && !meter.budget_exceeded());
// The taste profile leads every request, byte for byte — that is what makes
// DeepSeek's prefix cache hit (§3.6).
let prompts = backend.prompts();
assert!(
prompts
.iter()
.all(|p| p.system.starts_with("You are the editor")),
"the system prompt must be identical across requests"
);
// And it all assembles, builds and publishes like the skip-llm route does.
let colophon = Colophon {
model: cfg.deepseek.model.clone(),
entries_fetched: 8,
feeds_seen: 8,
candidates: 5,
cost_usd: meter.cost_usd(),
generator_version: format!("daily-epub {}", daily_epub::VERSION),
};
let mut lineup = lineup;
pipeline::apply_summaries(&mut lineup, &editorial_doc);
let issue = assemble_build_publish(&db, &cfg, lineup, colophon).await;
assert_eq!(issue.colophon.model, cfg.deepseek.model);
assert!(issue.colophon.cost_usd > 0.0);
}