1
//! Integration tests for `get-transaction-detail` (B-edit phase).
2
//!
3
//! Verifies the full plist shape returned by `run_get_transaction_detail`:
4
//! - `:id`, `:post-date`, `:enter-date`, `:note`
5
//! - `:splits` list with `:id`, `:account-id`, `:commodity-id`, `:value`
6
//! - `:prices` list with split linkage and `:value-num`/`:value-denom`
7
//!
8
//! Uses the same `sqlx::test` + `install_pool` fixture pattern as
9
//! `tests_logical`.
10

            
11
use server::command::account::CreateAccount;
12
use server::command::commodity::CreateCommodity;
13
use server::command::{CmdResult, FinanceEntity};
14
use sqlx::PgPool;
15
use uuid::Uuid;
16

            
17
use super::run::run_get_transaction_detail;
18
use super::run_logical::run_create_transaction_logical;
19

            
20
5
fn install_pool(pool: &PgPool) {
21
5
    server::db::DB_POOL.with(|c| c.set(pool as *const _));
22
5
}
23

            
24
5
async fn seed_user(pool: &PgPool, user_id: Uuid) -> anyhow::Result<()> {
25
5
    sqlx::query(
26
5
        "INSERT INTO users \
27
5
         (id, user_name, email, photo, verified, user_password, user_role, db_name, created_at) \
28
5
         VALUES ($1, 'Test', 'test@test.com', 'x', false, 'pw', 'user', 'db', NOW())",
29
5
    )
30
5
    .bind(user_id)
31
5
    .execute(pool)
32
5
    .await?;
33
5
    Ok(())
34
5
}
35

            
36
5
async fn seed_commodity(user_id: Uuid, symbol: &str, name: &str) -> anyhow::Result<Uuid> {
37
5
    match CreateCommodity::new()
38
5
        .symbol(symbol.to_string())
39
5
        .name(name.to_string())
40
5
        .user_id(user_id)
41
5
        .run()
42
5
        .await?
43
    {
44
5
        Some(CmdResult::String(id)) => Ok(Uuid::parse_str(&id)?),
45
        other => anyhow::bail!("unexpected CreateCommodity result: {other:?}"),
46
    }
47
5
}
48

            
49
8
async fn seed_account(user_id: Uuid, name: &str) -> anyhow::Result<Uuid> {
50
8
    match CreateAccount::new()
51
8
        .name(name.to_string())
52
8
        .user_id(user_id)
53
8
        .run()
54
8
        .await?
55
    {
56
8
        Some(CmdResult::Entity(FinanceEntity::Account(a))) => Ok(a.id),
57
        other => anyhow::bail!("unexpected CreateAccount result: {other:?}"),
58
    }
59
8
}
60

            
61
#[sqlx::test(migrator = "server::db::MIGRATOR")]
62
async fn get_transaction_single_currency_contains_splits(pool: PgPool) -> anyhow::Result<()> {
63
    install_pool(&pool);
64
    let user_id = Uuid::new_v4();
65
    seed_user(&pool, user_id).await?;
66
    let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
67
    let a_id = seed_account(user_id, "Assets").await?;
68
    let b_id = seed_account(user_id, "Expenses").await?;
69

            
70
    let payload = format!(
71
        "(:note \"test tx\" :splits ((:from \"{a_id}\" :to \"{b_id}\" \
72
         :from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 100)))"
73
    );
74
    let tx_id_str = run_create_transaction_logical(user_id, Some(payload))
75
        .await
76
        .map_err(|e| anyhow::anyhow!("{e}"))?;
77
    let tx_id = Uuid::parse_str(&tx_id_str)?;
78

            
79
    let plist = run_get_transaction_detail(user_id, Some(tx_id_str))
80
        .await
81
        .map_err(|e| anyhow::anyhow!("{e}"))?
82
        .ok_or_else(|| anyhow::anyhow!("get-transaction returned None"))?;
83

            
84
    assert!(
85
        plist.contains(&format!(":id \"{tx_id}\"")),
86
        "plist must contain :id; got: {plist}"
87
    );
88
    assert!(
89
        plist.contains(":post-date \""),
90
        "plist must contain :post-date; got: {plist}"
91
    );
92
    assert!(
93
        plist.contains(":enter-date \""),
94
        "plist must contain :enter-date; got: {plist}"
95
    );
96
    assert!(
97
        plist.contains(":note \"test tx\""),
98
        "plist must contain :note; got: {plist}"
99
    );
100
    assert!(
101
        plist.contains(":splits ("),
102
        "plist must have :splits key; got: {plist}"
103
    );
104
    assert!(
105
        plist.contains(&format!(":account-id \"{a_id}\"")),
106
        "plist must reference account a; got: {plist}"
107
    );
108
    assert!(
109
        plist.contains(&format!(":account-id \"{b_id}\"")),
110
        "plist must reference account b; got: {plist}"
111
    );
112
    assert!(
113
        plist.contains(&format!(":commodity-id \"{c_id}\"")),
114
        "plist must reference commodity; got: {plist}"
115
    );
