1
//! Integration tests for ADR-0021 Phase 2b: account balance, transaction list,
2
//! and transaction create leaves migrated to the in-process rpc::Session path.
3
//!
4
//! Gated on the `db` feature. Run via:
5
//!   DATABASE_URL=postgres://… cargo test -p tests-integration --features db
6

            
7
#![cfg(feature = "db")]
8

            
9
use cli_core::render::{WireValue, parse_wire, reparse_list};
10
use nomiscript::{Value, list_to_vec};
11
use rpc::{ScriptCtx, Session};
12
use server::db::DB_POOL;
13
use sqlx::PgPool;
14
use supp_macro::local_db_sqlx_test;
15
use uuid::Uuid;
16

            
17
8
async fn setup() {}
18

            
19
/// Read the formatted value following `key` in a structured plist `Value`,
20
/// tolerating a leading type tag (mirrors the CLI `plist_field` helper).
21
6
fn plist_str(value: &Value, key: &str) -> Option<String> {
22
6
    let items = list_to_vec(value)?;
23
6
    let pos = items
24
6
        .iter()
25
19
        .position(|v| matches!(v, Value::Symbol(s) if s == key))?;
26
6
    match items.get(pos + 1)? {
27
5
        Value::String(s) => Some(s.clone()),
28
        Value::Symbol(s) => Some(s.clone()),
29
1
        Value::Number(n) => Some(n.to_string()),
30
        _ => None,
31
    }
32
6
}
33

            
34
/// Render the migrated `transaction list` output lines from a real wire reply,
35
/// reading fields directly off each structured `Value::Pair` element — the same
36
/// path `print_transaction_list` takes in the CLI binary.
37
1
fn render_transaction_lines(wire: &str) -> Vec<String> {
38
1
    let WireValue::Value(Value::String(raw)) = parse_wire(wire).expect("parse_wire") else {
39
        return Vec::new();
40
    };
41
1
    let outer = reparse_list(&raw).expect("reparse outer");
42
1
    let Some(elements) = list_to_vec(&outer) else {
43
        return Vec::new();
44
    };
45
1
    elements
46
1
        .iter()
47
1
        .map(|el| {
48
1
            let id = plist_str(el, ":id").expect(":id");
49
1
            let post_date = plist_str(el, ":post-date").expect(":post-date");
50
1
            let note = plist_str(el, ":note").unwrap_or_default();
51
1
            let label = if note.is_empty() { id } else { note };
52
1
            format!("{label} - {post_date}")
53
1
        })
54
1
        .collect()
55
1
}
56

            
57
8
async fn insert_test_user(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
58
8
    sqlx::query!(
59
        "INSERT INTO users (
60
            id, user_name, email, photo, verified, user_password,
61
            user_role, db_name, created_at
62
        ) VALUES (
63
            $1, 'p2b-test-user', 'p2b-test@example.com', 'default.png',
64
            FALSE, 'irrelevant', 'user', 'p2b-test', NOW()
65
        )",
66
        id
67
    )
68
8
    .execute(pool)
69
8
    .await?;
70
8
    Ok(())
