1
//! Integration tests for `update-transaction-logical` and the prices-forward
2
//! fix in physical `update-transaction`.
3
//!
4
//! Each test receives an isolated Postgres DB from `sqlx::test`, installs it
5
//! as the server-side test pool, seeds fixtures, and exercises the native.
6

            
7
use chrono::{DateTime, TimeZone, Utc};
8
use server::command::account::CreateAccount;
9
use server::command::commodity::CreateCommodity;
10
use server::command::transaction::CreateTransaction;
11
use server::command::{CmdResult, FinanceEntity};
12
use sqlx::PgPool;
13
use uuid::Uuid;
14

            
15
use super::run::run_update_transaction;
16
use super::run_logical::{run_create_transaction_logical, run_update_transaction_logical};
17

            
18
8
fn install_pool(pool: &PgPool) {
19
8
    server::db::DB_POOL.with(|c| c.set(pool as *const _));
20
8
}
21

            
22
8
async fn seed_user(pool: &PgPool, user_id: Uuid) -> anyhow::Result<()> {
23
8
    sqlx::query(
24
8
        "INSERT INTO users \
25
8
         (id, user_name, email, photo, verified, user_password, user_role, db_name, created_at) \
26
8
         VALUES ($1, 'Test', 'test@test.com', 'x', false, 'pw', 'user', 'db', NOW())",
27
8
    )
28
8
    .bind(user_id)
29
8
    .execute(pool)
30
8
    .await?;
31
8
    Ok(())
32
8
}
33

            
34
11
async fn seed_commodity(user_id: Uuid, symbol: &str, name: &str) -> anyhow::Result<Uuid> {
35
11
    match CreateCommodity::new()
36
11
        .symbol(symbol.to_string())
37
11
        .name(name.to_string())
38
11
        .user_id(user_id)
39
11
        .run()
40
11
        .await?
41
    {
42
11
        Some(CmdResult::String(id)) => Ok(Uuid::parse_str(&id)?),
43
        other => anyhow::bail!("unexpected CreateCommodity result: {other:?}"),
44
    }
45
11
}
46

            
47
20
async fn seed_account(user_id: Uuid, name: &str) -> anyhow::Result<Uuid> {
48
20
    match CreateAccount::new()
49
20
        .name(name.to_string())
50
20
        .user_id(user_id)
51
20
        .run()
52
20
        .await?
53
    {
54
20
        Some(CmdResult::Entity(FinanceEntity::Account(a))) => Ok(a.id),
55
        other => anyhow::bail!("unexpected CreateAccount result: {other:?}"),
56
    }
57
20
}
58

            
59
/// Create a minimal two-split transaction with the given post_date, return its UUID.
60
6
async fn seed_transaction(
61
6
    user_id: Uuid,
62
6
    a_id: Uuid,
63
6
    b_id: Uuid,
64
6
    c_id: Uuid,
65
6
    post_date: DateTime<Utc>,
66
6
) -> anyhow::Result<Uuid> {
67
6
    let tx_id = Uuid::new_v4();
68
6
    let split1 = finance::split::Split {
69
6
        id: Uuid::new_v4(),
70
6
        tx_id,
71
6
        account_id: a_id,
72
6
        commodity_id: c_id,
73
6
        value_num: -100,
74
6
        value_denom: 1,
75
6
        reconcile_state: None,
76
6
        reconcile_date: None,
77
6
        lot_id: None,
78
6
    };
79
6
    let split2 = finance::split::Split {
80
6
        id: Uuid::new_v4(),
81
6
        tx_id,
82
6
        account_id: b_id,
83
6
        commodity_id: c_id,
84
6
        value_num: 100,
85
6
        value_denom: 1,
86
6
        reconcile_state: None,
87
6
        reconcile_date: None,
88
6
        lot_id: None,
89
6
    };
90
6
    let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
91
6
    match CreateTransaction::new()
92
6
        .user_id(user_id)
93
6
        .id(tx_id)
94
6
        .post_date(post_date)
95
6
        .enter_date(Utc::now())
96
6
        .splits(splits)
97
6
        .run()
98
6
        .await?
99
    {
100
6
        Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) => Ok(tx.id),
101
        other => anyhow::bail!("unexpected CreateTransaction result: {other:?}"),
102
    }