116
    assert!(
117
        plist.contains(":value -100") || plist.contains(":value 100"),
118
        "plist must contain split values; got: {plist}"
119
    );
120
    assert!(
121
        plist.contains(":prices ()"),
122
        "single-currency tx must have empty :prices; got: {plist}"
123
    );
124

            
125
    // Two physical splits were created; verify both ids appear in plist.
126
    let split_id_count =
127
        sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM splits WHERE tx_id = $1")
128
            .bind(tx_id)
129
            .fetch_one(&pool)
130
            .await?;
131
    assert_eq!(split_id_count, 2);
132
    let split_id_occurrences = plist.matches(":id \"").count();
133
    // One ":id" for the transaction itself + two for splits
134
    assert!(
135
        split_id_occurrences >= 3,
136
        "expected at least 3 :id occurrences (tx + 2 splits), got {split_id_occurrences}; plist: {plist}"
137
    );
138

            
139
    Ok(())
140
}
141

            
142
#[sqlx::test(migrator = "server::db::MIGRATOR")]
143
async fn get_transaction_cross_currency_contains_prices(pool: PgPool) -> anyhow::Result<()> {
144
    install_pool(&pool);
145
    let user_id = Uuid::new_v4();
146
    seed_user(&pool, user_id).await?;
147
    let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
148
    let eur_id = seed_commodity(user_id, "EUR", "Euro").await?;
149
    let a_id = seed_account(user_id, "Assets").await?;
150
    let b_id = seed_account(user_id, "Expenses").await?;
151

            
152
    // 100 USD → 92 EUR
153
    let payload = format!(
154
        "(:splits ((:from \"{a_id}\" :to \"{b_id}\" \
155
         :from-commodity \"{usd_id}\" :to-commodity \"{eur_id}\" \
156
         :value 100 :to-amount 92)))"
157
    );
158
    let tx_id_str = run_create_transaction_logical(user_id, Some(payload))
159
        .await
160
        .map_err(|e| anyhow::anyhow!("{e}"))?;
161
    let tx_id = Uuid::parse_str(&tx_id_str)?;
162

            
163
    let plist = run_get_transaction_detail(user_id, Some(tx_id_str))
164
        .await
165
        .map_err(|e| anyhow::anyhow!("{e}"))?
166
        .ok_or_else(|| anyhow::anyhow!("get-transaction returned None"))?;
167

            
168
    // Splits present
169
    assert!(
170
        plist.contains(":splits ("),
171
        "plist must have :splits; got: {plist}"
172
    );
173
    assert!(
174
        plist.contains(&format!(":account-id \"{a_id}\"")),
175
        "plist must reference account a; got: {plist}"
176
    );
177

            
178
    // Price row present and correctly linked
179
    assert!(
180
        !plist.contains(":prices ()"),
181
        "cross-currency tx must have non-empty :prices; got: {plist}"
182
    );
183
    assert!(
184
        plist.contains(":commodity-split \""),
185
        "price must carry :commodity-split; got: {plist}"
186
    );
187
    assert!(
188
        plist.contains(":currency-split \""),
189
        "price must carry :currency-split; got: {plist}"
190
    );
191
    assert!(
192
        plist.contains(":value-num "),
193
        "price must carry :value-num; got: {plist}"
194
    );
195
    assert!(
196
        plist.contains(":value-denom "),
197
        "price must carry :value-denom; got: {plist}"
198
    );
199
    assert!(
200
        plist.contains(":date \""),
201
        "price must carry :date; got: {plist}"
202
    );
203

            
204
    // The commodity-split and currency-split UUIDs must match actual split ids.
205
    let split_ids: Vec<Uuid> =
206
        sqlx::query_scalar::<_, Uuid>("SELECT id FROM splits WHERE tx_id = $1 ORDER BY id")
207
            .bind(tx_id)
208
            .fetch_all(&pool)
209
            .await?;
210
    assert_eq!(split_ids.len(), 2, "expected 2 physical splits");
211
    for sid in &split_ids {
212
        assert!(
213
            plist.contains(&sid.to_string()),
214
            "price must reference split {sid}; got: {plist}"
215
        );
216
    }
217

            
218
    Ok(())
219
}
220

            
221
#[sqlx::test(migrator = "server::db::MIGRATOR")]
222
async fn get_transaction_returns_none_for_missing_id(pool: PgPool) -> anyhow::Result<()> {
223
    install_pool(&pool);
224
    let user_id = Uuid::new_v4();
225
    seed_user(&pool, user_id).await?;
226

            
227
    let result = run_get_transaction_detail(user_id, Some(Uuid::new_v4().to_string()))
228
        .await
229
        .map_err(|e| anyhow::anyhow!("{e}"))?;
230
    assert!(
231
        result.is_none(),
232
        "non-existent transaction must return None"
233
    );
234

            
235
    Ok(())
236
}
237

            
238
#[sqlx::test(migrator = "server::db::MIGRATOR")]
239
async fn list_transactions_compact_shape_unchanged(pool: PgPool) -> anyhow::Result<()> {
240
    // Confirms list-transactions still returns the typed pair<transaction> chain
241
    // (compact) and is unaffected by the get-transaction enrichment.
242
    use super::run::run_list_transactions;
243
    use server::command::CmdResult;
244
    use server::command::transaction::ListTransactions;
245

            
246
    install_pool(&pool);
247
    let user_id = Uuid::new_v4();
248
    seed_user(&pool, user_id).await?;
249
    let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
250
    let a_id = seed_account(user_id, "Assets").await?;
251
    let b_id = seed_account(user_id, "Expenses").await?;
252

            
253
    let payload = format!(
254
        "(:splits ((:from \"{a_id}\" :to \"{b_id}\" \
255
         :from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 50)))"
