nexo-pairing 0.1.8

Setup-code pairing store and DM-challenge gate for Nexo channel plugins.
Documentation
use nexo_pairing::{Decision, PairingError, PairingStore};

#[tokio::test]
async fn upsert_returns_existing_code_for_same_sender() {
    let s = PairingStore::open_memory().await.unwrap();
    let a = s
        .upsert_pending("wa", "p", "+57", serde_json::json!({}))
        .await
        .unwrap();
    let b = s
        .upsert_pending("wa", "p", "+57", serde_json::json!({}))
        .await
        .unwrap();
    assert_eq!(a.code, b.code);
    assert!(a.created);
    assert!(!b.created);
}

#[tokio::test]
async fn max_pending_per_account_enforced() {
    let s = PairingStore::open_memory().await.unwrap();
    for i in 0..3 {
        s.upsert_pending("wa", "p", &format!("+5710{i}"), serde_json::json!({}))
            .await
            .unwrap();
    }
    let err = s
        .upsert_pending("wa", "p", "+57104", serde_json::json!({}))
        .await
        .unwrap_err();
    assert!(matches!(err, PairingError::MaxPending { .. }));
}

#[tokio::test]
async fn approve_moves_to_allow_from() {
    let s = PairingStore::open_memory().await.unwrap();
    let out = s
        .upsert_pending("wa", "p", "+57", serde_json::json!({}))
        .await
        .unwrap();
    let approved = s.approve(&out.code).await.unwrap();
    assert_eq!(approved.sender_id, "+57");
    assert!(s.is_allowed("wa", "p", "+57").await.unwrap());
    // Pending row is gone after approve.
    let pending = s.list_pending(None).await.unwrap();
    assert!(pending.is_empty());
}

#[tokio::test]
async fn revoke_is_soft_delete() {
    let s = PairingStore::open_memory().await.unwrap();
    s.seed("wa", "p", &["+57".into()]).await.unwrap();
    assert!(s.is_allowed("wa", "p", "+57").await.unwrap());
    let did = s.revoke("wa", "+57").await.unwrap();
    assert!(did);
    assert!(!s.is_allowed("wa", "p", "+57").await.unwrap());
}

#[tokio::test]
async fn seed_is_idempotent_and_reactivates_revoked() {
    let s = PairingStore::open_memory().await.unwrap();
    s.seed("wa", "p", &["+57".into(), "+58".into()])
        .await
        .unwrap();
    let n = s.seed("wa", "p", &["+57".into()]).await.unwrap();
    assert!(n >= 1, "seed should still ack the upsert");
    // Revoke + re-seed reactivates.
    s.revoke("wa", "+57").await.unwrap();
    assert!(!s.is_allowed("wa", "p", "+57").await.unwrap());
    s.seed("wa", "p", &["+57".into()]).await.unwrap();
    assert!(s.is_allowed("wa", "p", "+57").await.unwrap());
}

#[tokio::test]
async fn approve_unknown_code_errors() {
    let s = PairingStore::open_memory().await.unwrap();
    let err = s.approve("NONEXIST").await.unwrap_err();
    assert!(matches!(err, PairingError::UnknownCode));
}

#[tokio::test]
async fn list_pending_filters_by_channel() {
    let s = PairingStore::open_memory().await.unwrap();
    s.upsert_pending("wa", "p", "+57", serde_json::json!({}))
        .await
        .unwrap();
    s.upsert_pending("tg", "p", "@user", serde_json::json!({}))
        .await
        .unwrap();
    let wa = s.list_pending(Some("wa")).await.unwrap();
    let all = s.list_pending(None).await.unwrap();
    assert_eq!(wa.len(), 1);
    assert_eq!(all.len(), 2);
}

#[tokio::test]
async fn list_allow_returns_seeded_rows_and_filters_revoked() {
    let s = PairingStore::open_memory().await.unwrap();
    s.seed("wa", "p", &["+57".into(), "+58".into()])
        .await
        .unwrap();
    s.seed("tg", "alt", &["@user".into()]).await.unwrap();
    let all = s.list_allow(None, false).await.unwrap();
    assert_eq!(all.len(), 3);
    let wa_only = s.list_allow(Some("wa"), false).await.unwrap();
    assert_eq!(wa_only.len(), 2);
    // Soft-delete one: default listing hides it, include_revoked surfaces it.
    s.revoke("wa", "+57").await.unwrap();
    let active = s.list_allow(None, false).await.unwrap();
    assert_eq!(active.len(), 2);
    let with_revoked = s.list_allow(None, true).await.unwrap();
    assert_eq!(with_revoked.len(), 3);
    let revoked_row = with_revoked
        .iter()
        .find(|r| r.sender_id == "+57")
        .expect("revoked row present");
    assert!(revoked_row.revoked_at.is_some());
}

#[tokio::test]
async fn list_allow_via_field_distinguishes_seed_from_approve() {
    let s = PairingStore::open_memory().await.unwrap();
    s.seed("wa", "p", &["+57".into()]).await.unwrap();
    let upsert = s
        .upsert_pending("wa", "p", "+58", serde_json::json!({}))
        .await
        .unwrap();
    s.approve(&upsert.code).await.unwrap();
    let mut rows = s.list_allow(Some("wa"), false).await.unwrap();
    rows.sort_by(|a, b| a.sender_id.cmp(&b.sender_id));
    assert_eq!(rows[0].sender_id, "+57");
    assert_eq!(rows[0].approved_via, "seed");
    assert_eq!(rows[1].sender_id, "+58");
    assert_eq!(rows[1].approved_via, "cli");
}

#[tokio::test]
async fn full_decision_admit_after_approve() {
    // Smoke: combine store + the decision states the gate uses.
    let s = PairingStore::open_memory().await.unwrap();
    let upsert = s
        .upsert_pending("wa", "p", "+57", serde_json::json!({}))
        .await
        .unwrap();
    let _approved = s.approve(&upsert.code).await.unwrap();
    assert!(s.is_allowed("wa", "p", "+57").await.unwrap());
    // Sanity: Decision enum compiles into the public surface.
    let _: Decision = Decision::Admit;
}