tail-fin-core 0.5.2

Public session-lifecycle abstractions for tail-fin: Site trait, SessionManager, Credentials, SessionStatus, auth errors. The stable API surface that downstream agents (Flock A2A etc.) consume.
Documentation
//! Unit tests for `SessionManager::refresh_with_seed`.
//!
//! `BrowserSession` is a concrete type that normally owns a live Chrome
//! worker. For these tests we construct one via
//! `BrowserSession::from_sender` and attach a minimal fake worker that
//! just drains the command channel without replying. Any in-browser call
//! (`set_cookies`) therefore returns `NightFuryError::WorkerDead`, which
//! `refresh_with_seed` surfaces as `SiteError::RefreshFailed`. That error
//! path is enough to prove ordering:
//!
//! * empty seed    → browser channel stays idle, `Site::refresh` is called
//! * non-empty seed → `set_cookies` is attempted first (surface error),
//!   and `Site::refresh` is NOT called (call counter stays at 0). Because
//!   the implementation awaits `set_cookies` before calling `refresh`,
//!   these two facts together prove "set_cookies before refresh".
//!
//! A real-browser happy-path test lives in `tail-fin-twitter/tests/
//! twitter_live.rs` (see `session_manager_full_lifecycle`).

use async_trait::async_trait;
use serde_json::Value;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;

use night_fury_core::{BrowserCmd, BrowserSession};
use tail_fin_core::{
    AuthFailureKind, FailureIndicators, SessionManager, SessionStatus, Site, SiteError,
};

struct CountingSite {
    refresh_calls: AtomicUsize,
}

impl CountingSite {
    fn new() -> Self {
        Self {
            refresh_calls: AtomicUsize::new(0),
        }
    }

    fn refresh_count(&self) -> usize {
        self.refresh_calls.load(Ordering::SeqCst)
    }
}

#[async_trait]
impl Site for CountingSite {
    fn id(&self) -> &'static str {
        "rec"
    }
    fn display_name(&self) -> &'static str {
        "Recording Site"
    }
    fn cookie_domain_patterns(&self) -> &'static [&'static str] {
        &["*.rec.test"]
    }
    fn refresh_url(&self) -> &'static str {
        "https://rec.test/"
    }
    fn refresh_interval_min(&self) -> Duration {
        Duration::from_millis(1)
    }

    async fn refresh(&self, _session: &BrowserSession) -> Result<Vec<Value>, SiteError> {
        self.refresh_calls.fetch_add(1, Ordering::SeqCst);
        Ok(vec![serde_json::json!({
            "name": "fresh_token",
            "value": "v1",
            "domain": ".rec.test"
        })])
    }

    async fn validate(&self, _session: &BrowserSession) -> Result<SessionStatus, SiteError> {
        Ok(SessionStatus::Valid)
    }

    fn detect_auth_failure(&self, _ind: &FailureIndicators) -> Option<AuthFailureKind> {
        None
    }
}

/// Fake worker that drains the browser command channel and counts the
/// commands it saw, but never replies. Any `send_cmd!`-driven call on
/// `BrowserSession` therefore resolves to `NightFuryError::WorkerDead`.
#[derive(Default)]
struct FakeWorker {
    cmds_seen: Arc<AtomicUsize>,
}

impl FakeWorker {
    fn new() -> Self {
        Self::default()
    }

    fn cmds_seen(&self) -> Arc<AtomicUsize> {
        self.cmds_seen.clone()
    }

    fn spawn(self, mut rx: mpsc::Receiver<BrowserCmd>) -> tokio::task::JoinHandle<()> {
        let counter = self.cmds_seen.clone();
        tokio::spawn(async move {
            while let Some(_cmd) = rx.recv().await {
                counter.fetch_add(1, Ordering::SeqCst);
                // Drop the cmd (and its reply oneshot) without answering —
                // this simulates a dead/unresponsive browser worker and
                // lets us prove `set_cookies` was attempted via the error
                // path, without needing to name the private StorageCmd
                // type to destructure the inner oneshot.
            }
        })
    }
}

fn setup() -> (SessionManager, Arc<CountingSite>, Arc<AtomicUsize>) {
    let (tx, rx) = mpsc::channel(8);
    let worker = FakeWorker::new();
    let cmds_seen = worker.cmds_seen();
    let _handle = worker.spawn(rx);
    let browser = BrowserSession::from_sender(tx);
    let site = Arc::new(CountingSite::new());
    let manager = SessionManager::new(site.clone(), browser);
    (manager, site, cmds_seen)
}

#[tokio::test]
async fn refresh_with_seed_empty_delegates_to_refresh() {
    let (manager, site, cmds_seen) = setup();

    let cookies = manager
        .refresh_with_seed(&[])
        .await
        .expect("refresh_with_seed failed");

    // MockSite.refresh fired exactly once.
    assert_eq!(site.refresh_count(), 1);
    // Returned the cookies MockSite produced.
    assert_eq!(cookies.len(), 1);
    // No browser commands crossed the channel — empty seed must not
    // trigger a `set_cookies` call.
    assert_eq!(cmds_seen.load(Ordering::SeqCst), 0);

    // Cookie snapshot reflects the refresh output.
    let snapshot = manager.cookies().await;
    assert_eq!(snapshot.len(), 1);
    assert_eq!(snapshot[0]["name"], "fresh_token");
}

#[tokio::test]
async fn refresh_with_seed_nonempty_calls_set_cookies_before_refresh() {
    let (manager, site, cmds_seen) = setup();

    let seed = vec![
        serde_json::json!({"name": "seed_a", "value": "s1", "domain": ".rec.test"}),
        serde_json::json!({"name": "seed_b", "value": "s2", "domain": ".rec.test"}),
    ];
    let err = manager
        .refresh_with_seed(&seed)
        .await
        .expect_err("worker never replies, so set_cookies must error");

    // The error surfaces through the `set_cookies:` prefix we attach in
    // `refresh_with_seed` when `BrowserSession::set_cookies` fails. This
    // proves `set_cookies` was the call that errored — not `refresh`.
    match &err {
        SiteError::RefreshFailed { site: id, reason } => {
            assert_eq!(id, &"rec");
            assert!(
                reason.starts_with("set_cookies:"),
                "expected `set_cookies:` error prefix, got: {reason}"
            );
        }
        other => panic!("expected RefreshFailed, got {other:?}"),
    }

    // Exactly one browser command was sent — the `set_cookies` request.
    assert_eq!(cmds_seen.load(Ordering::SeqCst), 1);

    // `Site::refresh` was NOT reached: set_cookies must run (and here,
    // fail) before refresh is called. This is the ordering guarantee.
    assert_eq!(site.refresh_count(), 0);

    // Cookie snapshot untouched on error.
    assert!(manager.cookies().await.is_empty());
}