1
//! FORM→NATIVE round-trip integration tests.
2
//!
3
//! Verifies that each `cli_core::eval::build_*` helper emits a nomiscript
4
//! string whose arity and argument shape the compiler + runtime accept.
5
//!
6
//! A mis-shaped builder surfaces as `:error` in the wire response and trips
7
//! the assertion — catching the bug class where `build_create_account_form`
8
//! with no parent formerly emitted a 1-arg form while the native requires 2.
9
//!
10
//! Pattern (mirrors `rpc::natives::transaction::tests_logical`):
11
//!   `sqlx::test` → isolated Postgres DB → `install_pool` → seed fixtures
12
//!   → `Session::handle_form(built_string)` → assert `:value` (no `:error`).
13

            
14
use chrono::Utc;
15
use cli_core::eval::{
16
    LogicalSplit, build_create_account_form, build_create_commodity_form,
17
    build_create_transaction_form_dated, build_delete_transaction_form, build_set_account_tag_form,
18
    build_set_transaction_tag_form, escape_str,
19
};
20
use num_rational::Rational64;
21
use rpc::{ScriptCtx, Session};
22
use server::command::account::CreateAccount;
23
use server::command::commodity::CreateCommodity;
24
use server::command::{CmdResult, FinanceEntity};
25
use sqlx::PgPool;
26
use uuid::Uuid;
27

            
28
// ── Fixtures ──────────────────────────────────────────────────────────────────
29

            
30
14
fn install_pool(pool: &PgPool) {
31
14
    server::db::DB_POOL.with(|c| c.set(pool as *const _));
32
14
}
33

            
34
14
async fn seed_user(pool: &PgPool, user_id: Uuid) -> anyhow::Result<()> {
35
14
    sqlx::query(
36
14
        "INSERT INTO users \
37
14
         (id, user_name, email, photo, verified, user_password, user_role, db_name, created_at) \
38
14
         VALUES ($1, 'Test', 'test@test.com', 'x', false, 'pw', 'user', 'db', NOW())",
39
14
    )
40
14
    .bind(user_id)
41
14
    .execute(pool)
42
14
    .await?;
43
14
    Ok(())
44
14
}
45

            
46
15
async fn seed_commodity(user_id: Uuid, symbol: &str, name: &str) -> anyhow::Result<Uuid> {
47
15
    match CreateCommodity::new()
48
15
        .symbol(symbol.to_string())
49
15
        .name(name.to_string())
50
15
        .user_id(user_id)
51
15
        .run()
52
15
        .await?
53
    {
54
15
        Some(CmdResult::String(id)) => Ok(Uuid::parse_str(&id)?),
55
        other => anyhow::bail!("unexpected CreateCommodity result: {other:?}"),
56
    }
57
15
}
58

            
59
13
async fn seed_account(user_id: Uuid, name: &str) -> anyhow::Result<Uuid> {
60
13
    match CreateAccount::new()
61
13
        .name(name.to_string())
62
13
        .user_id(user_id)
63
13
        .run()
64
13
        .await?
65
    {
66
13
        Some(CmdResult::Entity(FinanceEntity::Account(a))) => Ok(a.id),
67
        other => anyhow::bail!("unexpected CreateAccount result: {other:?}"),
68
    }
69
13
}
70

            
71
3
async fn seed_price(
72
3
    pool: &PgPool,
73
3
    from_id: Uuid,
74
3
    to_id: Uuid,
75
3
    value_num: i64,
76
3
    value_denom: i64,
77
3
) -> anyhow::Result<()> {
78
3
    sqlx::query(
79
3
        "INSERT INTO prices \
80
3
         (id, commodity_id, currency_id, commodity_split_id, currency_split_id, price_date, value_num, value_denom) \
81
3
         VALUES ($1, $2, $3, NULL, NULL, NOW(), $4, $5)",
82
3
    )
83
3
    .bind(Uuid::new_v4())
84
3
    .bind(from_id)
85
3
    .bind(to_id)
86
3
    .bind(value_num)
87
3
    .bind(value_denom)
88
3
    .execute(pool)
89
3
    .await?;
90
3
    Ok(())
91
3
}
92

            
93
// ── Eval helpers ──────────────────────────────────────────────────────────────
94

            
95
14
fn make_session(user_id: Uuid) -> Session {
96
14
    Session::new(ScriptCtx::new(user_id)).expect("Session::new")
97
14
}
98

            
99
17
async fn eval(session: &mut Session, form: &str) -> String {
100
17
    session.handle_form(&format!("(:id 1 :form {form})")).await
101
17
}
102

            
103
/// Asserts no `:error` in `resp` and returns the `:value` slice.
104
13
fn value_of(resp: &str) -> &str {
105
13
    assert!(!resp.contains(":error"), "unexpected error: {resp}");
106
13
    resp.split_once(":value ")
107
13
        .map(|(_, rest)| rest.trim_end_matches(')').trim())
108
13
        .unwrap_or(resp)
109
13
}
110

            
111
/// Creates a transaction via `create-transaction-logical` and returns its UUID.
112
3
async fn seed_transaction_via_eval(
113
3
    session: &mut Session,
114
3
    a_id: Uuid,
115
3
    b_id: Uuid,
116
3
    c_id: Uuid,
117
3
) -> anyhow::Result<Uuid> {
118
3
    let payload = format!(
119
        "(:splits ((:from \"{a_id}\" :to \"{b_id}\" \
120
         :from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 100)))"
