1
//! Integration tests for `create-transaction-logical`.
2
//!
3
//! Each test receives an isolated Postgres DB from `sqlx::test`, installs it
4
//! as the server-side test pool, seeds the minimum fixtures, then calls
5
//! `run_create_transaction_logical` directly and asserts the outcome.
6

            
7
use server::command::account::CreateAccount;
8
use server::command::commodity::CreateCommodity;
9
use server::command::{CmdResult, FinanceEntity};
10
use sqlx::PgPool;
11
use uuid::Uuid;
12

            
13
use super::run_logical::run_create_transaction_logical;
14

            
15
/// Install `pool` as the server-side test pool so every server command in this
16
/// thread uses the isolated test database.
17
7
fn install_pool(pool: &PgPool) {
18
7
    server::db::DB_POOL.with(|c| c.set(pool as *const _));
19
7
}
20

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

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

            
48
/// Create an account with the given name and return its UUID.
49
13
async fn seed_account(user_id: Uuid, name: &str) -> anyhow::Result<Uuid> {
50
13
    match CreateAccount::new()
51
13
        .name(name.to_string())
52
13
        .user_id(user_id)
53
13
        .run()
54
13
        .await?
55
    {
56
13
        Some(CmdResult::Entity(FinanceEntity::Account(a))) => Ok(a.id),
57
        other => anyhow::bail!("unexpected CreateAccount result: {other:?}"),
58
    }
59
13
}
60

            
61
#[sqlx::test(migrator = "server::db::MIGRATOR")]
62
async fn single_currency_two_split_succeeds(pool: PgPool) -> anyhow::Result<()> {
63
    install_pool(&pool);
64
    let user_id = Uuid::new_v4();
65
    seed_user(&pool, user_id).await?;
66
    let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
67
    let a_id = seed_account(user_id, "Assets").await?;
68
    let b_id = seed_account(user_id, "Expenses").await?;
69

            
70
    let payload = format!(
71
        "(:splits ((:from \"{a_id}\" :to \"{b_id}\" \
72
         :from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 100)))"
73
    );
74
    let tx_id_str = run_create_transaction_logical(user_id, Some(payload))
75
        .await
76
        .map_err(|e| anyhow::anyhow!("{e}"))?;
77

            
78
    let tx_id = Uuid::parse_str(&tx_id_str)?;
79
    assert!(!tx_id.is_nil(), "returned tx uuid must not be nil");
80

            
81
    let split_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM splits WHERE tx_id = $1")
82
        .bind(tx_id)
83
        .fetch_one(&pool)
84
        .await?;
85
    assert_eq!(split_count, 2, "single logical split → 2 physical splits");
86

            
87
    Ok(())
88
}
89

            
90
#[sqlx::test(migrator = "server::db::MIGRATOR")]
91
async fn cross_currency_records_price_row(pool: PgPool) -> anyhow::Result<()> {
92
    install_pool(&pool);
93
    let user_id = Uuid::new_v4();
94
    seed_user(&pool, user_id).await?;
95
    let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
96
    let eur_id = seed_commodity(user_id, "EUR", "Euro").await?;
97
    let a_id = seed_account(user_id, "Assets").await?;
98
    let b_id = seed_account(user_id, "Expenses").await?;
99

            
100
    // 100 USD → 92 EUR exchange
101
    let payload = format!(
102
        "(:note \"fx test\" :splits ((:from \"{a_id}\" :to \"{b_id}\" \
103
         :from-commodity \"{usd_id}\" :to-commodity \"{eur_id}\" \
104
         :value 100 :to-amount 92)))"
105
    );
106
    let tx_id_str = run_create_transaction_logical(user_id, Some(payload))
107
        .await
108
        .map_err(|e| anyhow::anyhow!("{e}"))?;
109

            
110
    let tx_id = Uuid::parse_str(&tx_id_str)?;
111
    assert!(!tx_id.is_nil());
112

            
113
    let price_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM prices")
114
        .fetch_one(&pool)
115
        .await?;
116
    assert_eq!(
117
        price_count, 1,
118
        "cross-currency split must record a price row"
119
    );
120

            
121
    Ok(())
122
}
123

            
124
#[sqlx::test(migrator = "server::db::MIGRATOR")]
125
async fn ambiguous_account_name_returns_error(pool: PgPool) -> anyhow::Result<()> {
126
    install_pool(&pool);
127
    let user_id = Uuid::new_v4();
128
    seed_user(&pool, user_id).await?;
129
    let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
130
    let b_id = seed_account(user_id, "Expenses").await?;
131
    // Create two accounts with identical name "Cash"
132
    seed_account(user_id, "Cash").await?;
133
    seed_account(user_id, "Cash").await?;
134

            
135
    let payload = format!(
136
        "(:splits ((:from \"Cash\" :to \"{b_id}\" \
137
         :from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 50)))"
