1
//! End-to-end integration: nomiscript form -> rpc::Session -> server::command::*
2
//! -> real Postgres.
3
//!
4
//! Gated on the `db` feature. Run via:
5
//!   DATABASE_URL=postgres://… cargo test -p tests-integration --features db
6
//!
7
//! Without `--features db`, the file compiles to nothing (the entire module
8
//! is `#![cfg(feature = "db")]`-gated), so default `cargo test --workspace`
9
//! doesn't try to connect to Postgres.
10

            
11
#![cfg(feature = "db")]
12

            
13
use chrono::Utc;
14
use rpc::{ScriptCtx, Session};
15
use server::db::DB_POOL;
16
use sqlx::PgPool;
17
use supp_macro::local_db_sqlx_test;
18
use uuid::Uuid;
19

            
20
46
async fn setup() {}
21

            
22
43
async fn insert_test_user(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
23
43
    sqlx::query!(
24
        "INSERT INTO users (
25
            id, user_name, email, photo, verified, user_password,
26
            user_role, db_name, created_at
27
        ) VALUES (
28
            $1, 'rpc-test-user', 'rpc-test@example.com', 'default.png',
29
            FALSE, 'irrelevant', 'user', 'rpc-test', NOW()
30
        )",
31
        id
32
    )
33
43
    .execute(pool)
34
43
    .await?;
35
43
    Ok(())
