1
use chrono::TimeZone;
2
use finance::tag::Tag;
3
use finance::transaction::Transaction;
4
use server::command::{CmdResult, FinanceEntity, PaginationInfo};
5
use std::collections::HashMap;
6
use uuid::Uuid;
7

            
8
use super::parse::{
9
    parse_account_filter_arg, parse_create_transaction_payload, parse_transaction_id_arg,
10
    parse_update_transaction_payload,
11
};
12
use super::render::{format_tagged_transactions, list_transaction_entries};
13
use super::run::{run_create_transaction, run_delete_transaction, run_update_transaction};
14

            
15
4
fn tx_entity(id: Uuid) -> FinanceEntity {
16
4
    let post = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
17
4
    let enter = chrono::Utc.with_ymd_and_hms(2026, 5, 2, 9, 30, 0).unwrap();
18
4
    FinanceEntity::Transaction(Transaction {
19
4
        id,
20
4
        post_date: post,
21
4
        enter_date: enter,
22
4
    })
23
4
}
24

            
25
#[test]
26
1
fn format_empty_list_with_no_pagination() {
27
1
    assert_eq!(
28
1
        format_tagged_transactions(&[], None),
29
        "(:transactions () :pagination nil)"
30
    );
31
1
}
32

            
33
#[test]
34
1
fn format_single_transaction_with_note_and_pagination() {
35
1
    let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
36
1
    let mut tags = HashMap::new();
37
1
    tags.insert(
38
1
        "note".to_string(),
39
1
        FinanceEntity::Tag(Tag {
40
1
            id: Uuid::nil(),
41
1
            tag_name: "note".into(),
42
1
            tag_value: "groceries".into(),
43
1
            description: None,
44
1
        }),
45
    );
46
1
    let pagination = PaginationInfo {
47
1
        total_count: 1,
48
1
        limit: 20,
49
1
        offset: 0,
50
1
        has_more: false,
51
1
    };
52
1
    let out = format_tagged_transactions(&[(tx_entity(id), tags)], Some(&pagination));
53
1
    assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
54
1
    assert!(out.contains(":post-date \"2026-05-01T12:00:00+00:00\""));
55
1
    assert!(out.contains(":enter-date \"2026-05-02T09:30:00+00:00\""));
56
1
    assert!(out.contains(":note \"groceries\""));
57
1
    assert!(out.contains(":pagination (:total 1 :limit 20 :offset 0 :has-more nil)"));
58
1
}
59

            
60
#[test]
61
1
fn format_transaction_without_tags_omits_note() {
62
1
    let id = Uuid::nil();
63
1
    let out = format_tagged_transactions(&[(tx_entity(id), HashMap::new())], None);
64
1
    assert!(out.contains(":id \"00000000-0000-0000-0000-000000000000\""));
65
1
    assert!(!out.contains(":note"));
66
1
    assert!(out.ends_with(":pagination nil)"));
67
1
}
68

            
69
#[test]
70
1
fn parse_transaction_id_rejects_missing_arg() {
71
1
    let err = parse_transaction_id_arg(None).unwrap_err();
72
1
    assert!(err.to_string().contains("missing or empty"), "got: {err}");
73
1
}
74

            
75
#[test]
76
1
fn parse_transaction_id_rejects_invalid_uuid() {
77
1
    let err = parse_transaction_id_arg(Some("not-a-uuid".into())).unwrap_err();
78
1
    assert!(err.to_string().contains("invalid uuid"), "got: {err}");
79
1
}
80

            
81
#[tokio::test]
82
1
async fn run_delete_transaction_with_no_arg_emits_error() {
83
1
    let err = run_delete_transaction(Uuid::nil(), None).await.unwrap_err();
84
1
    assert!(err.to_string().contains("missing or empty"), "got: {err}");
85
1
}
86

            
87
#[tokio::test]
88
1
async fn run_delete_transaction_with_invalid_uuid_emits_error() {
89
1
    let err = run_delete_transaction(Uuid::nil(), Some("not-uuid".into()))
90
1
        .await
91
1
        .unwrap_err();
92
1
    assert!(err.to_string().contains("invalid uuid"), "got: {err}");
93
1
}
94

            
95
#[test]
96
1
fn parse_payload_split_id_preserved_when_given() {
97
1
    let src = r#"(:post-date "2026-01-15T00:00:00Z"
98
1
        :splits ((:id "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
99
1
                  :account-id "11111111-1111-1111-1111-111111111111"
100
1
                  :commodity-id "22222222-2222-2222-2222-222222222222"
101
1
                  :value -1)
102
1
                 (:account-id "33333333-3333-3333-3333-333333333333"
103
1
                  :commodity-id "22222222-2222-2222-2222-222222222222"
104
1
                  :value 1)))"#;