256
    );
257
    run_create_transaction_logical(user_id, Some(payload))
258
        .await
259
        .map_err(|e| anyhow::anyhow!("{e}"))?;
260

            
261
    // list-transactions via server: only Transaction entities in TaggedTransactions
262
    let result = ListTransactions::new().user_id(user_id).run().await?;
263
    if let Some(CmdResult::TaggedTransactions { entities, .. }) = result {
264
        let tx_count = entities
265
            .iter()
266
1
            .filter(|(e, _, _)| matches!(e, FinanceEntity::Transaction(_)))
267
            .count();
268
        assert_eq!(
269
            tx_count, 1,
270
            "list-transactions must return 1 transaction entity"
271
        );
272
        // No splits or prices mixed into list result
273
        let non_tx = entities
274
            .iter()
275
1
            .filter(|(e, _, _)| !matches!(e, FinanceEntity::Transaction(_)))
276
            .count();
277
        assert_eq!(
278
            non_tx, 0,
279
            "list-transactions must NOT include split/price entities"
280
        );
281
        let amount = entities
282
            .iter()
283
1
            .find_map(|(e, _, amt)| matches!(e, FinanceEntity::Transaction(_)).then_some(amt));
284
        assert_eq!(
285
            amount.and_then(Option::as_deref),
286
            Some("50 USD"),
287
            "list-transactions must carry the computed amount in its dedicated slot"
288
        );
289
    } else {
290
        anyhow::bail!("expected TaggedTransactions from ListTransactions");
291
    }
292

            
293
    // rpc layer: list-transactions entries are (id, note, amount, post_date) tuples
294
    let entries = run_list_transactions(user_id, None)
295
        .await
296
        .map_err(|e| anyhow::anyhow!("{e}"))?;
297
    assert_eq!(
298
        entries.len(),
299
        1,
300
        "rpc list-transactions must return 1 entry"
301
    );
302
    assert_eq!(
303
        entries[0].2.as_deref(),
304
        Some("50 USD"),
305
        "rpc list-transactions entry must expose the computed amount"
306
    );
307

            
308
    Ok(())
309
}
310

            
311
#[sqlx::test(migrator = "server::db::MIGRATOR")]
312
async fn get_transaction_returns_computed_amount(pool: PgPool) -> anyhow::Result<()> {
313
    use server::command::CmdResult;
314
    use server::command::FinanceEntity;
315
    use server::command::transaction::{GetTransaction, ListTransactions};
316

            
317
    install_pool(&pool);
318
    let user_id = Uuid::new_v4();
319
    seed_user(&pool, user_id).await?;
320
    let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
321
    let a_id = seed_account(user_id, "Assets").await?;
322
    let b_id = seed_account(user_id, "Expenses").await?;
323

            
324
    let payload = format!(
325
        "(:splits ((:from \"{a_id}\" :to \"{b_id}\" \
326
         :from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 50)))"
327
    );
328
    run_create_transaction_logical(user_id, Some(payload))
329
        .await
330
        .map_err(|e| anyhow::anyhow!("{e}"))?;
331

            
332
    let tx_id = match ListTransactions::new().user_id(user_id).run().await? {
333
        Some(CmdResult::TaggedTransactions { entities, .. }) => entities
334
            .into_iter()
335
1
            .find_map(|(e, _, _)| match e {
336
1
                FinanceEntity::Transaction(tx) => Some(tx.id),
337
                _ => None,
338
1
            })
339
            .ok_or_else(|| anyhow::anyhow!("no transaction listed"))?,
340
        _ => anyhow::bail!("expected TaggedTransactions"),
341
    };
342

            
343
    // get-transaction must compute the same per-commodity amount as list.
344
    match GetTransaction::new()
345
        .user_id(user_id)
346
        .transaction_id(tx_id)
347
        .run()
348
        .await?
349
    {
350
        Some(CmdResult::TaggedTransactions { entities, .. }) => {
351
1
            let amount = entities.first().and_then(|(_, _, amt)| amt.as_deref());
352
            assert_eq!(
353
                amount,
354
                Some("50 USD"),
355
                "get-transaction must return the aggregated amount"
356
            );
357
        }
358
        other => anyhow::bail!("expected TaggedTransactions, got {other:?}"),
359
    }
360

            
361
    Ok(())
362
}