103
6
}
104

            
105
// ── Deliverable 1 regression: physical update-transaction retains prices ──
106

            
107
#[sqlx::test(migrator = "server::db::MIGRATOR")]
108
async fn physical_update_transaction_retains_prices(pool: PgPool) -> anyhow::Result<()> {
109
    install_pool(&pool);
110
    let user_id = Uuid::new_v4();
111
    seed_user(&pool, user_id).await?;
112
    let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
113
    let eur_id = seed_commodity(user_id, "EUR", "Euro").await?;
114
    let a_id = seed_account(user_id, "Assets").await?;
115
    let b_id = seed_account(user_id, "Expenses").await?;
116

            
117
    // Create with logical to get a proper cross-currency transaction
118
    let create_payload = format!(
119
        "(:splits ((:from \"{a_id}\" :to \"{b_id}\" \
120
         :from-commodity \"{usd_id}\" :to-commodity \"{eur_id}\" \
121
         :value 100 :to-amount 92)))"
122
    );
123
    let tx_id_str = run_create_transaction_logical(user_id, Some(create_payload))
124
        .await
125
        .map_err(|e| anyhow::anyhow!("{e}"))?;
126
    let tx_id = Uuid::parse_str(&tx_id_str)?;
127

            
128
    let split1_id = Uuid::new_v4();
129
    let split2_id = Uuid::new_v4();
130

            
131
    // Now update via physical payload, supplying new splits AND prices
132
    let update_payload = format!(
133
        r#"(:transaction-id "{tx_id}"
134
           :splits ((:id "{split1_id}"
135
                     :account-id "{a_id}"
136
                     :commodity-id "{usd_id}"
137
                     :value -10000/100)
138
                    (:id "{split2_id}"
139
                     :account-id "{b_id}"
140
                     :commodity-id "{eur_id}"
141
                     :value 9200/100))
142
           :prices ((:commodity-id "{eur_id}"
143
                     :currency-id "{usd_id}"
144
                     :commodity-split "{split2_id}"
145
                     :currency-split "{split1_id}"
146
                     :value-num 9200
147
                     :value-denom 10000)))"#
148
    );
149
    run_update_transaction(user_id, Some(update_payload))
150
        .await
151
        .map_err(|e| anyhow::anyhow!("{e}"))?;
152

            
153
    let price_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM prices")
154
        .fetch_one(&pool)
155
        .await?;
156
    assert_eq!(
157
        price_count, 1,
158
        "prices must be retained after a physical update-transaction with :prices"
159
    );
160

            
161
    Ok(())
162
}
163

            
164
// ── Deliverable 2: update-transaction-logical ──
165

            
166
#[sqlx::test(migrator = "server::db::MIGRATOR")]
167
async fn logical_update_single_currency_edits_splits(pool: PgPool) -> anyhow::Result<()> {
168
    install_pool(&pool);
169
    let user_id = Uuid::new_v4();
170
    seed_user(&pool, user_id).await?;
171
    let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
172
    let a_id = seed_account(user_id, "Assets").await?;
173
    let b_id = seed_account(user_id, "Expenses").await?;
174
    let c_id = seed_account(user_id, "Income").await?;
175

            
176
    let tx_id = seed_transaction(user_id, a_id, b_id, usd_id, Utc::now()).await?;
177

            
178
    // Update: redirect the flow from Assets→Income instead
179
    let update_payload = format!(
180
        r#"(:transaction-id "{tx_id}"
181
           :splits ((:from "{a_id}" :to "{c_id}"
182
                     :from-commodity "{usd_id}" :to-commodity "{usd_id}"
183
                     :value 50)))"#
184
    );