105
1
    let parsed = parse_create_transaction_payload(src).expect("parse");
106
1
    assert_eq!(
107
1
        parsed.splits[0].id.to_string(),
108
        "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
109
    );
110
1
    assert_ne!(parsed.splits[1].id, Uuid::nil());
111
1
}
112

            
113
#[test]
114
1
fn parse_payload_cross_currency_with_prices_and_split_ids() {
115
1
    let src = r#"(:post-date "2026-01-15T00:00:00Z"
116
1
        :splits ((:id "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
117
1
                  :account-id "11111111-1111-1111-1111-111111111111"
118
1
                  :commodity-id "22222222-2222-2222-2222-222222222222"
119
1
                  :value -10000/100)
120
1
                 (:id "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
121
1
                  :account-id "33333333-3333-3333-3333-333333333333"
122
1
                  :commodity-id "44444444-4444-4444-4444-444444444444"
123
1
                  :value 9000/100))
124
1
        :prices ((:commodity-id "44444444-4444-4444-4444-444444444444"
125
1
                  :currency-id "22222222-2222-2222-2222-222222222222"
126
1
                  :commodity-split "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
127
1
                  :currency-split "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
128
1
                  :value-num 9000
129
1
                  :value-denom 10000)))"#;
130
1
    let parsed = parse_create_transaction_payload(src).expect("parse");
131
1
    assert_eq!(parsed.splits.len(), 2);
132
1
    assert_eq!(
133
1
        parsed.splits[0].id.to_string(),
134
        "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
135
    );
136
1
    assert_eq!(
137
1
        parsed.splits[1].id.to_string(),
138
        "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
139
    );
140
1
    assert_eq!(parsed.prices.len(), 1);
141
1
    assert_eq!(
142
1
        parsed.prices[0].commodity_id.to_string(),
143
        "44444444-4444-4444-4444-444444444444"
144
    );
145
1
    assert_eq!(
146
1
        parsed.prices[0].currency_id.to_string(),
147
        "22222222-2222-2222-2222-222222222222"
148
    );
149
1
    assert_eq!(
150
1
        parsed.prices[0].commodity_split,
151
1
        Some(Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").expect("test uuid"))
152
    );
153
1
    assert_eq!(
154
1
        parsed.prices[0].currency_split,
155
1
        Some(Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").expect("test uuid"))
156
    );
157
1
    assert_eq!(parsed.prices[0].value_num, 9000);
158
1
    assert_eq!(parsed.prices[0].value_denom, 10000);
159
1
    assert_eq!(
160
1
        parsed.prices[0].date.to_rfc3339(),
161
        "2026-01-15T00:00:00+00:00"
162
    );
163
1
}
164

            
165
#[test]
166
1
fn parse_payload_price_with_explicit_date() {
167
1
    let src = r#"(:post-date "2026-01-15T00:00:00Z"
168
1
        :splits ((:id "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
169
1
                  :account-id "11111111-1111-1111-1111-111111111111"
170
1
                  :commodity-id "22222222-2222-2222-2222-222222222222"
171
1
                  :value -1)
172
1
                 (:id "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
173
1
                  :account-id "33333333-3333-3333-3333-333333333333"
174
1
                  :commodity-id "44444444-4444-4444-4444-444444444444"
175
1
                  :value 1))
176
1
        :prices ((:commodity-id "44444444-4444-4444-4444-444444444444"
177
1
                  :currency-id "22222222-2222-2222-2222-222222222222"
178
1
                  :commodity-split "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
179
1
                  :currency-split "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
180
1
                  :date "2026-03-01T00:00:00Z"
181
1
                  :value-num 100
182
1
                  :value-denom 90)))"#;
183
1
    let parsed = parse_create_transaction_payload(src).expect("parse");
184
1
    assert_eq!(
185
1
        parsed.prices[0].date.to_rfc3339(),
186
        "2026-03-01T00:00:00+00:00"
187
    );
188
1
}
189

            
190
#[test]
191
1
fn parse_account_filter_none_and_empty_yield_no_filter() {
192
1
    assert!(parse_account_filter_arg(None).expect("none").is_none());
193
1
    assert!(
194
1
        parse_account_filter_arg(Some(String::new()))
195
1
            .expect("empty")
196
1
            .is_none()
197
    );
198
1
}
199

            
200
#[test]
201
1
fn parse_account_filter_valid_uuid_is_passed_through() {
202
1
    let raw = "550e8400-e29b-41d4-a716-446655440000";
203
1
    let parsed = parse_account_filter_arg(Some(raw.into())).expect("valid uuid");
204
1
    assert_eq!(parsed.map(|u| u.to_string()).as_deref(), Some(raw));
205
1
}
206

            
207
#[test]
208
1
fn parse_account_filter_invalid_uuid_emits_error() {
209
1
    let err = parse_account_filter_arg(Some("not-a-uuid".into())).unwrap_err();
210
1
    assert!(
211
1
        err.to_string().contains("invalid account uuid"),
212
        "got: {err}"
213
    );
214
1
}
215

            
216
#[test]
217
1
fn parse_payload_minimal_two_splits() {
218
1
    let src = r#"(:post-date "2026-01-15T00:00:00Z"
219
1
        :splits ((:account-id "11111111-1111-1111-1111-111111111111"
220
1
                  :commodity-id "22222222-2222-2222-2222-222222222222"
221
1
                  :value -5000/100)
222
1
                 (:account-id "33333333-3333-3333-3333-333333333333"
223
1
                  :commodity-id "22222222-2222-2222-2222-222222222222"
224
1
                  :value 5000/100)))"#;
