Files
smirk-backend-core/tests/tips.rs
T
jwinterm b63cac19e2 feat(tips): stage 3 — attach-funding + funding-verification worker (money-in)
- attach_funding_to_tip: atomic guarded UPDATE (COALESCE + CASE), idempotent on
  same txid, 400 on a different txid; non-drafts land pending_confirmation so the
  verifier (not the caller) owns the ->pending transition.
- worker (src/tips/confirmation.rs): XMR/WOW confirmation counting via LWS;
  amount verification for BTC/LTC (Electrum full history, confirmed-only) +
  XMR/WOW (classify_funding_amount, separate-then-subtract net). Verified->pending,
  Short->funding_mismatch; Unfunded/Drained/LwsError never mutate status (outage-
  safe). Spawned behind FEATURE_TIPS on a 60s loop w/ catch_unwind guard.
- POST /tips/social/:id/attach-funding endpoint + openapi.
- 5 DB tests + 6 classify unit tests; build clean.
2026-07-10 14:53:46 -04:00

402 lines
13 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.
//! Public social-tips route tests (Stage 2: create / get-public / get-sent /
//! cancel). Self-skips when `TEST_DATABASE_URL` is unset (see `tests/common`).
mod common;
use axum::http::StatusCode;
use serde_json::json;
/// Boot the app with tips ENABLED + a share base configured (BTC is on by
/// default in the test config). Returns None (skip) when no test DB.
async fn tips_app() -> Option<common::TestApp> {
common::try_app_with(|c| {
c.features.tips = true;
c.tip_share_base = Some("https://tips.example".into());
})
.await
}
/// A valid SHA256 hash shape: exactly 64 hex chars (4 × 16).
const CLAIM_HASH: &str = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const ENC_KEY_HEX: &str = "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd";
const BTC_ADDR: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4";
fn draft_body() -> serde_json::Value {
json!({
"asset": "btc",
"amount": 100_000,
"is_public": true,
"claim_key_hash": CLAIM_HASH,
"encrypted_key": ENC_KEY_HEX,
"tip_address": BTC_ADDR,
})
}
#[tokio::test]
async fn public_tip_draft_create_get_cancel_flow() {
let Some(app) = tips_app().await else {
eprintln!("skipping: TEST_DATABASE_URL not set");
return;
};
let (_uid, token, _refresh) = app.mint_session().await;
// 1. Create a draft (no funding_txid) -> draft + share_url.
let (status, body) = app
.request("POST", "/api/v1/tips/social", Some(&token), Some(draft_body()))
.await;
assert_eq!(status, StatusCode::OK, "create: {body}");
assert_eq!(body["status"], "draft");
let tip_id = body["tip_id"].as_str().expect("tip_id").to_string();
assert_eq!(body["share_url"], format!("https://tips.example/{tip_id}"));
// 2. Public read (UNAUTH) surfaces it and is not yet claimable.
let (status, pub_body) = app
.request("GET", &format!("/api/v1/tips/social/{tip_id}/public"), None, None)
.await;
assert_eq!(status, StatusCode::OK, "get_public: {pub_body}");
assert_eq!(pub_body["is_public"], true);
assert_eq!(pub_body["is_claimable"], false);
assert_eq!(pub_body["encrypted_key"], ENC_KEY_HEX);
assert_eq!(pub_body["tip_address"], BTC_ADDR);
// 3. Unknown id -> 404 (not 200-with-nulls).
let (status, _) = app
.request(
"GET",
"/api/v1/tips/social/00000000-0000-0000-0000-000000000000/public",
None,
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
// 4. Cancel the draft (owner) -> {ok:true}.
let (status, cancel_body) = app
.request("POST", &format!("/api/v1/tips/social/{tip_id}/cancel"), Some(&token), None)
.await;
assert_eq!(status, StatusCode::OK, "cancel: {cancel_body}");
assert_eq!(cancel_body["ok"], true);
// 5. Cancelling again -> 404 (no longer a draft).
let (status, _) = app
.request("POST", &format!("/api/v1/tips/social/{tip_id}/cancel"), Some(&token), None)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
// 6. Sent list shows the tip as cancelled.
let (status, sent) = app
.request("GET", "/api/v1/tips/social/sent", Some(&token), None)
.await;
assert_eq!(status, StatusCode::OK, "sent: {sent}");
let tips = sent["tips"].as_array().expect("tips array");
let found = tips.iter().find(|t| t["id"] == tip_id).expect("tip in sent list");
assert_eq!(found["status"], "cancelled");
assert_eq!(found["is_public"], true);
}
#[tokio::test]
async fn db_create_draft_roundtrips_all_columns() {
let Some(app) = tips_app().await else { return };
let uid = app.create_user().await;
use smirk_backend_core::infra::db::NewSocialTip;
let enc = hex::decode("cdcd").unwrap();
let new = NewSocialTip {
sender_user_id: uid,
asset: "btc",
amount: 100_000,
claim_key_hash: Some(CLAIM_HASH),
encrypted_key: Some(&enc),
tip_address: Some(BTC_ADDR),
funding_txid: None,
tip_view_key: None,
confirmations_required: 0,
};
// Exercises the full 31-column FromRow decode on RETURNING.
let row = app.state.db.create_draft_social_tip(new).await.expect("create draft");
assert_eq!(row.status, "draft");
assert_eq!(row.amount, 100_000);
assert!(row.is_public);
assert_eq!(row.encrypted_key.as_deref(), Some(&[0xcd, 0xcd][..]));
}
#[tokio::test]
async fn targeted_tip_is_rejected() {
let Some(app) = tips_app().await else { return };
let (_uid, token, _r) = app.mint_session().await;
let (status, body) = app
.request(
"POST",
"/api/v1/tips/social",
Some(&token),
Some(json!({
"asset": "btc", "amount": 100, "is_public": false,
"platform": "telegram", "username": "bob",
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "targeted must be rejected: {body}");
}
#[tokio::test]
async fn create_requires_positive_amount_and_claim_hash() {
let Some(app) = tips_app().await else { return };
let (_uid, token, _r) = app.mint_session().await;
// Non-positive amount.
let mut b = draft_body();
b["amount"] = json!(0);
let (status, _) = app.request("POST", "/api/v1/tips/social", Some(&token), Some(b)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
// Missing claim_key_hash on a public tip.
let mut b = draft_body();
b.as_object_mut().unwrap().remove("claim_key_hash");
let (status, _) = app.request("POST", "/api/v1/tips/social", Some(&token), Some(b)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn tips_disabled_returns_400() {
// Default config: FEATURE_TIPS off.
let Some(app) = common::try_app().await else {
eprintln!("skipping: TEST_DATABASE_URL not set");
return;
};
let (_uid, token, _r) = app.mint_session().await;
let (status, _) = app
.request("POST", "/api/v1/tips/social", Some(&token), Some(draft_body()))
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "tips off must 400");
}
// ── Stage 3: money-in (attach-funding + funding verifier) DB-level tests ──────
use smirk_backend_core::error::AppError;
use smirk_backend_core::infra::db::NewSocialTip;
/// Create a draft tip for `uid` and return its id. `conf` = confirmations_required.
async fn make_draft(
app: &common::TestApp,
uid: uuid::Uuid,
asset: &str,
amount: i64,
conf: i32,
view_key: Option<&str>,
) -> uuid::Uuid {
let new = NewSocialTip {
sender_user_id: uid,
asset,
amount,
claim_key_hash: Some(CLAIM_HASH),
encrypted_key: None,
tip_address: Some(BTC_ADDR),
funding_txid: None,
tip_view_key: view_key,
confirmations_required: conf,
};
app.state
.db
.create_draft_social_tip(new)
.await
.expect("create draft")
.id
}
#[tokio::test]
async fn attach_funding_is_idempotent_and_advances_draft() {
let Some(app) = tips_app().await else { return };
let uid = app.create_user().await;
let tip_id = make_draft(&app, uid, "btc", 100_000, 0, None).await;
// First attach: draft -> pending_confirmation, funding_txid recorded.
let r1 = app
.state
.db
.attach_funding_to_tip(tip_id, uid, "txid-aaa")
.await
.expect("attach");
assert_eq!(r1.id, tip_id);
assert_eq!(r1.status, "pending_confirmation");
assert_eq!(r1.funding_txid.as_deref(), Some("txid-aaa"));
assert!(!r1.funding_amount_verified);
// Second attach, SAME txid: idempotent no-op returning the same row.
let r2 = app
.state
.db
.attach_funding_to_tip(tip_id, uid, "txid-aaa")
.await
.expect("attach idempotent");
assert_eq!(r2.id, tip_id);
assert_eq!(r2.status, "pending_confirmation");
assert_eq!(r2.funding_txid.as_deref(), Some("txid-aaa"));
}
#[tokio::test]
async fn attach_funding_conflicting_txid_is_validation_error() {
let Some(app) = tips_app().await else { return };
let uid = app.create_user().await;
let tip_id = make_draft(&app, uid, "btc", 100_000, 0, None).await;
app.state
.db
.attach_funding_to_tip(tip_id, uid, "txid-first")
.await
.expect("attach");
// A DIFFERENT txid on the same tip -> ValidationError (400).
let err = app
.state
.db
.attach_funding_to_tip(tip_id, uid, "txid-DIFFERENT")
.await
.unwrap_err();
assert!(matches!(err, AppError::ValidationError(_)), "expected ValidationError, got {err:?}");
// Not-owned / unknown tip -> NotFound (404).
let other = app.create_user().await;
let err = app
.state
.db
.attach_funding_to_tip(tip_id, other, "txid-x")
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "expected NotFound, got {err:?}");
}
#[tokio::test]
async fn mark_verified_only_from_pending_confirmation() {
let Some(app) = tips_app().await else { return };
let uid = app.create_user().await;
// A draft is NOT pending_confirmation -> mark returns None (guard misses).
let draft_id = make_draft(&app, uid, "btc", 100_000, 0, None).await;
let none = app
.state
.db
.mark_tip_funding_verified(draft_id, 100_000)
.await
.expect("mark on draft");
assert!(none.is_none(), "mark_verified on a draft must return None");
// Attach -> pending_confirmation; verify flips it to pending + records observed.
app.state
.db
.attach_funding_to_tip(draft_id, uid, "txid-v")
.await
.expect("attach");
let verified = app
.state
.db
.mark_tip_funding_verified(draft_id, 250_000)
.await
.expect("verify")
.expect("verify returns row");
assert_eq!(verified.status, "pending");
assert!(verified.funding_amount_verified);
assert_eq!(verified.funding_amount_observed, Some(250_000));
// Re-verify -> None (funding_amount_verified guard no longer matches).
let again = app
.state
.db
.mark_tip_funding_verified(draft_id, 250_000)
.await
.expect("verify idempotent");
assert!(again.is_none(), "re-verify must return None");
}
#[tokio::test]
async fn mark_mismatch_transitions_to_funding_mismatch() {
let Some(app) = tips_app().await else { return };
let uid = app.create_user().await;
let tip_id = make_draft(&app, uid, "btc", 1_000_000, 0, None).await;
app.state
.db
.attach_funding_to_tip(tip_id, uid, "txid-m")
.await
.expect("attach");
let mismatch = app
.state
.db
.mark_tip_funding_mismatch(tip_id, 10)
.await
.expect("mismatch")
.expect("mismatch returns row");
assert_eq!(mismatch.status, "funding_mismatch");
assert!(mismatch.funding_amount_verified);
assert_eq!(mismatch.funding_amount_observed, Some(10));
// Idempotent, and a funding_mismatch row can never be flipped claimable.
let again = app
.state
.db
.mark_tip_funding_mismatch(tip_id, 10)
.await
.expect("mismatch idempotent");
assert!(again.is_none());
let verify_after = app
.state
.db
.mark_tip_funding_verified(tip_id, 10)
.await
.expect("verify after mismatch");
assert!(verify_after.is_none(), "a funding_mismatch row must not become claimable");
}
#[tokio::test]
async fn get_tips_pending_confirmation_filters() {
let Some(app) = tips_app().await else { return };
// Deterministic: this is the only test that creates xmr rows. Clear leftovers
// so the query's LIMIT 50 / ORDER BY created_at window can't hide our row.
sqlx::query("DELETE FROM social_tips WHERE asset = 'xmr'")
.execute(app.state.db.pool())
.await
.expect("clear xmr rows");
let uid = app.create_user().await;
let vk = "0".repeat(64);
// XMR tip: threshold 10 + funding attached -> INCLUDED.
let xmr_id = make_draft(&app, uid, "xmr", 500, 10, Some(vk.as_str())).await;
app.state
.db
.attach_funding_to_tip(xmr_id, uid, "xmrtxid")
.await
.expect("attach xmr");
// XMR draft with NO funding -> EXCLUDED (funding_txid IS NULL, status draft).
let xmr_draft = make_draft(&app, uid, "xmr", 500, 10, Some(vk.as_str())).await;
// BTC tip: threshold 0 -> EXCLUDED (confirmations_required > 0 guard).
let btc_id = make_draft(&app, uid, "btc", 100_000, 0, None).await;
app.state
.db
.attach_funding_to_tip(btc_id, uid, "btctxid")
.await
.expect("attach btc");
let xmr_pending = app
.state
.db
.get_tips_pending_confirmation("xmr")
.await
.expect("xmr pending");
assert!(xmr_pending.iter().any(|t| t.id == xmr_id), "funded xmr tip must be returned");
assert!(
!xmr_pending.iter().any(|t| t.id == xmr_draft),
"unfunded xmr draft must be excluded"
);
let btc_pending = app
.state
.db
.get_tips_pending_confirmation("btc")
.await
.expect("btc pending");
assert!(
!btc_pending.iter().any(|t| t.id == btc_id),
"btc tip (threshold 0) must be excluded from the confirmation query"
);
}