71
8
}
72

            
73
22
fn extract_uuid(response: &str) -> Option<String> {
74
22
    let start = response.find(":value \"")?;
75
22
    let after = &response[start + 8..];
76
22
    let end = after.find('"')?;
77
22
    Some(after[..end].to_string())
78
22
}
79

            
80
#[local_db_sqlx_test]
81
async fn get_balances_returns_balance_plist_for_single_currency(
82
    pool: PgPool,
83
) -> anyhow::Result<()> {
84
    let user_id = Uuid::new_v4();
85
    insert_test_user(&pool, user_id).await?;
86
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
87

            
88
    let resp = session
89
        .handle_form("(:id 1 :form (create-commodity \"USD\" \"US Dollar\"))")
90
        .await;
91
    let usd = extract_uuid(&resp).expect("usd id");
92

            
93
    let resp = session
94
        .handle_form("(:id 2 :form (create-account \"Wallet\" \"\"))")
95
        .await;
96
    let wallet = extract_uuid(&resp).expect("wallet id");
97

            
98
    let resp = session
99
        .handle_form("(:id 3 :form (create-account \"Sink\" \"\"))")
100
        .await;
101
    let sink = extract_uuid(&resp).expect("sink id");
102

            
103
    let tx = format!(
104
        "(:id 4 :form (create-transaction \"(:post-date \\\"2026-01-01T00:00:00Z\\\" \
105
         :splits ((:account-id \\\"{wallet}\\\" :commodity-id \\\"{usd}\\\" :value -50) \
106
                  (:account-id \\\"{sink}\\\" :commodity-id \\\"{usd}\\\" :value 50)))\"))"
107
    );
108
    session.handle_form(&tx).await;
109

            
110
    let resp = session
111
        .handle_form(&format!("(:id 5 :form (get-balances \"{wallet}\"))"))
112
        .await;
113
    assert!(!resp.contains(":error"), "got error: {resp}");
114
    assert!(
115
        resp.contains(":value-num"),
116
        "expected balance plist, got: {resp}"
117
    );
118
    assert!(
119
        resp.contains("-50"),
120
        "expected wallet balance -50, got: {resp}"
121
    );
122
    assert!(resp.contains("USD"), "expected USD symbol, got: {resp}");
123
}
124

            
125
#[local_db_sqlx_test]
126
async fn get_balances_empty_for_account_with_no_splits(pool: PgPool) -> anyhow::Result<()> {
127
    let user_id = Uuid::new_v4();
128
    insert_test_user(&pool, user_id).await?;
129
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
130

            
131
    let resp = session
132
        .handle_form("(:id 6 :form (get-balances \"11111111-1111-1111-1111-111111111111\"))")
133
        .await;
134
    assert!(!resp.contains(":error"), "got error: {resp}");
135
    assert!(
136
        resp.contains(":value \"()\""),
137
        "expected empty list, got: {resp}"
138
    );
139
}
140

            
141
#[local_db_sqlx_test]
142
async fn list_transactions_shows_created_transaction(pool: PgPool) -> anyhow::Result<()> {
143
    let user_id = Uuid::new_v4();
144
    insert_test_user(&pool, user_id).await?;
145
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
146

            
147
    let resp = session
148
        .handle_form("(:id 10 :form (create-commodity \"EUR\" \"Euro\"))")
149
        .await;
150
    let eur = extract_uuid(&resp).expect("eur id");
151

            
152
    let resp = session
153
        .handle_form("(:id 11 :form (create-account \"From\" \"\"))")
154
        .await;
155
    let from = extract_uuid(&resp).expect("from id");
156

            
157
    let resp = session
158
        .handle_form("(:id 12 :form (create-account \"To\" \"\"))")
159
        .await;
160
    let to = extract_uuid(&resp).expect("to id");
161

            
162
    let tx = format!(
163
        "(:id 13 :form (create-transaction \"(:post-date \\\"2026-03-10T00:00:00Z\\\" \
164
         :note \\\"p2b-test-note\\\" \
165
         :splits ((:account-id \\\"{from}\\\" :commodity-id \\\"{eur}\\\" :value -200) \
166
                  (:account-id \\\"{to}\\\" :commodity-id \\\"{eur}\\\" :value 200)))\"))"
167
    );
168
    session.handle_form(&tx).await;
169

            
170
    let resp = session
171
        .handle_form("(:id 14 :form (list-transactions \"\"))")
172
        .await;
173
    assert!(!resp.contains(":error"), "got error: {resp}");
174

            
175
    // Render the populated list the way the CLI does — each element is a
176
    // structured `Value::Pair`, NOT a string; reading fields directly must
177
    // produce the legacy `"{note} - {post_date}"` line (regression guard for
178
    // the second-reparse bug that errored on any non-empty list).
179
    let lines = render_transaction_lines(&resp);
180
    assert_eq!(lines.len(), 1, "expected one rendered line, got: {lines:?}");
181
    assert_eq!(
182
        lines[0], "p2b-test-note - 2026-03-10T00:00:00+00:00",
183
        "rendered transaction line must match legacy format"
184
    );
185

            
186
    assert!(
187
        resp.contains("p2b-test-note"),
188
        "expected note in transaction entity, got: {resp}"
189
    );
190
    assert!(
191
        resp.contains("2026-03-10"),
192
        "expected post-date in transaction entity, got: {resp}"
193
    );
194
}
195

            
196
#[local_db_sqlx_test]
197
async fn create_transaction_single_currency_returns_uuid(pool: PgPool) -> anyhow::Result<()> {
198
    let user_id = Uuid::new_v4();
199
    insert_test_user(&pool, user_id).await?;
200
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
201

            
202
    let resp = session
203
        .handle_form("(:id 20 :form (create-commodity \"GBP\" \"British Pound\"))")
204
        .await;
205
    let gbp = extract_uuid(&resp).expect("gbp id");
206

            
207
    let resp = session
208
        .handle_form("(:id 21 :form (create-account \"AccA\" \"\"))")
209
        .await;
210
    let acct_a = extract_uuid(&resp).expect("acct_a id");
211

            
212
    let resp = session
213
        .handle_form("(:id 22 :form (create-account \"AccB\" \"\"))")
214
        .await;
215
    let acct_b = extract_uuid(&resp).expect("acct_b id");
216

            
217
    let tx = format!(
218
        "(:id 23 :form (create-transaction \"(:post-date \\\"2026-04-01T00:00:00Z\\\" \
219
         :splits ((:account-id \\\"{acct_a}\\\" :commodity-id \\\"{gbp}\\\" :value -75) \
220
                  (:account-id \\\"{acct_b}\\\" :commodity-id \\\"{gbp}\\\" :value 75)))\"))"
221
    );
222
    let resp = session.handle_form(&tx).await;
223
    assert!(!resp.contains(":error"), "got error: {resp}");
224
    assert!(
225
        resp.contains(":value \""),
226
        "expected uuid in :value, got: {resp}"
227
    );
228
    let tx_id = extract_uuid(&resp).expect("tx uuid");
229
    assert_eq!(tx_id.len(), 36, "expected uuid format, got: {tx_id}");
230
}
231

            
232
#[local_db_sqlx_test]
233
async fn create_transaction_cross_currency_with_prices_returns_uuid(
234
    pool: PgPool,
235
) -> anyhow::Result<()> {
236
    let user_id = Uuid::new_v4();
237
    insert_test_user(&pool, user_id).await?;
238
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
239

            
240
    let resp = session
241
        .handle_form("(:id 30 :form (create-commodity \"USD\" \"US Dollar\"))")
242
        .await;
243
    let usd = extract_uuid(&resp).expect("usd id");
244

            
245
    let resp = session
246
        .handle_form("(:id 31 :form (create-commodity \"JPY\" \"Japanese Yen\"))")
247
        .await;
248
    let jpy = extract_uuid(&resp).expect("jpy id");
249

            
250
    let resp = session
251
        .handle_form("(:id 32 :form (create-account \"UsdAcct\" \"\"))")
252
        .await;
253
    let usd_acct = extract_uuid(&resp).expect("usd_acct id");
254

            
255
    let resp = session
256
        .handle_form("(:id 33 :form (create-account \"JpyAcct\" \"\"))")
257
        .await;
258
    let jpy_acct = extract_uuid(&resp).expect("jpy_acct id");
259

            
260
    let from_split_id = Uuid::new_v4();
261
    let to_split_id = Uuid::new_v4();
262

            
263
    let tx = format!(
264
        "(:id 34 :form (create-transaction \"(:post-date \\\"2026-05-01T00:00:00Z\\\" \
265
         :splits ((:account-id \\\"{usd_acct}\\\" :commodity-id \\\"{usd}\\\" \
266
                   :id \\\"{from_split_id}\\\" :value -100) \
267
                  (:account-id \\\"{jpy_acct}\\\" :commodity-id \\\"{jpy}\\\" \
268
                   :id \\\"{to_split_id}\\\" :value 15000)) \
269
         :prices ((:commodity-id \\\"{jpy}\\\" :currency-id \\\"{usd}\\\" \
270
                   :commodity-split \\\"{to_split_id}\\\" :currency-split \\\"{from_split_id}\\\" \
271
                   :value-num 100 :value-denom 15000)))\"  ))"
272
    );
273
    let resp = session.handle_form(&tx).await;
274
    assert!(!resp.contains(":error"), "got error: {resp}");
275
    assert!(
276
        resp.contains(":value \""),
277
        "expected uuid in :value, got: {resp}"
278
    );
279
    let tx_id = extract_uuid(&resp).expect("tx uuid");
280
    assert_eq!(tx_id.len(), 36, "expected uuid format, got: {tx_id}");
281
}
282

            
283
#[local_db_sqlx_test]
284
async fn get_balances_after_transaction_reflects_split(pool: PgPool) -> anyhow::Result<()> {
285
    let user_id = Uuid::new_v4();
286
    insert_test_user(&pool, user_id).await?;
287
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
288

            
289
    let resp = session
290
        .handle_form("(:id 40 :form (create-commodity \"CHF\" \"Swiss Franc\"))")
291
        .await;
292
    let chf = extract_uuid(&resp).expect("chf id");
293

            
294
    let resp = session
295
        .handle_form("(:id 41 :form (create-account \"Source\" \"\"))")
296
        .await;
297
    let source = extract_uuid(&resp).expect("source id");
298

            
299
    let resp = session
300
        .handle_form("(:id 42 :form (create-account \"Dest\" \"\"))")
301
        .await;
302
    let dest = extract_uuid(&resp).expect("dest id");
303

            
304
    let tx = format!(
305
        "(:id 43 :form (create-transaction \"(:post-date \\\"2026-06-01T00:00:00Z\\\" \
306
         :splits ((:account-id \\\"{source}\\\" :commodity-id \\\"{chf}\\\" :value -300) \
307
                  (:account-id \\\"{dest}\\\" :commodity-id \\\"{chf}\\\" :value 300)))\"))"
308
    );
309
    session.handle_form(&tx).await;
310

            
311
    let resp = session
312
        .handle_form(&format!("(:id 44 :form (get-balances \"{source}\"))"))
313
        .await;
314
    assert!(!resp.contains(":error"), "got error: {resp}");
315
    assert!(
316
        resp.contains("-300"),
317
        "expected -300 balance for source, got: {resp}"
318
    );
319
    assert!(resp.contains("CHF"), "expected CHF symbol, got: {resp}");
320

            
321
    let resp2 = session
322
        .handle_form(&format!("(:id 45 :form (get-balances \"{dest}\"))"))
323
        .await;
324
    assert!(!resp2.contains(":error"), "got error: {resp2}");
325
    assert!(
326
        resp2.contains("300"),
327
        "expected 300 balance for dest, got: {resp2}"
328
    );
329
}
330

            
331
/// Render the migrated `account balance` output from a real wire reply,
332
/// mirroring `print_balance_list`: empty list / single zero balance →
333
/// `0 NONE (No transaction yet)`; single non-zero → `{value} {symbol} ({name})`.
334
2
fn render_balance(wire: &str) -> String {
335
2
    let WireValue::Value(value) = parse_wire(wire).expect("parse_wire");
336
2
    let raw = match value {
337
2
        Value::String(s) => s,
338
        _ => return "0 NONE (No transaction yet)".to_string(),
339
    };
340
2
    let outer = reparse_list(&raw).expect("reparse outer");
341
2
    let elements = list_to_vec(&outer).unwrap_or_default();
342
2
    if elements.is_empty() {
343
1
        return "0 NONE (No transaction yet)".to_string();
344
1
    }
345
1
    let first = &elements[0];
346
1
    let inner_str = match first {
347
1
        Value::String(s) => s.clone(),
348
        _ => panic!("balance element must be a string, got: {first:?}"),
349
    };
350
1
    let inner = reparse_list(&inner_str).expect("reparse inner");
351
1
    let num = plist_str(&inner, ":value-num").expect(":value-num");
352
1
    let symbol = plist_str(&inner, ":symbol").expect(":symbol");
353
1
    let name = plist_str(&inner, ":name").expect(":name");
354
1
    if elements.len() == 1 && num == "0" {
355
1
        "0 NONE (No transaction yet)".to_string()
356
    } else {
357
        format!("{num} {symbol} ({name})")
358
    }
359
2
}
360

            
361
#[local_db_sqlx_test]
362
async fn account_balance_no_splits_prints_no_transaction_sentinel(
363
    pool: PgPool,
364
) -> anyhow::Result<()> {
365
    let user_id = Uuid::new_v4();
366
    insert_test_user(&pool, user_id).await?;
367
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
368

            
369
    let resp = session
370
        .handle_form("(:id 50 :form (create-account \"Fresh\" \"\"))")
371
        .await;
372
    let fresh = extract_uuid(&resp).expect("fresh id");
373

            
374
    let resp = session
375
        .handle_form(&format!("(:id 51 :form (get-balances \"{fresh}\"))"))
376
        .await;
377
    assert!(!resp.contains(":error"), "got error: {resp}");
378
    assert_eq!(
379
        render_balance(&resp),
380
        "0 NONE (No transaction yet)",
381
        "no-splits account must render the legacy sentinel"
382
    );
383
}
384

            
385
#[local_db_sqlx_test]
386
async fn account_balance_zero_single_currency_prints_sentinel(pool: PgPool) -> anyhow::Result<()> {
387
    let user_id = Uuid::new_v4();
388
    insert_test_user(&pool, user_id).await?;
389
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
390

            
391
    let resp = session
392
        .handle_form("(:id 60 :form (create-commodity \"DKK\" \"Danish Krone\"))")
393
        .await;
394
    let dkk = extract_uuid(&resp).expect("dkk id");
395
    let resp = session
396
        .handle_form("(:id 61 :form (create-account \"Zeroed\" \"\"))")
397
        .await;
398
    let zeroed = extract_uuid(&resp).expect("zeroed id");
399
    let resp = session
400
        .handle_form("(:id 62 :form (create-account \"Other\" \"\"))")
401
        .await;
402
    let other = extract_uuid(&resp).expect("other id");
403

            
404
    // Two offsetting transactions leave `zeroed` at a net zero single-currency
405
    // balance — legacy prints the sentinel, not `0 DKK (Danish Krone)`.
406
    let tx_in = format!(
407
        "(:id 63 :form (create-transaction \"(:post-date \\\"2026-07-01T00:00:00Z\\\" \
408
         :splits ((:account-id \\\"{zeroed}\\\" :commodity-id \\\"{dkk}\\\" :value 100) \
409
                  (:account-id \\\"{other}\\\" :commodity-id \\\"{dkk}\\\" :value -100)))\"))"
410
    );
411
    session.handle_form(&tx_in).await;
412
    let tx_out = format!(
413
        "(:id 64 :form (create-transaction \"(:post-date \\\"2026-07-02T00:00:00Z\\\" \
414
         :splits ((:account-id \\\"{zeroed}\\\" :commodity-id \\\"{dkk}\\\" :value -100) \
415
                  (:account-id \\\"{other}\\\" :commodity-id \\\"{dkk}\\\" :value 100)))\"))"
416
    );
417
    session.handle_form(&tx_out).await;
418

            
419
    let resp = session
420
        .handle_form(&format!("(:id 65 :form (get-balances \"{zeroed}\"))"))
421
        .await;
422
    assert!(!resp.contains(":error"), "got error: {resp}");
423
    assert_eq!(
424
        render_balance(&resp),
425
        "0 NONE (No transaction yet)",
426
        "zero single-currency balance must render the legacy sentinel"
427
    );
428
}