1
//! Wire-shape guard: captures REAL handle_form output and verifies parse_wire + value_to_rows.
2
//!
3
//! Gated on the `db` feature.
4
//!   DATABASE_URL=postgres://… cargo test -p tests-integration --features db
5

            
6
#![cfg(feature = "db")]
7

            
8
use cli_core::render::{WireValue, parse_wire, reparse_list, value_to_rows};
9
use nomiscript::{Value, list_to_vec};
10
use rpc::{ScriptCtx, Session};
11
use server::db::DB_POOL;
12
use sqlx::PgPool;
13
use supp_macro::local_db_sqlx_test;
14
use uuid::Uuid;
15

            
16
6
async fn setup() {}
17

            
18
5
async fn insert_test_user(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
19
5
    sqlx::query!(
20
        "INSERT INTO users (
21
            id, user_name, email, photo, verified, user_password,
22
            user_role, db_name, created_at
23
        ) VALUES (
24
            $1, 'render-test-user', 'render-test@example.com', 'default.png',
25
            FALSE, 'irrelevant', 'user', 'render-test', NOW()
26
        )",
27
        id
28
    )
29
5
    .execute(pool)
30
5
    .await?;
31
5
    Ok(())
32
5
}
33

            
34
#[local_db_sqlx_test]
35
async fn parse_wire_handles_empty_list_accounts(pool: PgPool) -> anyhow::Result<()> {
36
    let user_id = Uuid::new_v4();
37
    insert_test_user(&pool, user_id).await?;
38
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
39

            
40
    let wire = session.handle_form("(:id 1 :form (list-accounts))").await;
41
    assert!(wire.contains(":id 1"), "got: {wire}");
42

            
43
    let WireValue::Value(raw) = parse_wire(&wire).expect("parse_wire must succeed");
44
    // list-accounts encodes its result as a printed-list string; callers must
45
    // call reparse_list for pair:* results.
46
    let s = match &raw {
47
        Value::String(s) => s.clone(),
48
        other => format!("{other:?}"),
49
    };
50
    let v = reparse_list(&s).expect("reparse_list must succeed");
51
    let rows = value_to_rows(&v);
52
    assert_eq!(
53
        rows.len(),
54
        0,
55
        "empty accounts list should yield 0 rows, got: {rows:?}"
56
    );
57
}
58

            
59
#[local_db_sqlx_test]
60
async fn parse_wire_handles_populated_list_accounts(pool: PgPool) -> anyhow::Result<()> {
61
    let user_id = Uuid::new_v4();
62
    insert_test_user(&pool, user_id).await?;
63
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
64

            
65
    let r1 = session
66
        .handle_form("(:id 2 :form (create-account \"acct-alpha\" \"\"))")
67
        .await;
68
    assert!(
69
        r1.contains(":value \""),
70
        "create-account should return uuid: {r1}"
71
    );
72
    let r2 = session
73
        .handle_form("(:id 3 :form (create-account \"acct-beta\" \"\"))")
74
        .await;
75
    assert!(
76
        r2.contains(":value \""),
77
        "create-account should return uuid: {r2}"
78
    );
79

            
80
    let wire = session.handle_form("(:id 4 :form (list-accounts))").await;
81
    assert!(wire.contains(":id 4"), "got: {wire}");
82

            
83
    let WireValue::Value(raw) = parse_wire(&wire).expect("parse_wire must succeed");
84
    let s = match &raw {
85
        Value::String(s) => s.clone(),
86
        other => format!("{other:?}"),
87
    };
88
    let v = reparse_list(&s).expect("reparse_list must succeed");
89
    let rows = value_to_rows(&v);
90
    assert!(
91
        rows.len() >= 2,
92
        "expected >=2 rows for 2 accounts, got: {rows:?}"
93
    );
94
}
95

            
96
/// Pins the `pair:Account` element shape the cli `account list` renderer
97
/// depends on: each element is `(:account :id … :name … :parent …)`. If the
98
/// native shape drifts, the cli `account_list_line` (`:name` extraction) breaks.
99
#[local_db_sqlx_test]
100
async fn list_accounts_element_carries_account_and_name_keys(pool: PgPool) -> anyhow::Result<()> {
101
    let user_id = Uuid::new_v4();
102
    insert_test_user(&pool, user_id).await?;
103
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
104

            
105
    session
106
        .handle_form("(:id 30 :form (create-account \"acct-alpha\" \"\"))")
107
        .await;
108

            
109
    let wire = session.handle_form("(:id 31 :form (list-accounts))").await;
110
    let WireValue::Value(raw) = parse_wire(&wire).expect("parse_wire");
111
    let Value::String(s) = &raw else {
112
        panic!("list-accounts value must be a string, got: {raw:?}");
113
    };
114
    assert!(
115
        s.contains(":account") && s.contains(":name") && s.contains("acct-alpha"),
116
        "account element must carry :account/:name keys, got: {s}"
117
    );
118
}
119

            
120
/// Pins the `pair:Commodity` element shape the cli `commodity list` renderer
121
/// depends on: `(:commodity :id … :symbol … :name …)`.
122
#[local_db_sqlx_test]
123
async fn list_commodities_element_carries_symbol_and_name_keys(pool: PgPool) -> anyhow::Result<()> {
124
    let user_id = Uuid::new_v4();
125
    insert_test_user(&pool, user_id).await?;
126
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
127

            
128
    session
129
        .handle_form("(:id 32 :form (create-commodity \"USD\" \"US Dollar\"))")
130
        .await;
131

            
132
    let wire = session
133
        .handle_form("(:id 33 :form (list-commodities))")
134
        .await;
135
    let WireValue::Value(raw) = parse_wire(&wire).expect("parse_wire");
136
    let Value::String(s) = &raw else {
137
        panic!("list-commodities value must be a string, got: {raw:?}");
138
    };
139
    assert!(
140
        s.contains(":commodity") && s.contains(":symbol") && s.contains(":name"),
141
        "commodity element must carry :commodity/:symbol/:name keys, got: {s}"
142
    );
143
    assert!(s.contains("USD") && s.contains("US Dollar"), "got: {s}");
144
}
145

            
146
#[local_db_sqlx_test]
147
async fn parse_wire_handles_wire_error(pool: PgPool) -> anyhow::Result<()> {
148
    let _ = pool;
149
    let user_id = Uuid::new_v4();
150
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
151

            
152
    let wire = session
153
        .handle_form(
154
            "(:id 5 :form (set-account-tag \
155
                \"00000000-0000-0000-0000-000000000000\" \
156
                \"name\" \
157
                \"x\"))",
158
        )
159
        .await;
160
    assert!(wire.contains(":error"), "expected error wire, got: {wire}");
161

            
162
    let result = parse_wire(&wire);
163
    assert!(
164
        result.is_err(),
165
        "parse_wire must return Err for wire error, got: {result:?}"
166
    );
167
}
168

            
169
#[local_db_sqlx_test]
170
async fn parse_wire_get_balances_cell_values(pool: PgPool) -> anyhow::Result<()> {
171
    let user_id = Uuid::new_v4();
172
    insert_test_user(&pool, user_id).await?;
173
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
174

            
175
    // Create commodity, accounts, and a transaction with splits
176
    let rc = session
177
        .handle_form("(:id 10 :form (create-commodity \"USD\" \"US Dollar\"))")
178
        .await;
179
    assert!(rc.contains(":value \""), "create-commodity: {rc}");
180
    let commodity_id = extract_uuid_value(&rc).expect("commodity uuid");
181

            
182
    let r1 = session
183
        .handle_form("(:id 11 :form (create-account \"wallet\" \"\"))")
184
        .await;
185
    assert!(r1.contains(":value \""), "create wallet: {r1}");
186
    let wallet_id = extract_uuid_value(&r1).expect("wallet uuid");
187

            
188
    let r2 = session
189
        .handle_form("(:id 12 :form (create-account \"income\" \"\"))")
190
        .await;
191
    assert!(r2.contains(":value \""), "create income: {r2}");
192
    let income_id = extract_uuid_value(&r2).expect("income uuid");
193

            
194
    let form = format!(
195
        "(:id 13 :form (create-transaction \
196
            \"(:post-date \\\"2024-01-01T00:00:00Z\\\" \
197
            :note \\\"test deposit\\\" \
198
            :splits ((:account-id \\\"{wallet_id}\\\" :commodity-id \\\"{commodity_id}\\\" :value 100) \
199
                     (:account-id \\\"{income_id}\\\" :commodity-id \\\"{commodity_id}\\\" :value -100)))\"))"
200
    );
201
    let r3 = session.handle_form(&form).await;
202
    assert!(r3.contains(":value \""), "create-transaction: {r3}");
203

            
204
    let wire = session
205
        .handle_form(&format!("(:id 14 :form (get-balances \"{wallet_id}\"))"))
206
        .await;
207
    assert!(wire.contains(":id 14"), "got: {wire}");
208

            
209
    let WireValue::Value(raw) = parse_wire(&wire).expect("parse_wire must succeed");
210
    let s = match &raw {
211
        Value::String(s) => s.clone(),
212
        other => panic!("expected string value, got: {other:?}"),
213
    };
214
    // get-balances returns `pair:string`: the outer reparse yields a LIST whose
215
    // elements are themselves plist STRINGS, so each balance needs a SECOND
216
    // reparse to reach its `(:commodity-id .. :symbol .. :value-num ..)` fields.
217
    // (Unlike list-accounts `pair:Entity`, whose elements reparse to structured
218
    // plists directly — tracked as a Phase 2 shape-consistency item.)
219
    let outer = reparse_list(&s).expect("reparse_list on get-balances output");
220
    let elements = list_to_vec(&outer).expect("get-balances value is a list");
221
    assert!(
222
        !elements.is_empty(),
223
        "get-balances with a split should yield at least one balance element, got: {outer:?}"
224
    );
225
    let Value::String(plist_str) = &elements[0] else {
226
        panic!(
227
            "balance element should be a plist string, got: {:?}",
228
            elements[0]
229
        );
230
    };
231
    let plist = reparse_list(plist_str).expect("reparse inner balance plist");
232
    // A reparsed plist renders as exactly ONE row of its field values.
233
    let rows = value_to_rows(&plist);
234
    assert_eq!(
235
        rows.len(),
236
        1,
237
        "balance plist should be one row, got: {rows:?}"
238
    );
239
    let cells = &rows[0];
240
    assert!(
241
2
        cells.iter().any(|c| c.contains("USD")),
242
        "expected USD symbol in balance row, got: {cells:?}"
243
    );
244
    assert!(
245
4
        cells.iter().any(|c| c == "100"),
246
        "expected value-num 100 in balance row, got: {cells:?}"
247
    );
248
}
249

            
250
3
fn extract_uuid_value(wire: &str) -> Option<String> {
251
3
    let needle = ":value \"";
252
3
    let start = wire.find(needle)? + needle.len();
253
3
    let end = wire[start..].find('"')? + start;
254
3
    Some(wire[start..end].to_string())
255
3
}