1
//! End-to-end integration: a template (per-user nomiscript source) ->
2
//! rpc::render_template -> real Postgres -> TransactionDraft.
3
//!
4
//! Gated on the `db` feature (see list_accounts.rs). Run via:
5
//!   DATABASE_URL=postgres://… cargo test -p tests-integration --features db
6
//!
7
//! Proves the render surface works against a real per-user database: the
8
//! template resolves accounts/commodities via the allowlisted read natives and
9
//! accumulates a draft via the draft natives, all without touching any
10
//! mutating or secret-bearing native.
11

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

            
14
use rpc::{ScriptCtx, Session, render_template};
15
use server::db::DB_POOL;
16
use sqlx::PgPool;
17
use supp_macro::local_db_sqlx_test;
18
use uuid::Uuid;
19

            
20
5
async fn setup() {}
21

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

            
38
3
fn value_uuid(response: &str) -> String {
39
3
    let needle = ":value \"";
40
3
    let start = response.find(needle).expect("value uuid present") + needle.len();
41
3
    let end = start + response[start..].find('"').expect("closing quote");
42
3
    response[start..end].to_string()
43
3
}
44

            
45
#[local_db_sqlx_test]
46
async fn render_template_builds_draft_from_db_entities(pool: PgPool) -> anyhow::Result<()> {
47
    let user_id = Uuid::new_v4();
48
    insert_test_user(&pool, user_id).await?;
49

            
50
    // Build the prerequisites the template will look up by name.
51
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
52
    let resp = session
53
        .handle_form("(:id 1 :form (create-commodity \"USD\" \"US Dollar\"))")
54
        .await;
55
    let usd = value_uuid(&resp);
56
    let resp = session
57
        .handle_form("(:id 2 :form (create-account \"Assets:Checking\"))")
58
        .await;
59
    let checking = value_uuid(&resp);
60
    let resp = session
61
        .handle_form("(:id 3 :form (create-account \"Expenses:Food\"))")
62
        .await;
63
    let food = value_uuid(&resp);
64

            
65
    // A template: resolve accounts by name and the commodity by SYMBOL, set
66
    // note/date/tag, draft two balancing splits. Uses only allowlisted read
67
    // natives + draft natives. (`get-account` accepts a name; `get-commodity`
68
    // accepts a uuid or a symbol — here "USD", which must resolve to `usd`.)
69
    let source = r#"
70
        (set-draft-note "Groceries")
71
        (set-draft-date "2026-06-15")
72
        (draft-tag "category" "food")
73
        (draft-split (get-account "Assets:Checking") (get-commodity "USD") -50)
74
        (draft-split (get-account "Expenses:Food")   (get-commodity "USD")  50)
75
    "#
76
    .to_string();
77

            
78
    let draft = render_template(&ScriptCtx::new(user_id), &source)
79
        .await
80
        .expect("render must succeed");
81

            
82
    assert_eq!(draft.note.as_deref(), Some("Groceries"));
83
    assert_eq!(draft.date.as_deref(), Some("2026-06-15"));
84
    assert_eq!(draft.tags.len(), 1);
85
    assert_eq!(draft.tags[0].name, "category");
86
    assert_eq!(draft.tags[0].value, "food");
87

            
88
    assert_eq!(
89
        draft.splits.len(),
90
        2,
91
        "two splits expected: {:?}",
92
        draft.splits
93
    );
94
    // The template resolved entities to their canonical uuids.
95
    assert_eq!(draft.splits[0].account_id, checking);
96
    assert_eq!(draft.splits[0].commodity_id, usd);
97
    assert_eq!(draft.splits[0].value_num, -50);
98
    assert_eq!(draft.splits[0].value_denom, 1);
99
    assert_eq!(draft.splits[1].account_id, food);
100
    assert_eq!(draft.splits[1].commodity_id, usd);
101
    assert_eq!(draft.splits[1].value_num, 50);
102

            
103
    // The drafted splits sum to zero (balanced double entry).
104
    let sum: i64 = draft.splits.iter().map(|s| s.value_num).sum();
105
    assert_eq!(sum, 0, "drafted splits must balance");
106
}
107

            
108
#[local_db_sqlx_test]
109
async fn render_template_split_tag_attaches_to_the_returned_split(
110
    pool: PgPool,
111
) -> anyhow::Result<()> {
112
    // draft-split returns the split's index; draft-split-tag(index, ..) attaches
113
    // a tag to THAT split. A second split with no tag stays untagged.
114
    let user_id = Uuid::new_v4();
115
    insert_test_user(&pool, user_id).await?;
116

            
117
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
118
    session
119
        .handle_form("(:id 1 :form (create-commodity \"USD\" \"US Dollar\"))")
120
        .await;
121
    session
122
        .handle_form("(:id 2 :form (create-account \"Checking\"))")
123
        .await;
124
    session
125
        .handle_form("(:id 3 :form (create-account \"Food\"))")
126
        .await;
127

            
128
    let source = r#"
129
        (let ((from (draft-split (get-account "Checking") (get-commodity "USD") -50))
130
              (to   (draft-split (get-account "Food")     (get-commodity "USD")  50)))
131
          (draft-split-tag from "memo" "lunch"))
132
    "#;
133
    let draft = render_template(&ScriptCtx::new(user_id), source)
134
        .await
135
        .expect("render must succeed");
136

            
137
    assert_eq!(draft.splits.len(), 2);
138
    assert_eq!(draft.splits[0].value_num, -50);
139
    assert_eq!(
140
        draft.splits[0].tags.len(),
141
        1,
142
        "first split should be tagged"
143
    );
144
    assert_eq!(draft.splits[0].tags[0].name, "memo");
145
    assert_eq!(draft.splits[0].tags[0].value, "lunch");
146
    assert!(
147
        draft.splits[1].tags.is_empty(),
148
        "second split must stay untagged"
149
    );
150
}
151

            
152
#[local_db_sqlx_test]
153
async fn render_template_split_tag_bad_index_errors(pool: PgPool) -> anyhow::Result<()> {
154
    // A draft-split-tag handle that no draft-split produced must fail loudly,
155
    // not silently no-op.
156
    let user_id = Uuid::new_v4();
157
    insert_test_user(&pool, user_id).await?;
158

            
159
    let result = render_template(&ScriptCtx::new(user_id), "(draft-split-tag 7 \"k\" \"v\")").await;
160
    assert!(result.is_err(), "stale split handle must surface an error");
161
}
162

            
163
#[local_db_sqlx_test]
164
async fn render_template_empty_source_yields_empty_draft(pool: PgPool) -> anyhow::Result<()> {
165
    let user_id = Uuid::new_v4();
166
    insert_test_user(&pool, user_id).await?;
167

            
168
    let draft = render_template(&ScriptCtx::new(user_id), "")
169
        .await
170
        .expect("empty template renders");
171

            
172
    assert!(draft.note.is_none());
173
    assert!(draft.splits.is_empty());
174
    assert!(draft.tags.is_empty());
175
}
176

            
177
#[local_db_sqlx_test]
178
async fn render_template_unknown_account_surfaces_error(pool: PgPool) -> anyhow::Result<()> {
179
    let user_id = Uuid::new_v4();
180
    insert_test_user(&pool, user_id).await?;
181

            
182
    // get-account of an unknown name returns nil; draft-split then has a null
183
    // account ref and the native errors with a clean TemplateError::Runtime
184
    // rather than panicking or recording a bogus split.
185
    let source = r#"
186
        (draft-split (get-account "Nope") (get-commodity "Nope") 1)
187
    "#;
188
    let result = render_template(&ScriptCtx::new(user_id), source).await;
189
    assert!(result.is_err(), "missing entity must surface an error");
190
}