121
    );
122
3
    let form = format!("(create-transaction-logical {})", escape_str(&payload));
123
3
    let resp = eval(session, &form).await;
124
3
    let val = value_of(&resp);
125
3
    Ok(Uuid::parse_str(val.trim().trim_matches('"'))?)
126
3
}
127

            
128
// ── Tests ─────────────────────────────────────────────────────────────────────
129

            
130
/// REGRESSION: `build_create_account_form` with no parent formerly emitted
131
/// `(create-account "name")` (1 arg); the native requires 2 → runtime error.
132
/// Now emits `(create-account "name" "")` which succeeds.
133
#[sqlx::test(migrator = "server::db::MIGRATOR")]
134
async fn create_root_account_form_evals_to_uuid(pool: PgPool) -> anyhow::Result<()> {
135
    install_pool(&pool);
136
    let user_id = Uuid::new_v4();
137
    seed_user(&pool, user_id).await?;
138
    let mut session = make_session(user_id);
139

            
140
    let form = build_create_account_form("Assets", None);
141
    let resp = eval(&mut session, &form).await;
142
    let id_str = value_of(&resp).trim().trim_matches('"');
143
    Uuid::parse_str(id_str)?;
144
    Ok(())
145
}
146

            
147
#[sqlx::test(migrator = "server::db::MIGRATOR")]
148
async fn create_child_account_form_evals_with_parent(pool: PgPool) -> anyhow::Result<()> {
149
    install_pool(&pool);
150
    let user_id = Uuid::new_v4();
151
    seed_user(&pool, user_id).await?;
152
    let parent_id = seed_account(user_id, "Assets").await?;
153
    let mut session = make_session(user_id);
154

            
155
    let form = build_create_account_form("Cash", Some(&parent_id.to_string()));
156
    let resp = eval(&mut session, &form).await;
157
    let id_str = value_of(&resp).trim().trim_matches('"');
158
    let child_id = Uuid::parse_str(id_str)?;
159
    assert!(!child_id.is_nil());
160
    Ok(())
161
}
162

            
163
#[sqlx::test(migrator = "server::db::MIGRATOR")]
164
async fn create_commodity_form_evals_to_uuid(pool: PgPool) -> anyhow::Result<()> {
165
    install_pool(&pool);
166
    let user_id = Uuid::new_v4();
167
    seed_user(&pool, user_id).await?;
168
    let mut session = make_session(user_id);
169

            
170
    let form = build_create_commodity_form("USD", "US Dollar");
171
    let resp = eval(&mut session, &form).await;
172
    let id_str = value_of(&resp).trim().trim_matches('"');
173
    Uuid::parse_str(id_str)?;
174
    Ok(())
175
}
176

            
177
#[sqlx::test(migrator = "server::db::MIGRATOR")]
178
async fn set_account_tag_form_evals_successfully(pool: PgPool) -> anyhow::Result<()> {
179
    install_pool(&pool);
180
    let user_id = Uuid::new_v4();
181
    seed_user(&pool, user_id).await?;
182
    let account_id = seed_account(user_id, "Assets").await?;
183
    let mut session = make_session(user_id);
184

            
185
    let form = build_set_account_tag_form(&account_id.to_string(), "name", "Renamed Assets");
186
    let resp = eval(&mut session, &form).await;
187
    value_of(&resp);
188
    Ok(())
189
}
190

            
191
#[sqlx::test(migrator = "server::db::MIGRATOR")]
192
async fn set_transaction_tag_form_evals_successfully(pool: PgPool) -> anyhow::Result<()> {
193
    install_pool(&pool);
194
    let user_id = Uuid::new_v4();
195
    seed_user(&pool, user_id).await?;
196
    let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
197
    let a_id = seed_account(user_id, "Assets").await?;
198
    let b_id = seed_account(user_id, "Expenses").await?;
199
    let mut session = make_session(user_id);
200
    let tx_id = seed_transaction_via_eval(&mut session, a_id, b_id, c_id).await?;
201

            
202
    let form = build_set_transaction_tag_form(&tx_id.to_string(), "category", "groceries");
203
    let resp = eval(&mut session, &form).await;
204
    value_of(&resp);
205
    Ok(())
206
}
207

            
208
#[sqlx::test(migrator = "server::db::MIGRATOR")]
209
async fn delete_transaction_form_evals_successfully(pool: PgPool) -> anyhow::Result<()> {
210
    install_pool(&pool);
211
    let user_id = Uuid::new_v4();
212
    seed_user(&pool, user_id).await?;
213
    let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
214
    let a_id = seed_account(user_id, "Assets").await?;
215
    let b_id = seed_account(user_id, "Expenses").await?;
216
    let mut session = make_session(user_id);
217
    let tx_id = seed_transaction_via_eval(&mut session, a_id, b_id, c_id).await?;
218

            
219
    let form = build_delete_transaction_form(&tx_id.to_string());
220
    let resp = eval(&mut session, &form).await;
221
    value_of(&resp);
222
    Ok(())
223
}
224

            
225
/// Tests `build_create_transaction_form_dated` — the physical-path builder that
226
/// lowers logical splits to physical splits and emits `(create-transaction "...")`.
227
#[sqlx::test(migrator = "server::db::MIGRATOR")]
228
async fn create_transaction_physical_form_evals_to_uuid(pool: PgPool) -> anyhow::Result<()> {
229
    install_pool(&pool);
230
    let user_id = Uuid::new_v4();
231
    seed_user(&pool, user_id).await?;
232
    let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
233
    let a_id = seed_account(user_id, "Assets").await?;
234
    let b_id = seed_account(user_id, "Expenses").await?;
235
    let mut session = make_session(user_id);
236

            
237
    let ls = LogicalSplit {
238
        from: a_id,
239
        to: b_id,
240
        from_commodity: c_id,
241
        to_commodity: c_id,
242
        value: Rational64::new(100, 1),
243
        to_amount: None,
244
    };
245
    let form = build_create_transaction_form_dated(&ls, Some("test payment"), Utc::now())
246
        .map_err(|e| anyhow::anyhow!("{e}"))?;
247
    let resp = eval(&mut session, &form).await;
248
    let id_str = value_of(&resp).trim().trim_matches('"');
249
    let tx_id = Uuid::parse_str(id_str)?;
250
    assert!(!tx_id.is_nil());
251
    Ok(())
252
}
253

            
254
/// Evals `(create-transaction-logical "...")` directly through the Session
255
/// compile + runtime path, asserting the native's 1-arg arity is accepted.
256
#[sqlx::test(migrator = "server::db::MIGRATOR")]
257
async fn create_transaction_logical_form_evals_to_uuid(pool: PgPool) -> anyhow::Result<()> {
258
    install_pool(&pool);
259
    let user_id = Uuid::new_v4();
260
    seed_user(&pool, user_id).await?;
261
    let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
262
    let a_id = seed_account(user_id, "Assets").await?;
263
    let b_id = seed_account(user_id, "Expenses").await?;
264
    let mut session = make_session(user_id);
265

            
266
    let payload = format!(
267
        "(:splits ((:from \"{a_id}\" :to \"{b_id}\" \
268
         :from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 100)))"
