mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
refactor: finish the phase-B seam for the post-send path, funnel settlement
docs/architecture-refactor.md §3 stopped half-done: `url_media` got an injected `AppContext`, but `send.rs`'s post-send half kept reaching for the process-wide `CHAT_STORE`/`TASK_QUEUE`/`LINK_CACHE` statics, so the whole shell after a successful send (edit-before-forward prompt, channel forward, retry enqueue, cache write) had no test and no way to get one. - `ctx.rs` now owns `AppContext` (sender + the three stores + config) with `from_statics` for production and a `CONTEXT` static for the spawned worker closures; `handlers/urls.rs` drops its private copy and the duplicated assembler, and the queue handler/dead-letter callbacks take the context (main wires them with `CONTEXT`). - `send_media_sequence`/`send_animation`/`forward_messages`/`post_send_actions` take `&AppContext`; the cache write goes through the injected cache. - New `settle_task(ctx, task, Sent|Failed)` is the single place that ends a task: release its keep-alive temp media, and drop the link-cache entry only on failure. All five former call sites funnel through it — the earlier keep-alive leak existed precisely because one of them had to remember. `invalidate_cache`/`invalidate_cache_with` (static + injected pair, the latter only existing because of the former) collapse into one private fn. - `ctx::test_support::TestStores` gives tests a tempdir store set + context; `handlers/urls.rs` tests use it instead of hand-rolled setup. Tests: +5 (post-send forward ok / queued / notified, settle Sent/Failed); the post-send and settle paths were previously untested. fmt/clippy clean, 60 + 69 tests pass.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
//! Runtime context: the collaborators a handler needs, injected as one struct
|
||||
//! so tests can substitute a scripted sender and tempdir-backed stores.
|
||||
//!
|
||||
//! The production context is assembled from the process-wide statics
|
||||
//! ([`AppContext::from_statics`]); the spawned worker closures hold
|
||||
//! [`CONTEXT`], which is `'static` for that reason.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::handlers::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE};
|
||||
use crate::link_cache::LinkCache;
|
||||
use crate::media_sender::MediaSender;
|
||||
use crate::queue::PersistentTaskQueue;
|
||||
use crate::send::BOT;
|
||||
use crate::state::ChatStore;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub struct AppContext<'a> {
|
||||
pub sender: &'a dyn MediaSender,
|
||||
pub chat_store: &'a ChatStore,
|
||||
pub task_queue: &'a PersistentTaskQueue,
|
||||
pub link_cache: &'a LinkCache,
|
||||
pub config: &'a Config,
|
||||
}
|
||||
|
||||
impl<'a> AppContext<'a> {
|
||||
/// The stores are the process-wide statics; `sender` is whatever the caller
|
||||
/// was handed (the dispatcher's `Bot` clone for update handlers, the shared
|
||||
/// queue `Bot` for the worker loops). Update handlers build their own
|
||||
/// context from the `Bot` they received so the same code path works with an
|
||||
/// injected mock in tests.
|
||||
pub fn from_statics(sender: &'a dyn MediaSender) -> AppContext<'a> {
|
||||
AppContext {
|
||||
sender,
|
||||
chat_store: &CHAT_STORE,
|
||||
task_queue: &TASK_QUEUE,
|
||||
link_cache: &LINK_CACHE,
|
||||
config: &CONFIG,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL/queue workers' context: `'static` because `tokio::spawn`ed closures
|
||||
/// and the queue's handler type require it.
|
||||
pub static CONTEXT: LazyLock<AppContext<'static>> =
|
||||
LazyLock::new(|| AppContext::from_statics(&*BOT));
|
||||
|
||||
/// Test support: a tempdir-backed set of stores plus the context borrowing
|
||||
/// them, so a handler test needs one line of setup.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) struct TestStores {
|
||||
_dir: tempfile::TempDir,
|
||||
pool: Arc<crate::db::DbPool>,
|
||||
chat_store: ChatStore,
|
||||
task_queue: PersistentTaskQueue,
|
||||
link_cache: LinkCache,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl TestStores {
|
||||
pub(crate) fn new() -> Self {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pool = crate::db::open_store(dir.path().join("ctx.db").to_str().unwrap()).unwrap();
|
||||
TestStores {
|
||||
_dir: dir,
|
||||
chat_store: ChatStore::new(Arc::clone(&pool)),
|
||||
task_queue: PersistentTaskQueue::new(Arc::clone(&pool)),
|
||||
link_cache: LinkCache::new(Arc::clone(&pool)),
|
||||
config: Config::load(),
|
||||
pool,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ctx<'a>(&'a self, sender: &'a dyn MediaSender) -> AppContext<'a> {
|
||||
AppContext {
|
||||
sender,
|
||||
chat_store: &self.chat_store,
|
||||
task_queue: &self.task_queue,
|
||||
link_cache: &self.link_cache,
|
||||
config: &self.config,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn link_cache(&self) -> &LinkCache {
|
||||
&self.link_cache
|
||||
}
|
||||
|
||||
/// Rows persisted in the task queue: what "queued for retry" looks like
|
||||
/// from the outside.
|
||||
pub(crate) async fn queued_tasks(&self) -> i64 {
|
||||
let pool = Arc::clone(&self.pool);
|
||||
pool.with_conn(|conn| {
|
||||
conn.query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get(0))
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The single queued task payload, for asserting what was rescheduled.
|
||||
pub(crate) async fn queued_payload(&self) -> serde_json::Value {
|
||||
let pool = Arc::clone(&self.pool);
|
||||
let payload: String = pool
|
||||
.with_conn(|conn| {
|
||||
conn.query_row("SELECT payload FROM tasks LIMIT 1", [], |row| row.get(0))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_str(&payload).unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user