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
45
async fn setup() {}
21

            
22
42
async fn insert_test_user(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
23
42
    sqlx::query!(
24
        "INSERT INTO users (
25
            id, user_name, email, photo, verified, user_password,
26
            user_role, database_name, created_at
27
        ) VALUES (
28
            -- `verified = TRUE`: this fixture stands in for a usable account,
29
            -- and the SSH-key write commands refuse a disabled one.
30
            $1, 'rpc-test-user', 'rpc-test@example.com', 'default.png',
31
            TRUE, 'irrelevant', 'user', 'rpc-test', NOW()
32
        )",
33
        id
34
    )
35
42
    .execute(pool)
36
42
    .await?;
37
42
    Ok(())
38
42
}
39

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
337
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
338

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
452
#[local_db_sqlx_test]
453
async fn create_account_then_list_accounts_surfaces_new_row(pool: PgPool) -> anyhow::Result<()> {
454
    // Single-arg write returns the new entity's UUID as a bare
455
    // `StringRef`. Verify via the typed `(account-name (car ...))`
456
    // composition rather than the old `:accounts ((:name …))`
457
    // plist surface — accessor composition is the P4-A5 contract.
458
    let user_id = Uuid::new_v4();
459
    insert_test_user(&pool, user_id).await?;
460

            
461
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
462
    let create_resp = session
463
        .handle_form("(:id 23 :form (create-account \"rpc-test-account\" \"\"))")
464
        .await;
465
    assert!(create_resp.contains(":id 23"), "got: {create_resp}");
466
    // Bare UUID string return — single-record write surfaces the
467
    // server-assigned id as a quoted scalar.
468
    assert!(
469
        create_resp.contains(":value \""),
470
        "expected :value with uuid string, got: {create_resp}"
471
    );
472

            
473
    let name_resp = session
474
        .handle_form("(:id 24 :form (account-name (car (list-accounts))))")
475
        .await;
476
    assert!(name_resp.contains(":id 24"), "got: {name_resp}");
477
    assert!(
478
        name_resp.contains("\"rpc-test-account\""),
479
        "expected name accessor to surface tag value, got: {name_resp}"
480
    );
481
}
482

            
483
#[local_db_sqlx_test]
484
async fn set_account_tag_on_missing_account_returns_error(pool: PgPool) -> anyhow::Result<()> {
485
    // First 3-arg StringRef end-to-end test. Compiler emits three byte-
486
    // stream/finish pairs in declaration order (account-id, tag-name,
487
    // tag-value); host pops via FIFO take_arg in matching order. Server
488
    // rejects unknown account_id with CmdError::Args, which we surface
489
    // as the standard :error envelope.
490
    let user_id = Uuid::new_v4();
491
    insert_test_user(&pool, user_id).await?;
492

            
493
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
494
    let response = session
495
        .handle_form(
496
            "(:id 22 :form (set-account-tag \
497
                \"44444444-4444-4444-4444-444444444444\" \
498
                \"name\" \
499
                \"Renamed\"))",
500
        )
501
        .await;
502

            
503
    assert!(response.contains(":id 22"), "got: {response}");
504
    assert!(
505
        response.contains(":error (:code runtime") && response.contains("set-account-tag"),
506
        "expected set-account-tag runtime error envelope, got: {response}"
507
    );
508
}
509

            
510
#[local_db_sqlx_test]
511
async fn create_commodity_then_list_commodities_surfaces_new_row(
512
    pool: PgPool,
513
) -> anyhow::Result<()> {
514
    // Two-arg write returning the new entity's UUID. After create, the
515
    // commodity must show up in list-commodities for the same user with
516
    // the symbol/name tags intact — exercises the write path's
517
    // tag-population side effect.
518
    let user_id = Uuid::new_v4();
519
    insert_test_user(&pool, user_id).await?;
520

            
521
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
522
    let create_resp = session
523
        .handle_form("(:id 20 :form (create-commodity \"FOO\" \"Foo Coin\"))")
524
        .await;
525
    assert!(create_resp.contains(":id 20"), "got: {create_resp}");
526
    assert!(
527
        create_resp.contains(":value \""),
528
        "expected uuid string return, got: {create_resp}"
529
    );
530

            
531
    // Verify the new row via the typed-entity accessor surface
532
    // — `(commodity-symbol (car (list-commodities)))` traverses
533
    // the pair head, downcasts to `EntityRef(Commodity)`, and
534
    // reads field 1 (symbol).
535
    let symbol_resp = session
536
        .handle_form("(:id 21 :form (commodity-symbol (car (list-commodities))))")
537
        .await;
538
    assert!(symbol_resp.contains(":id 21"), "got: {symbol_resp}");
539
    assert!(
540
        symbol_resp.contains("\"FOO\""),
541
        "expected FOO symbol, got: {symbol_resp}"
542
    );
543

            
544
    let name_resp = session
545
        .handle_form("(:id 22 :form (commodity-name (car (list-commodities))))")
546
        .await;
547
    assert!(
548
        name_resp.contains("\"Foo Coin\""),
549
        "expected commodity name, got: {name_resp}"
550
    );
551
}
552

            
553
#[local_db_sqlx_test]
554
async fn set_config_then_get_config_round_trips_value(pool: PgPool) -> anyhow::Result<()> {
555
    // First multi-arg StringRef end-to-end test: compiler emits two
556
    // byte-stream/finish pairs in declaration order, host pops via FIFO
557
    // take_arg. Round-trip through Postgres confirms the args queue
558
    // preserves order (name first, value second).
559
    let user_id = Uuid::new_v4();
560
    insert_test_user(&pool, user_id).await?;
561

            
562
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
563

            
564
    let set_resp = session
565
        .handle_form("(:id 18 :form (set-config \"rpc-test-key\" \"hello-world\"))")
566
        .await;
567
    assert!(set_resp.contains(":id 18"), "got: {set_resp}");
568
    // P4 A4 bool returns surface as i32. set-config returns 1 on
569
    // success rather than the old plist `t` symbol.
570
    assert!(
571
        set_resp.contains(":value 1"),
572
        "expected set-config to return :value 1, got: {set_resp}"
573
    );
574

            
575
    let get_resp = session
576
        .handle_form("(:id 19 :form (get-config \"rpc-test-key\"))")
577
        .await;
578
    assert!(get_resp.contains(":id 19"), "got: {get_resp}");
579
    assert!(
580
        get_resp.contains("(:config-value \\\"hello-world\\\")"),
581
        "expected hello-world inside :config-value plist, got: {get_resp}"
582
    );
583
}
584

            
585
#[local_db_sqlx_test]
586
async fn config_field_is_case_insensitively_unique(pool: PgPool) -> anyhow::Result<()> {
587
    // The lower(field) unique index (migration 0006) must forbid case-variant
588
    // duplicates so reads (lower(field) = lower($1)) are unambiguous.
589
    sqlx::query(
590
        "INSERT INTO config (id, field, contents) VALUES (gen_random_uuid(), 'Theme', 'a')",
591
    )
592
    .execute(&pool)
593
    .await?;
594
    let dup = sqlx::query(
595
        "INSERT INTO config (id, field, contents) VALUES (gen_random_uuid(), 'theme', 'b')",
596
    )
597
    .execute(&pool)
598
    .await;
599
    assert!(
600
        dup.is_err(),
601
        "a case-variant duplicate config field must be rejected by the unique index"
602
    );
603
}
604

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

            
614
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
615
    let response = session