185
    let returned_id = run_update_transaction_logical(user_id, Some(update_payload))
186
        .await
187
        .map_err(|e| anyhow::anyhow!("{e}"))?;
188

            
189
    assert_eq!(returned_id, tx_id.to_string(), "must return the same tx id");
190

            
191
    let split_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM splits WHERE tx_id = $1")
192
        .bind(tx_id)
193
        .fetch_one(&pool)
194
        .await?;
195
    assert_eq!(split_count, 2, "single logical split → 2 physical splits");
196

            
197
    let income_split_count = sqlx::query_scalar::<_, i64>(
198
        "SELECT COUNT(*) FROM splits WHERE tx_id = $1 AND account_id = $2",
199
    )
200
    .bind(tx_id)
201
    .bind(c_id)
202
    .fetch_one(&pool)
203
    .await?;
204
    assert_eq!(
205
        income_split_count, 1,
206
        "new split must target Income account"
207
    );
208

            
209
    Ok(())
210
}
211

            
212
#[sqlx::test(migrator = "server::db::MIGRATOR")]
213
async fn logical_update_cross_currency_retains_price(pool: PgPool) -> anyhow::Result<()> {
214
    install_pool(&pool);
215
    let user_id = Uuid::new_v4();
216
    seed_user(&pool, user_id).await?;
217
    let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
218
    let eur_id = seed_commodity(user_id, "EUR", "Euro").await?;
219
    let a_id = seed_account(user_id, "Assets").await?;
220
    let b_id = seed_account(user_id, "Expenses").await?;
221

            
222
    let tx_id = seed_transaction(user_id, a_id, b_id, usd_id, Utc::now()).await?;
223

            
224
    let update_payload = format!(
225
        r#"(:transaction-id "{tx_id}"
226
           :splits ((:from "{a_id}" :to "{b_id}"
227
                     :from-commodity "{usd_id}" :to-commodity "{eur_id}"
228
                     :value 100 :to-amount 92)))"#
229
    );
230
    run_update_transaction_logical(user_id, Some(update_payload))
231
        .await
232
        .map_err(|e| anyhow::anyhow!("{e}"))?;
233

            
234
    let price_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM prices")
235
        .fetch_one(&pool)
236
        .await?;
237
    assert_eq!(
238
        price_count, 1,
239
        "cross-currency logical update must record a price row"
240
    );
241

            
242
    Ok(())
243
}
244

            
245
#[sqlx::test(migrator = "server::db::MIGRATOR")]
246
async fn logical_update_unknown_account_returns_error(pool: PgPool) -> anyhow::Result<()> {
247
    install_pool(&pool);
248
    let user_id = Uuid::new_v4();
249
    seed_user(&pool, user_id).await?;
250
    let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
251
    let a_id = seed_account(user_id, "Assets").await?;
252
    let b_id = seed_account(user_id, "Expenses").await?;
253

            
254
    let tx_id = seed_transaction(user_id, a_id, b_id, usd_id, Utc::now()).await?;
255

            
256
    let update_payload = format!(
257
        r#"(:transaction-id "{tx_id}"
258
           :splits ((:from "NoSuchAccount" :to "{b_id}"
259
                     :from-commodity "{usd_id}" :to-commodity "{usd_id}"
260
                     :value 10)))"#
261
    );
262
    let err = run_update_transaction_logical(user_id, Some(update_payload))
263
        .await
264
        .unwrap_err();
265

            
266
    assert!(
267
        err.to_string().contains("unknown account"),
268
        "expected unknown-account error, got: {err}"
269
    );
270

            
271
    let split_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM splits WHERE tx_id = $1")
272
        .bind(tx_id)
273
        .fetch_one(&pool)
274
        .await?;
275
    assert_eq!(split_count, 2, "transaction must be unchanged on error");
276

            
277
    Ok(())