225
1
    let parsed = parse_create_transaction_payload(src).expect("parse");
226
1
    assert_eq!(parsed.splits.len(), 2);
227
1
    assert_eq!(parsed.splits[0].value_num, -50);
228
1
    assert_eq!(parsed.splits[0].value_denom, 1);
229
1
    assert_eq!(parsed.splits[1].value_num, 50);
230
1
}
231

            
232
#[test]
233
1
fn parse_payload_picks_up_note() {
234
1
    let src = r#"(:post-date "2026-01-15T00:00:00Z"
235
1
        :note "groceries"
236
1
        :splits ((:account-id "11111111-1111-1111-1111-111111111111"
237
1
                  :commodity-id "22222222-2222-2222-2222-222222222222"
238
1
                  :value -1)
239
1
                 (:account-id "33333333-3333-3333-3333-333333333333"
240
1
                  :commodity-id "22222222-2222-2222-2222-222222222222"
241
1
                  :value 1)))"#;
242
1
    let parsed = parse_create_transaction_payload(src).expect("parse");
243
1
    assert_eq!(parsed.note.as_deref(), Some("groceries"));
244
1
}
245

            
246
#[test]
247
1
fn parse_payload_rejects_missing_post_date() {
248
1
    let src = r#"(:splits ((:account-id "x" :commodity-id "y" :value 1)
249
1
                            (:account-id "x" :commodity-id "y" :value -1)))"#;
250
1
    let err = parse_create_transaction_payload(src).unwrap_err();
251
1
    assert!(err.contains(":post-date"), "got: {err}");
252
1
}
253

            
254
#[test]
255
1
fn parse_payload_rejects_single_split() {
256
1
    let src = r#"(:post-date "2026-01-15T00:00:00Z"
257
1
                  :splits ((:account-id "x" :commodity-id "y" :value 1)))"#;
258
1
    let err = parse_create_transaction_payload(src).unwrap_err();
259
1
    assert!(err.contains("at least two"), "got: {err}");