616
        .handle_form("(:id 17 :form (remove-ssh-key \"SHA256:never-registered\"))")
617
        .await;
618

            
619
    assert!(response.contains(":id 17"), "got: {response}");
620
    assert!(
621
        response.contains(":value 1"),
622
        "expected idempotent 1 return (i32 bool), got: {response}"
623
    );
624
}
625

            
626
#[local_db_sqlx_test]
627
async fn list_splits_for_account_without_splits_returns_empty(pool: PgPool) -> anyhow::Result<()> {
628
    let user_id = Uuid::new_v4();
629
    insert_test_user(&pool, user_id).await?;
630

            
631
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
632
    let response = session
633
        .handle_form("(:id 16 :form (list-splits \"33333333-3333-3333-3333-333333333333\"))")
634
        .await;
635

            
636
    assert!(response.contains(":id 16"), "got: {response}");
637
    assert!(
638
        response.contains(":value nil)"),
639
        "expected empty splits pair for account with no splits, got: {response}"
640
    );
641
}
642

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

            
650
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
651
    let response = session
652
        .handle_form("(:id 15 :form (lookup-user-by-ssh-key \"SHA256:never-registered\"))")
653
        .await;
654

            
655
    assert!(response.contains(":id 15"), "got: {response}");
656
    assert!(
657
        response.contains(":value nil"),
658
        "expected nil for unknown fingerprint (Option<String> None), got: {response}"
659
    );
