1
//! End-to-end runtime test for the Metro split-tagging flow.
2
//!
3
//! Drives the Metro tagging logic through `rpc::Session` against real
4
//! Postgres and asserts the resulting `split_tags` rows — the only way to
5
//! catch the silent no-op a compile-check can't see: `list-splits` filters
6
//! by account id, so `(list-splits (transaction-id tx))` matched nothing.
7
//! This test uses the transaction-keyed `list-splits-by-transaction` native.
8
//!
9
//! Gated on the `db` feature. Run via:
10
//!   DATABASE_URL=postgres://… cargo test -p tests-integration --features db -- metro
11

            
12
#![cfg(feature = "db")]
13

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

            
22
1
async fn insert_test_user(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
23
1
    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, 'metro-test-user', 'metro-test@example.com', 'default.png',
29
            FALSE, 'irrelevant', 'user', 'metro-test', NOW()
30
        )",
31
        id
32
    )
33
1
    .execute(pool)
34
1
    .await?;
35
1
    Ok(())
36
1
}
37

            
38
/// Single-record writes (create-account / create-commodity / create-transaction)
39
/// surface the server-assigned UUID as a bare `:value "<uuid>"` string.
40
10
fn extract_value_uuid(response: &str) -> Option<String> {
41
10
    let needle = ":value \"";
42
10
    let start = response.find(needle)? + needle.len();
43
10
    let end = start + response[start..].find('"')?;
44
10
    Some(response[start..end].to_string())
45
10
}
46

            
47
/// Builds a `create-transaction` form from an explicit list of
48
/// `(account_id, value)` legs (values must sum to zero). Drives both the
49
/// two-split fare and the three-split multi-leg cases through one builder, so
50
/// the leg count a test exercises is visible at the call site.
51
5
fn tx_with_splits(
52
5
    id: u32,
53
5
    post_date: &str,
54
5
    note: &str,
55
5
    commodity: &str,
56
5
    legs: &[(&str, i64)],
57
5
) -> String {
58
5
    let splits = legs
59
5
        .iter()
60
11
        .map(|(account, value)| {
61
11
            format!(
62
                "(:account-id \\\"{account}\\\" :commodity-id \\\"{commodity}\\\" :value {value})"
63
            )
64
11
        })
65
5
        .collect::<Vec<_>>()
66
5
        .join(" ");
67
5
    format!(
68
        "(:id {id} :form (create-transaction \"(:post-date \\\"{post_date}\\\" \
69
         :note \\\"{note}\\\" :splits ({splits}))\"))"
70
    )
71
5
}
72

            
73
/// The category tag value on the split belonging to `account_id` within
74
/// `tx_id`, or `None` if that split carries no `category` tag.
75
11
async fn split_category(
76
11
    pool: &PgPool,
77
11
    tx_id: &str,
78
11
    account_id: &str,
79
11
) -> anyhow::Result<Option<String>> {
80
11
    let tx = Uuid::parse_str(tx_id)?;
81
11
    let account = Uuid::parse_str(account_id)?;
82
11
    let row = sqlx::query!(
83
        "SELECT t.tag_value \
84
         FROM splits AS s \
85
         INNER JOIN split_tags AS st ON st.split_id = s.id \
86
         INNER JOIN tags AS t ON t.id = st.tag_id \
87
         WHERE s.tx_id = $1 AND s.account_id = $2 AND t.tag_name = 'category'",
88
        tx,
89
        account
90
    )
91
11
    .fetch_optional(pool)
92
11
    .await?;
93
11
    Ok(row.map(|r| r.tag_value))
94
11
}
95

            
96
/// The split id belonging to `account_id` within `tx_id` (for direct
97
/// pre-tagging in the "already categorized" case).
98
1
async fn split_id_for(pool: &PgPool, tx_id: &str, account_id: &str) -> anyhow::Result<Uuid> {
99
1
    let tx = Uuid::parse_str(tx_id)?;
100
1
    let account = Uuid::parse_str(account_id)?;
101
1
    let row = sqlx::query!(
102
        "SELECT id FROM splits WHERE tx_id = $1 AND account_id = $2",
103
        tx,
104
        account
105
    )
106
1
    .fetch_one(pool)
107
1
    .await?;
108
1
    Ok(row.id)
109
1
}
110

            
111
// Mirrors the shipped tag-metro-splits.nms sample (host-prelude
112
// split:list-for-transaction loaded on the Session path), proving the full
113
// stack: namespaced reader → host-prelude resolution → list-splits-by-transaction
114
// native → server by_transaction query → split_tags. A Metro FARE is a clean
115
// two-split transaction; BOTH its splits get category=transportation. Metro
116
// transactions that aren't exactly two splits are skipped.
117
const METRO_COUNT_DEFUN: &str = "(:id 99 :form (defun metro-split-count (splits) \
118
    (length (filter (lambda (s) \
119
                      (equal? (account-name (get-account (split-account-id s))) \"Metro\")) \
120
                    splits))))";