278
}
279

            
280
#[sqlx::test(migrator = "server::db::MIGRATOR")]
281
async fn logical_update_missing_transaction_id_returns_error(pool: PgPool) -> anyhow::Result<()> {
282
    install_pool(&pool);
283
    let user_id = Uuid::new_v4();
284
    seed_user(&pool, user_id).await?;
285
    let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
286
    let a_id = seed_account(user_id, "Assets").await?;
287
    let b_id = seed_account(user_id, "Expenses").await?;
288

            
289
    let bad_payload = format!(
290
        r#"(:splits ((:from "{a_id}" :to "{b_id}"
291
                     :from-commodity "{usd_id}" :to-commodity "{usd_id}"
292
                     :value 10)))"#
293
    );
294
    let err = run_update_transaction_logical(user_id, Some(bad_payload))
295
        .await
296
        .unwrap_err();
297

            
298
    assert!(
299
        err.to_string().contains("transaction-id"),
300
        "expected missing-transaction-id error, got: {err}"
301
    );
302

            
303
    Ok(())
304
}
305

            
306
#[sqlx::test(migrator = "server::db::MIGRATOR")]
307
async fn logical_update_ambiguous_account_returns_error(pool: PgPool) -> anyhow::Result<()> {
308
    install_pool(&pool);
309
    let user_id = Uuid::new_v4();
310
    seed_user(&pool, user_id).await?;
311
    let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
312
    let a_id = seed_account(user_id, "Assets").await?;
313
    let b_id = seed_account(user_id, "Expenses").await?;
314
    seed_account(user_id, "Cash").await?;
315
    seed_account(user_id, "Cash").await?;
316

            
317
    let tx_id = seed_transaction(user_id, a_id, b_id, usd_id, Utc::now()).await?;
318

            
319
    let update_payload = format!(
320
        r#"(:transaction-id "{tx_id}"
321
           :splits ((:from "Cash" :to "{b_id}"
322
                     :from-commodity "{usd_id}" :to-commodity "{usd_id}"
323
                     :value 5)))"#
324
    );
325
    let err = run_update_transaction_logical(user_id, Some(update_payload))
326
        .await
327
        .unwrap_err();
328

            
329
    assert!(
330
        err.to_string().contains("ambiguous account name"),
331
        "expected ambiguous-account error, got: {err}"
332
    );
333

            
334
    Ok(())
335
}
336

            
337
// ── FIX 1 (MAJOR): physical update rejects a price referencing a foreign split ──
338

            
339
#[sqlx::test(migrator = "server::db::MIGRATOR")]
340
async fn physical_update_rejects_price_with_foreign_split(pool: PgPool) -> anyhow::Result<()> {
341
    install_pool(&pool);
342
    let user_id = Uuid::new_v4();
343
    seed_user(&pool, user_id).await?;
344
    let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
345
    let eur_id = seed_commodity(user_id, "EUR", "Euro").await?;
346
    let a_id = seed_account(user_id, "Assets").await?;
347
    let b_id = seed_account(user_id, "Expenses").await?;
348

            
349
    let tx_id = seed_transaction(user_id, a_id, b_id, usd_id, Utc::now()).await?;
350

            
351
    let split1_id = Uuid::new_v4();
352
    let split2_id = Uuid::new_v4();
353
    // currency_split references a uuid that is NOT in the replacement split set.
354
    let foreign_split = Uuid::new_v4();
355

            
356
    let update_payload = format!(
357
        r#"(:transaction-id "{tx_id}"
358
           :splits ((:id "{split1_id}"
359
                     :account-id "{a_id}"
360
                     :commodity-id "{usd_id}"
361
                     :value -10000/100)
362
                    (:id "{split2_id}"
363
                     :account-id "{b_id}"
364
                     :commodity-id "{eur_id}"
365
                     :value 9200/100))
366
           :prices ((:commodity-id "{eur_id}"
367
                     :currency-id "{usd_id}"
368
                     :commodity-split "{split2_id}"
369
                     :currency-split "{foreign_split}"
370
                     :value-num 9200
371
                     :value-denom 10000)))"#
372
    );