36
43
}
37

            
38
#[local_db_sqlx_test]
39
async fn list_accounts_returns_empty_for_fresh_user(pool: PgPool) -> anyhow::Result<()> {
40
    let user_id = Uuid::new_v4();
41
    insert_test_user(&pool, user_id).await?;
42

            
43
    // Async Session: handle_form awaits on the test runtime directly. No
44
    // spawn_blocking, no nested-runtime panic — sqlx::test's single-thread
45
    // runtime drives both the test and the wasmtime async dispatch on the
46
    // same thread that has DB_POOL set.
47
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
48
    let response = session.handle_form("(:id 1 :form (list-accounts))").await;
49

            
50
    assert!(
51
        response.contains(":id 1"),
52
        "expected response to carry id, got: {response}"
53
    );
54
    // P4 A5 wire shape: list-accounts returns `pair<account>`. The
55
    // empty list rides nomi-eval's anyref return slot as null; the
56
    // decoder surfaces null `PairRef(_)` as `"()"`. No more
57
    // `:accounts ()` plist wrapper — the typed-entity shape is the
58
    // single source of truth.
59
    assert!(
60
        response.contains(":value \"()\""),
61
        "expected empty accounts list, got: {response}"
62
    );
63
}
64

            
65
#[local_db_sqlx_test]
66
async fn get_version_returns_baked_hash(pool: PgPool) -> anyhow::Result<()> {
67
    setup().await;
68
    let _ = pool;
69
    let user_id = Uuid::new_v4();
70

            
71
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
72
    let response = session.handle_form("(:id 7 :form (get-version))").await;
73

            
74
    assert!(response.starts_with("(:id 7 :value \""), "got: {response}");
75
    assert!(response.ends_with("\")"), "got: {response}");
76
}
77

            
78
#[local_db_sqlx_test]
79
async fn list_commodities_returns_empty_for_fresh_user(pool: PgPool) -> anyhow::Result<()> {
80
    let user_id = Uuid::new_v4();
81
    insert_test_user(&pool, user_id).await?;
82

            
83
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
84
    let response = session
85
        .handle_form("(:id 3 :form (list-commodities))")
86
        .await;
87

            
88
    assert!(response.contains(":id 3"), "got: {response}");
89
    assert!(
90
        response.contains(":value \"()\""),
91
        "expected empty commodities list, got: {response}"
92
    );
93
}
94

            
95
#[local_db_sqlx_test]
96
async fn list_transactions_returns_empty_for_fresh_user(pool: PgPool) -> anyhow::Result<()> {
97
    let user_id = Uuid::new_v4();
98
    insert_test_user(&pool, user_id).await?;
99

            
100
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
101
    let response = session
102
        .handle_form("(:id 4 :form (list-transactions \"\"))")
103
        .await;
104

            
105
    assert!(response.contains(":id 4"), "got: {response}");
106
    // Pagination metadata (:has-more, cursor) moved out of the
107
    // list-X surface during the typed-entity migration. Plain pair
108
    // chain only — pagination natives land in a follow-up slice
109
    // once the rpc protocol carries cursor opt-args.
110
    assert!(
111
        response.contains(":value \"()\""),
112
        "expected empty transactions list, got: {response}"
113
    );
114
}
115

            
116
#[local_db_sqlx_test]
117
async fn list_ssh_keys_returns_empty_for_fresh_user(pool: PgPool) -> anyhow::Result<()> {
118
    let user_id = Uuid::new_v4();
119
    insert_test_user(&pool, user_id).await?;
120

            
121
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
122
    let response = session.handle_form("(:id 5 :form (list-ssh-keys))").await;
123

            
124
    assert!(response.contains(":id 5"), "got: {response}");
125
    assert!(
126
        response.contains(":value \"()\""),
127
        "expected empty ssh-keys list, got: {response}"
128
    );
129
}
130

            
131
#[local_db_sqlx_test]
132
async fn get_commodity_with_unknown_uuid_returns_error_envelope(
133
    pool: PgPool,
134
) -> anyhow::Result<()> {
135
    // A uuid arg is an id lookup; a uuid with no matching row surfaces a runtime
136
    // error envelope (GetCommodity uses fetch_one → RowNotFound). The symbol
137
    // fallback only applies to NON-uuid args, so uuid behavior is unchanged.
138
    let user_id = Uuid::new_v4();
139
    insert_test_user(&pool, user_id).await?;
140

            
141
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
142
    let response = session
143
        .handle_form("(:id 6 :form (get-commodity \"00000000-0000-0000-0000-000000000000\"))")
144
        .await;
145

            
146
    assert!(response.contains(":id 6"), "got: {response}");
147
    assert!(
148
        response.contains(":error (:code runtime") && response.contains("get-commodity"),
149
        "expected runtime error envelope, got: {response}"
150
    );
151
}
152

            
153
#[local_db_sqlx_test]
154
async fn get_commodity_resolves_by_symbol(pool: PgPool) -> anyhow::Result<()> {
155
    // get-commodity accepts a symbol (not just a uuid), mirroring get-account's
156
    // name fallback — so templates can write `(get-commodity "USD")`.
157
    let user_id = Uuid::new_v4();
158
    insert_test_user(&pool, user_id).await?;
159
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
160

            
161
    let resp = session
162
        .handle_form("(:id 1 :form (create-commodity \"USD\" \"US Dollar\"))")
163
        .await;
164
    let created = extract_id_field(&resp, "commodity-id").expect("created commodity uuid");
165

            
166
    // Look it up by symbol; the resolved entity's id must equal the created id.
167
    let response = session
168
        .handle_form("(:id 2 :form (commodity-id (get-commodity \"USD\")))")
169
        .await;
170
    assert!(response.contains(":id 2"), "got: {response}");
171
    assert!(
172
        response.contains(&created),
173
        "symbol lookup must resolve to the created uuid {created}, got: {response}"
174
    );
175

            
176
    // Case-insensitive.
177
    let response = session
178
        .handle_form("(:id 3 :form (commodity-id (get-commodity \"usd\")))")
179
        .await;
180
    assert!(
181
        response.contains(&created),
182
        "symbol lookup must be case-insensitive, got: {response}"
183
    );
184

            
185
    // An unknown symbol resolves to nil (not a trap, not a wrong match).
186
    let response = session
187
        .handle_form("(:id 4 :form (get-commodity \"NOPE\"))")
188
        .await;
189
    assert!(
190
        response.contains(":value NIL"),
191
        "unknown symbol must be NIL, got: {response}"
192
    );
193
}
194

            
195
#[local_db_sqlx_test]
196
async fn get_commodity_ambiguous_symbol_errors(pool: PgPool) -> anyhow::Result<()> {
197
    // Symbols aren't unique in the schema. Two commodities sharing a symbol must
198
    // make a symbol lookup fail loudly rather than silently bind to one — a
199
    // wrong-currency draft is worse than an error.
200
    let user_id = Uuid::new_v4();
201
    insert_test_user(&pool, user_id).await?;
202
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
203

            
204
    session
205
        .handle_form("(:id 1 :form (create-commodity \"DUP\" \"First\"))")
206
        .await;
207
    session
208
        .handle_form("(:id 2 :form (create-commodity \"DUP\" \"Second\"))")
209
        .await;
210

            
211
    let response = session
212
        .handle_form("(:id 3 :form (get-commodity \"DUP\"))")
213
        .await;
214
    assert!(
215
        response.contains(":error (:code runtime") && response.contains("ambiguous"),
216
        "ambiguous symbol must error, got: {response}"
217
    );
218
}
219

            
220
#[local_db_sqlx_test]
221
async fn get_account_with_unknown_uuid_returns_empty_envelope(pool: PgPool) -> anyhow::Result<()> {
222
    let user_id = Uuid::new_v4();
223
    insert_test_user(&pool, user_id).await?;
224

            
225
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
226
    let response = session
227
        .handle_form("(:id 7 :form (get-account \"00000000-0000-0000-0000-000000000000\"))")
228
        .await;
229

            
230
    assert!(response.contains(":id 7"), "got: {response}");
231
    // GetAccount returns Option<TaggedEntity> → None for unknown uuid
232
    // → host fn returns `Option<Rooted<StructRef>>` as None →
233
    // decode_eval_result surfaces null `EntityRef(_)` as `NIL`. Old
234
    // `:accounts ()` plist shape retired with A5.
235
    assert!(
236
        response.contains(":value NIL"),
237
        "expected NIL for unknown account, got: {response}"
238
    );
239
}
240

            
241
#[local_db_sqlx_test]
242
async fn get_account_with_unknown_name_returns_empty_envelope(pool: PgPool) -> anyhow::Result<()> {
243
    let user_id = Uuid::new_v4();
244
    insert_test_user(&pool, user_id).await?;
245

            
246
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
247
    let response = session
248
        .handle_form("(:id 8 :form (get-account \"never-existed\"))")
249
        .await;
250

            
251
    assert!(response.contains(":id 8"), "got: {response}");
252
    assert!(
253
        response.contains(":value NIL"),
254
        "expected NIL for unknown account (uuid parse fell through to name lookup), got: {response}"
255
    );
256
}
257

            
258
#[local_db_sqlx_test]
259
async fn activity_report_for_fresh_user_returns_well_formed_envelope(
260
    pool: PgPool,
261
) -> anyhow::Result<()> {
262
    let user_id = Uuid::new_v4();
263
    insert_test_user(&pool, user_id).await?;
264

            
265
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
266
    let response = session
267
        .handle_form(
268
            "(:id 30 :form (activity-report \"2026-01-01T00:00:00Z\" \"2026-12-31T23:59:59Z\"))",
269
        )
270
        .await;
271

            
272
    assert!(response.contains(":id 30"), "got: {response}");
273
    assert!(
274
        response.contains(":activity-report"),
275
        "expected :activity-report head, got: {response}"
276
    );
277
    assert!(
278
        response.contains(":date-from \\\"2026-01-01T00:00:00+00:00\\\""),
279
        "expected date-from echoed, got: {response}"
280
    );
281
}
282

            
283
#[local_db_sqlx_test]
284
async fn category_breakdown_for_fresh_user_returns_well_formed_envelope(
285
    pool: PgPool,
286
) -> anyhow::Result<()> {
287
    let user_id = Uuid::new_v4();
288
    insert_test_user(&pool, user_id).await?;
289

            
290
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
291
    let response = session
292
        .handle_form(
293
            "(:id 31 :form (category-breakdown \"2026-01-01T00:00:00Z\" \"2026-12-31T23:59:59Z\"))",
294
        )
295
        .await;
296

            
297
    assert!(response.contains(":id 31"), "got: {response}");
298
    assert!(
299
        response.contains(":category-breakdown"),
300
        "expected :category-breakdown head, got: {response}"
301
    );
302
}
303

            
304
#[local_db_sqlx_test]
305
async fn update_transaction_for_unknown_id_returns_error(pool: PgPool) -> anyhow::Result<()> {
306
    let user_id = Uuid::new_v4();
307
    insert_test_user(&pool, user_id).await?;
308

            
309
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
310
    let response = session
311
        .handle_form(
312
            "(:id 37 :form (update-transaction \"(:transaction-id \\\"00000000-0000-0000-0000-000000000000\\\" :note \\\"edited\\\")\"))",
313
        )
314
        .await;
315

            
316
    assert!(response.contains(":id 37"), "got: {response}");
317
    assert!(
318
        response.contains(":error (:code runtime") && response.contains("update-transaction"),
319
        "expected update-transaction error envelope, got: {response}"
320
    );
321
}
322

            
323
#[local_db_sqlx_test]
324
async fn create_transaction_with_well_formed_payload_round_trips(
325
    pool: PgPool,
326
) -> anyhow::Result<()> {
327
    // Full end-to-end on the S-expr compound payload. We build the
328
    // pre-requisites (commodity + two accounts) via create-commodity /
329
    // create-account, then call create-transaction with two balancing
330
    // splits, then assert the transaction shows up in list-transactions
331
    // with the supplied note.
332
    let user_id = Uuid::new_v4();
333
    insert_test_user(&pool, user_id).await?;
334

            
335
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
336

            
337
    let resp = session
338
        .handle_form("(:id 32 :form (create-commodity \"BAR\" \"Bar Coin\"))")
339
        .await;
340
    let comm = extract_id_field(&resp, "commodity-id").expect("commodity-id");
341

            
342
    let resp = session
343
        .handle_form("(:id 33 :form (create-account \"From\" \"\"))")
344
        .await;
345
    let from = extract_id_field(&resp, "account-id").expect("from account-id");
346

            
347
    let resp = session
348
        .handle_form("(:id 34 :form (create-account \"To\" \"\"))")
349
        .await;
350
    let to = extract_id_field(&resp, "account-id").expect("to account-id");
351

            
352
    let form = format!(
353
        "(:id 35 :form (create-transaction \"(:post-date \\\"2026-01-15T00:00:00Z\\\" \
354
         :note \\\"rpc-test-tx\\\" \
355
         :splits ((:account-id \\\"{from}\\\" :commodity-id \\\"{comm}\\\" :value -100) \
356
                  (:account-id \\\"{to}\\\" :commodity-id \\\"{comm}\\\" :value 100)))\"))"
357
    );
358
    let create_resp = session.handle_form(&form).await;
359
    assert!(create_resp.contains(":id 35"), "got: {create_resp}");
360
    // Bare uuid string return — single-record write surfaces the
361
    // server-assigned id via :value "<uuid>".
362
    assert!(
363
        create_resp.contains(":value \""),
364
        "expected :value with transaction uuid, got: {create_resp}"
365
    );
366

            
367
    // Verify via the typed accessor surface rather than asserting on
368
    // a plist-style :note key — pair-rendered entity cars decode to
369
    // a placeholder; the note tag lives behind `transaction-note`.
370
    let note_resp = session
371
        .handle_form("(:id 36 :form (transaction-note (car (list-transactions \"\"))))")
372
        .await;
373
    assert!(note_resp.contains(":id 36"), "got: {note_resp}");
374
    assert!(
375
        note_resp.contains("\"rpc-test-tx\""),
376
        "expected new transaction note via accessor, got: {note_resp}"
377
    );
378
}
379

            
380
45
fn extract_id_field(response: &str, _key: &str) -> Option<String> {
381
    // P4 A4 wire change: single-record writes (create-account,
382
    // create-commodity, create-transaction) return bare UUID
383
    // strings rather than `:foo-id "<uuid>"` plists. Pull the
384
    // quoted uuid out of `:value "<uuid>"`. `_key` stays in the
385
    // signature so call sites still document which entity they
386
    // expect — the extraction itself is uniform now.
387
45
    let needle = ":value \"";
388
45
    let start = response.find(needle)? + needle.len();
389
45
    let end = start + response[start..].find('"')?;
390
45
    Some(response[start..end].to_string())
391
45
}
392

            
393
#[local_db_sqlx_test]
394
async fn delete_transaction_unknown_uuid_returns_error(pool: PgPool) -> anyhow::Result<()> {
395
    // Not idempotent: server's DeleteTransaction surfaces an Args error
396
    // when the row isn't there. Native wraps as :error envelope.
397
    let user_id = Uuid::new_v4();
398
    insert_test_user(&pool, user_id).await?;
399

            
400
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
401
    let response = session
402
        .handle_form("(:id 28 :form (delete-transaction \"00000000-0000-0000-0000-000000000000\"))")
403
        .await;
404

            
405
    assert!(response.contains(":id 28"), "got: {response}");
406
    assert!(
407
        response.contains(":error (:code runtime") && response.contains("delete-transaction"),
408
        "expected delete-transaction error envelope, got: {response}"
409
    );
410
}
411

            
412
#[local_db_sqlx_test]
413
async fn get_account_for_manage_unknown_uuid_returns_empty_tree(
414
    pool: PgPool,
415
) -> anyhow::Result<()> {
416
    let user_id = Uuid::new_v4();
417
    insert_test_user(&pool, user_id).await?;
418

            
419
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
420
    let response = session
421
        .handle_form(
422
            "(:id 27 :form (get-account-for-manage \"00000000-0000-0000-0000-000000000000\"))",
423
        )
424
        .await;
425

            
426
    assert!(response.contains(":id 27"), "got: {response}");
427
    assert!(
428
        response.contains(":value NIL"),
429
        "expected NIL on miss, got: {response}"
430
    );
431
}
432

            
433
#[local_db_sqlx_test]
434
async fn list_accounts_for_manage_returns_empty_for_fresh_user(pool: PgPool) -> anyhow::Result<()> {
435
    let user_id = Uuid::new_v4();
436
    insert_test_user(&pool, user_id).await?;
437

            
438
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
439
    let response = session
440
        .handle_form("(:id 26 :form (list-accounts-for-manage))")
441
        .await;
442

            
443
    assert!(response.contains(":id 26"), "got: {response}");
444
    assert!(
445
        response.contains(":value \"()\""),
446
        "expected empty accounts-tree (pair shape), got: {response}"
447
    );
448
}
449

            
450
#[local_db_sqlx_test]
451
async fn verify_user_password_unknown_email_returns_nil(pool: PgPool) -> anyhow::Result<()> {
452
    let user_id = Uuid::new_v4();
453
    insert_test_user(&pool, user_id).await?;
454

            
455
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
456
    let response = session
457
        .handle_form("(:id 25 :form (verify-user-password \"nobody@example.invalid\" \"wrong\"))")
458
        .await;
459

            
460
    assert!(response.contains(":id 25"), "got: {response}");
461
    assert!(
462
        response.contains(":value NIL"),
463
        "expected NIL for unknown user (Option<String> None → null StringRef), got: {response}"
464
    );
465
}
466

            
467
#[local_db_sqlx_test]
468
async fn create_account_then_list_accounts_surfaces_new_row(pool: PgPool) -> anyhow::Result<()> {
469
    // Single-arg write returns the new entity's UUID as a bare
470
    // `StringRef`. Verify via the typed `(account-name (car ...))`
471
    // composition rather than the old `:accounts ((:name …))`
472
    // plist surface — accessor composition is the P4-A5 contract.
473
    let user_id = Uuid::new_v4();
474
    insert_test_user(&pool, user_id).await?;
475

            
476
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
477
    let create_resp = session
478
        .handle_form("(:id 23 :form (create-account \"rpc-test-account\" \"\"))")
479
        .await;
480
    assert!(create_resp.contains(":id 23"), "got: {create_resp}");
481
    // Bare UUID string return — single-record write surfaces the
482
    // server-assigned id as a quoted scalar.
483
    assert!(
484
        create_resp.contains(":value \""),
485
        "expected :value with uuid string, got: {create_resp}"
486
    );
487

            
488
    let name_resp = session
489
        .handle_form("(:id 24 :form (account-name (car (list-accounts))))")
490
        .await;
491
    assert!(name_resp.contains(":id 24"), "got: {name_resp}");
492
    assert!(
493
        name_resp.contains("\"rpc-test-account\""),
494
        "expected name accessor to surface tag value, got: {name_resp}"
495
    );
496
}
497

            
498
#[local_db_sqlx_test]
499
async fn set_account_tag_on_missing_account_returns_error(pool: PgPool) -> anyhow::Result<()> {
500
    // First 3-arg StringRef end-to-end test. Compiler emits three byte-
501
    // stream/finish pairs in declaration order (account-id, tag-name,
502
    // tag-value); host pops via FIFO take_arg in matching order. Server
503
    // rejects unknown account_id with CmdError::Args, which we surface
504
    // as the standard :error envelope.
505
    let user_id = Uuid::new_v4();
506
    insert_test_user(&pool, user_id).await?;
507

            
508
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
509
    let response = session
510
        .handle_form(
511
            "(:id 22 :form (set-account-tag \
512
                \"44444444-4444-4444-4444-444444444444\" \
513
                \"name\" \
514
                \"Renamed\"))",
515
        )
516
        .await;
517

            
518
    assert!(response.contains(":id 22"), "got: {response}");
519
    assert!(
520
        response.contains(":error (:code runtime") && response.contains("set-account-tag"),
521
        "expected set-account-tag runtime error envelope, got: {response}"
522
    );
523
}
524

            
525
#[local_db_sqlx_test]
526
async fn create_commodity_then_list_commodities_surfaces_new_row(
527
    pool: PgPool,
528
) -> anyhow::Result<()> {
529
    // Two-arg write returning the new entity's UUID. After create, the
530
    // commodity must show up in list-commodities for the same user with
531
    // the symbol/name tags intact — exercises the write path's
532
    // tag-population side effect.
533
    let user_id = Uuid::new_v4();
534
    insert_test_user(&pool, user_id).await?;
535

            
536
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
537
    let create_resp = session
538
        .handle_form("(:id 20 :form (create-commodity \"FOO\" \"Foo Coin\"))")
539
        .await;
540
    assert!(create_resp.contains(":id 20"), "got: {create_resp}");
541
    assert!(
542
        create_resp.contains(":value \""),
543
        "expected uuid string return, got: {create_resp}"
544
    );
545

            
546
    // Verify the new row via the typed-entity accessor surface
547
    // — `(commodity-symbol (car (list-commodities)))` traverses
548
    // the pair head, downcasts to `EntityRef(Commodity)`, and
549
    // reads field 1 (symbol).
550
    let symbol_resp = session
551
        .handle_form("(:id 21 :form (commodity-symbol (car (list-commodities))))")
552
        .await;
553
    assert!(symbol_resp.contains(":id 21"), "got: {symbol_resp}");
554
    assert!(
555
        symbol_resp.contains("\"FOO\""),
556
        "expected FOO symbol, got: {symbol_resp}"
557
    );
558

            
559
    let name_resp = session
560
        .handle_form("(:id 22 :form (commodity-name (car (list-commodities))))")
561
        .await;
562
    assert!(
563
        name_resp.contains("\"Foo Coin\""),
564
        "expected commodity name, got: {name_resp}"
565
    );
566
}
567

            
568
#[local_db_sqlx_test]
569
async fn set_config_then_get_config_round_trips_value(pool: PgPool) -> anyhow::Result<()> {
570
    // First multi-arg StringRef end-to-end test: compiler emits two
571
    // byte-stream/finish pairs in declaration order, host pops via FIFO
572
    // take_arg. Round-trip through Postgres confirms the args queue
573
    // preserves order (name first, value second).
574
    let user_id = Uuid::new_v4();
575
    insert_test_user(&pool, user_id).await?;
576

            
577
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
578

            
579
    let set_resp = session
580
        .handle_form("(:id 18 :form (set-config \"rpc-test-key\" \"hello-world\"))")
581
        .await;
582
    assert!(set_resp.contains(":id 18"), "got: {set_resp}");
583
    // P4 A4 bool returns surface as i32. set-config returns 1 on
584
    // success rather than the old plist `t` symbol.
585
    assert!(
586
        set_resp.contains(":value 1"),
587
        "expected set-config to return :value 1, got: {set_resp}"
588
    );
589

            
590
    let get_resp = session
591
        .handle_form("(:id 19 :form (get-config \"rpc-test-key\"))")
592
        .await;
593
    assert!(get_resp.contains(":id 19"), "got: {get_resp}");
594
    assert!(
595
        get_resp.contains("(:config-value \\\"hello-world\\\")"),
596
        "expected hello-world inside :config-value plist, got: {get_resp}"
597
    );
598
}
599

            
600
#[local_db_sqlx_test]
601
async fn config_field_is_case_insensitively_unique(pool: PgPool) -> anyhow::Result<()> {
602
    // The lower(field) unique index (migration 0006) must forbid case-variant
603
    // duplicates so reads (lower(field) = lower($1)) are unambiguous.
604
    sqlx::query(
605
        "INSERT INTO config (id, field, contents) VALUES (gen_random_uuid(), 'Theme', 'a')",
606
    )
607
    .execute(&pool)
608
    .await?;
609
    let dup = sqlx::query(
610
        "INSERT INTO config (id, field, contents) VALUES (gen_random_uuid(), 'theme', 'b')",
611
    )
612
    .execute(&pool)
613
    .await;
614
    assert!(
615
        dup.is_err(),
616
        "a case-variant duplicate config field must be rejected by the unique index"
617
    );
618
}
619

            
620
#[local_db_sqlx_test]
621
async fn remove_ssh_key_idempotent_for_unknown_fingerprint(pool: PgPool) -> anyhow::Result<()> {
622
    // First write op exercised end-to-end. RemoveSshKey is idempotent on
623
    // the wire: server returns Bool(true) whether or not the row existed.
624
    // Fresh user has no keys, so this surfaces as :value "t" — same shape
625
    // a successful deletion would take.
626
    let user_id = Uuid::new_v4();
627
    insert_test_user(&pool, user_id).await?;
628

            
629
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
630
    let response = session
631
        .handle_form("(:id 17 :form (remove-ssh-key \"SHA256:never-registered\"))")
632
        .await;
633

            
634
    assert!(response.contains(":id 17"), "got: {response}");
635
    assert!(
636
        response.contains(":value 1"),
637
        "expected idempotent 1 return (i32 bool), got: {response}"
638
    );
639
}
640

            
641
#[local_db_sqlx_test]
642
async fn list_splits_for_account_without_splits_returns_empty(pool: PgPool) -> anyhow::Result<()> {
643
    let user_id = Uuid::new_v4();
644
    insert_test_user(&pool, user_id).await?;
645

            
646
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
647
    let response = session
648
        .handle_form("(:id 16 :form (list-splits \"33333333-3333-3333-3333-333333333333\"))")
649
        .await;
650

            
651
    assert!(response.contains(":id 16"), "got: {response}");
652
    assert!(
653
        response.contains(":value \"()\""),
654
        "expected empty splits pair for account with no splits, got: {response}"
655
    );
656
}
657

            
658
#[local_db_sqlx_test]
659
async fn lookup_user_by_ssh_key_for_unknown_fingerprint_returns_nil(
660
    pool: PgPool,
661
) -> anyhow::Result<()> {
662
    let user_id = Uuid::new_v4();
663
    insert_test_user(&pool, user_id).await?;
664

            
665
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
666
    let response = session
667
        .handle_form("(:id 15 :form (lookup-user-by-ssh-key \"SHA256:never-registered\"))")
668
        .await;
669

            
670
    assert!(response.contains(":id 15"), "got: {response}");
671
    assert!(
672
        response.contains(":value NIL"),
673
        "expected NIL for unknown fingerprint (Option<String> None), got: {response}"
674
    );
675
}
676

            
677
#[local_db_sqlx_test]
678
async fn get_config_for_missing_key_returns_nil(pool: PgPool) -> anyhow::Result<()> {
679
    // An absent key is "not set", returned as a successful `(:config-value nil)`
680
    // — NOT a hard error/trap. Absent (nil) stays distinct from a key holding
681
    // the empty string (""), and a missing key no longer traps the guest.
682
    let user_id = Uuid::new_v4();
683
    insert_test_user(&pool, user_id).await?;
684

            
685
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
686
    let response = session
687
        .handle_form("(:id 13 :form (get-config \"never-set-this-key\"))")
688
        .await;
689

            
690
    assert!(response.contains(":id 13"), "got: {response}");
691
    assert!(
692
        response.contains(":value") && response.contains("(:config-value nil)"),
693
        "expected a (:config-value nil) value, got: {response}"
694
    );
695
    assert!(
696
        !response.contains(":error"),
697
        "an absent key must not surface as an error, got: {response}"
698
    );
699
}
700

            
701
#[local_db_sqlx_test]
702
async fn get_config_returns_stored_value(pool: PgPool) -> anyhow::Result<()> {
703
    // The migration set no longer seeds config (seeding moved to
704
    // `bootstrap::seed`), so this test stores its own value and reads it back
705
    // through the script `get-config` native rather than relying on a baseline
706
    // seed row.
707
    let user_id = Uuid::new_v4();
708
    insert_test_user(&pool, user_id).await?;
709
    server::user::User { id: user_id }
710
        .set_config("fixture_key", "YES".into())
711
        .await?;
712

            
713
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
714
    let response = session
715
        .handle_form("(:id 14 :form (get-config \"fixture_key\"))")
716
        .await;
717

            
718
    assert!(response.contains(":id 14"), "got: {response}");
719
    // get-config still wraps the value in a `(:config-value …)`
720
    // plist string for legacy emacs consumers; the StringRef return
721
    // carries that plist literally. Quotes inside the outer rpc
722
    // envelope show up backslash-escaped on the wire.
723
    assert!(
724
        response.contains("(:config-value \\\"YES\\\")"),
725
        "expected fixture_key=YES inside config-value plist, got: {response}"
726
    );
727
}
728

            
729
#[local_db_sqlx_test]
730
async fn get_account_commodities_for_unknown_account_returns_empty(
731
    pool: PgPool,
732
) -> anyhow::Result<()> {
733
    let user_id = Uuid::new_v4();
734
    insert_test_user(&pool, user_id).await?;
735

            
736
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
737
    let response = session
738
        .handle_form(
739
            "(:id 12 :form (get-account-commodities \"22222222-2222-2222-2222-222222222222\"))",
740
        )
741
        .await;
742

            
743
    assert!(response.contains(":id 12"), "got: {response}");
744
    assert!(
745
        response.contains(":value \"()\""),
746
        "expected empty commodity-info list, got: {response}"
747
    );
748
}
749

            
750
#[local_db_sqlx_test]
751
async fn account_balance_same_commodity_sum_composes(pool: PgPool) -> anyhow::Result<()> {
752
    // P3b/1c: same-commodity `+` over two account-balance results
753
    // composes through commodity_add and surfaces a Commodity value in
754
    // the envelope. Splits are -100 from A and +100 to B in FOO, so the
755
    // sum is 0/1 carrying FOO's id.
756
    let user_id = Uuid::new_v4();
757
    insert_test_user(&pool, user_id).await?;
758
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
759

            
760
    let resp = session
761
        .handle_form("(:id 40 :form (create-commodity \"FOO\" \"Foo Coin\"))")
762
        .await;
763
    let foo = extract_id_field(&resp, "commodity-id").expect("foo commodity");
764
    let resp = session
765
        .handle_form("(:id 41 :form (create-account \"A-foo\" \"\"))")
766
        .await;
767
    let acct_a = extract_id_field(&resp, "account-id").expect("A id");
768
    let resp = session
769
        .handle_form("(:id 42 :form (create-account \"B-foo\" \"\"))")
770
        .await;
771
    let acct_b = extract_id_field(&resp, "account-id").expect("B id");
772

            
773
    let tx = format!(
774
        "(:id 43 :form (create-transaction \"(:post-date \\\"2026-02-01T00:00:00Z\\\" \
775
         :note \\\"foo-tx\\\" \
776
         :splits ((:account-id \\\"{acct_a}\\\" :commodity-id \\\"{foo}\\\" :value -100) \
777
                  (:account-id \\\"{acct_b}\\\" :commodity-id \\\"{foo}\\\" :value 100)))\"))"
778
    );
779
    let _ = session.handle_form(&tx).await;
780

            
781
    let response = session
782
        .handle_form(&format!(
783
            "(:id 44 :form (+ (account-balance \"{acct_a}\") (account-balance \"{acct_b}\")))"
784
        ))
785
        .await;
786
    assert!(response.contains(":id 44"), "got: {response}");
787
    assert!(
788
        response.contains(":commodity"),
789
        "expected :commodity-shaped value, got: {response}"
790
    );
791
    assert!(
792
        response.contains(&foo),
793
        "expected FOO commodity id in result, got: {response}"
794
    );
795
}
796

            
797
#[local_db_sqlx_test]
798
async fn account_balance_cross_commodity_sum_traps(pool: PgPool) -> anyhow::Result<()> {
799
    // Same form crosses currencies — commodity_add `throw`s a
800
    // `commodity-mismatch` `$nomi_error` inside the wasm guest; uncaught, the
801
    // boundary wrapper bridges it to `__nomi_raise` and the rpc envelope
802
    // surfaces `:code commodity-mismatch` (ADR-0014 + ADR-0026).
803
    let user_id = Uuid::new_v4();
804
    insert_test_user(&pool, user_id).await?;
805
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
806

            
807
    let resp = session
808
        .handle_form("(:id 50 :form (create-commodity \"USD\" \"Dollar\"))")
809
        .await;
810
    let usd = extract_id_field(&resp, "commodity-id").expect("usd id");
811
    let resp = session
812
        .handle_form("(:id 51 :form (create-commodity \"JPY\" \"Yen\"))")
813
        .await;
814
    let jpy = extract_id_field(&resp, "commodity-id").expect("jpy id");
815
    let resp = session
816
        .handle_form("(:id 52 :form (create-account \"Wallet-USD\" \"\"))")
817
        .await;
818
    let usd_acct = extract_id_field(&resp, "account-id").expect("usd acct");
819
    let resp = session
820
        .handle_form("(:id 53 :form (create-account \"Shop-USD\" \"\"))")
821
        .await;
822
    let usd_other = extract_id_field(&resp, "account-id").expect("usd other");
823
    let resp = session
824
        .handle_form("(:id 54 :form (create-account \"Wallet-JPY\" \"\"))")
825
        .await;
826
    let jpy_acct = extract_id_field(&resp, "account-id").expect("jpy acct");
827
    let resp = session
828
        .handle_form("(:id 55 :form (create-account \"Shop-JPY\" \"\"))")
829
        .await;
830
    let jpy_other = extract_id_field(&resp, "account-id").expect("jpy other");
831

            
832
    let usd_tx = format!(
833
        "(:id 56 :form (create-transaction \"(:post-date \\\"2026-02-02T00:00:00Z\\\" \
834
         :note \\\"usd-tx\\\" \
835
         :splits ((:account-id \\\"{usd_acct}\\\" :commodity-id \\\"{usd}\\\" :value -50) \
836
                  (:account-id \\\"{usd_other}\\\" :commodity-id \\\"{usd}\\\" :value 50)))\"))"
837
    );
838
    let _ = session.handle_form(&usd_tx).await;
839
    let jpy_tx = format!(
840
        "(:id 57 :form (create-transaction \"(:post-date \\\"2026-02-02T00:00:00Z\\\" \
841
         :note \\\"jpy-tx\\\" \
842
         :splits ((:account-id \\\"{jpy_acct}\\\" :commodity-id \\\"{jpy}\\\" :value -1700) \
843
                  (:account-id \\\"{jpy_other}\\\" :commodity-id \\\"{jpy}\\\" :value 1700)))\"))"
844
    );
845
    let _ = session.handle_form(&jpy_tx).await;
846

            
847
    let response = session
848
        .handle_form(&format!(
849
            "(:id 58 :form (+ (account-balance \"{usd_acct}\") (account-balance \"{jpy_acct}\")))"
850
        ))
851
        .await;
852
    assert!(response.contains(":id 58"), "got: {response}");
853
    // Structured commodity-mismatch code (ADR-0014 + ADR-0026). The
854
    // commodity_add guest helper `throw`s a `$nomi_error` carrying a
855
    // `COMMODITY-MISMATCH` condition on id mismatch; the boundary wrapper
856
    // catches the uncaught throw and bridges it to `__nomi_raise`, so the
857
    // classifier yields `ScriptRaised{code:"COMMODITY-MISMATCH"}` and the
858
    // rpc envelope surfaces `:code COMMODITY-MISMATCH` rather than the
859
    // generic `:code runtime`. The code is the reader-folded (upper-cased)
860
    // symbol form, identical to a script `(error 'commodity-mismatch …)`.
861
    assert!(
862
        response.contains(":code COMMODITY-MISMATCH"),
863
        "expected structured COMMODITY-MISMATCH code, got: {response}"
864
    );
865
}
866

            
867
#[local_db_sqlx_test]
868
async fn cross_commodity_mismatch_is_catchable_by_handler_case(pool: PgPool) -> anyhow::Result<()> {
869
    // The Tier 3.4 engine-error bridge: a commodity mismatch `throw`s
870
    // `$nomi_error` in-guest (ADR-0026), so an enclosing `(handler-case)`
871
    // catches it on the `commodity-mismatch` code (reader-folded to
872
    // `COMMODITY-MISMATCH`, matching the thrown condition) instead of
873
    // aborting the module. Proves engine errors travel the same catchable
874
    // exception channel as script `(error)` raises.
875
    let user_id = Uuid::new_v4();
876
    insert_test_user(&pool, user_id).await?;
877
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
878

            
879
    let resp = session
880
        .handle_form("(:id 60 :form (create-commodity \"USD\" \"Dollar\"))")
881
        .await;
882
    let usd = extract_id_field(&resp, "commodity-id").expect("usd id");
883
    let resp = session
884
        .handle_form("(:id 61 :form (create-commodity \"JPY\" \"Yen\"))")
885
        .await;
886
    let jpy = extract_id_field(&resp, "commodity-id").expect("jpy id");
887
    let resp = session
888
        .handle_form("(:id 62 :form (create-account \"W-USD\" \"\"))")
889
        .await;
890
    let usd_acct = extract_id_field(&resp, "account-id").expect("usd acct");
891
    let resp = session
892
        .handle_form("(:id 63 :form (create-account \"S-USD\" \"\"))")
893
        .await;
894
    let usd_other = extract_id_field(&resp, "account-id").expect("usd other");
895
    let resp = session
896
        .handle_form("(:id 64 :form (create-account \"W-JPY\" \"\"))")
897
        .await;
898
    let jpy_acct = extract_id_field(&resp, "account-id").expect("jpy acct");
899
    let resp = session
900
        .handle_form("(:id 65 :form (create-account \"S-JPY\" \"\"))")
901
        .await;
902
    let jpy_other = extract_id_field(&resp, "account-id").expect("jpy other");
903

            
904
    let usd_tx = format!(
905
        "(:id 66 :form (create-transaction \"(:post-date \\\"2026-02-02T00:00:00Z\\\" \
906
         :note \\\"usd-tx\\\" \
907
         :splits ((:account-id \\\"{usd_acct}\\\" :commodity-id \\\"{usd}\\\" :value -50) \
908
                  (:account-id \\\"{usd_other}\\\" :commodity-id \\\"{usd}\\\" :value 50)))\"))"
909
    );
910
    let _ = session.handle_form(&usd_tx).await;
911
    let jpy_tx = format!(
912
        "(:id 67 :form (create-transaction \"(:post-date \\\"2026-02-02T00:00:00Z\\\" \
913
         :note \\\"jpy-tx\\\" \
914
         :splits ((:account-id \\\"{jpy_acct}\\\" :commodity-id \\\"{jpy}\\\" :value -1700) \
915
                  (:account-id \\\"{jpy_other}\\\" :commodity-id \\\"{jpy}\\\" :value 1700)))\"))"
916
    );
917
    let _ = session.handle_form(&jpy_tx).await;
918

            
919
    // The cross-commodity sum mismatches; the handler-case catches it on its
920
    // code. Both arms must agree in type (handler-case unifies body + clause),
921
    // so the clause yields a commodity value too — a single-currency balance —
922
    // proving the catch fired by returning a value, not an error envelope.
923
    let response = session
924
        .handle_form(&format!(
925
            "(:id 68 :form (handler-case \
926
               (+ (account-balance \"{usd_acct}\") (account-balance \"{jpy_acct}\")) \
927
               (commodity-mismatch (e) (account-balance \"{usd_acct}\"))))"
928
        ))
929
        .await;
930
    assert!(response.contains(":id 68"), "got: {response}");
931
    assert!(
932
        !response.contains(":error"),
933
        "handler-case must catch the mismatch, not let it abort: {response}"
934
    );
935
}
936

            
937
#[local_db_sqlx_test]
938
async fn convert_commodity_uses_latest_price_row(pool: PgPool) -> anyhow::Result<()> {
939
    // P3b/1d: end-to-end on the new commodity-typed positional arg path
940
    // and the ConvertCommodity server command. Seeds: USD/JPY commodities,
941
    // a Price row USD→JPY at 150/1, an account holding 2 USD. The form
942
    // `(convert-commodity (account-balance "<usd>") "<jpy>")` walks the
943
    // capture-arg-commodity wire path on the way in, runs ConvertCommodity
944
    // server-side, and surfaces a Commodity carrying JPY's id.
945
    let user_id = Uuid::new_v4();
946
    insert_test_user(&pool, user_id).await?;
947
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
948

            
949
    let resp = session
950
        .handle_form("(:id 60 :form (create-commodity \"USD\" \"Dollar\"))")
951
        .await;
952
    let usd = extract_id_field(&resp, "commodity-id").expect("usd id");
953
    let resp = session
954
        .handle_form("(:id 61 :form (create-commodity \"JPY\" \"Yen\"))")
955
        .await;
956
    let jpy = extract_id_field(&resp, "commodity-id").expect("jpy id");
957
    let resp = session
958
        .handle_form("(:id 62 :form (create-account \"Wallet-USD\" \"\"))")
959
        .await;
960
    let wallet = extract_id_field(&resp, "account-id").expect("wallet");
961
    let resp = session
962
        .handle_form("(:id 63 :form (create-account \"Sink-USD\" \"\"))")
963
        .await;
964
    let sink = extract_id_field(&resp, "account-id").expect("sink");
965

            
966
    let tx = format!(
967
        "(:id 64 :form (create-transaction \"(:post-date \\\"2026-03-01T00:00:00Z\\\" \
968
         :note \\\"usd-conv-tx\\\" \
969
         :splits ((:account-id \\\"{wallet}\\\" :commodity-id \\\"{usd}\\\" :value 2) \
970
                  (:account-id \\\"{sink}\\\" :commodity-id \\\"{usd}\\\" :value -2)))\"))"
971
    );
972
    let _ = session.handle_form(&tx).await;
973

            
974
    // Direct insert: a fixed USD→JPY rate. No CreatePrice native exists
975
    // (see server::command::account tests for the same pattern), so the
976
    // test rides raw sqlx like the existing test suite does.
977
    let usd_uuid = Uuid::parse_str(&usd)?;
978
    let jpy_uuid = Uuid::parse_str(&jpy)?;
979
    let price_id = Uuid::new_v4();
980
    let price_date = Utc::now();
981
    sqlx::query!(
982
        "INSERT INTO prices (id, commodity_id, currency_id, commodity_split_id, \
983
         currency_split_id, price_date, value_num, value_denom) \
984
         VALUES ($1, $2, $3, NULL, NULL, $4, $5, $6)",
985
        price_id,
986
        usd_uuid,
987
        jpy_uuid,
988
        price_date,
989
        150_i64,
990
        1_i64,
991
    )
992
    .execute(&pool)
993
    .await?;
994

            
995
    let response = session
996
        .handle_form(&format!(
997
            "(:id 65 :form (convert-commodity (account-balance \"{wallet}\") \"{jpy}\"))"
998
        ))
999
        .await;
    assert!(response.contains(":id 65"), "got: {response}");
    assert!(
        response.contains(":commodity"),
        "expected Commodity-shaped value, got: {response}"
    );
    assert!(
        response.contains(&jpy),
        "expected JPY id in result, got: {response}"
    );
    // 2 USD × 150/1 = 300 in JPY.
    assert!(
        response.contains(":commodity 300"),
        "expected 300 JPY, got: {response}"
    );
}
#[local_db_sqlx_test]
async fn convert_commodity_missing_price_row_traps(pool: PgPool) -> anyhow::Result<()> {
    // ConvertCommodity raises CmdError::Args when no Price row exists in
    // either direction. The rpc native maps it to wasmtime::Error, which
    // surfaces as :code runtime in the envelope.
    let user_id = Uuid::new_v4();
    insert_test_user(&pool, user_id).await?;
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
    let resp = session
        .handle_form("(:id 70 :form (create-commodity \"AAA\" \"Alpha\"))")
        .await;
    let aaa = extract_id_field(&resp, "commodity-id").expect("aaa");
    let resp = session
        .handle_form("(:id 71 :form (create-commodity \"BBB\" \"Beta\"))")
        .await;
    let bbb = extract_id_field(&resp, "commodity-id").expect("bbb");
    let resp = session
        .handle_form("(:id 72 :form (create-account \"A-wallet\" \"\"))")
        .await;
    let acct = extract_id_field(&resp, "account-id").expect("a wallet");
    let resp = session
        .handle_form("(:id 73 :form (create-account \"A-sink\" \"\"))")
        .await;
    let sink = extract_id_field(&resp, "account-id").expect("a sink");
    let tx = format!(
        "(:id 74 :form (create-transaction \"(:post-date \\\"2026-03-02T00:00:00Z\\\" \
         :note \\\"aaa-tx\\\" \
         :splits ((:account-id \\\"{acct}\\\" :commodity-id \\\"{aaa}\\\" :value 5) \
                  (:account-id \\\"{sink}\\\" :commodity-id \\\"{aaa}\\\" :value -5)))\"))"
    );
    let _ = session.handle_form(&tx).await;
    let response = session
        .handle_form(&format!(
            "(:id 75 :form (convert-commodity (account-balance \"{acct}\") \"{bbb}\"))"
        ))
        .await;
    assert!(response.contains(":id 75"), "got: {response}");
    // P3b structured no-conversion code (ADR-0014 follow-up).
    // ConvertCommodity's "no Price row" CmdError::Args surfaces
    // as `EngineError::NoConversion`, which maps to
    // `:code no-conversion`. Distinct from `commodity-mismatch`
    // because the remedy is different — add a price row, not
    // restructure arithmetic.
    assert!(
        response.contains(":code no-conversion"),
        "expected no-conversion structured code, got: {response}"
    );
}
#[local_db_sqlx_test]
async fn account_balance_traps_when_no_commodity_yet(pool: PgPool) -> anyhow::Result<()> {
    // wraps_commodity migration (P3b/1b): account-balance now resolves
    // commodity via GetAccountCommodities before reading the rational. An
    // account with no splits has no associated commodity, so the native
    // traps rather than returning a phantom 0-of-no-currency value. Surfaces
    // through the envelope as :error.
    let user_id = Uuid::new_v4();
    insert_test_user(&pool, user_id).await?;
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
    let response = session
        .handle_form("(:id 30 :form (account-balance \"22222222-2222-2222-2222-222222222222\"))")
        .await;
    assert!(response.contains(":id 30"), "got: {response}");
    // 1b surfaces the trap as a generic runtime error envelope; structured
    // classification (commodity-mismatch / no-commodity codes) lands in 1c
    // together with the rest of commodity-aware trap dispatch.
    assert!(
        response.contains(":code runtime"),
        "expected :code runtime trap envelope, got: {response}"
    );
}
#[local_db_sqlx_test]
async fn get_balance_for_account_with_no_splits_returns_zero(pool: PgPool) -> anyhow::Result<()> {
    let user_id = Uuid::new_v4();
    insert_test_user(&pool, user_id).await?;
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
    // Random uuid → no splits exist for it → GetBalance short-circuits to
    // CmdResult::Rational(0/1) → wire form bare ratio `:value 0` (native
    // Ratio, not a string-wrapped plist).
    let response = session
        .handle_form("(:id 11 :form (get-balance \"11111111-1111-1111-1111-111111111111\"))")
        .await;
    assert!(response.contains(":id 11"), "got: {response}");
    assert!(
        response.contains(":value 0"),
        "expected :value 0, got: {response}"
    );
}
#[local_db_sqlx_test]
async fn user_has_ssh_key_returns_nil_for_fresh_user(pool: PgPool) -> anyhow::Result<()> {
    let user_id = Uuid::new_v4();
    insert_test_user(&pool, user_id).await?;
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
    let response = session
        .handle_form("(:id 10 :form (user-has-ssh-key))")
        .await;
    assert!(response.contains(":id 10"), "got: {response}");
    // P4 A4 bool returns surface as i32 — 0 for false (no key
    // registered yet). The old `:value "nil"` plist string retired
    // with the capture-protocol removal.
    assert!(
        response.contains(":value 0"),
        "expected :value 0 (i32 bool false), got: {response}"
    );
}
#[local_db_sqlx_test]
async fn get_transaction_with_unknown_uuid_returns_empty_envelope(
    pool: PgPool,
) -> anyhow::Result<()> {
    let user_id = Uuid::new_v4();
    insert_test_user(&pool, user_id).await?;
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
    let response = session
        .handle_form("(:id 9 :form (get-transaction \"00000000-0000-0000-0000-000000000000\"))")
        .await;
    assert!(response.contains(":id 9"), "got: {response}");
    // GetTransaction returns Ok(None) on miss → host fn returns
    // `Option<Rooted<StructRef>>` as None → decoder surfaces null
    // EntityRef as `NIL`. The pagination metadata moved out with A5.
    assert!(
        response.contains(":value NIL"),
        "expected NIL for unknown transaction, got: {response}"
    );
}
#[local_db_sqlx_test]
async fn create_cross_currency_transaction_with_price_persists(pool: PgPool) -> anyhow::Result<()> {
    let user_id = Uuid::new_v4();
    insert_test_user(&pool, user_id).await?;
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
    let resp = session
        .handle_form("(:id 70 :form (create-commodity \"USD\" \"US Dollar\"))")
        .await;
    let usd = extract_id_field(&resp, "commodity-id").expect("usd id");
    let resp = session
        .handle_form("(:id 71 :form (create-commodity \"EUR\" \"Euro\"))")
        .await;
    let eur = extract_id_field(&resp, "commodity-id").expect("eur id");
    let resp = session
        .handle_form("(:id 72 :form (create-account \"Wallet USD\" \"\"))")
        .await;
    let from_acct = extract_id_field(&resp, "account-id").expect("from account");
    let resp = session
        .handle_form("(:id 73 :form (create-account \"Wallet EUR\" \"\"))")
        .await;
    let to_acct = extract_id_field(&resp, "account-id").expect("to account");
    let from_split_id = Uuid::new_v4().to_string();
    let to_split_id = Uuid::new_v4().to_string();
    // The EUR (commodity-side) split is listed first, so finance picks EUR as
    // the base commodity and the non-base USD split is reached through the
    // price's `currency_split` reference — the path the old single-sided
    // `else if` price index used to miss.
    let form = format!(
        "(:id 74 :form (create-transaction \
         \"(:post-date \\\"2026-03-15T00:00:00Z\\\" \
         :splits ((:id \\\"{to_split_id}\\\" \
                   :account-id \\\"{to_acct}\\\" \
                   :commodity-id \\\"{eur}\\\" \
                   :value 9200) \
                  (:id \\\"{from_split_id}\\\" \
                   :account-id \\\"{from_acct}\\\" \
                   :commodity-id \\\"{usd}\\\" \
                   :value -10000)) \
         :prices ((:commodity-id \\\"{eur}\\\" \
                   :currency-id \\\"{usd}\\\" \
                   :commodity-split \\\"{to_split_id}\\\" \
                   :currency-split \\\"{from_split_id}\\\" \
                   :value-num 10000 \
                   :value-denom 9200)))\"))"
    );
    let create_resp = session.handle_form(&form).await;
    assert!(create_resp.contains(":id 74"), "got: {create_resp}");
    assert!(
        create_resp.contains(":value \""),
        "expected transaction uuid in :value, got: {create_resp}"
    );
    let tx_resp = session
        .handle_form("(:id 75 :form (car (list-transactions \"\")))")
        .await;
    assert!(tx_resp.contains(":id 75"), "got: {tx_resp}");
    assert!(
        !tx_resp.contains(":value NIL"),
        "expected a transaction entity, got: {tx_resp}"
    );
}
#[local_db_sqlx_test]
async fn get_balances_no_splits_returns_empty_list(pool: PgPool) -> anyhow::Result<()> {
    let user_id = Uuid::new_v4();
    insert_test_user(&pool, user_id).await?;
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
    let response = session
        .handle_form("(:id 10 :form (get-balances \"11111111-1111-1111-1111-111111111111\"))")
        .await;
    assert!(response.contains(":id 10"), "got: {response}");
    assert!(!response.contains(":error"), "got: {response}");
    assert!(
        response.contains(":value \"()\""),
        "expected empty pair list, got: {response}"
    );
}
#[local_db_sqlx_test]
async fn get_balances_missing_arg_returns_error(pool: PgPool) -> anyhow::Result<()> {
    let user_id = Uuid::new_v4();
    insert_test_user(&pool, user_id).await?;
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
    let response = session
        .handle_form("(:id 10 :form (get-balances \"\"))")
        .await;
    assert!(response.contains(":id 10"), "got: {response}");
    assert!(
        response.contains(":error") || response.contains(":code"),
        "got: {response}"
    );
}
#[local_db_sqlx_test]
async fn get_balances_single_currency_returns_one_element_list(pool: PgPool) -> anyhow::Result<()> {
    let user_id = Uuid::new_v4();
    insert_test_user(&pool, user_id).await?;
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
    let resp = session
        .handle_form("(:id 10 :form (create-commodity \"USD\" \"US Dollar\"))")
        .await;
    let usd = extract_id_field(&resp, "commodity-id").expect("usd id");
    let resp = session
        .handle_form("(:id 11 :form (create-account \"Wallet\" \"\"))")
        .await;
    let wallet = extract_id_field(&resp, "account-id").expect("wallet");
    let resp = session
        .handle_form("(:id 12 :form (create-account \"Sink\" \"\"))")
        .await;
    let sink = extract_id_field(&resp, "account-id").expect("sink");
    let tx = format!(
        "(:id 13 :form (create-transaction \"(:post-date \\\"2026-01-01T00:00:00Z\\\" \
         :note \\\"test-tx\\\" \
         :splits ((:account-id \\\"{wallet}\\\" :commodity-id \\\"{usd}\\\" :value -50) \
                  (:account-id \\\"{sink}\\\" :commodity-id \\\"{usd}\\\" :value 50)))\"))"
    );
    session.handle_form(&tx).await;
    let response = session
        .handle_form(&format!("(:id 14 :form (get-balances \"{wallet}\"))"))
        .await;
    assert!(response.contains(":id 14"), "got: {response}");
    assert!(!response.contains(":error"), "got: {response}");
    assert!(response.contains(":value-num"), "got: {response}");
    assert!(response.contains("-50"), "got: {response}");
    assert!(response.contains("USD"), "got: {response}");
}
#[local_db_sqlx_test]
async fn get_balances_multi_currency_returns_n_elements(pool: PgPool) -> anyhow::Result<()> {
    let user_id = Uuid::new_v4();
    insert_test_user(&pool, user_id).await?;
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
    let resp = session
        .handle_form("(:id 100 :form (create-commodity \"USD\" \"US Dollar\"))")
        .await;
    let usd = extract_id_field(&resp, "commodity-id").expect("usd");
    let resp = session
        .handle_form("(:id 101 :form (create-commodity \"JPY\" \"Yen\"))")
        .await;
    let jpy = extract_id_field(&resp, "commodity-id").expect("jpy");
    let resp = session
        .handle_form("(:id 102 :form (create-account \"Mixed\" \"\"))")
        .await;
    let mixed = extract_id_field(&resp, "account-id").expect("mixed");
    let resp = session
        .handle_form("(:id 103 :form (create-account \"SinkA\" \"\"))")
        .await;
    let sink_a = extract_id_field(&resp, "account-id").expect("sink_a");
    let resp = session
        .handle_form("(:id 104 :form (create-account \"SinkB\" \"\"))")
        .await;
    let sink_b = extract_id_field(&resp, "account-id").expect("sink_b");
    let tx1 = format!(
        "(:id 105 :form (create-transaction \"(:post-date \\\"2026-01-01T00:00:00Z\\\" \
         :note \\\"tx1\\\" \
         :splits ((:account-id \\\"{mixed}\\\" :commodity-id \\\"{usd}\\\" :value -100) \
                  (:account-id \\\"{sink_a}\\\" :commodity-id \\\"{usd}\\\" :value 100)))\"))"
    );
    session.handle_form(&tx1).await;
    let tx2 = format!(
        "(:id 106 :form (create-transaction \"(:post-date \\\"2026-01-02T00:00:00Z\\\" \
         :note \\\"tx2\\\" \
         :splits ((:account-id \\\"{mixed}\\\" :commodity-id \\\"{jpy}\\\" :value -5000) \
                  (:account-id \\\"{sink_b}\\\" :commodity-id \\\"{jpy}\\\" :value 5000)))\"))"
    );
    session.handle_form(&tx2).await;
    let response = session
        .handle_form(&format!("(:id 107 :form (get-balances \"{mixed}\"))"))
        .await;
    assert!(response.contains(":id 107"), "got: {response}");
    assert!(!response.contains(":error"), "got: {response}");
    assert!(
        response.contains("USD") || response.contains("JPY"),
        "expected commodity symbol in response, got: {response}"
    );
}
#[local_db_sqlx_test]
async fn list_transactions_account_filter_returns_only_matching(
    pool: PgPool,
) -> anyhow::Result<()> {
    let user_id = Uuid::new_v4();
    insert_test_user(&pool, user_id).await?;
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
    let resp = session
        .handle_form("(:id 80 :form (create-commodity \"COIN\" \"Test Coin\"))")
        .await;
    let comm = extract_id_field(&resp, "commodity-id").expect("commodity");
    let resp = session
        .handle_form("(:id 81 :form (create-account \"Alpha\" \"\"))")
        .await;
    let alpha = extract_id_field(&resp, "account-id").expect("alpha");
    let resp = session
        .handle_form("(:id 82 :form (create-account \"Beta\" \"\"))")
        .await;
    let beta = extract_id_field(&resp, "account-id").expect("beta");
    let resp = session
        .handle_form("(:id 83 :form (create-account \"Gamma\" \"\"))")
        .await;
    let gamma = extract_id_field(&resp, "account-id").expect("gamma");
    // First transaction: alpha ↔ beta
    let form = format!(
        "(:id 84 :form (create-transaction \
         \"(:post-date \\\"2026-04-01T00:00:00Z\\\" \
         :splits ((:account-id \\\"{alpha}\\\" :commodity-id \\\"{comm}\\\" :value -50) \
                  (:account-id \\\"{beta}\\\" :commodity-id \\\"{comm}\\\" :value 50)))\"))"
    );
    session.handle_form(&form).await;
    // Second transaction: alpha ↔ gamma
    let form = format!(
        "(:id 85 :form (create-transaction \
         \"(:post-date \\\"2026-04-02T00:00:00Z\\\" \
         :splits ((:account-id \\\"{alpha}\\\" :commodity-id \\\"{comm}\\\" :value -30) \
                  (:account-id \\\"{gamma}\\\" :commodity-id \\\"{comm}\\\" :value 30)))\"))"
    );
    session.handle_form(&form).await;
    // Unfiltered: both transactions
    let all_resp = session
        .handle_form("(:id 86 :form (length (list-transactions \"\")))")
        .await;
    assert!(all_resp.contains(":id 86"), "got: {all_resp}");
    assert!(
        all_resp.contains(":value 2"),
        "expected 2 transactions total, got: {all_resp}"
    );
    // Filtered by gamma account: only the second transaction
    let form = format!("(:id 87 :form (length (list-transactions \"{gamma}\")))");
    let filtered_resp = session.handle_form(&form).await;
    assert!(filtered_resp.contains(":id 87"), "got: {filtered_resp}");
    assert!(
        filtered_resp.contains(":value 1"),
        "expected 1 transaction for gamma, got: {filtered_resp}"
    );
}
#[local_db_sqlx_test]
async fn create_account_with_parent_persists_hierarchy(pool: PgPool) -> anyhow::Result<()> {
    let user_id = Uuid::new_v4();
    insert_test_user(&pool, user_id).await?;
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
    let resp = session
        .handle_form("(:id 10 :form (create-account \"Parent\" \"\"))")
        .await;
    let parent_id = extract_id_field(&resp, "account-id").expect("parent");
    let resp = session
        .handle_form(&format!(
            "(:id 11 :form (create-account \"Child\" \"{parent_id}\"))"
        ))
        .await;
    let child_id = extract_id_field(&resp, "account-id").expect("child");
    let resp = session
        .handle_form(&format!(
            "(:id 12 :form (get-account-for-manage \"{child_id}\"))"
        ))
        .await;
    assert!(resp.contains(":id 12"), "got: {resp}");
    assert!(
        resp.contains(&parent_id),
        "expected parent id in response, got: {resp}"
    );
}