121

            
122
const TAG_METRO_DEFUN: &str = "(:id 100 :form (defun tag-metro-splits-in (tx) \
123
    (let ((splits (split:list-for-transaction tx))) \
124
      (when (and (= (length splits) 2) \
125
                 (> (metro-split-count splits) 0)) \
126
        (dolist (s splits) \
127
          (when (equal? (get-split-tag (split-id s) \"category\") \"\") \
128
            (set-split-tag (split-id s) \"category\" \"transportation\")))))))";
129

            
130
const RUN_DRIVER: &str =
131
    "(:id 101 :form (catch-each (list-transactions) tx (tag-metro-splits-in tx)))";
132

            
133
#[local_db_sqlx_test]
134
async fn metro_splits_get_transportation_category(pool: PgPool) -> anyhow::Result<()> {
135
    setup().await;
136
    let user_id = Uuid::new_v4();
137
    insert_test_user(&pool, user_id).await?;
138
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
139

            
140
    let jpy = extract_value_uuid(
141
        &session
142
            .handle_form("(:id 1 :form (create-commodity \"JPY\" \"Yen\"))")
143
            .await,
144
    )
145
    .expect("jpy commodity id");
146
    let suica = extract_value_uuid(
147
        &session
148
            .handle_form("(:id 2 :form (create-account \"Suica\"))")
149
            .await,
150
    )
151
    .expect("suica account id");
152
    let metro = extract_value_uuid(
153
        &session
154
            .handle_form("(:id 3 :form (create-account \"Metro\"))")
155
            .await,
156
    )
157
    .expect("metro account id");
158
    let cafe = extract_value_uuid(
159
        &session
160
            .handle_form("(:id 4 :form (create-account \"Cafe\"))")
161
            .await,
162
    )
163
    .expect("cafe account id");
164
    let bus = extract_value_uuid(
165
        &session
166
            .handle_form("(:id 9 :form (create-account \"Bus\"))")
167
            .await,
168
    )
169
    .expect("bus account id");
170

            
171
    let metro_tx = extract_value_uuid(
172
        &session
173
            .handle_form(&tx_with_splits(
174
                5,
175
                "2026-02-01T00:00:00Z",
176
                "metro-ride",
177
                &jpy,
178
                &[(&suica, -1000), (&metro, 1000)],
179
            ))
180
            .await,
181
    )
182
    .expect("metro tx id");
183
    let cafe_tx = extract_value_uuid(
184
        &session
185
            .handle_form(&tx_with_splits(
186
                6,
187
                "2026-02-02T00:00:00Z",
188
                "coffee",
189
                &jpy,
190
                &[(&suica, -500), (&cafe, 500)],
191
            ))
192
            .await,
193
    )
194
    .expect("cafe tx id");
195
    let pretagged_tx = extract_value_uuid(
196
        &session
197
            .handle_form(&tx_with_splits(
198
                7,
199
                "2026-02-03T00:00:00Z",
200
                "metro-prepaid",
201
                &jpy,
202
                &[(&suica, -200), (&metro, 200)],
203
            ))
204
            .await,
205
    )
206
    .expect("pretagged tx id");
207

            
208
    // A three-split transaction that DOES touch Metro (Suica → Metro + Bus).
209
    // It is not a clean two-split fare, so the `(= (length splits) 2)` guard
210
    // must skip it entirely — no split gets tagged.
211
    let multileg_tx = extract_value_uuid(
212
        &session
213
            .handle_form(&tx_with_splits(
214
                10,
215
                "2026-02-04T00:00:00Z",
216
                "metro-plus-bus",
217
                &jpy,
218
                &[(&suica, -800), (&metro, 500), (&bus, 300)],
219
            ))
220
            .await,
221
    )
222
    .expect("multileg tx id");
223

            
224
    // A two-split transaction where Metro is the SOURCE (negative) leg, not the
225
    // target. The tagger is deliberately direction-agnostic — any Metro leg in a
226
    // two-split tx qualifies — so this must still tag BOTH legs. Locks that the
227
    // no-direction-check behavior is intentional, not an accidental omission.
228
    let metro_refund_tx = extract_value_uuid(
229
        &session
230
            .handle_form(&tx_with_splits(
231
                11,
232
                "2026-02-05T00:00:00Z",
233
                "metro-refund",
234
                &jpy,
235
                &[(&metro, -300), (&suica, 300)],
236
            ))
237
            .await,
238
    )
239
    .expect("metro refund tx id");
240

            
241
    // Pre-tag the Metro split of the third transaction so the script's
242
    // empty-category guard must skip it.
243
    let pretagged_split = split_id_for(&pool, &pretagged_tx, &metro).await?;
244
    let set_resp = session
245
        .handle_form(&format!(
246
            "(:id 8 :form (set-split-tag \"{pretagged_split}\" \"category\" \"groceries\"))"
247
        ))
248
        .await;
249
    assert!(
250
        set_resp.contains(":value 1"),
251
        "pre-tag set-split-tag failed: {set_resp}"
252
    );
253

            
254
    // Define + run the Metro tagging logic across forms on one session.
255
    let count_resp = session.handle_form(METRO_COUNT_DEFUN).await;
256
    assert!(
257
        !count_resp.contains(":error"),
258
        "metro-split-count defun failed: {count_resp}"
259
    );
260
    let defun_resp = session.handle_form(TAG_METRO_DEFUN).await;
261
    assert!(!defun_resp.contains(":error"), "defun failed: {defun_resp}");
262
    let run_resp = session.handle_form(RUN_DRIVER).await;
263
    assert!(!run_resp.contains(":error"), "driver failed: {run_resp}");
264

            
265
    // Drive the three-split tx through the tagger DIRECTLY (no catch-each), so a
266
    // runtime failure on the filter+lambda+length path would surface as a
267
    // top-level `:error` rather than being swallowed as an `(err ...)` cell and
268
    // mistaken for a clean guard-skip. A `:value` response proves the body ran
269
    // to completion and chose not to tag (the `(= length 2)` guard), not that it
270
    // threw.
271
    let direct_resp = session
272
        .handle_form(&format!(
273
            "(:id 102 :form (tag-metro-splits-in (get-transaction \"{multileg_tx}\")))"
274
        ))
275
        .await;
276
    assert!(
277
        direct_resp.contains(":value") && !direct_resp.contains(":error"),
278
        "direct multileg run must complete cleanly (guard-skip, not error): {direct_resp}"
279
    );
280

            
281
    // The two-split Metro fare: BOTH splits are now transportation — the Metro
282
    // expense leg AND the Suica counter-leg, because the category describes the
283
    // whole movement.
284
    assert_eq!(
285
        split_category(&pool, &metro_tx, &metro).await?,
286
        Some("transportation".to_string()),
287
        "Metro split of the fare should be tagged transportation"
288
    );
289
    assert_eq!(
290
        split_category(&pool, &metro_tx, &suica).await?,
291
        Some("transportation".to_string()),
292
        "Suica counter-split of the fare should ALSO be tagged transportation"
293
    );
294
    // The Cafe transaction has no Metro split → nothing tagged (neither leg).
295
    assert_eq!(
296
        split_category(&pool, &cafe_tx, &cafe).await?,
297
        None,
298
        "Cafe split must not be tagged"
299
    );
300
    assert_eq!(
301
        split_category(&pool, &cafe_tx, &suica).await?,
302
        None,
303
        "Cafe counter-split must not be tagged"
304
    );
305
    // The three-split Metro transaction is not a clean fare → guard skips it,
306
    // so NO leg is tagged (not the Metro leg, not the Bus leg, not the source).
307
    assert_eq!(
308
        split_category(&pool, &multileg_tx, &metro).await?,
309
        None,
310
        "multi-leg Metro split must not be tagged (not a two-split fare)"
311
    );
312
    assert_eq!(
313
        split_category(&pool, &multileg_tx, &bus).await?,
314
        None,
315
        "multi-leg Bus split must not be tagged"
316
    );
317
    assert_eq!(
318
        split_category(&pool, &multileg_tx, &suica).await?,
319
        None,
320
        "multi-leg Suica source split must not be tagged"
321
    );
322
    // The Metro-as-source two-split refund: direction-agnostic, so BOTH legs
323
    // tagged (the Metro source leg and the Suica target leg).
324
    assert_eq!(
325
        split_category(&pool, &metro_refund_tx, &metro).await?,
326
        Some("transportation".to_string()),
327
        "Metro source leg of a two-split tx should be tagged transportation"
328
    );
329
    assert_eq!(
330
        split_category(&pool, &metro_refund_tx, &suica).await?,
331
        Some("transportation".to_string()),
332
        "Suica target leg of the Metro-source refund should ALSO be tagged"
333
    );
334
    // The pre-tagged fare: its Metro leg keeps groceries (empty-category guard
335
    // skipped it), while the still-empty Suica leg gets transportation — the
336
    // guard is per-split, so a partially-tagged fare tags only the blank legs.
337
    assert_eq!(
338
        split_category(&pool, &pretagged_tx, &metro).await?,
339
        Some("groceries".to_string()),
340
        "pre-tagged Metro split must keep groceries"
341
    );
342
    assert_eq!(
343
        split_category(&pool, &pretagged_tx, &suica).await?,
344
        Some("transportation".to_string()),
345
        "pre-tagged fare's blank Suica leg should still get transportation"
346
    );
347
}