373
    let err = run_update_transaction(user_id, Some(update_payload))
374
        .await
375
        .unwrap_err();
376

            
377
    assert!(
378
        err.to_string().contains("not part of this transaction"),
379
        "expected price-split-membership error, got: {err}"
380
    );
381

            
382
    // Atomic rollback: the original two splits survive, no prices, no foreign split.
383
    let split_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM splits WHERE tx_id = $1")
384
        .bind(tx_id)
385
        .fetch_one(&pool)
386
        .await?;
387
    assert_eq!(split_count, 2, "original splits must be unchanged on error");
388

            
389
    let replacement_exists =
390
        sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM splits WHERE id = $1")
391
            .bind(split1_id)
392
            .fetch_one(&pool)
393
            .await?;
394
    assert_eq!(replacement_exists, 0, "replacement split must not persist");
395

            
396
    let price_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM prices")
397
        .fetch_one(&pool)
398
        .await?;
399
    assert_eq!(price_count, 0, "no price may persist on a rejected update");
400

            
401
    Ok(())
402
}
403

            
404
// ── FIX 2 (MINOR): logical update preserves dates unless :date is given ──
405

            
406
#[sqlx::test(migrator = "server::db::MIGRATOR")]
407
async fn logical_update_preserves_post_date_without_date(pool: PgPool) -> anyhow::Result<()> {
408
    install_pool(&pool);
409
    let user_id = Uuid::new_v4();
410
    seed_user(&pool, user_id).await?;
411
    let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
412
    let a_id = seed_account(user_id, "Assets").await?;
413
    let b_id = seed_account(user_id, "Expenses").await?;
414
    let c_id = seed_account(user_id, "Income").await?;
415

            
416
    let original = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).single().unwrap();
417
    let tx_id = seed_transaction(user_id, a_id, b_id, usd_id, original).await?;
418

            
419
    // Split-only edit — no :date — must leave the stored post_date intact.
420
    let no_date_payload = format!(
421
        r#"(:transaction-id "{tx_id}"
422
           :splits ((:from "{a_id}" :to "{c_id}"
423
                     :from-commodity "{usd_id}" :to-commodity "{usd_id}"
424
                     :value 50)))"#
425
    );
426
    run_update_transaction_logical(user_id, Some(no_date_payload))
427
        .await
428
        .map_err(|e| anyhow::anyhow!("{e}"))?;
429

            
430
    let after_no_date =
431
        sqlx::query_scalar::<_, DateTime<Utc>>("SELECT post_date FROM transactions WHERE id = $1")
432
            .bind(tx_id)
433
            .fetch_one(&pool)
434
            .await?;
435
    assert_eq!(
436
        after_no_date, original,
437
        "a split-only edit must not rewrite post_date"
438
    );
439

            
440
    // Now supply :date — the stored post_date must change to it.
441
    let with_date_payload = format!(
442
        r#"(:transaction-id "{tx_id}"
443
           :date "2021-06-15"
444
           :splits ((:from "{a_id}" :to "{c_id}"
445
                     :from-commodity "{usd_id}" :to-commodity "{usd_id}"
446
                     :value 50)))"#
447
    );
448
    run_update_transaction_logical(user_id, Some(with_date_payload))
449
        .await
450
        .map_err(|e| anyhow::anyhow!("{e}"))?;
451

            
452
    let after_with_date =
453
        sqlx::query_scalar::<_, DateTime<Utc>>("SELECT post_date FROM transactions WHERE id = $1")
454
            .bind(tx_id)
455
            .fetch_one(&pool)
456
            .await?;
457
    let expected = Utc.with_ymd_and_hms(2021, 6, 15, 0, 0, 0).single().unwrap();
458
    assert_eq!(
459
        after_with_date, expected,
460
        "an explicit :date must rewrite post_date"
461
    );
462

            
463
    Ok(())
464
}