taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

commit 7bfaf2c5e3f7ee89c29cc9175da77ee7cb582b18
parent 3f6a0fd79c5c9f294536810a2edb2f5419acf326
Author: Antoine A <>
Date:   Thu,  1 Oct 2026 12:07:34 +0200

common: improve test db logic with macros

Diffstat:
Madapters/taler-cyclos/Cargo.toml | 1+
Madapters/taler-cyclos/src/api.rs | 57++++++++++++++++++++++++++++-----------------------------
Madapters/taler-cyclos/src/db.rs | 67+++++++++++++++++++++++--------------------------------------------
Madapters/taler-cyclos/src/lib.rs | 1+
Madapters/taler-magnet-bank/Cargo.toml | 1+
Madapters/taler-magnet-bank/src/api.rs | 59+++++++++++++++++++++++++++++------------------------------
Madapters/taler-magnet-bank/src/db.rs | 65++++++++++++++++++++++-------------------------------------------
Madapters/taler-wise/Cargo.toml | 1+
Madapters/taler-wise/src/api.rs | 39+++++++++++++++++++--------------------
Aadapters/taler-wise/src/constants.rs | 19+++++++++++++++++++
Madapters/taler-wise/src/db.rs | 16+++++-----------
Madapters/taler-wise/src/lib.rs | 5+----
Madapters/taler-wise/src/main.rs | 3++-
Mcommon/taler-api/Cargo.toml | 1+
Mcommon/taler-api/src/test.rs | 72++++++++++++++++++++++++++++++++++--------------------------------------
Mcommon/taler-macros/src/lib.rs | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/taler-test-utils/src/db.rs | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mtaler-apns-relay/src/api.rs | 9+++++----
Mtaler-apns-relay/src/db.rs | 16++++------------
Mtaler-apns-relay/src/lib.rs | 1+
20 files changed, 506 insertions(+), 296 deletions(-)