138
    );
139
    let err = run_create_transaction_logical(user_id, Some(payload))
140
        .await
141
        .unwrap_err();
142

            
143
    assert!(
144
        err.to_string().contains("ambiguous account name"),
145
        "expected ambiguity error, got: {err}"
146
    );
147

            
148
    let tx_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM transactions")
149
        .fetch_one(&pool)
150
        .await?;
151
    assert_eq!(tx_count, 0, "no transaction must be created on error");
152

            
153
    Ok(())
154
}
155

            
156
#[sqlx::test(migrator = "server::db::MIGRATOR")]
157
async fn unknown_account_returns_error(pool: PgPool) -> anyhow::Result<()> {
158
    install_pool(&pool);
159
    let user_id = Uuid::new_v4();
160
    seed_user(&pool, user_id).await?;
161
    let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
162
    let b_id = seed_account(user_id, "Expenses").await?;
163

            
164
    let payload = format!(
165
        "(:splits ((:from \"NoSuchAccount\" :to \"{b_id}\" \
166
         :from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 10)))"
167
    );
168
    let err = run_create_transaction_logical(user_id, Some(payload))
169
        .await
170
        .unwrap_err();
171

            
172
    assert!(
173
        err.to_string().contains("unknown account"),
174
        "expected unknown-account error, got: {err}"
175
    );
176
    Ok(())
177
}
178

            
179
#[sqlx::test(migrator = "server::db::MIGRATOR")]
180
async fn unknown_commodity_returns_error(pool: PgPool) -> anyhow::Result<()> {
181
    install_pool(&pool);
182
    let user_id = Uuid::new_v4();
183
    seed_user(&pool, user_id).await?;
184
    let a_id = seed_account(user_id, "Assets").await?;
185
    let b_id = seed_account(user_id, "Expenses").await?;
186

            
187
    let payload = format!(
188
        "(:splits ((:from \"{a_id}\" :to \"{b_id}\" \
189
         :from-commodity \"GHOST\" :to-commodity \"GHOST\" :value 1)))"
190
    );
191
    let err = run_create_transaction_logical(user_id, Some(payload))
192
        .await
193
        .unwrap_err();
194

            
195
    assert!(
196
        err.to_string().contains("unknown commodity"),
197
        "expected unknown-commodity error, got: {err}"
198
    );
199
    Ok(())
200
}
201

            
202
#[sqlx::test(migrator = "server::db::MIGRATOR")]
203
async fn nonexistent_account_uuid_returns_error(pool: PgPool) -> anyhow::Result<()> {
204
    install_pool(&pool);
205
    let user_id = Uuid::new_v4();
206
    seed_user(&pool, user_id).await?;
207
    let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
208
    let b_id = seed_account(user_id, "Expenses").await?;
209
    // A syntactically valid uuid that belongs to no account.
210
    let ghost = Uuid::new_v4();
211

            
212
    let payload = format!(
213
        "(:splits ((:from \"{ghost}\" :to \"{b_id}\" \
214
         :from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 50)))"
215
    );
216
    let err = run_create_transaction_logical(user_id, Some(payload))
217
        .await
218
        .unwrap_err();
219

            
220
    assert!(
221
        err.to_string().contains("unknown account"),
222
        "expected unknown-account error for a nonexistent uuid, got: {err}"
223
    );
224

            
225
    let tx_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM transactions")
226
        .fetch_one(&pool)
227
        .await?;
228
    assert_eq!(tx_count, 0, "no transaction must be created on error");
229
    Ok(())
230
}
231

            
232
#[sqlx::test(migrator = "server::db::MIGRATOR")]
233
async fn missing_to_amount_on_cross_currency_returns_error(pool: PgPool) -> anyhow::Result<()> {
234
    install_pool(&pool);
235
    let user_id = Uuid::new_v4();
236
    seed_user(&pool, user_id).await?;
237
    let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
238
    let eur_id = seed_commodity(user_id, "EUR", "Euro").await?;
239
    let a_id = seed_account(user_id, "Assets").await?;
240
    let b_id = seed_account(user_id, "Expenses").await?;
241

            
242
    // No :to-amount on a cross-currency split
243
    let payload = format!(
244
        "(:splits ((:from \"{a_id}\" :to \"{b_id}\" \
245
         :from-commodity \"{usd_id}\" :to-commodity \"{eur_id}\" :value 100)))"
246
    );
247
    let err = run_create_transaction_logical(user_id, Some(payload))
248
        .await
249
        .unwrap_err();
250

            
251
    assert!(
252
        err.to_string().contains("to_amount") || err.to_string().contains("required"),
253
        "expected missing-to-amount error, got: {err}"
254
    );
255
    Ok(())
256
}