269
    );
270
    let form = format!("(create-transaction-logical {})", escape_str(&payload));
271
    let resp = eval(&mut session, &form).await;
272
    let id_str = value_of(&resp).trim().trim_matches('"');
273
    let tx_id = Uuid::parse_str(id_str)?;
274
    assert!(!tx_id.is_nil());
275
    Ok(())
276
}
277

            
278
/// Evals `(update-transaction-logical "...")` through the Session path.
279
#[sqlx::test(migrator = "server::db::MIGRATOR")]
280
async fn update_transaction_logical_form_evals_successfully(pool: PgPool) -> anyhow::Result<()> {
281
    install_pool(&pool);
282
    let user_id = Uuid::new_v4();
283
    seed_user(&pool, user_id).await?;
284
    let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
285
    let a_id = seed_account(user_id, "Assets").await?;
286
    let b_id = seed_account(user_id, "Expenses").await?;
287
    let c_acct = seed_account(user_id, "Income").await?;
288
    let mut session = make_session(user_id);
289
    let tx_id = seed_transaction_via_eval(&mut session, a_id, b_id, c_id).await?;
290

            
291
    let payload = format!(
292
        "(:transaction-id \"{tx_id}\" \
293
         :splits ((:from \"{a_id}\" :to \"{c_acct}\" \
294
         :from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 50)))"
295
    );