diff --git a/adapters/taler-cyclos/Cargo.toml b/adapters/taler-cyclos/Cargo.toml @@ -19,6 +19,7 @@ jiff.workspace = true taler-common.workspace = true taler-api.workspace = true taler-build.workspace = true +taler-macros.workspace = true http-client.workspace = true clap.workspace = true serde.workspace = true diff --git a/adapters/taler-cyclos/src/api.rs b/adapters/taler-cyclos/src/api.rs @@ -399,9 +399,9 @@ mod test { payto::{PaytoURI, payto}, }, }; + use taler_macros::db_test; use taler_test_utils::{ Router, - db::db_test_setup, routine::{ Status, admin_add_incoming_routine, in_history_routine, out_history_routine, registration_routine, revenue_routine, transfer_routine, @@ -412,7 +412,6 @@ mod test { use crate::{ api::CyclosApi, - constants::CONFIG_SOURCE, db::{self, TxIn, TxOutKind}, payto::{FullCyclosPayto, cyclos_payto}, }; @@ -425,27 +424,24 @@ mod test { payto("payto://cyclos/localhost/7762070814178012478?receiver-name=Unknown") }); - async fn setup() -> (Router, PgPool) { - let (_, pool) = db_test_setup(CONFIG_SOURCE).await; + async fn setup(pool: &PgPool) -> Router { let api = Arc::new(CyclosApi::start( pool.clone(), CompactString::const_new("localhost"), PAYTO.clone(), Currency::TEST, )); - let server = Router::new() + Router::new() .wire_gateway(api.clone(), AuthMethod::None) .prepared_transfer(api.clone()) .revenue(api.clone(), AuthMethod::None) .observability(api, AuthMethod::None) - .finalize(); - - (server, pool) + .finalize() } - #[tokio::test] - async fn config() { - let (server, _) = setup().await; + #[db_test] + async fn config(pool: PgPool) { + let server = setup(&pool).await; server .get("/taler-wire-gateway/config") .await @@ -465,9 +461,9 @@ mod test { server.get("/taler-observability/metrics").await.assert_ok(); } - #[tokio::test] - async fn transfer() { - let (server, _) = setup().await; + #[db_test] + async fn transfer(pool: PgPool) { + let server = setup(&pool).await; transfer_routine( &server.prefix("/taler-wire-gateway"), TransferState::pending, @@ -539,9 +535,10 @@ mod test { out(db, &TxOutKind::Simple).await } - #[tokio::test] - async fn outgoing_history() { - let (server, db) = &setup().await; + #[db_test] + async fn outgoing_history(db: PgPool) { + let db = &db; + let server = setup(db).await; out_history_routine( &server.prefix("/taler-wire-gateway"), @@ -556,9 +553,9 @@ mod test { .await; } - #[tokio::test] - async fn admin_add_incoming() { - let (server, _) = setup().await; + #[db_test] + async fn admin_add_incoming(db: PgPool) { + let server = setup(&db).await; admin_add_incoming_routine( &server.prefix("/taler-wire-gateway"), &server.prefix("/taler-prepared-transfer"), @@ -568,9 +565,10 @@ mod test { .await; } - #[tokio::test] - async fn in_history() { - let (server, db) = &setup().await; + #[db_test] + async fn in_history(db: PgPool) { + let db = &db; + let server = setup(db).await; in_history_routine( &server.prefix("/taler-wire-gateway"), &server.prefix("/taler-prepared-transfer"), @@ -587,9 +585,10 @@ mod test { .await; } - #[tokio::test] - async fn revenue() { - let (server, db) = &setup().await; + #[db_test] + async fn revenue(db: PgPool) { + let db = &db; + let server = setup(db).await; revenue_routine( &server.prefix("/taler-wire-gateway"), &server.prefix("/taler-revenue"), @@ -634,9 +633,9 @@ mod test { .unwrap() } - #[tokio::test] - async fn registration() { - let (server, pool) = setup().await; + #[db_test] + async fn registration(pool: PgPool) { + let server = setup(&pool).await; registration_routine( &server.prefix("/taler-wire-gateway"), &server.prefix("/taler-prepared-transfer"), diff --git a/adapters/taler-cyclos/src/db.rs b/adapters/taler-cyclos/src/db.rs @@ -916,7 +916,7 @@ mod test { use compact_str::CompactString; use jiff::{Span, Timestamp}; use serde_json::json; - use sqlx::{PgPool, Postgres, Row as _, pool::PoolConnection, postgres::PgRow}; + use sqlx::{PgConnection, PgPool, Row as _, postgres::PgRow}; use taler_api::{ db::TypeHelper, notification::dummy_listen, @@ -934,26 +934,18 @@ mod test { utils::now_sql_stable_ts, }, }; + use taler_macros::db_test; - use crate::{ - constants::CONFIG_SOURCE, - db::{ - self, AddIncomingResult, AddOutgoingResult, BounceResult, ChargebackFailureResult, - Transfer, TransferResult, TxIn, TxInAdmin, TxOut, TxOutKind, - }, + use crate::db::{ + self, AddIncomingResult, AddOutgoingResult, BounceResult, ChargebackFailureResult, + Transfer, TransferResult, TxIn, TxInAdmin, TxOut, TxOutKind, }; pub const CURR: Currency = Currency::TEST; pub const ROOT: CompactString = CompactString::const_new("localhost"); - async fn setup() -> (PoolConnection<Postgres>, PgPool) { - taler_test_utils::db::db_test_setup(CONFIG_SOURCE).await - } - - #[tokio::test] - async fn kv() { - let (mut db, _) = setup().await; - + #[db_test] + async fn kv(mut db: PgConnection) { let value = json!({ "name": "Mr Smith", "no way": 32 @@ -975,14 +967,12 @@ mod test { ); } - #[tokio::test] - async fn tx_in() { - let (mut db, pool) = setup().await; - + #[db_test] + async fn tx_in(mut db: PgConnection, pool: PgPool) { let mut routine = async |first: &Option<IncomingKey>, second: &Option<IncomingKey>| { let id = sqlx::query("SELECT count(*) + 1 FROM tx_in") .try_map(|r: PgRow| r.try_get_u64(0)) - .fetch_one(&mut *db) + .fetch_one(&mut db) .await .unwrap(); let now = now_sql_stable_ts(); @@ -1102,10 +1092,8 @@ mod test { ); } - #[tokio::test] - async fn tx_in_admin() { - let (_, pool) = setup().await; - + #[db_test] + async fn tx_in_admin(pool: PgPool) { // Empty db assert_eq!( db::incoming_history(&pool, &History::default(), &CURR, &ROOT, dummy_listen) @@ -1166,14 +1154,12 @@ mod test { ); } - #[tokio::test] - async fn tx_out() { - let (mut db, pool) = setup().await; - + #[db_test] + async fn tx_out(mut db: PgConnection, pool: PgPool) { let mut routine = async |first: &TxOutKind, second: &TxOutKind| { let transfer_id = sqlx::query("SELECT count(*) + 1 FROM tx_out") .try_map(|r: PgRow| r.try_get(0)) - .fetch_one(&mut *db) + .fetch_one(&mut db) .await .unwrap(); let now = now_sql_stable_ts(); @@ -1293,10 +1279,8 @@ mod test { // TODO tx out failure - #[tokio::test] - async fn transfer() { - let (_, pool) = setup().await; - + #[db_test] + async fn transfer(pool: PgPool) { // Empty db assert_eq!( db::transfer_by_id(&pool, 0, &CURR, &ROOT).await.unwrap(), @@ -1415,10 +1399,8 @@ mod test { ); } - #[tokio::test] - async fn bounce() { - let (mut db, _) = setup().await; - + #[db_test] + async fn bounce(mut db: PgConnection) { let amount = decimal("10"); let now = now_sql_stable_ts(); @@ -1497,10 +1479,8 @@ mod test { ); } - #[tokio::test] - async fn status() { - let (mut db, pool) = setup().await; - + #[db_test] + async fn status(mut db: PgConnection, pool: PgPool) { let check_status = async |id: u64, status: TransferState, msg: Option<&str>| { let transfer = db::transfer_by_id(&pool, id, &CURR, &ROOT) .await @@ -1597,9 +1577,8 @@ mod test { ); } - #[tokio::test] - async fn batch() { - let (mut db, pool) = setup().await; + #[db_test] + async fn batch(mut db: PgConnection, pool: PgPool) { let start = Timestamp::now(); // Empty db diff --git a/adapters/taler-cyclos/src/lib.rs b/adapters/taler-cyclos/src/lib.rs @@ -32,6 +32,7 @@ pub mod notification; pub mod payto; pub mod setup; pub mod worker; +pub use crate::constants::CONFIG_SOURCE; pub async fn run_serve(cfg: &Config, pool: PgPool) -> anyhow::Result<()> { let cfg = ServeCfg::parse(cfg)?; diff --git a/adapters/taler-magnet-bank/Cargo.toml b/adapters/taler-magnet-bank/Cargo.toml @@ -23,6 +23,7 @@ jiff.workspace = true taler-common.workspace = true taler-api.workspace = true taler-build.workspace = true +taler-macros.workspace = true http-client.workspace = true clap.workspace = true serde.workspace = true diff --git a/adapters/taler-magnet-bank/src/api.rs b/adapters/taler-magnet-bank/src/api.rs @@ -371,9 +371,9 @@ mod test { payto::{PaytoURI, payto}, }, }; + use taler_macros::db_test; use taler_test_utils::{ Router, - db::db_test_setup, routine::{ Status, admin_add_incoming_routine, in_history_routine, out_history_routine, registration_routine, revenue_routine, transfer_routine, @@ -385,7 +385,6 @@ mod test { use crate::{ FullHuPayto, api::MagnetApi, - constants::CONFIG_SOURCE, db::{self, TxIn, TxOutKind}, magnet_api::types::TxStatus, magnet_payto, @@ -398,22 +397,19 @@ mod test { static UNKNOWN: LazyLock<PaytoURI> = LazyLock::new(|| payto("payto://iban/HU60162006491000639900000000?receiver-name=Unknown")); - async fn setup() -> (Router, PgPool) { - let (_, pool) = db_test_setup(CONFIG_SOURCE).await; + async fn setup(pool: &PgPool) -> Router { let api = Arc::new(MagnetApi::start(pool.clone(), PAYTO.clone()).await); - let server = Router::new() + Router::new() .wire_gateway(api.clone(), AuthMethod::None) .prepared_transfer(api.clone()) .revenue(api.clone(), AuthMethod::None) .observability(api, AuthMethod::None) - .finalize(); - - (server, pool) + .finalize() } - #[tokio::test] - async fn config() { - let (server, _) = setup().await; + #[db_test] + async fn config(pool: PgPool) { + let server = setup(&pool).await; server .get("/taler-wire-gateway/config") .await @@ -433,9 +429,9 @@ mod test { server.get("/taler-observability/metrics").await.assert_ok(); } - #[tokio::test] - async fn transfer() { - let (server, _) = setup().await; + #[db_test] + async fn transfer(pool: PgPool) { + let server = setup(&pool).await; transfer_routine( &server.prefix("/taler-wire-gateway"), TransferState::pending, @@ -504,9 +500,10 @@ mod test { out(db, &TxOutKind::Simple).await } - #[tokio::test] - async fn outgoing_history() { - let (server, db) = &setup().await; + #[db_test] + async fn outgoing_history(db: PgPool) { + let db = &db; + let server = setup(db).await; out_history_routine( &server.prefix("/taler-wire-gateway"), tasks!({ out_talerable(db).await }), @@ -520,9 +517,9 @@ mod test { .await; } - #[tokio::test] - async fn admin_add_incoming() { - let (server, _) = setup().await; + #[db_test] + async fn admin_add_incoming(db: PgPool) { + let server = setup(&db).await; admin_add_incoming_routine( &server.prefix("/taler-wire-gateway"), &server.prefix("/taler-prepared-transfer"), @@ -532,9 +529,10 @@ mod test { .await; } - #[tokio::test] - async fn in_history() { - let (server, db) = &setup().await; + #[db_test] + async fn in_history(db: PgPool) { + let db = &db; + let server = setup(db).await; in_history_routine( &server.prefix("/taler-wire-gateway"), &server.prefix("/taler-prepared-transfer"), @@ -551,9 +549,10 @@ mod test { .await; } - #[tokio::test] - async fn revenue() { - let (server, db) = &setup().await; + #[db_test] + async fn revenue(db: PgPool) { + let db = &db; + let server = setup(db).await; revenue_routine( &server.prefix("/taler-wire-gateway"), &server.prefix("/taler-revenue"), @@ -598,16 +597,16 @@ mod test { .unwrap() } - #[tokio::test] - async fn registration() { - let (server, pool) = setup().await; + #[db_test] + async fn registration(db: PgPool) { + let server = setup(&db).await; registration_routine( &server.prefix("/taler-wire-gateway"), &server.prefix("/taler-prepared-transfer"), &EXCHANGE, &EXCHANGE, &UNKNOWN, - || check_in(&pool), + || check_in(&db), ) .await; } diff --git a/adapters/taler-magnet-bank/src/db.rs b/adapters/taler-magnet-bank/src/db.rs @@ -881,7 +881,7 @@ pub async fn transfer_unregister(db: &PgPool, req: &Unregistration) -> sqlx::Res mod test { use jiff::{Span, Timestamp, Zoned, tz::TimeZone}; use serde_json::json; - use sqlx::{PgPool, Postgres, pool::PoolConnection, postgres::PgRow}; + use sqlx::{PgConnection, PgPool, postgres::PgRow}; use taler_api::{ db::TypeHelper, notification::dummy_listen, @@ -898,10 +898,10 @@ mod test { utils::now_sql_stable_ts, }, }; + use taler_macros::db_test; use super::TxInAdmin; use crate::{ - constants::CONFIG_SOURCE, db::{ self, AddIncomingResult, AddOutgoingResult, BounceResult, Initiated, OutFailureResult, TransferResult, TxIn, TxOut, TxOutKind, kv_get, kv_set, make_transfer, @@ -911,14 +911,8 @@ mod test { magnet_payto, }; - async fn setup() -> (PoolConnection<Postgres>, PgPool) { - taler_test_utils::db::db_test_setup(CONFIG_SOURCE).await - } - - #[tokio::test] - async fn kv() { - let (mut db, _) = setup().await; - + #[db_test] + async fn kv(mut db: PgConnection) { let value = json!({ "name": "Mr Smith", "no way": 32 @@ -936,15 +930,13 @@ mod test { ); } - #[tokio::test] - async fn tx_in() { - let (mut db, pool) = setup().await; - + #[db_test] + async fn tx_in(mut db: PgConnection, pool: PgPool) { let mut routine = async |first: &Option<IncomingKey>, second: &Option<IncomingKey>| { let (id, code) = sqlx::query("SELECT count(*) + 1, COALESCE(max(magnet_code), 0) + 20 FROM tx_in") .try_map(|r: PgRow| Ok((r.try_get_u64(0)?, r.try_get_u64(1)?))) - .fetch_one(&mut *db) + .fetch_one(&mut db) .await .unwrap(); let now = now_sql_stable_ts(); @@ -1063,10 +1055,8 @@ mod test { ); } - #[tokio::test] - async fn tx_in_admin() { - let (_, pool) = setup().await; - + #[db_test] + async fn tx_in_admin(pool: PgPool) { // Empty db assert_eq!( db::incoming_history(&pool, &History::default(), dummy_listen) @@ -1128,15 +1118,13 @@ mod test { ); } - #[tokio::test] - async fn tx_out() { - let (mut db, pool) = setup().await; - + #[db_test] + async fn tx_out(mut db: PgConnection, pool: PgPool) { let mut routine = async |first: &TxOutKind, second: &TxOutKind| { let (id, code) = sqlx::query("SELECT count(*) + 1, COALESCE(max(magnet_code), 0) + 20 FROM tx_out") .try_map(|r: PgRow| Ok((r.try_get_u64(0)?, r.try_get_u64(1)?))) - .fetch_one(&mut *db) + .fetch_one(&mut db) .await .unwrap(); let now = now_sql_stable_ts(); @@ -1254,10 +1242,8 @@ mod test { ); } - #[tokio::test] - async fn tx_out_failure() { - let (mut db, pool) = setup().await; - + #[db_test] + async fn tx_out_failure(mut db: PgConnection, pool: PgPool) { let now = now_sql_stable_ts(); // Unknown @@ -1359,10 +1345,8 @@ mod test { ); } - #[tokio::test] - async fn transfer() { - let (_, pool) = setup().await; - + #[db_test] + async fn transfer(pool: PgPool) { // Empty db assert_eq!(db::transfer_by_id(&pool, 0).await.unwrap(), None); assert_eq!( @@ -1458,10 +1442,8 @@ mod test { ); } - #[tokio::test] - async fn bounce() { - let (mut db, _) = setup().await; - + #[db_test] + async fn bounce(mut db: PgConnection) { let amount = amount("HUF:10"); let payto = magnet_payto("payto://iban/HU30162000031000163100000000?receiver-name=name"); let now = now_sql_stable_ts(); @@ -1613,10 +1595,8 @@ mod test { ); } - #[tokio::test] - async fn status() { - let (mut db, _) = setup().await; - + #[db_test] + async fn status(mut db: PgConnection) { // Unknown transfer db::initiated_submit_permanent_failure(&mut db, 1, &Timestamp::now(), "msg") .await @@ -1626,9 +1606,8 @@ mod test { .unwrap(); } - #[tokio::test] - async fn batch() { - let (mut db, pool) = setup().await; + #[db_test] + async fn batch(mut db: PgConnection, pool: PgPool) { let start = Timestamp::now(); let magnet_payto = magnet_payto("payto://iban/HU30162000031000163100000000?receiver-name=name"); diff --git a/adapters/taler-wise/Cargo.toml b/adapters/taler-wise/Cargo.toml @@ -17,6 +17,7 @@ sqlx.workspace = true taler-api.workspace = true taler-build.workspace = true taler-common.workspace = true +taler-macros.workspace = true url.workspace = true http-client.workspace = true serde.workspace = true diff --git a/adapters/taler-wise/src/api.rs b/adapters/taler-wise/src/api.rs @@ -364,16 +364,16 @@ mod test { payto::{BankID, PaytoURI}, }, }; + use taler_macros::db_test; use taler_test_utils::{ Router, - db::db_test_setup, routine::{admin_add_incoming_routine, in_history_routine, revenue_routine}, server::TestServer, tasks, }; use crate::{ - CONFIG_SOURCE, api, + api, config::WiseBalance, db::{TxIn, register_tx_in}, payto::{FullWisePayto, WiseAccount}, @@ -386,8 +386,7 @@ mod test { }); static EXCHANGE: LazyLock<PaytoURI> = LazyLock::new(|| PAYTO.as_uri()); - async fn setup() -> (Router, PgPool) { - let (_, pool) = db_test_setup(CONFIG_SOURCE).await; + async fn setup(pool: &PgPool) -> Router { let balances = vec![ WiseBalance { id: 42, @@ -400,7 +399,7 @@ mod test { payto: PAYTO.clone().into_inner().0, }, ]; - let server = api::start( + api::start( pool.clone(), "Exchange", balances, @@ -415,14 +414,12 @@ mod test { }), ) .await - .finalize(); - - (server, pool) + .finalize() } - #[tokio::test] - async fn config() { - let (server, _) = setup().await; + #[db_test] + async fn config(db: PgPool) { + let server = setup(&db).await; for id in [42, 34] { server .get(format!("/balances/{id}/taler-wire-gateway/config")) @@ -477,9 +474,9 @@ mod test { r#in(db, Some(IncomingKey::reserve(EddsaPublicKey::rand()))).await } - #[tokio::test] - async fn admin_add_incoming() { - let (server, _) = setup().await; + #[db_test] + async fn admin_add_incoming(db: PgPool) { + let server = setup(&db).await; admin_add_incoming_routine( &server.prefix("/balances/42/taler-wire-gateway"), &server.prefix("/balances/42/taler-prepared-transfer"), @@ -489,9 +486,10 @@ mod test { .await; } - #[tokio::test] - async fn in_history() { - let (server, db) = &setup().await; + #[db_test] + async fn in_history(db: PgPool) { + let server = setup(&db).await; + let db = &db; in_history_routine( &server.prefix("/balances/42/taler-wire-gateway"), &server.prefix("/balances/42/taler-prepared-transfer"), @@ -503,9 +501,10 @@ mod test { .await; } - #[tokio::test] - async fn revenue() { - let (server, db) = &setup().await; + #[db_test] + async fn revenue(db: PgPool) { + let server = setup(&db).await; + let db = &db; revenue_routine( &server.prefix("/balances/42/taler-wire-gateway"), &server.prefix("/balances/42/taler-revenue"), diff --git a/adapters/taler-wise/src/constants.rs b/adapters/taler-wise/src/constants.rs @@ -0,0 +1,19 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use taler_common::config::parser::ConfigSource; + +pub const CONFIG_SOURCE: ConfigSource = ConfigSource::new("taler-wise", "wise", "taler-wise"); diff --git a/adapters/taler-wise/src/db.rs b/adapters/taler-wise/src/db.rs @@ -331,7 +331,7 @@ mod test { use compact_str::{CompactString, format_compact}; use jiff::Span; - use sqlx::{PgPool, Postgres, pool::PoolConnection, postgres::PgRow}; + use sqlx::{PgConnection, PgPool, postgres::PgRow}; use taler_api::{db::TypeHelper, notification::dummy_listen, subject::IncomingKey}; use taler_common::{ api::{EddsaPublicKey, params::History}, @@ -342,28 +342,22 @@ mod test { utils::now_sql_stable_ts, }, }; + use taler_macros::db_test; use crate::{ - CONFIG_SOURCE, db::{AddIncomingResult, TxIn, incoming_history, register_tx_in, revenue_history}, payto::WiseAccount, }; - async fn setup() -> (PoolConnection<Postgres>, PgPool) { - taler_test_utils::db::db_test_setup(CONFIG_SOURCE).await - } - const BALANCE_ID: u32 = 42; const CURR: Currency = Currency::TEST; - #[tokio::test] - async fn tx_in() { - let (mut db, pool) = setup().await; - + #[db_test] + async fn tx_in(mut db: PgConnection, pool: PgPool) { let mut routine = async |first: &Option<IncomingKey>, second: &Option<IncomingKey>| { let id = sqlx::query("SELECT count(*) + 1 FROM tx_in") .try_map(|r: PgRow| r.try_get_u64(0)) - .fetch_one(&mut *db) + .fetch_one(&mut db) .await .unwrap(); let now = now_sql_stable_ts(); diff --git a/adapters/taler-wise/src/lib.rs b/adapters/taler-wise/src/lib.rs @@ -14,14 +14,11 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use taler_common::config::parser::ConfigSource; - pub mod api; pub mod config; +pub mod constants; pub mod db; pub mod payto; pub mod setup; pub mod wise_api; pub mod worker; - -pub const CONFIG_SOURCE: ConfigSource = ConfigSource::new("taler-wise", "wise", "taler-wise"); diff --git a/adapters/taler-wise/src/main.rs b/adapters/taler-wise/src/main.rs @@ -19,8 +19,9 @@ use taler_api::api::TalerRouter as _; use taler_build::long_version; use taler_common::{CommonArgs, cli::ConfigCmd, config::Config, taler_main}; use taler_wise::{ - CONFIG_SOURCE, api, + api, config::ServeCfg, + constants::CONFIG_SOURCE, db::{dbinit, pool}, setup::{self}, worker::run_worker, diff --git a/common/taler-api/Cargo.toml b/common/taler-api/Cargo.toml @@ -18,6 +18,7 @@ http-body-util.workspace = true zlib-rs = "0.6.3" tower-http = { version = "0.7", features = ["cors"] } tokio = { workspace = true, features = ["signal"] } +taler-macros.workspace = true rand.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/common/taler-api/src/test.rs b/common/taler-api/src/test.rs @@ -41,8 +41,8 @@ use taler_common::{ url, }, }; +use taler_macros::db_test; use taler_test_utils::{ - db::db_test_setup_manual, routine::{ Status, admin_add_incoming_routine, in_history_routine, out_history_routine, registration_routine, revenue_routine, transfer_routine, @@ -94,17 +94,13 @@ fn test_api(pool: PgPool, currency: Currency) -> Router { .observability(state, AuthMethod::None) } -async fn setup() -> (Router, PgPool) { - let (_, pool) = db_test_setup_manual("db".as_ref(), "taler-api").await; - ( - test_api(pool.clone(), "EUR".parse().unwrap()).finalize(), - pool, - ) +async fn setup(db: &PgPool) -> Router { + test_api(db.clone(), "EUR".parse().unwrap()).finalize() } -#[tokio::test] -async fn body_parsing() { - let (server, _) = setup().await; +#[db_test(sql_dir = "db", component = "taler-api")] +async fn body_parsing(db: PgPool) { + let server = setup(&db).await; let normal_body = TransferRequest { request_uid: Base32::rand(), amount: Amount::zero(&Currency::EUR), @@ -185,9 +181,9 @@ async fn body_parsing() { .assert_error(ErrorCode::GENERIC_UPLOAD_EXCEEDS_LIMIT); } -#[tokio::test] -async fn errors() { - let (server, _) = setup().await; +#[db_test(sql_dir = "db", component = "taler-api")] +async fn errors(db: PgPool) { + let server = setup(&db).await; server .get("/unknown") .await @@ -198,9 +194,9 @@ async fn errors() { .assert_error(ErrorCode::GENERIC_METHOD_INVALID); } -#[tokio::test] -async fn config() { - let (server, _) = setup().await; +#[db_test(sql_dir = "db", component = "taler-api")] +async fn config(db: PgPool) { + let server = setup(&db).await; server .get("/taler-wire-gateway/config") .await @@ -215,9 +211,9 @@ async fn config() { .assert_ok_json::<RevenueConfig>(); } -#[tokio::test] -async fn transfer() { - let (server, _) = setup().await; +#[db_test(sql_dir = "db", component = "taler-api")] +async fn transfer(db: PgPool) { + let server = setup(&db).await; transfer_routine( &server.prefix("/taler-wire-gateway"), TransferState::success, @@ -226,9 +222,9 @@ async fn transfer() { .await; } -#[tokio::test] -async fn outgoing_history() { - let (server, _) = &setup().await; +#[db_test(sql_dir = "db", component = "taler-api")] +async fn outgoing_history(db: PgPool) { + let server = setup(&db).await; out_history_routine( &server.prefix("/taler-wire-gateway"), tasks!({ @@ -249,9 +245,9 @@ async fn outgoing_history() { .await; } -#[tokio::test] -async fn admin_add_incoming() { - let (server, _) = setup().await; +#[db_test(sql_dir = "db", component = "taler-api")] +async fn admin_add_incoming(db: PgPool) { + let server = setup(&db).await; admin_add_incoming_routine( &server.prefix("/taler-wire-gateway"), &server.prefix("/taler-prepared-transfer"), @@ -261,9 +257,9 @@ async fn admin_add_incoming() { .await; } -#[tokio::test] -async fn in_history() { - let (server, _) = setup().await; +#[db_test(sql_dir = "db", component = "taler-api")] +async fn in_history(db: PgPool) { + let server = setup(&db).await; in_history_routine( &server.prefix("/taler-wire-gateway"), &server.prefix("/taler-prepared-transfer"), @@ -275,9 +271,9 @@ async fn in_history() { .await; } -#[tokio::test] -async fn revenue() { - let (server, _) = setup().await; +#[db_test(sql_dir = "db", component = "taler-api")] +async fn revenue(db: PgPool) { + let server = setup(&db).await; revenue_routine( &server.prefix("/taler-wire-gateway"), &server.prefix("/taler-revenue"), @@ -288,9 +284,9 @@ async fn revenue() { .await; } -#[tokio::test] -async fn account_check() { - let (server, _) = setup().await; +#[db_test(sql_dir = "db", component = "taler-api")] +async fn account_check(db: PgPool) { + let server = setup(&db).await; server .get("/taler-wire-gateway/account/check") .query("account", "payto://test") @@ -330,16 +326,16 @@ async fn check_in(pool: &PgPool) -> Vec<Status> { .unwrap() } -#[tokio::test] -async fn registration() { - let (server, pool) = setup().await; +#[db_test(sql_dir = "db", component = "taler-api")] +async fn registration(db: PgPool) { + let server = setup(&db).await; registration_routine( &server.prefix("/taler-wire-gateway"), &server.prefix("/taler-prepared-transfer"), &EXCHANGE, &EXCHANGE, &UNKNOWN, - || check_in(&pool), + || check_in(&db), ) .await; } diff --git a/common/taler-macros/src/lib.rs b/common/taler-macros/src/lib.rs @@ -348,3 +348,186 @@ pub fn api_config(attr: TokenStream, item: TokenStream) -> TokenStream { TokenStream::from(expanded) } + +/// Run an async database test with a pool initialized from `crate::CONFIG_SOURCE`. +/// +/// An explicit config expression can override the default. Components without +/// installed configuration can use `#[db_test(sql_dir = "db", component = "name")]`. +/// Use `#[db_test(raw)]` to reserve a database without initializing schemas; its +/// argument is `PgConnectOptions` instead of `PgPool`. +/// Initialized tests can take a `PgPool`, a `PoolConnection<Postgres>`, a +/// `PgConnection`, or `&mut PgConnection`. A pool and a connection may be combined +/// in either order. The function must return `()` or `Result<(), E>`. +/// Do not combine this attribute with `#[test]` or `#[tokio::test]`. +#[proc_macro_attribute] +pub fn db_test(attr: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as syn::ItemFn); + match expand_db_test(attr.into(), input) { + Ok(tokens) => tokens.into(), + Err(error) => error.to_compile_error().into(), + } +} + +fn expand_db_test( + attr: proc_macro2::TokenStream, + input: syn::ItemFn, +) -> syn::Result<proc_macro2::TokenStream> { + let sig = &input.sig; + if sig.asyncness.is_none() + || !(1..=2).contains(&sig.inputs.len()) + || !sig.generics.params.is_empty() + || sig.generics.where_clause.is_some() + || sig.constness.is_some() + || !matches!(sig.safety, syn::Safety::Default) + || sig.abi.is_some() + || sig.variadic.is_some() + || sig + .inputs + .iter() + .any(|arg| matches!(arg, syn::FnArg::Receiver(_))) + { + return Err(Error::new_spanned( + sig, + "db_test requires an async function with one or two database arguments and no generics", + )); + } + for attr in &input.attrs { + if attr + .path() + .segments + .last() + .is_some_and(|s| s.ident == "test") + { + return Err(Error::new_spanned( + attr, + "db_test supplies the test attribute; remove the other test attribute", + )); + } + } + + let name = &sig.ident; + let callback = || -> syn::Result<proc_macro2::TokenStream> { + let mut has_pool = false; + let mut has_conn = false; + let mut args = Vec::new(); + for arg in &sig.inputs { + let syn::FnArg::Typed(arg) = arg else { + unreachable!() + }; + let (ty, borrowed) = match arg.ty.as_ref() { + syn::Type::Reference(reference) if reference.mutability.is_some() => { + (reference.elem.as_ref(), true) + } + ty => (ty, false), + }; + let ident = match ty { + syn::Type::Path(path) => path.path.segments.last().map(|s| &s.ident), + _ => None, + }; + let (slot, value) = match ident { + Some(ident) if ident == "PgPool" && !borrowed => { + (&mut has_pool, quote! { __taler_pool }) + } + Some(ident) if ident == "PoolConnection" && !borrowed => { + (&mut has_conn, quote! { __taler_conn }) + } + Some(ident) if ident == "PgConnection" => ( + &mut has_conn, + if borrowed { + quote! { &mut *__taler_conn } + } else { + quote! { __taler_conn.detach() } + }, + ), + _ => { + return Err(Error::new_spanned( + &arg.ty, + "expected PgPool, PoolConnection<Postgres>, PgConnection, or &mut PgConnection", + )); + } + }; + if std::mem::replace(slot, true) { + return Err(Error::new_spanned( + &arg.ty, + "expected at most one pool and one connection", + )); + } + args.push(value); + } + let acquire = has_conn.then(|| quote! { + let mut __taler_conn = __taler_pool.acquire().await + .unwrap_or_else(|e| panic!("{}: acquire test connection: {e}", __taler_test_name)); + }); + Ok(quote! { + async |__taler_pool: ::sqlx::PgPool| { + #acquire + #name(#(#args),*).await + } + }) + }; + let runner = (|input: syn::parse::ParseStream<'_>| { + if input.peek(syn::Ident) && input.peek2(syn::Token![=]) { + let mut sql_dir = None; + let mut component = None; + while !input.is_empty() { + let key: syn::Ident = input.parse()?; + input.parse::<syn::Token![=]>()?; + let value: Expr = input.parse()?; + let slot = match key.to_string().as_str() { + "sql_dir" => &mut sql_dir, + "component" => &mut component, + _ => return Err(Error::new(key.span(), "expected sql_dir or component")), + }; + if slot.replace(value).is_some() { + return Err(Error::new(key.span(), "duplicate db_test option")); + } + if !input.is_empty() { + input.parse::<syn::Token![,]>()?; + } + } + let sql_dir = sql_dir.ok_or_else(|| input.error("missing sql_dir"))?; + let component = component.ok_or_else(|| input.error("missing component"))?; + let callback = callback()?; + return Ok(quote! { + ::taler_test_utils::db::run_db_test_manual( + ::std::path::Path::new(#sql_dir), #component, __taler_test_name, #callback + ) + }); + } + let source: Expr = if input.is_empty() { + syn::parse_quote!(crate::constants::CONFIG_SOURCE) + } else { + input.parse()? + }; + if matches!(&source, Expr::Path(path) if path.path.is_ident("raw")) { + if sig.inputs.len() != 1 { + return Err(Error::new_spanned( + sig, + "raw db_test requires one PgConnectOptions argument", + )); + } + Ok(quote! { + ::taler_test_utils::db::run_db_test_raw(__taler_test_name, #name) + }) + } else { + let callback = callback()?; + Ok(quote! { + ::taler_test_utils::db::run_db_test(#source, __taler_test_name, #callback) + }) + } + }) + .parse2(attr)?; + let output = &sig.output; + let attrs = &input.attrs; + let vis = &input.vis; + let body = &input.block; + Ok(quote! { + #(#attrs)* + #[test] + #vis fn #name() #output { + #sig #body + let __taler_test_name = concat!(module_path!(), "::", stringify!(#name)); + #runner + } + }) +} diff --git a/common/taler-test-utils/src/db.rs b/common/taler-test-utils/src/db.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2025, 2026 Taler Systems SA + Copyright (C) 2025-2026 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -16,9 +16,7 @@ use std::{path::Path, str::FromStr as _}; -use sqlx::{ - Connection, PgConnection, PgPool, Postgres, pool::PoolConnection, postgres::PgConnectOptions, -}; +use sqlx::{Connection, PgConnection, PgPool, postgres::PgConnectOptions}; use taler_api::config::DbCfg; use taler_common::{ config::{Config, parser::ConfigSource}, @@ -28,76 +26,144 @@ use tracing::info; use crate::setup_tracing; -/// Create a reusable test database and run dbinit for a Taler component -pub async fn db_test_setup(src: ConfigSource) -> (PoolConnection<Postgres>, PgPool) { - let cfg = Config::load(src, None::<&str>).unwrap(); - let name = format!("{}db-postgres", src.component_name); - let sect = cfg.section(&name); - let db_cfg = DbCfg::parse(sect).unwrap(); - db_test_setup_manual(db_cfg.sql_dir.as_ref(), src.component_name).await +/// An exclusive reservation of a reusable test database. +/// +/// The administrative connection holds a session advisory lock. Dropping this +/// reservation closes that connection and releases the slot. Keep it alive +/// until every task and connection using the database has been dropped. +pub struct TestDb { + _lock: PgConnection, + options: PgConnectOptions, +} + +impl TestDb { + /// Connection options for the reserved database. + pub fn connect_options(&self) -> PgConnectOptions { + self.options.clone() + } + + /// Reset the component schema and create a pool using its local config. + pub async fn setup(&self, src: ConfigSource) -> PgPool { + let cfg = Config::load(src, None::<&str>).unwrap(); + let name = format!("{}db-postgres", src.component_name); + let db_cfg = DbCfg::parse(cfg.section(&name)).unwrap(); + self.setup_manual(db_cfg.sql_dir.as_ref(), src.component_name) + .await + } + + /// Reset the component schema using an explicit SQL directory. + pub async fn setup_manual(&self, sql_dir: &Path, component_name: &str) -> PgPool { + let pool = pool(self.connect_options(), &component_name.replace('-', "_")) + .await + .unwrap(); + let mut conn = pool.acquire().await.unwrap(); + dbinit(&mut conn, sql_dir, component_name, true) + .await + .unwrap(); + drop(conn); + pool + } +} + +/// Run a database test using a component's local configuration. +/// +/// The test gets its own current-thread Tokio runtime. On return or panic the +/// runtime drops its remaining tasks before the database reservation is released. +/// Return values must not retain database resources; use `()` or `Result<(), E>`. +/// Keep all work using the database on this runtime so teardown can stop it. +pub fn run_db_test(src: ConfigSource, test_name: &str, test: impl AsyncFnOnce(PgPool)) { + run(test_name, async |db| test(db.setup(src).await).await) } -/// Create a reusable test database and run dbinit for a Taler component -pub async fn db_test_setup_manual( +/// Run a database test using an explicit SQL directory and component name. +pub fn run_db_test_manual( sql_dir: &Path, component_name: &str, -) -> (PoolConnection<Postgres>, PgPool) { - setup_tracing(); - let cfg = test_db().await; - let pool = pool(cfg, &component_name.replace("-", "_")).await.unwrap(); - let mut conn = pool.acquire().await.unwrap(); + test_name: &str, + test: impl AsyncFnOnce(PgPool), +) { + run(test_name, async |db| { + test(db.setup_manual(sql_dir, component_name).await).await + }) +} - dbinit(&mut conn, sql_dir, component_name, true) - .await - .unwrap(); +/// Run a test with reserved connection options and no schema initialization. +/// +/// Use this for migration tests, custom fixtures, and tests sharing a database +/// across components. As with `run_db_test`, the reservation outlives the test +/// runtime, and all database work must remain on that runtime. +pub fn run_db_test_raw(test_name: &str, test: impl AsyncFnOnce(PgConnectOptions)) { + run(test_name, async |db| test(db.connect_options()).await) +} - (conn, pool) +fn run(test_name: &str, test: impl AsyncFnOnce(&TestDb)) { + setup_tracing(); + // Locals drop in reverse declaration order, including during unwinding. + // The reservation MUST outlive the runtime: detached tasks may own pools, + // checked-out connections, or listeners until runtime shutdown drops them. + let mut reservation = None; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("create database test runtime"); + runtime.block_on(async { + reservation = Some( + reserve_db(test_name) + .await + .unwrap_or_else(|e| panic!("{test_name}: reserve test database: {e}")), + ); + let db = reservation.as_ref().unwrap(); + info!(target: "test", test_name, database = ?db.options.get_database(), "Reserved test database"); + test(db).await + }) } -/// Create a temporary test database that will be reuse by future tests -pub async fn test_db() -> PgConnectOptions { - let mut conn = PgConnection::connect("postgres:///taler_rust_check") - .await - .unwrap(); - - // Find a free slot via Advisory Locks - let row: Option<(String, bool)> = sqlx::query_as( - " - SELECT - v.db_name, - EXISTS ( - SELECT 1 - FROM pg_catalog.pg_database - WHERE datname = v.db_name - ) AS already_exists - FROM generate_series(0, 1000) AS id - CROSS JOIN LATERAL ( - SELECT CASE - WHEN id = 0 THEN 'taler_rust_test' - ELSE 'taler_rust_test_' || id - END AS db_name - ) AS v - WHERE pg_try_advisory_lock(id) - LIMIT 1; - ", +/// Reserve a reusable test database through a session advisory lock. +/// +/// Connection defaults are `postgres:///taler_rust_check`. PostgreSQL's usual +/// `PGHOST`, `PGPORT`, and `PGUSER` settings can select another test server. +pub async fn test_db() -> sqlx::Result<TestDb> { + reserve_db("taler-test-utils").await +} + +async fn reserve_db(test_name: &str) -> sqlx::Result<TestDb> { + let admin = + PgConnectOptions::from_str("postgres:///taler_rust_check")?.application_name(test_name); + let mut conn = PgConnection::connect_with(&admin).await?; + + // Find a free slot via Advisory Locks. + let slot: Option<i32> = sqlx::query_scalar( + "SELECT id FROM generate_series(0, 1000) AS id + WHERE pg_try_advisory_lock(id) LIMIT 1", ) .fetch_optional(&mut conn) - .await - .unwrap(); + .await?; - let Some((name, exists)) = row else { - panic!("Could not find a free database slot after 1000 attempts.") + let Some(slot) = slot else { + return Err(sqlx::Error::Protocol( + "Could not find a free database slot after 1001 attempts.".into(), + )); + }; + let name = match slot { + 0 => "taler_rust_test".to_owned(), + id => format!("taler_rust_test_{id}"), }; + // Check in a fresh statement after acquiring the lock. A previous holder + // may have created this database after the allocation query's snapshot. + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)", + ) + .bind(&name) + .fetch_one(&mut conn) + .await?; if !exists { sqlx::raw_sql(&format!("CREATE DATABASE {name}")) .execute(&mut conn) - .await - .unwrap(); + .await?; } - // We need this connection to stay open to keep the advisory lock in place - // Leaking it is OK in tests - std::mem::forget(conn); - let db_url = format!("postgresql:///{name}"); - info!(target: "test", "Running on db {db_url}"); - PgConnectOptions::from_str(&db_url).unwrap() + let options = admin.database(&name); + Ok(TestDb { + _lock: conn, + options, + }) } diff --git a/taler-apns-relay/src/api.rs b/taler-apns-relay/src/api.rs @@ -98,17 +98,18 @@ pub fn router(state: Arc<RelayApi>) -> Router { mod test { use std::sync::Arc; + use sqlx::PgPool; use taler_api::api::TalerRouter as _; + use taler_macros::db_test; use taler_test_utils::{json, server::TestServer as _}; use crate::{ api::{ApnsRelayConfig, RelayApi, router}, - db::{all_registrations, test::setup}, + db::all_registrations, }; - #[tokio::test] - async fn api() { - let pool = setup().await; + #[db_test] + async fn api(pool: PgPool) { let api = Arc::new(RelayApi::new(pool.clone())); let server = router(api).finalize(); diff --git a/taler-apns-relay/src/db.rs b/taler-apns-relay/src/db.rs @@ -85,20 +85,12 @@ pub async fn clear_registration(db: &PgPool) -> sqlx::Result<()> { pub mod test { use jiff::{SignedDuration, Timestamp}; use sqlx::PgPool; - use taler_test_utils::db::db_test_setup; + use taler_macros::db_test; - use crate::{ - constants::CONFIG_SOURCE, - db::{all_registrations, clear_registration, register, unregister}, - }; + use crate::db::{all_registrations, clear_registration, register, unregister}; - pub async fn setup() -> PgPool { - db_test_setup(CONFIG_SOURCE).await.1 - } - - #[tokio::test] - async fn registration() { - let db = setup().await; + #[db_test] + async fn registration(db: PgPool) { let token1 = "device_token1"; let token2 = "device_token2"; let now = Timestamp::now(); diff --git a/taler-apns-relay/src/lib.rs b/taler-apns-relay/src/lib.rs @@ -30,6 +30,7 @@ pub mod config; pub mod constants; pub mod db; pub mod worker; +pub use crate::constants::CONFIG_SOURCE; pub async fn setup(cfg: &Config, pool: &PgPool, reset: bool) -> anyhow::Result<()> { let apns_cfg = ApnsConfig::parse(cfg)?;