660
}
661

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

            
670
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
671
    let response = session
672
        .handle_form("(:id 13 :form (get-config \"never-set-this-key\"))")
673
        .await;
674

            
675
    assert!(response.contains(":id 13"), "got: {response}");
676
    assert!(
677
        response.contains(":value") && response.contains("(:config-value nil)"),
678
        "expected a (:config-value nil) value, got: {response}"
679
    );
680
    assert!(
681
        !response.contains(":error"),
682
        "an absent key must not surface as an error, got: {response}"
683
    );
684
}
685

            
686
#[local_db_sqlx_test]
687
async fn get_config_returns_stored_value(pool: PgPool) -> anyhow::Result<()> {
688
    // The migration set no longer seeds config (seeding moved to
689
    // `bootstrap::seed`), so this test stores its own value and reads it back
690
    // through the script `get-config` native rather than relying on a baseline
691
    // seed row.
692
    let user_id = Uuid::new_v4();
693
    insert_test_user(&pool, user_id).await?;
694
    server::user::User { id: user_id }
695
        .set_config("fixture_key", "YES".into())
696
        .await?;
697

            
698
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
699
    let response = session
700
        .handle_form("(:id 14 :form (get-config \"fixture_key\"))")
701
        .await;
702

            
703
    assert!(response.contains(":id 14"), "got: {response}");
704
    // get-config still wraps the value in a `(:config-value …)`
705
    // plist string for legacy emacs consumers; the StringRef return
706
    // carries that plist literally. Quotes inside the outer rpc
707
    // envelope show up backslash-escaped on the wire.
708
    assert!(
709
        response.contains("(:config-value \\\"YES\\\")"),
710
        "expected fixture_key=YES inside config-value plist, got: {response}"
711
    );
712
}
713

            
714
#[local_db_sqlx_test]
715
async fn get_account_commodities_for_unknown_account_returns_empty(
716
    pool: PgPool,
717
) -> anyhow::Result<()> {
718
    let user_id = Uuid::new_v4();
719
    insert_test_user(&pool, user_id).await?;
720

            
721
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
722
    let response = session
723
        .handle_form(
724
            "(:id 12 :form (get-account-commodities \"22222222-2222-2222-2222-222222222222\"))",
725
        )
726
        .await;
727

            
728
    assert!(response.contains(":id 12"), "got: {response}");
729
    assert!(
730
        response.contains(":value nil)"),
731
        "expected empty commodity-info list, got: {response}"
732
    );
733
}
734

            
735
#[local_db_sqlx_test]
736
async fn account_balance_same_commodity_sum_composes(pool: PgPool) -> anyhow::Result<()> {
737
    // P3b/1c: same-commodity `+` over two account-balance results
738
    // composes through commodity_add and surfaces a Commodity value in
739
    // the envelope. Splits are -100 from A and +100 to B in FOO, so the
740
    // sum is 0/1 carrying FOO's id.
741
    let user_id = Uuid::new_v4();
742
    insert_test_user(&pool, user_id).await?;
743
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
744

            
745
    let resp = session
746
        .handle_form("(:id 40 :form (create-commodity \"FOO\" \"Foo Coin\"))")
747
        .await;
748
    let foo = extract_id_field(&resp, "commodity-id").expect("foo commodity");
749
    let resp = session
750
        .handle_form("(:id 41 :form (create-account \"A-foo\" \"\"))")
751
        .await;
752
    let acct_a = extract_id_field(&resp, "account-id").expect("A id");
753
    let resp = session
754
        .handle_form("(:id 42 :form (create-account \"B-foo\" \"\"))")
755
        .await;
756
    let acct_b = extract_id_field(&resp, "account-id").expect("B id");
757

            
758
    let tx = format!(
759
        "(:id 43 :form (create-transaction \"(:post-date \\\"2026-02-01T00:00:00Z\\\" \
760
         :note \\\"foo-tx\\\" \
761
         :splits ((:account-id \\\"{acct_a}\\\" :commodity-id \\\"{foo}\\\" :value -100) \
762
                  (:account-id \\\"{acct_b}\\\" :commodity-id \\\"{foo}\\\" :value 100)))\"))"
763
    );
764
    let _ = session.handle_form(&tx).await;
765

            
766
    let response = session
767
        .handle_form(&format!(
768
            "(:id 44 :form (+ (account-balance \"{acct_a}\") (account-balance \"{acct_b}\")))"
769
        ))
770
        .await;
771
    assert!(response.contains(":id 44"), "got: {response}");
772
    assert!(
773
        response.contains(":commodity"),
774
        "expected :commodity-shaped value, got: {response}"
775
    );
776
    assert!(
777
        response.contains(&foo),
778
        "expected FOO commodity id in result, got: {response}"
779
    );
780
}
781

            
782
#[local_db_sqlx_test]
783
async fn account_balance_cross_commodity_sum_traps(pool: PgPool) -> anyhow::Result<()> {
784
    // Same form crosses currencies — commodity_add `throw`s a
785
    // `commodity-mismatch` `$nomi_error` inside the wasm guest; uncaught, the
786
    // boundary wrapper bridges it to `__nomi_raise` and the rpc envelope
787
    // surfaces `:code commodity-mismatch` (ADR-0014 + ADR-0026).
788
    let user_id = Uuid::new_v4();
789
    insert_test_user(&pool, user_id).await?;
790
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
791

            
792
    let resp = session
793
        .handle_form("(:id 50 :form (create-commodity \"USD\" \"Dollar\"))")
794
        .await;
795
    let usd = extract_id_field(&resp, "commodity-id").expect("usd id");
796
    let resp = session
797
        .handle_form("(:id 51 :form (create-commodity \"JPY\" \"Yen\"))")
798
        .await;
799
    let jpy = extract_id_field(&resp, "commodity-id").expect("jpy id");
800
    let resp = session
801
        .handle_form("(:id 52 :form (create-account \"Wallet-USD\" \"\"))")
802
        .await;
803
    let usd_acct = extract_id_field(&resp, "account-id").expect("usd acct");
804
    let resp = session
805
        .handle_form("(:id 53 :form (create-account \"Shop-USD\" \"\"))")
806
        .await;
807
    let usd_other = extract_id_field(&resp, "account-id").expect("usd other");
808
    let resp = session
809
        .handle_form("(:id 54 :form (create-account \"Wallet-JPY\" \"\"))")
810
        .await;
811
    let jpy_acct = extract_id_field(&resp, "account-id").expect("jpy acct");
812
    let resp = session
813
        .handle_form("(:id 55 :form (create-account \"Shop-JPY\" \"\"))")
814
        .await;
815
    let jpy_other = extract_id_field(&resp, "account-id").expect("jpy other");
816

            
817
    let usd_tx = format!(
818
        "(:id 56 :form (create-transaction \"(:post-date \\\"2026-02-02T00:00:00Z\\\" \
819
         :note \\\"usd-tx\\\" \
820
         :splits ((:account-id \\\"{usd_acct}\\\" :commodity-id \\\"{usd}\\\" :value -50) \
821
                  (:account-id \\\"{usd_other}\\\" :commodity-id \\\"{usd}\\\" :value 50)))\"))"
822
    );
823
    let _ = session.handle_form(&usd_tx).await;
824
    let jpy_tx = format!(
825
        "(:id 57 :form (create-transaction \"(:post-date \\\"2026-02-02T00:00:00Z\\\" \
826
         :note \\\"jpy-tx\\\" \
827
         :splits ((:account-id \\\"{jpy_acct}\\\" :commodity-id \\\"{jpy}\\\" :value -1700) \
828
                  (:account-id \\\"{jpy_other}\\\" :commodity-id \\\"{jpy}\\\" :value 1700)))\"))"
829
    );
830
    let _ = session.handle_form(&jpy_tx).await;
831

            
832
    let response = session
833
        .handle_form(&format!(
834
            "(:id 58 :form (+ (account-balance \"{usd_acct}\") (account-balance \"{jpy_acct}\")))"
835
        ))
836
        .await;
837
    assert!(response.contains(":id 58"), "got: {response}");
838
    // Structured commodity-mismatch code (ADR-0014 + ADR-0026). The
839
    // commodity_add guest helper `throw`s a `$nomi_error` carrying a
840
    // `COMMODITY-MISMATCH` condition on id mismatch; the boundary wrapper
841
    // catches the uncaught throw and bridges it to `__nomi_raise`, so the
842
    // classifier yields `ScriptRaised{code:"COMMODITY-MISMATCH"}` and the
843
    // rpc envelope surfaces `:code COMMODITY-MISMATCH` rather than the
844
    // generic `:code runtime`. The code is the reader-folded (upper-cased)
845
    // symbol form, identical to a script `(error 'commodity-mismatch …)`.
846
    assert!(
847
        response.contains(":code COMMODITY-MISMATCH"),
848
        "expected structured COMMODITY-MISMATCH code, got: {response}"
849
    );
850
}
851

            
852
#[local_db_sqlx_test]
853
async fn cross_commodity_mismatch_is_catchable_by_handler_case(pool: PgPool) -> anyhow::Result<()> {
854
    // The Tier 3.4 engine-error bridge: a commodity mismatch `throw`s
855
    // `$nomi_error` in-guest (ADR-0026), so an enclosing `(handler-case)`
856
    // catches it on the `commodity-mismatch` code (reader-folded to
857
    // `COMMODITY-MISMATCH`, matching the thrown condition) instead of
858
    // aborting the module. Proves engine errors travel the same catchable
859
    // exception channel as script `(error)` raises.
860
    let user_id = Uuid::new_v4();
861
    insert_test_user(&pool, user_id).await?;
862
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
863

            
864
    let resp = session
865
        .handle_form("(:id 60 :form (create-commodity \"USD\" \"Dollar\"))")
866
        .await;
867
    let usd = extract_id_field(&resp, "commodity-id").expect("usd id");
868
    let resp = session
869
        .handle_form("(:id 61 :form (create-commodity \"JPY\" \"Yen\"))")
870
        .await;
871
    let jpy = extract_id_field(&resp, "commodity-id").expect("jpy id");
872
    let resp = session
873
        .handle_form("(:id 62 :form (create-account \"W-USD\" \"\"))")
874
        .await;
875
    let usd_acct = extract_id_field(&resp, "account-id").expect("usd acct");
876
    let resp = session
877
        .handle_form("(:id 63 :form (create-account \"S-USD\" \"\"))")
878
        .await;
879
    let usd_other = extract_id_field(&resp, "account-id").expect("usd other");
880
    let resp = session
881
        .handle_form("(:id 64 :form (create-account \"W-JPY\" \"\"))")
882
        .await;
883
    let jpy_acct = extract_id_field(&resp, "account-id").expect("jpy acct");
884
    let resp = session
885
        .handle_form("(:id 65 :form (create-account \"S-JPY\" \"\"))")
886
        .await;
887
    let jpy_other = extract_id_field(&resp, "account-id").expect("jpy other");
888

            
889
    let usd_tx = format!(
890
        "(:id 66 :form (create-transaction \"(:post-date \\\"2026-02-02T00:00:00Z\\\" \
891
         :note \\\"usd-tx\\\" \
892
         :splits ((:account-id \\\"{usd_acct}\\\" :commodity-id \\\"{usd}\\\" :value -50) \
893
                  (:account-id \\\"{usd_other}\\\" :commodity-id \\\"{usd}\\\" :value 50)))\"))"
894
    );
895
    let _ = session.handle_form(&usd_tx).await;
896
    let jpy_tx = format!(
897
        "(:id 67 :form (create-transaction \"(:post-date \\\"2026-02-02T00:00:00Z\\\" \
898
         :note \\\"jpy-tx\\\" \
899
         :splits ((:account-id \\\"{jpy_acct}\\\" :commodity-id \\\"{jpy}\\\" :value -1700) \
900
                  (:account-id \\\"{jpy_other}\\\" :commodity-id \\\"{jpy}\\\" :value 1700)))\"))"
901
    );
902
    let _ = session.handle_form(&jpy_tx).await;
903

            
904
    // The cross-commodity sum mismatches; the handler-case catches it on its
905
    // code. Both arms must agree in type (handler-case unifies body + clause),
906
    // so the clause yields a commodity value too — a single-currency balance —
907
    // proving the catch fired by returning a value, not an error envelope.
908
    let response = session
909
        .handle_form(&format!(
910
            "(:id 68 :form (handler-case \
911
               (+ (account-balance \"{usd_acct}\") (account-balance \"{jpy_acct}\")) \
912
               (commodity-mismatch (e) (account-balance \"{usd_acct}\"))))"
913
        ))
914
        .await;
915
    assert!(response.contains(":id 68"), "got: {response}");
916
    assert!(
917
        !response.contains(":error"),
918
        "handler-case must catch the mismatch, not let it abort: {response}"
919
    );
920
}
921

            
922
#[local_db_sqlx_test]
923
async fn convert_commodity_uses_latest_price_row(pool: PgPool) -> anyhow::Result<()> {
924
    // P3b/1d: end-to-end on the new commodity-typed positional arg path
925
    // and the ConvertCommodity server command. Seeds: USD/JPY commodities,
926
    // a Price row USD→JPY at 150/1, an account holding 2 USD. The form
927
    // `(convert-commodity (account-balance "<usd>") "<jpy>")` walks the
928
    // capture-arg-commodity wire path on the way in, runs ConvertCommodity
929
    // server-side, and surfaces a Commodity carrying JPY's id.
930
    let user_id = Uuid::new_v4();
931
    insert_test_user(&pool, user_id).await?;
932
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
933

            
934
    let resp = session
935
        .handle_form("(:id 60 :form (create-commodity \"USD\" \"Dollar\"))")
936
        .await;
937
    let usd = extract_id_field(&resp, "commodity-id").expect("usd id");
938
    let resp = session
939
        .handle_form("(:id 61 :form (create-commodity \"JPY\" \"Yen\"))")
940
        .await;
941
    let jpy = extract_id_field(&resp, "commodity-id").expect("jpy id");
942
    let resp = session
943
        .handle_form("(:id 62 :form (create-account \"Wallet-USD\" \"\"))")
944
        .await;
945
    let wallet = extract_id_field(&resp, "account-id").expect("wallet");
946
    let resp = session
947
        .handle_form("(:id 63 :form (create-account \"Sink-USD\" \"\"))")
948
        .await;
949
    let sink = extract_id_field(&resp, "account-id").expect("sink");
950

            
951
    let tx = format!(
952
        "(:id 64 :form (create-transaction \"(:post-date \\\"2026-03-01T00:00:00Z\\\" \
953
         :note \\\"usd-conv-tx\\\" \
954
         :splits ((:account-id \\\"{wallet}\\\" :commodity-id \\\"{usd}\\\" :value 2) \
955
                  (:account-id \\\"{sink}\\\" :commodity-id \\\"{usd}\\\" :value -2)))\"))"
956
    );
957
    let _ = session.handle_form(&tx).await;
958

            
959
    // Direct insert: a fixed USD→JPY rate. No CreatePrice native exists
960
    // (see server::command::account tests for the same pattern), so the
961
    // test rides raw sqlx like the existing test suite does.
962
    let usd_uuid = Uuid::parse_str(&usd)?;
963
    let jpy_uuid = Uuid::parse_str(&jpy)?;
964
    let price_id = Uuid::new_v4();
965
    let price_date = Utc::now();
966
    sqlx::query!(
967
        "INSERT INTO prices (id, commodity_id, currency_id, commodity_split_id, \
968
         currency_split_id, price_date, value_num, value_denom) \
969
         VALUES ($1, $2, $3, NULL, NULL, $4, $5, $6)",
970
        price_id,
971
        usd_uuid,
972
        jpy_uuid,
973
        price_date,
974
        150_i64,
975
        1_i64,
976
    )
977
    .execute(&pool)
978
    .await?;
979

            
980
    let response = session
981
        .handle_form(&format!(
982
            "(:id 65 :form (convert-commodity (account-balance \"{wallet}\") \"{jpy}\"))"
983
        ))
984
        .await;
985
    assert!(response.contains(":id 65"), "got: {response}");
986
    assert!(
987
        response.contains(":commodity"),
988
        "expected Commodity-shaped value, got: {response}"
989
    );
990
    assert!(
991
        response.contains(&jpy),
992
        "expected JPY id in result, got: {response}"
993
    );
994
    // 2 USD × 150/1 = 300 in JPY.
995
    assert!(
996
        response.contains(":commodity 300"),
997
        "expected 300 JPY, got: {response}"
998
    );
999
}
#[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 nil)"),
        "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}"
    );
}