296
    let form = format!("(update-transaction-logical {})", escape_str(&payload));
297
    let resp = eval(&mut session, &form).await;
298
    let returned = value_of(&resp).trim().trim_matches('"');
299
    assert_eq!(returned, tx_id.to_string());
300
    Ok(())
301
}
302

            
303
/// `(convert-amount "num/denom" "<from>" "<to>")` — happy path: price row
304
/// exists so the conversion succeeds and returns the reduced ratio string.
305
/// 100 USD × 9/10 = 900/10, reduced to 90/1, returned as "90".
306
#[sqlx::test(migrator = "server::db::MIGRATOR")]
307
async fn convert_amount_with_price_returns_ratio_string(pool: PgPool) -> anyhow::Result<()> {
308
    install_pool(&pool);
309
    let user_id = Uuid::new_v4();
310
    seed_user(&pool, user_id).await?;
311
    let from_id = seed_commodity(user_id, "USD", "US Dollar").await?;
312
    let to_id = seed_commodity(user_id, "EUR", "Euro").await?;
313
    seed_price(&pool, from_id, to_id, 9, 10).await?;
314
    let mut session = make_session(user_id);
315

            
316
    let form = format!(r#"(convert-amount "100/1" "{from_id}" "{to_id}")"#);
317
    let resp = eval(&mut session, &form).await;
318
    let val = value_of(&resp).trim().trim_matches('"');
319
    assert_eq!(val, "90");
320
    Ok(())
321
}
322

            
323
/// No price row → `convert-amount` surfaces an error (no conversion path).
324
#[sqlx::test(migrator = "server::db::MIGRATOR")]
325
async fn convert_amount_without_price_emits_error(pool: PgPool) -> anyhow::Result<()> {
326
    install_pool(&pool);
327
    let user_id = Uuid::new_v4();
328
    seed_user(&pool, user_id).await?;
329
    let from_id = seed_commodity(user_id, "USD", "US Dollar").await?;
330
    let to_id = seed_commodity(user_id, "EUR", "Euro").await?;
331
    let mut session = make_session(user_id);
332

            
333
    let form = format!(r#"(convert-amount "100/1" "{from_id}" "{to_id}")"#);
334
    let resp = eval(&mut session, &form).await;
335
    assert!(
336
        resp.contains(":error"),
337
        "expected :error without price: {resp}"
338
    );
339
    Ok(())
340
}
341

            
342
/// Passing a non-numeric amount string → error before any DB access.
343
#[sqlx::test(migrator = "server::db::MIGRATOR")]
344
async fn convert_amount_invalid_amount_string_emits_error(pool: PgPool) -> anyhow::Result<()> {
345
    install_pool(&pool);
346
    let user_id = Uuid::new_v4();
347
    seed_user(&pool, user_id).await?;
348
    let from_id = seed_commodity(user_id, "USD", "US Dollar").await?;
349
    let to_id = seed_commodity(user_id, "EUR", "Euro").await?;
350
    let mut session = make_session(user_id);
351

            
352
    let form = format!(r#"(convert-amount "not-a-number" "{from_id}" "{to_id}")"#);
353
    let resp = eval(&mut session, &form).await;
354
    assert!(
355
        resp.contains(":error"),
356
        "expected :error for bad amount: {resp}"
357
    );
358
    Ok(())
359
}
360

            
361
/// A huge amount × huge price overflows i64 — `convert-amount` must surface a
362
/// `:error` (conversion overflow), not panic under debug-assertions or wrap.
363
#[sqlx::test(migrator = "server::db::MIGRATOR")]
364
async fn convert_amount_overflow_emits_error(pool: PgPool) -> anyhow::Result<()> {
365
    install_pool(&pool);
366
    let user_id = Uuid::new_v4();
367
    seed_user(&pool, user_id).await?;
368
    let from_id = seed_commodity(user_id, "USD", "US Dollar").await?;
369
    let to_id = seed_commodity(user_id, "EUR", "Euro").await?;
370
    seed_price(&pool, from_id, to_id, i64::MAX, 1).await?;
371
    let mut session = make_session(user_id);
372

            
373
    let amount = format!("{}/1", i64::MAX);
374
    let form = format!(r#"(convert-amount "{amount}" "{from_id}" "{to_id}")"#);
375
    let resp = eval(&mut session, &form).await;
376
    assert!(resp.contains(":error"), "expected overflow :error: {resp}");
377
    Ok(())
378
}
379

            
380
/// A stored price row with `value_denom == 0` (no DB CHECK forbids it) must
381
/// surface a `:error`, not panic in `Rational64::new`.
382
#[sqlx::test(migrator = "server::db::MIGRATOR")]
383
async fn convert_amount_zero_denom_price_emits_error(pool: PgPool) -> anyhow::Result<()> {
384
    install_pool(&pool);
385
    let user_id = Uuid::new_v4();
386
    seed_user(&pool, user_id).await?;
387
    let from_id = seed_commodity(user_id, "USD", "US Dollar").await?;
388
    let to_id = seed_commodity(user_id, "EUR", "Euro").await?;
389
    seed_price(&pool, from_id, to_id, 9, 0).await?;
390
    let mut session = make_session(user_id);
391

            
392
    let form = format!(r#"(convert-amount "100/1" "{from_id}" "{to_id}")"#);
393
    let resp = eval(&mut session, &form).await;
394
    assert!(
395
        resp.contains(":error"),
396
        "expected zero-denom :error: {resp}"
397
    );
398
    Ok(())
399
}