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

            
22
38
async fn insert_test_user(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
23
38
    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
38
    .execute(pool)
34
38
    .await?;
35
38
    Ok(())
36
38
}
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 balance_report_for_fresh_user_returns_typed_root(pool: PgPool) -> anyhow::Result<()> {
306
    // P4 A6.b: balance-report returns a typed `$report_node` entity
307
    // wrapping all period roots under a synthetic super-root. The
308
    // synthetic root carries the literal `balance-report` label so
309
    // emacs/scripts can verify they reached the report without
310
    // surfacing the full entity-ref shape from the eval pipeline.
311
    // Composable: `(node-label (balance-report))` returns the label
312
    // as a `StringRef`; the wire form is the label's literal text.
313
    let user_id = Uuid::new_v4();
314
    insert_test_user(&pool, user_id).await?;
315

            
316
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
317
    let response = session
318
        .handle_form("(:id 29 :form (node-label (balance-report)))")
319
        .await;
320

            
321
    assert!(response.contains(":id 29"), "got: {response}");
322
    assert!(
323
        response.contains("\"balance-report\""),
324
        "expected synthetic root label in response, got: {response}"
325
    );
326
}
327

            
328
#[local_db_sqlx_test]
329
async fn balance_report_node_depth_is_zero_at_synthetic_root(pool: PgPool) -> anyhow::Result<()> {
330
    // Synthetic super-root sits at depth 0 — actual chart-of-accounts
331
    // roots its children inherit depth from server's BalanceReport
332
    // output. Verifies the typed-entity surface composes with the
333
    // I32-returning NODE-DEPTH accessor.
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
    let response = session
339
        .handle_form("(:id 30 :form (node-depth (balance-report)))")
340
        .await;
341

            
342
    assert!(response.contains(":id 30"), "got: {response}");
343
    assert!(
344
        response.contains(":value 0"),
345
        "expected depth 0 for synthetic root, got: {response}"
346
    );
347
}
348

            
349
#[local_db_sqlx_test]
350
async fn update_transaction_for_unknown_id_returns_error(pool: PgPool) -> anyhow::Result<()> {
351
    let user_id = Uuid::new_v4();
352
    insert_test_user(&pool, user_id).await?;
353

            
354
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
355
    let response = session
356
        .handle_form(
357
            "(:id 37 :form (update-transaction \"(:transaction-id \\\"00000000-0000-0000-0000-000000000000\\\" :note \\\"edited\\\")\"))",
358
        )
359
        .await;
360

            
361
    assert!(response.contains(":id 37"), "got: {response}");
362
    assert!(
363
        response.contains(":error (:code runtime") && response.contains("update-transaction"),
364
        "expected update-transaction error envelope, got: {response}"
365
    );
366
}
367

            
368
#[local_db_sqlx_test]
369
async fn create_transaction_with_well_formed_payload_round_trips(
370
    pool: PgPool,
371
) -> anyhow::Result<()> {
372
    // Full end-to-end on the S-expr compound payload. We build the
373
    // pre-requisites (commodity + two accounts) via create-commodity /
374
    // create-account, then call create-transaction with two balancing
375
    // splits, then assert the transaction shows up in list-transactions
376
    // with the supplied note.
377
    let user_id = Uuid::new_v4();
378
    insert_test_user(&pool, user_id).await?;
379

            
380
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
381

            
382
    let resp = session
383
        .handle_form("(:id 32 :form (create-commodity \"BAR\" \"Bar Coin\"))")
384
        .await;
385
    let comm = extract_id_field(&resp, "commodity-id").expect("commodity-id");
386

            
387
    let resp = session
388
        .handle_form("(:id 33 :form (create-account \"From\"))")
389
        .await;
390
    let from = extract_id_field(&resp, "account-id").expect("from account-id");
391

            
392
    let resp = session
393
        .handle_form("(:id 34 :form (create-account \"To\"))")
394
        .await;
395
    let to = extract_id_field(&resp, "account-id").expect("to account-id");
396

            
397
    let form = format!(
398
        "(:id 35 :form (create-transaction \"(:post-date \\\"2026-01-15T00:00:00Z\\\" \
399
         :note \\\"rpc-test-tx\\\" \
400
         :splits ((:account-id \\\"{from}\\\" :commodity-id \\\"{comm}\\\" :value -100) \
401
                  (:account-id \\\"{to}\\\" :commodity-id \\\"{comm}\\\" :value 100)))\"))"
402
    );
403
    let create_resp = session.handle_form(&form).await;
404
    assert!(create_resp.contains(":id 35"), "got: {create_resp}");
405
    // Bare uuid string return — single-record write surfaces the
406
    // server-assigned id via :value "<uuid>".
407
    assert!(
408
        create_resp.contains(":value \""),
409
        "expected :value with transaction uuid, got: {create_resp}"
410
    );
411

            
412
    // Verify via the typed accessor surface rather than asserting on
413
    // a plist-style :note key — pair-rendered entity cars decode to
414
    // a placeholder; the note tag lives behind `transaction-note`.
415
    let note_resp = session
416
        .handle_form("(:id 36 :form (transaction-note (car (list-transactions))))")
417
        .await;
418
    assert!(note_resp.contains(":id 36"), "got: {note_resp}");
419
    assert!(
420
        note_resp.contains("\"rpc-test-tx\""),
421
        "expected new transaction note via accessor, got: {note_resp}"
422
    );
423
}
424

            
425
27
fn extract_id_field(response: &str, _key: &str) -> Option<String> {
426
    // P4 A4 wire change: single-record writes (create-account,
427
    // create-commodity, create-transaction) return bare UUID
428
    // strings rather than `:foo-id "<uuid>"` plists. Pull the
429
    // quoted uuid out of `:value "<uuid>"`. `_key` stays in the
430
    // signature so call sites still document which entity they
431
    // expect — the extraction itself is uniform now.
432
27
    let needle = ":value \"";
433
27
    let start = response.find(needle)? + needle.len();
434
27
    let end = start + response[start..].find('"')?;
435
27
    Some(response[start..end].to_string())
436
27
}
437

            
438
#[local_db_sqlx_test]
439
async fn delete_transaction_unknown_uuid_returns_error(pool: PgPool) -> anyhow::Result<()> {
440
    // Not idempotent: server's DeleteTransaction surfaces an Args error
441
    // when the row isn't there. Native wraps as :error envelope.
442
    let user_id = Uuid::new_v4();
443
    insert_test_user(&pool, user_id).await?;
444

            
445
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
446
    let response = session
447
        .handle_form("(:id 28 :form (delete-transaction \"00000000-0000-0000-0000-000000000000\"))")
448
        .await;
449

            
450
    assert!(response.contains(":id 28"), "got: {response}");
451
    assert!(
452
        response.contains(":error (:code runtime") && response.contains("delete-transaction"),
453
        "expected delete-transaction error envelope, got: {response}"
454
    );
455
}
456

            
457
#[local_db_sqlx_test]
458
async fn get_account_for_manage_unknown_uuid_returns_empty_tree(
459
    pool: PgPool,
460
) -> anyhow::Result<()> {
461
    let user_id = Uuid::new_v4();
462
    insert_test_user(&pool, user_id).await?;
463

            
464
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
465
    let response = session
466
        .handle_form(
467
            "(:id 27 :form (get-account-for-manage \"00000000-0000-0000-0000-000000000000\"))",
468
        )
469
        .await;
470

            
471
    assert!(response.contains(":id 27"), "got: {response}");
472
    assert!(
473
        response.contains(":value NIL"),
474
        "expected NIL on miss, got: {response}"
475
    );
476
}
477

            
478
#[local_db_sqlx_test]
479
async fn list_accounts_for_manage_returns_empty_for_fresh_user(pool: PgPool) -> anyhow::Result<()> {
480
    let user_id = Uuid::new_v4();
481
    insert_test_user(&pool, user_id).await?;
482

            
483
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
484
    let response = session
485
        .handle_form("(:id 26 :form (list-accounts-for-manage))")
486
        .await;
487

            
488
    assert!(response.contains(":id 26"), "got: {response}");
489
    assert!(
490
        response.contains(":value \"()\""),
491
        "expected empty accounts-tree (pair shape), got: {response}"
492
    );
493
}
494

            
495
#[local_db_sqlx_test]
496
async fn verify_user_password_unknown_email_returns_nil(pool: PgPool) -> anyhow::Result<()> {
497
    let user_id = Uuid::new_v4();
498
    insert_test_user(&pool, user_id).await?;
499

            
500
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
501
    let response = session
502
        .handle_form("(:id 25 :form (verify-user-password \"nobody@example.invalid\" \"wrong\"))")
503
        .await;
504

            
505
    assert!(response.contains(":id 25"), "got: {response}");
506
    assert!(
507
        response.contains(":value NIL"),
508
        "expected NIL for unknown user (Option<String> None → null StringRef), got: {response}"
509
    );
510
}
511

            
512
#[local_db_sqlx_test]
513
async fn create_account_then_list_accounts_surfaces_new_row(pool: PgPool) -> anyhow::Result<()> {
514
    // Single-arg write returns the new entity's UUID as a bare
515
    // `StringRef`. Verify via the typed `(account-name (car ...))`
516
    // composition rather than the old `:accounts ((:name …))`
517
    // plist surface — accessor composition is the P4-A5 contract.
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 23 :form (create-account \"rpc-test-account\"))")
524
        .await;
525
    assert!(create_resp.contains(":id 23"), "got: {create_resp}");
526
    // Bare UUID string return — single-record write surfaces the
527
    // server-assigned id as a quoted scalar.
528
    assert!(
529
        create_resp.contains(":value \""),
530
        "expected :value with uuid string, got: {create_resp}"
531
    );
532

            
533
    let name_resp = session
534
        .handle_form("(:id 24 :form (account-name (car (list-accounts))))")
535
        .await;
536
    assert!(name_resp.contains(":id 24"), "got: {name_resp}");
537
    assert!(
538
        name_resp.contains("\"rpc-test-account\""),
539
        "expected name accessor to surface tag value, got: {name_resp}"
540
    );
541
}
542

            
543
#[local_db_sqlx_test]
544
async fn set_account_tag_on_missing_account_returns_error(pool: PgPool) -> anyhow::Result<()> {
545
    // First 3-arg StringRef end-to-end test. Compiler emits three byte-
546
    // stream/finish pairs in declaration order (account-id, tag-name,
547
    // tag-value); host pops via FIFO take_arg in matching order. Server
548
    // rejects unknown account_id with CmdError::Args, which we surface
549
    // as the standard :error envelope.
550
    let user_id = Uuid::new_v4();
551
    insert_test_user(&pool, user_id).await?;
552

            
553
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
554
    let response = session
555
        .handle_form(
556
            "(:id 22 :form (set-account-tag \
557
                \"44444444-4444-4444-4444-444444444444\" \
558
                \"name\" \
559
                \"Renamed\"))",
560
        )
561
        .await;
562

            
563
    assert!(response.contains(":id 22"), "got: {response}");
564
    assert!(
565
        response.contains(":error (:code runtime") && response.contains("set-account-tag"),
566
        "expected set-account-tag runtime error envelope, got: {response}"
567
    );
568
}
569

            
570
#[local_db_sqlx_test]
571
async fn create_commodity_then_list_commodities_surfaces_new_row(
572
    pool: PgPool,
573
) -> anyhow::Result<()> {
574
    // Two-arg write returning the new entity's UUID. After create, the
575
    // commodity must show up in list-commodities for the same user with
576
    // the symbol/name tags intact — exercises the write path's
577
    // tag-population side effect.
578
    let user_id = Uuid::new_v4();
579
    insert_test_user(&pool, user_id).await?;
580

            
581
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
582
    let create_resp = session
583
        .handle_form("(:id 20 :form (create-commodity \"FOO\" \"Foo Coin\"))")
584
        .await;
585
    assert!(create_resp.contains(":id 20"), "got: {create_resp}");
586
    assert!(
587
        create_resp.contains(":value \""),
588
        "expected uuid string return, got: {create_resp}"
589
    );
590

            
591
    // Verify the new row via the typed-entity accessor surface
592
    // — `(commodity-symbol (car (list-commodities)))` traverses
593
    // the pair head, downcasts to `EntityRef(Commodity)`, and
594
    // reads field 1 (symbol).
595
    let symbol_resp = session
596
        .handle_form("(:id 21 :form (commodity-symbol (car (list-commodities))))")
597
        .await;
598
    assert!(symbol_resp.contains(":id 21"), "got: {symbol_resp}");
599
    assert!(
600
        symbol_resp.contains("\"FOO\""),
601
        "expected FOO symbol, got: {symbol_resp}"
602
    );
603

            
604
    let name_resp = session
605
        .handle_form("(:id 22 :form (commodity-name (car (list-commodities))))")
606
        .await;
607
    assert!(
608
        name_resp.contains("\"Foo Coin\""),
609
        "expected commodity name, got: {name_resp}"
610
    );
611
}
612

            
613
#[local_db_sqlx_test]
614
async fn set_config_then_get_config_round_trips_value(pool: PgPool) -> anyhow::Result<()> {
615
    // First multi-arg StringRef end-to-end test: compiler emits two
616
    // byte-stream/finish pairs in declaration order, host pops via FIFO
617
    // take_arg. Round-trip through Postgres confirms the args queue
618
    // preserves order (name first, value second).
619
    let user_id = Uuid::new_v4();
620
    insert_test_user(&pool, user_id).await?;
621

            
622
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
623

            
624
    let set_resp = session
625
        .handle_form("(:id 18 :form (set-config \"rpc-test-key\" \"hello-world\"))")
626
        .await;
627
    assert!(set_resp.contains(":id 18"), "got: {set_resp}");
628
    // P4 A4 bool returns surface as i32. set-config returns 1 on
629
    // success rather than the old plist `t` symbol.
630
    assert!(
631
        set_resp.contains(":value 1"),
632
        "expected set-config to return :value 1, got: {set_resp}"
633
    );
634

            
635
    let get_resp = session
636
        .handle_form("(:id 19 :form (get-config \"rpc-test-key\"))")
637
        .await;
638
    assert!(get_resp.contains(":id 19"), "got: {get_resp}");
639
    assert!(
640
        get_resp.contains("(:config-value \\\"hello-world\\\")"),
641
        "expected hello-world inside :config-value plist, got: {get_resp}"
642
    );
643
}
644

            
645
#[local_db_sqlx_test]
646
async fn config_field_is_case_insensitively_unique(pool: PgPool) -> anyhow::Result<()> {
647
    // The lower(field) unique index (migration 0006) must forbid case-variant
648
    // duplicates so reads (lower(field) = lower($1)) are unambiguous.
649
    sqlx::query(
650
        "INSERT INTO config (id, field, contents) VALUES (gen_random_uuid(), 'Theme', 'a')",
651
    )
652
    .execute(&pool)
653
    .await?;
654
    let dup = sqlx::query(
655
        "INSERT INTO config (id, field, contents) VALUES (gen_random_uuid(), 'theme', 'b')",
656
    )
657
    .execute(&pool)
658
    .await;
659
    assert!(
660
        dup.is_err(),
661
        "a case-variant duplicate config field must be rejected by the unique index"
662
    );
663
}
664

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

            
674
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
675
    let response = session
676
        .handle_form("(:id 17 :form (remove-ssh-key \"SHA256:never-registered\"))")
677
        .await;
678

            
679
    assert!(response.contains(":id 17"), "got: {response}");
680
    assert!(
681
        response.contains(":value 1"),
682
        "expected idempotent 1 return (i32 bool), got: {response}"
683
    );
684
}
685

            
686
#[local_db_sqlx_test]
687
async fn list_splits_for_account_without_splits_returns_empty(pool: PgPool) -> anyhow::Result<()> {
688
    let user_id = Uuid::new_v4();
689
    insert_test_user(&pool, user_id).await?;
690

            
691
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
692
    let response = session
693
        .handle_form("(:id 16 :form (list-splits \"33333333-3333-3333-3333-333333333333\"))")
694
        .await;
695

            
696
    assert!(response.contains(":id 16"), "got: {response}");
697
    assert!(
698
        response.contains(":value \"()\""),
699
        "expected empty splits pair for account with no splits, got: {response}"
700
    );
701
}
702

            
703
#[local_db_sqlx_test]
704
async fn lookup_user_by_ssh_key_for_unknown_fingerprint_returns_nil(
705
    pool: PgPool,
706
) -> anyhow::Result<()> {
707
    let user_id = Uuid::new_v4();
708
    insert_test_user(&pool, user_id).await?;
709

            
710
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
711
    let response = session
712
        .handle_form("(:id 15 :form (lookup-user-by-ssh-key \"SHA256:never-registered\"))")
713
        .await;
714

            
715
    assert!(response.contains(":id 15"), "got: {response}");
716
    assert!(
717
        response.contains(":value NIL"),
718
        "expected NIL for unknown fingerprint (Option<String> None), got: {response}"
719
    );
720
}
721

            
722
#[local_db_sqlx_test]
723
async fn get_config_for_missing_key_returns_error(pool: PgPool) -> anyhow::Result<()> {
724
    // Server's `config()` treats absent keys as a hard error (ConfigError::
725
    // NoSuchField), not Ok(None). Native surfaces that as an :error envelope
726
    // — emacs clients dispatch on the error tag rather than nil to tell
727
    // "key absent" apart from "key holds empty string".
728
    let user_id = Uuid::new_v4();
729
    insert_test_user(&pool, user_id).await?;
730

            
731
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
732
    let response = session
733
        .handle_form("(:id 13 :form (get-config \"never-set-this-key\"))")
734
        .await;
735

            
736
    assert!(response.contains(":id 13"), "got: {response}");
737
    // get-config wraps server errors inside the value plist as
738
    // `(:error "<message>")` rather than tripping a wasmtime trap
739
    // — the native catches and stringifies before returning.
740
    assert!(
741
        response.contains("(:error") && response.contains("get-config"),
742
        "expected get-config (:error …) plist inside value, got: {response}"
743
    );
744
}
745

            
746
#[local_db_sqlx_test]
747
async fn get_config_returns_stored_value(pool: PgPool) -> anyhow::Result<()> {
748
    // The migration set no longer seeds config (seeding moved to
749
    // `bootstrap::seed`), so this test stores its own value and reads it back
750
    // through the script `get-config` native rather than relying on a baseline
751
    // seed row.
752
    let user_id = Uuid::new_v4();
753
    insert_test_user(&pool, user_id).await?;
754
    server::user::User { id: user_id }
755
        .set_config("fixture_key", "YES".into())
756
        .await?;
757

            
758
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
759
    let response = session
760
        .handle_form("(:id 14 :form (get-config \"fixture_key\"))")
761
        .await;
762

            
763
    assert!(response.contains(":id 14"), "got: {response}");
764
    // get-config still wraps the value in a `(:config-value …)`
765
    // plist string for legacy emacs consumers; the StringRef return
766
    // carries that plist literally. Quotes inside the outer rpc
767
    // envelope show up backslash-escaped on the wire.
768
    assert!(
769
        response.contains("(:config-value \\\"YES\\\")"),
770
        "expected fixture_key=YES inside config-value plist, got: {response}"
771
    );
772
}
773

            
774
#[local_db_sqlx_test]
775
async fn get_account_commodities_for_unknown_account_returns_empty(
776
    pool: PgPool,
777
) -> anyhow::Result<()> {
778
    let user_id = Uuid::new_v4();
779
    insert_test_user(&pool, user_id).await?;
780

            
781
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
782
    let response = session
783
        .handle_form(
784
            "(:id 12 :form (get-account-commodities \"22222222-2222-2222-2222-222222222222\"))",
785
        )
786
        .await;
787

            
788
    assert!(response.contains(":id 12"), "got: {response}");
789
    assert!(
790
        response.contains(":value \"()\""),
791
        "expected empty commodity-info list, got: {response}"
792
    );
793
}
794

            
795
#[local_db_sqlx_test]
796
async fn account_balance_same_commodity_sum_composes(pool: PgPool) -> anyhow::Result<()> {
797
    // P3b/1c: same-commodity `+` over two account-balance results
798
    // composes through commodity_add and surfaces a Commodity value in
799
    // the envelope. Splits are -100 from A and +100 to B in FOO, so the
800
    // sum is 0/1 carrying FOO's id.
801
    let user_id = Uuid::new_v4();
802
    insert_test_user(&pool, user_id).await?;
803
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
804

            
805
    let resp = session
806
        .handle_form("(:id 40 :form (create-commodity \"FOO\" \"Foo Coin\"))")
807
        .await;
808
    let foo = extract_id_field(&resp, "commodity-id").expect("foo commodity");
809
    let resp = session
810
        .handle_form("(:id 41 :form (create-account \"A-foo\"))")
811
        .await;
812
    let acct_a = extract_id_field(&resp, "account-id").expect("A id");
813
    let resp = session
814
        .handle_form("(:id 42 :form (create-account \"B-foo\"))")
815
        .await;
816
    let acct_b = extract_id_field(&resp, "account-id").expect("B id");
817

            
818
    let tx = format!(
819
        "(:id 43 :form (create-transaction \"(:post-date \\\"2026-02-01T00:00:00Z\\\" \
820
         :note \\\"foo-tx\\\" \
821
         :splits ((:account-id \\\"{acct_a}\\\" :commodity-id \\\"{foo}\\\" :value -100) \
822
                  (:account-id \\\"{acct_b}\\\" :commodity-id \\\"{foo}\\\" :value 100)))\"))"
823
    );
824
    let _ = session.handle_form(&tx).await;
825

            
826
    let response = session
827
        .handle_form(&format!(
828
            "(:id 44 :form (+ (account-balance \"{acct_a}\") (account-balance \"{acct_b}\")))"
829
        ))
830
        .await;
831
    assert!(response.contains(":id 44"), "got: {response}");
832
    assert!(
833
        response.contains(":commodity"),
834
        "expected :commodity-shaped value, got: {response}"
835
    );
836
    assert!(
837
        response.contains(&foo),
838
        "expected FOO commodity id in result, got: {response}"
839
    );
840
}
841

            
842
#[local_db_sqlx_test]
843
async fn account_balance_cross_commodity_sum_traps(pool: PgPool) -> anyhow::Result<()> {
844
    // Same form crosses currencies — commodity_add `throw`s a
845
    // `commodity-mismatch` `$nomi_error` inside the wasm guest; uncaught, the
846
    // boundary wrapper bridges it to `__nomi_raise` and the rpc envelope
847
    // surfaces `:code commodity-mismatch` (ADR-0014 + ADR-0026).
848
    let user_id = Uuid::new_v4();
849
    insert_test_user(&pool, user_id).await?;
850
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
851

            
852
    let resp = session
853
        .handle_form("(:id 50 :form (create-commodity \"USD\" \"Dollar\"))")
854
        .await;
855
    let usd = extract_id_field(&resp, "commodity-id").expect("usd id");
856
    let resp = session
857
        .handle_form("(:id 51 :form (create-commodity \"JPY\" \"Yen\"))")
858
        .await;
859
    let jpy = extract_id_field(&resp, "commodity-id").expect("jpy id");
860
    let resp = session
861
        .handle_form("(:id 52 :form (create-account \"Wallet-USD\"))")
862
        .await;
863
    let usd_acct = extract_id_field(&resp, "account-id").expect("usd acct");
864
    let resp = session
865
        .handle_form("(:id 53 :form (create-account \"Shop-USD\"))")
866
        .await;
867
    let usd_other = extract_id_field(&resp, "account-id").expect("usd other");
868
    let resp = session
869
        .handle_form("(:id 54 :form (create-account \"Wallet-JPY\"))")
870
        .await;
871
    let jpy_acct = extract_id_field(&resp, "account-id").expect("jpy acct");
872
    let resp = session
873
        .handle_form("(:id 55 :form (create-account \"Shop-JPY\"))")
874
        .await;
875
    let jpy_other = extract_id_field(&resp, "account-id").expect("jpy other");
876

            
877
    let usd_tx = format!(
878
        "(:id 56 :form (create-transaction \"(:post-date \\\"2026-02-02T00:00:00Z\\\" \
879
         :note \\\"usd-tx\\\" \
880
         :splits ((:account-id \\\"{usd_acct}\\\" :commodity-id \\\"{usd}\\\" :value -50) \
881
                  (:account-id \\\"{usd_other}\\\" :commodity-id \\\"{usd}\\\" :value 50)))\"))"
882
    );
883
    let _ = session.handle_form(&usd_tx).await;
884
    let jpy_tx = format!(
885
        "(:id 57 :form (create-transaction \"(:post-date \\\"2026-02-02T00:00:00Z\\\" \
886
         :note \\\"jpy-tx\\\" \
887
         :splits ((:account-id \\\"{jpy_acct}\\\" :commodity-id \\\"{jpy}\\\" :value -1700) \
888
                  (:account-id \\\"{jpy_other}\\\" :commodity-id \\\"{jpy}\\\" :value 1700)))\"))"
889
    );
890
    let _ = session.handle_form(&jpy_tx).await;
891

            
892
    let response = session
893
        .handle_form(&format!(
894
            "(:id 58 :form (+ (account-balance \"{usd_acct}\") (account-balance \"{jpy_acct}\")))"
895
        ))
896
        .await;
897
    assert!(response.contains(":id 58"), "got: {response}");
898
    // Structured commodity-mismatch code (ADR-0014 + ADR-0026). The
899
    // commodity_add guest helper `throw`s a `$nomi_error` carrying a
900
    // `COMMODITY-MISMATCH` condition on id mismatch; the boundary wrapper
901
    // catches the uncaught throw and bridges it to `__nomi_raise`, so the
902
    // classifier yields `ScriptRaised{code:"COMMODITY-MISMATCH"}` and the
903
    // rpc envelope surfaces `:code COMMODITY-MISMATCH` rather than the
904
    // generic `:code runtime`. The code is the reader-folded (upper-cased)
905
    // symbol form, identical to a script `(error 'commodity-mismatch …)`.
906
    assert!(
907
        response.contains(":code COMMODITY-MISMATCH"),
908
        "expected structured COMMODITY-MISMATCH code, got: {response}"
909
    );
910
}
911

            
912
#[local_db_sqlx_test]
913
async fn cross_commodity_mismatch_is_catchable_by_handler_case(pool: PgPool) -> anyhow::Result<()> {
914
    // The Tier 3.4 engine-error bridge: a commodity mismatch `throw`s
915
    // `$nomi_error` in-guest (ADR-0026), so an enclosing `(handler-case)`
916
    // catches it on the `commodity-mismatch` code (reader-folded to
917
    // `COMMODITY-MISMATCH`, matching the thrown condition) instead of
918
    // aborting the module. Proves engine errors travel the same catchable
919
    // exception channel as script `(error)` raises.
920
    let user_id = Uuid::new_v4();
921
    insert_test_user(&pool, user_id).await?;
922
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
923

            
924
    let resp = session
925
        .handle_form("(:id 60 :form (create-commodity \"USD\" \"Dollar\"))")
926
        .await;
927
    let usd = extract_id_field(&resp, "commodity-id").expect("usd id");
928
    let resp = session
929
        .handle_form("(:id 61 :form (create-commodity \"JPY\" \"Yen\"))")
930
        .await;
931
    let jpy = extract_id_field(&resp, "commodity-id").expect("jpy id");
932
    let resp = session
933
        .handle_form("(:id 62 :form (create-account \"W-USD\"))")
934
        .await;
935
    let usd_acct = extract_id_field(&resp, "account-id").expect("usd acct");
936
    let resp = session
937
        .handle_form("(:id 63 :form (create-account \"S-USD\"))")
938
        .await;
939
    let usd_other = extract_id_field(&resp, "account-id").expect("usd other");
940
    let resp = session
941
        .handle_form("(:id 64 :form (create-account \"W-JPY\"))")
942
        .await;
943
    let jpy_acct = extract_id_field(&resp, "account-id").expect("jpy acct");
944
    let resp = session
945
        .handle_form("(:id 65 :form (create-account \"S-JPY\"))")
946
        .await;
947
    let jpy_other = extract_id_field(&resp, "account-id").expect("jpy other");
948

            
949
    let usd_tx = format!(
950
        "(:id 66 :form (create-transaction \"(:post-date \\\"2026-02-02T00:00:00Z\\\" \
951
         :note \\\"usd-tx\\\" \
952
         :splits ((:account-id \\\"{usd_acct}\\\" :commodity-id \\\"{usd}\\\" :value -50) \
953
                  (:account-id \\\"{usd_other}\\\" :commodity-id \\\"{usd}\\\" :value 50)))\"))"
954
    );
955
    let _ = session.handle_form(&usd_tx).await;
956
    let jpy_tx = format!(
957
        "(:id 67 :form (create-transaction \"(:post-date \\\"2026-02-02T00:00:00Z\\\" \
958
         :note \\\"jpy-tx\\\" \
959
         :splits ((:account-id \\\"{jpy_acct}\\\" :commodity-id \\\"{jpy}\\\" :value -1700) \
960
                  (:account-id \\\"{jpy_other}\\\" :commodity-id \\\"{jpy}\\\" :value 1700)))\"))"
961
    );
962
    let _ = session.handle_form(&jpy_tx).await;
963

            
964
    // The cross-commodity sum mismatches; the handler-case catches it on its
965
    // code. Both arms must agree in type (handler-case unifies body + clause),
966
    // so the clause yields a commodity value too — a single-currency balance —
967
    // proving the catch fired by returning a value, not an error envelope.
968
    let response = session
969
        .handle_form(&format!(
970
            "(:id 68 :form (handler-case \
971
               (+ (account-balance \"{usd_acct}\") (account-balance \"{jpy_acct}\")) \
972
               (commodity-mismatch (e) (account-balance \"{usd_acct}\"))))"
973
        ))
974
        .await;
975
    assert!(response.contains(":id 68"), "got: {response}");
976
    assert!(
977
        !response.contains(":error"),
978
        "handler-case must catch the mismatch, not let it abort: {response}"
979
    );
980
}
981

            
982
#[local_db_sqlx_test]
983
async fn convert_commodity_uses_latest_price_row(pool: PgPool) -> anyhow::Result<()> {
984
    // P3b/1d: end-to-end on the new commodity-typed positional arg path
985
    // and the ConvertCommodity server command. Seeds: USD/JPY commodities,
986
    // a Price row USD→JPY at 150/1, an account holding 2 USD. The form
987
    // `(convert-commodity (account-balance "<usd>") "<jpy>")` walks the
988
    // capture-arg-commodity wire path on the way in, runs ConvertCommodity
989
    // server-side, and surfaces a Commodity carrying JPY's id.
990
    let user_id = Uuid::new_v4();
991
    insert_test_user(&pool, user_id).await?;
992
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
993

            
994
    let resp = session
995
        .handle_form("(:id 60 :form (create-commodity \"USD\" \"Dollar\"))")
996
        .await;
997
    let usd = extract_id_field(&resp, "commodity-id").expect("usd id");
998
    let resp = session
999
        .handle_form("(:id 61 :form (create-commodity \"JPY\" \"Yen\"))")
        .await;
    let jpy = extract_id_field(&resp, "commodity-id").expect("jpy id");
    let resp = session
        .handle_form("(:id 62 :form (create-account \"Wallet-USD\"))")
        .await;
    let wallet = extract_id_field(&resp, "account-id").expect("wallet");
    let resp = session
        .handle_form("(:id 63 :form (create-account \"Sink-USD\"))")
        .await;
    let sink = extract_id_field(&resp, "account-id").expect("sink");
    let tx = format!(
        "(:id 64 :form (create-transaction \"(:post-date \\\"2026-03-01T00:00:00Z\\\" \
         :note \\\"usd-conv-tx\\\" \
         :splits ((:account-id \\\"{wallet}\\\" :commodity-id \\\"{usd}\\\" :value 2) \
                  (:account-id \\\"{sink}\\\" :commodity-id \\\"{usd}\\\" :value -2)))\"))"
    );
    let _ = session.handle_form(&tx).await;
    // Direct insert: a fixed USD→JPY rate. No CreatePrice native exists
    // (see server::command::account tests for the same pattern), so the
    // test rides raw sqlx like the existing test suite does.
    let usd_uuid = Uuid::parse_str(&usd)?;
    let jpy_uuid = Uuid::parse_str(&jpy)?;
    let price_id = Uuid::new_v4();
    let price_date = Utc::now();
    sqlx::query!(
        "INSERT INTO prices (id, commodity_id, currency_id, commodity_split_id, \
         currency_split_id, price_date, value_num, value_denom) \
         VALUES ($1, $2, $3, NULL, NULL, $4, $5, $6)",
        price_id,
        usd_uuid,
        jpy_uuid,
        price_date,
        150_i64,
        1_i64,
    )
    .execute(&pool)
    .await?;
    let response = session
        .handle_form(&format!(
            "(:id 65 :form (convert-commodity (account-balance \"{wallet}\") \"{jpy}\"))"
        ))
        .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}"
    );
}