260
1
}
261

            
262
#[tokio::test]
263
1
async fn run_create_transaction_with_no_payload_emits_error() {
264
1
    let err = run_create_transaction(Uuid::nil(), None).await.unwrap_err();
265
1
    assert!(err.to_string().contains("missing or empty"), "got: {err}");
266
1
}
267

            
268
#[tokio::test]
269
1
async fn run_create_transaction_with_garbage_payload_surfaces_parse_error() {
270
1
    let err = run_create_transaction(Uuid::nil(), Some("not-an-sexpr".into()))
271
1
        .await
272
1
        .unwrap_err();
273
1
    assert!(err.to_string().contains("create-transaction"), "got: {err}");
274
1
}
275

            
276
#[test]
277
1
fn parse_update_payload_minimum_just_id() {
278
1
    let src = r#"(:transaction-id "550e8400-e29b-41d4-a716-446655440000")"#;
279
1
    let parsed = parse_update_transaction_payload(src).expect("parse");
280
1
    assert_eq!(
281
1
        parsed.transaction_id.to_string(),
282
        "550e8400-e29b-41d4-a716-446655440000"
283
    );
284
1
    assert!(parsed.note.is_none());
285
1
    assert!(parsed.splits.is_none());
286
1
}
287

            
288
#[test]
289
1
fn parse_update_payload_rejects_missing_transaction_id() {
290
1
    let src = r#"(:note "x")"#;
291
1
    let err = parse_update_transaction_payload(src).unwrap_err();
292
1
    assert!(err.contains(":transaction-id"), "got: {err}");
293
1
}
294

            
295
#[test]
296
1
fn parse_update_payload_carries_partial_fields() {
297
1
    let src = r#"(:transaction-id "550e8400-e29b-41d4-a716-446655440000"
298
1
                  :post-date "2026-05-11T12:00:00Z"
299
1
                  :note "edited")"#;
300
1
    let parsed = parse_update_transaction_payload(src).expect("parse");
301
1
    assert!(parsed.post_date.is_some());
302
1
    assert_eq!(parsed.note.as_deref(), Some("edited"));
303
1
    assert!(parsed.splits.is_none());
304
1
}
305

            
306
#[tokio::test]
307
1
async fn run_update_transaction_with_no_payload_emits_error() {
308
1
    let err = run_update_transaction(Uuid::nil(), None).await.unwrap_err();
309
1
    assert!(err.to_string().contains("missing or empty"), "got: {err}");
310
1
}
311

            
312
#[test]
313
1
fn format_pagination_has_more_emits_t() {
314
1
    let pagination = PaginationInfo {
315
1
        total_count: 100,
316
1
        limit: 20,
317
1
        offset: 0,
318
1
        has_more: true,
319
1
    };
320
1
    let out = format_tagged_transactions(&[], Some(&pagination));
321
1
    assert!(out.contains(":has-more t)"));
322
1
}
323

            
324
2
fn user_tag(name: &str, value: &str) -> FinanceEntity {
325
2
    FinanceEntity::Tag(Tag {
326
2
        id: Uuid::nil(),
327
2
        tag_name: name.into(),
328
2
        tag_value: value.into(),
329
2
        description: None,
330
2
    })
331
2
}
332

            
333
#[test]
334
1
fn list_transaction_entries_extracts_amount_from_dedicated_slot() {
335
1
    let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
336
1
    let result = Ok(Some(CmdResult::TaggedTransactions {
337
1
        entities: vec![(tx_entity(id), HashMap::new(), Some("42 EUR".to_string()))],
338
1
        pagination: None,
339
1
    }));
340
1
    let entries = list_transaction_entries("test", result).unwrap();
341
1
    assert_eq!(entries.len(), 1);
342
1
    let (_, _, amount, _) = &entries[0];
343
1
    assert_eq!(amount.as_deref(), Some("42 EUR"));
344
1
}
345

            
346
/// Regression for the amount/user-tag collision (review issue #3): a
347
/// transaction carrying a genuine user tag named `amount` must keep that
348
/// tag untouched while the computed summary rides its own slot.
349
#[test]
350
1
fn computed_amount_is_independent_of_user_amount_tag() {
351
1
    let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
352
1
    let mut tags = HashMap::new();
353
1
    tags.insert("amount".to_string(), user_tag("amount", "user-private"));
354
1
    tags.insert("note".to_string(), user_tag("note", "lunch"));
355
1
    let result = Ok(Some(CmdResult::TaggedTransactions {
356
1
        entities: vec![(tx_entity(id), tags, Some("100 USD".to_string()))],
357
1
        pagination: None,
358
1
    }));
359
1
    let entries = list_transaction_entries("test", result).unwrap();
360
1
    assert_eq!(entries.len(), 1);
361
1
    let (_, note, amount, _) = &entries[0];
362
1
    assert_eq!(note.as_deref(), Some("lunch"));
363
1
    assert_eq!(
364
1
        amount.as_deref(),
365
        Some("100 USD"),
366
        "computed amount must come from the dedicated slot, not the user tag"
367
    );
368
1
}