use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use actix_web::{http::header, test};
use async_trait::async_trait;
use bytes::Bytes;
use serde_json::Value;
use solid_pod_rs::auth::nip98;
use solid_pod_rs::error::PodError;
use solid_pod_rs::storage::memory::MemoryBackend;
use solid_pod_rs::storage::{ResourceMeta, Storage, StorageEvent};
use solid_pod_rs_server::{build_app, AppState};
struct YieldingStorage(MemoryBackend);
#[async_trait]
impl Storage for YieldingStorage {
async fn get(&self, path: &str) -> Result<(Bytes, ResourceMeta), PodError> {
tokio::task::yield_now().await;
self.0.get(path).await
}
async fn put(&self, path: &str, body: Bytes, ct: &str) -> Result<ResourceMeta, PodError> {
self.0.put(path, body, ct).await
}
async fn delete(&self, path: &str) -> Result<(), PodError> {
self.0.delete(path).await
}
async fn list(&self, container: &str) -> Result<Vec<String>, PodError> {
self.0.list(container).await
}
async fn head(&self, path: &str) -> Result<ResourceMeta, PodError> {
self.0.head(path).await
}
async fn exists(&self, path: &str) -> Result<bool, PodError> {
self.0.exists(path).await
}
async fn watch(
&self,
path: &str,
) -> Result<tokio::sync::mpsc::Receiver<StorageEvent>, PodError> {
self.0.watch(path).await
}
}
const SECRET: &str = "5555555555555555555555555555555555555555555555555555555555555555";
fn auth(body: &[u8], offset: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(10)
+ offset;
let token = nip98::mint_with_payload(
"http://localhost:8080/pay/.sell",
"POST",
Some(body),
SECRET,
now,
)
.unwrap();
format!("Nostr {token}")
}
#[actix_web::test]
async fn concurrent_payment_state_writes_lose_no_update() {
let storage = Arc::new(YieldingStorage(MemoryBackend::new()));
let seller = {
let sk = hex::decode(SECRET).unwrap();
let signing = k256::schnorr::SigningKey::from_slice(&sk).unwrap();
format!(
"did:nostr:{}",
hex::encode(signing.verifying_key().to_bytes())
)
};
let mut ledger = solid_pod_rs::payments::WebLedger::new("Pod Credits");
ledger
.credit_by_outpoint(&seller, "tbtc3", &"e1".repeat(32), 0, 1_000)
.unwrap();
storage
.put(
"/.well-known/webledgers/webledgers.json",
serde_json::to_vec(&ledger).unwrap().into(),
"application/json",
)
.await
.unwrap();
let app = test::init_service(build_app(AppState::new(storage))).await;
let sell = |price: u64, offset: u64| {
let body = format!(
r#"{{"sell_currency":"tbtc3","sell_amount":10,"buy_currency":"sat","price":{price}}}"#
);
test::TestRequest::post()
.uri("/pay/.sell")
.insert_header((header::AUTHORIZATION, auth(body.as_bytes(), offset)))
.insert_header((header::CONTENT_TYPE, "application/json"))
.set_payload(body)
.to_request()
};
let (first, second) = futures_util::future::join(
test::call_service(&app, sell(100, 0)),
test::call_service(&app, sell(200, 1)),
)
.await;
assert_eq!(first.status().as_u16(), 200);
assert_eq!(second.status().as_u16(), 200);
let offers = test::call_service(
&app,
test::TestRequest::get().uri("/pay/.offers").to_request(),
)
.await;
let offers: Value = test::read_body_json(offers).await;
let mut prices: Vec<u64> = offers
.as_array()
.unwrap()
.iter()
.map(|o| o["price"].as_u64().unwrap())
.collect();
prices.sort_unstable();
assert_eq!(prices, [100, 200], "both concurrent orders must persist");
}
#[actix_web::test]
async fn concurrent_sells_cannot_oversell_the_balance() {
let storage = Arc::new(YieldingStorage(MemoryBackend::new()));
let seller = {
let sk = hex::decode(SECRET).unwrap();
let signing = k256::schnorr::SigningKey::from_slice(&sk).unwrap();
format!(
"did:nostr:{}",
hex::encode(signing.verifying_key().to_bytes())
)
};
let mut ledger = solid_pod_rs::payments::WebLedger::new("Pod Credits");
ledger
.credit_by_outpoint(&seller, "tbtc3", &"e2".repeat(32), 0, 10)
.unwrap();
storage
.put(
"/.well-known/webledgers/webledgers.json",
serde_json::to_vec(&ledger).unwrap().into(),
"application/json",
)
.await
.unwrap();
let app = test::init_service(build_app(AppState::new(storage))).await;
let sell = |price: u64, offset: u64| {
let body = format!(
r#"{{"sell_currency":"tbtc3","sell_amount":10,"buy_currency":"sat","price":{price}}}"#
);
test::TestRequest::post()
.uri("/pay/.sell")
.insert_header((header::AUTHORIZATION, auth(body.as_bytes(), offset)))
.insert_header((header::CONTENT_TYPE, "application/json"))
.set_payload(body)
.to_request()
};
let (first, second) = futures_util::future::join(
test::call_service(&app, sell(300, 0)),
test::call_service(&app, sell(400, 1)),
)
.await;
let mut codes = [first.status().as_u16(), second.status().as_u16()];
codes.sort_unstable();
assert_eq!(codes, [200, 402], "exactly one sell may list the balance");
let offers = test::call_service(
&app,
test::TestRequest::get().uri("/pay/.offers").to_request(),
)
.await;
let offers: Value = test::read_body_json(offers).await;
assert_eq!(offers.as_array().unwrap().len(), 1, "{offers}");
}