1
//! Integration tests for the `scripting-sdk` entity wrappers. The SDK
2
//! is consumed by guest WASM scripts (rust + C++); none of these
3
//! accessors had direct unit coverage before — `cargo tarpaulin`
4
//! reported 0/106 on `entity.rs`. The tests below pin every accessor
5
//! plus the entity-type discrimination + OOB branches so a future
6
//! refactor of the wire format catches the change before guest
7
//! scripts break.
8

            
9
use scripting_format::{
10
    AccountData, CommodityData, ENTITY_HEADER_SIZE, EntityHeader, EntityType, Operation,
11
    SPLIT_DATA_SIZE, SplitData, TRANSACTION_DATA_SIZE, TagData, TransactionData,
12
};
13
use scripting_sdk::entity::EntityRef;
14
use scripting_sdk::error::Error;
15

            
16
30
fn empty_header(entity_type: u8, operation: u8) -> EntityHeader {
17
30
    EntityHeader {
18
30
        entity_type,
19
30
        operation,
20
30
        flags: 0,
21
30
        reserved: [0; 1],
22
30
        id: [7u8; 16],
23
30
        parent_idx: -1,
24
30
        data_offset: 0,
25
30
        data_size: 0,
26
30
    }
27
30
}
28

            
29
2
fn tx_data() -> [u8; TRANSACTION_DATA_SIZE] {
30
2
    TransactionData {
31
2
        post_date: 1000,
32
2
        enter_date: 2000,
33
2
        split_count: 3,
34
2
        tag_count: 1,
35
2
        is_multi_currency: 1,
36
2
        reserved: [0; 23],
37
2
    }
38
2
    .to_bytes()
39
2
}
40

            
41
2
fn split_data() -> [u8; SPLIT_DATA_SIZE] {
42
2
    SplitData {
43
2
        account_id: [10u8; 16],
44
2
        commodity_id: [20u8; 16],
45
2
        value_num: -5000,
46
2
        value_denom: 100,
47
2
        reconcile_state: 2,
48
2
        reserved: [0; 7],
49
2
        reconcile_date: 12345,
50
2
        account_name_offset: 0,
51
2
        account_name_len: 0,
52
2
    }
53
2
    .to_bytes()
54
2
}
55

            
56
#[test]
57
1
fn entity_type_resolves_each_known_value() {
58
5
    for (raw, expected) in [
59
1
        (0u8, EntityType::Transaction),
60
1
        (1u8, EntityType::Split),
61
1
        (2u8, EntityType::Tag),
62
1
        (3u8, EntityType::Account),
63
1
        (4u8, EntityType::Commodity),
64
1
    ] {
65
5
        let header = empty_header(raw, 0);
66
5
        let entity = EntityRef {
67
5
            header,
68
5
            data: &[],
69
5
            strings_pool: &[],
70
5
        };
71
5
        assert_eq!(entity.entity_type().unwrap(), expected);
72
    }
73
1
}
74

            
75
#[test]
76
1
fn entity_type_rejects_unknown_byte() {
77
1
    let header = empty_header(0x55, 0);
78
1
    let entity = EntityRef {
79
1
        header,
80
1
        data: &[],
81
1
        strings_pool: &[],
82
1
    };
83
1
    assert_eq!(entity.entity_type().unwrap_err(), Error::InvalidEntityType);
84
1
}
85

            
86
#[test]
87
1
fn operation_resolves_each_known_value() {
88
6
    for (raw, expected) in [
89
1
        (0u8, Operation::Nop),
90
1
        (1u8, Operation::Create),
91
1
        (2u8, Operation::Update),
92
1
        (3u8, Operation::Delete),
93
1
        (4u8, Operation::Link),
94
1
        (5u8, Operation::Unlink),
95
1
    ] {
96
6
        let header = empty_header(0, raw);
97
6
        let entity = EntityRef {
98
6
            header,
99
6
            data: &[],
100
6
            strings_pool: &[],
101
6
        };
102
6
        assert_eq!(entity.operation().unwrap(), expected);
103
    }
104
1
}
105

            
106
#[test]
107
1
fn operation_rejects_unknown_byte() {
108
1
    let header = empty_header(0, 0x7F);
109
1
    let entity = EntityRef {
110
1
        header,
111
1
        data: &[],
112
1
        strings_pool: &[],
113
1
    };
114
1
    assert_eq!(entity.operation().unwrap_err(), Error::InvalidOperation);
115
1
}
116

            
117
#[test]
118
1
fn id_and_parent_idx_pass_through_header() {
119
1
    let mut header = empty_header(0, 0);
120
1
    header.id = [0xAB; 16];
121
1
    header.parent_idx = 42;
122
1
    let entity = EntityRef {
123
1
        header,
124
1
        data: &[],
125
1
        strings_pool: &[],
126
1
    };
127
1
    assert_eq!(entity.id(), [0xAB; 16]);
128
1
    assert_eq!(entity.parent_idx(), 42);
129
1
}
130

            
131
#[test]
132
1
fn as_transaction_returns_typed_view() {
133
1
    let data = tx_data();
134
1
    let entity = EntityRef {
135
1
        header: empty_header(0, 2),
136
1
        data: &data,
137
1
        strings_pool: &[],
138
1
    };
139
1
    let tx = entity.as_transaction().unwrap();
140
1
    assert_eq!(tx.id(), [7u8; 16]);
141
1
    assert_eq!(tx.post_date(), 1000);
142
1
    assert_eq!(tx.enter_date(), 2000);
143
1
    assert_eq!(tx.split_count(), 3);
144
1
    assert_eq!(tx.tag_count(), 1);
145
1
    assert!(tx.is_multi_currency());
146
1
}
147

            
148
#[test]
149
1
fn as_transaction_rejects_wrong_entity_type() {
150
1
    let entity = EntityRef {
151
1
        header: empty_header(1, 0),
152
1
        data: &tx_data(),
153
1
        strings_pool: &[],
154
1
    };
155
1
    assert!(matches!(
156
1
        entity.as_transaction(),
157
        Err(Error::InvalidEntityType)
158
    ));
159
1
}
160

            
161
#[test]
162
1
fn as_transaction_rejects_truncated_data() {
163
1
    let short = [0u8; TRANSACTION_DATA_SIZE - 1];
164
1
    let entity = EntityRef {
165
1
        header: empty_header(0, 0),
166
1
        data: &short,
167
1
        strings_pool: &[],
168
1
    };
169
1
    assert!(matches!(entity.as_transaction(), Err(Error::OutOfBounds)));
170
1
}
171

            
172
#[test]
173
1
fn as_split_returns_typed_view() {
174
1
    let data = split_data();
175
1
    let entity = EntityRef {
176
1
        header: empty_header(1, 0),
177
1
        data: &data,
178
1
        strings_pool: &[],
179
1
    };
180
1
    let split = entity.as_split().unwrap();
181
1
    assert_eq!(split.account_id(), [10u8; 16]);
182
1
    assert_eq!(split.commodity_id(), [20u8; 16]);
183
1
    assert_eq!(split.value_num(), -5000);
184
1
    assert_eq!(split.value_denom(), 100);
185
1
    assert_eq!(split.reconcile_state(), 2);
186
1
    assert_eq!(split.reconcile_date(), 12345);
187
1
    assert_eq!(split.parent_idx(), -1);
188
1
}
189

            
190
#[test]
191
1
fn as_split_rejects_wrong_entity_type() {
192
1
    let entity = EntityRef {
193
1
        header: empty_header(0, 0),
194
1
        data: &split_data(),
195
1
        strings_pool: &[],
196
1
    };
197
1
    assert!(matches!(entity.as_split(), Err(Error::InvalidEntityType)));
198
1
}
199

            
200
#[test]
201
1
fn as_split_rejects_truncated_data() {
202
1
    let short = [0u8; SPLIT_DATA_SIZE - 1];
203
1
    let entity = EntityRef {
204
1
        header: empty_header(1, 0),
205
1
        data: &short,
206
1
        strings_pool: &[],
207
1
    };
208
1
    assert!(matches!(entity.as_split(), Err(Error::OutOfBounds)));
209
1
}
210

            
211
#[test]
212
1
fn as_tag_reads_strings_from_pool() {
213
1
    let pool = b"namevalue";
214
1
    let tag_data = TagData {
215
1
        name_offset: 0,
216
1
        value_offset: 4,
217
1
        name_len: 4,
218
1
        value_len: 5,
219
1
        reserved: [0; 4],
220
1
    }
221
1
    .to_bytes();
222
1
    let entity = EntityRef {
223
1
        header: empty_header(2, 0),
224
1
        data: &tag_data,
225
1
        strings_pool: pool,
226
1
    };
227
1
    let tag = entity.as_tag().unwrap();
228
1
    assert_eq!(tag.name().unwrap(), "name");
229
1
    assert_eq!(tag.value().unwrap(), "value");
230
1
    assert_eq!(tag.parent_idx(), -1);
231
1
}
232

            
233
#[test]
234
1
fn as_tag_rejects_wrong_entity_type() {
235
1
    let tag_data = TagData {
236
1
        name_offset: 0,
237
1
        value_offset: 0,
238
1
        name_len: 0,
239
1
        value_len: 0,
240
1
        reserved: [0; 4],
241
1
    }
242
1
    .to_bytes();
243
1
    let entity = EntityRef {
244
1
        header: empty_header(0, 0),
245
1
        data: &tag_data,
246
1
        strings_pool: &[],
247
1
    };
248
1
    assert!(matches!(entity.as_tag(), Err(Error::InvalidEntityType)));
249
1
}
250

            
251
#[test]
252
1
fn tag_name_oob_returns_out_of_bounds_error() {
253
1
    let pool = b"short";
254
1
    let tag_data = TagData {
255
1
        name_offset: 0,
256
1
        value_offset: 0,
257
1
        name_len: 100,
258
1
        value_len: 0,
259
1
        reserved: [0; 4],
260
1
    }
261
1
    .to_bytes();
262
1
    let entity = EntityRef {
263
1
        header: empty_header(2, 0),
264
1
        data: &tag_data,
265
1
        strings_pool: pool,
266
1
    };
267
1
    let tag = entity.as_tag().unwrap();
268
1
    assert_eq!(tag.name().unwrap_err(), Error::OutOfBounds);
269
1
}
270

            
271
#[test]
272
1
fn tag_name_invalid_utf8_returns_utf8_error() {
273
1
    let pool = &[0xC0u8, 0xC1u8];
274
1
    let tag_data = TagData {
275
1
        name_offset: 0,
276
1
        value_offset: 0,
277
1
        name_len: 2,
278
1
        value_len: 0,
279
1
        reserved: [0; 4],
280
1
    }
281
1
    .to_bytes();
282
1
    let entity = EntityRef {
283
1
        header: empty_header(2, 0),
284
1
        data: &tag_data,
285
1
        strings_pool: pool,
286
1
    };
287
1
    let tag = entity.as_tag().unwrap();
288
1
    assert_eq!(tag.name().unwrap_err(), Error::Utf8Error);
289
1
}
290

            
291
#[test]
292
1
fn as_account_reads_name_and_path() {
293
1
    let pool = b"Walletassets:wallet";
294
1
    let acct_data = AccountData {
295
1
        parent_account_id: [99u8; 16],
296
1
        name_offset: 0,
297
1
        path_offset: 6,
298
1
        tag_count: 0,
299
1
        name_len: 6,
300
1
        path_len: 13,
301
1
        reserved: [0; 16],
302
1
    }
303
1
    .to_bytes();
304
1
    let entity = EntityRef {
305
1
        header: empty_header(3, 0),
306
1
        data: &acct_data,
307
1
        strings_pool: pool,
308
1
    };
309
1
    let acct = entity.as_account().unwrap();
310
1
    assert_eq!(acct.parent_account_id(), [99u8; 16]);
311
1
    assert_eq!(acct.name().unwrap(), "Wallet");
312
1
    assert_eq!(acct.path().unwrap(), "assets:wallet");
313
1
    assert_eq!(acct.tag_count(), 0);
314
1
}
315

            
316
#[test]
317
1
fn as_account_rejects_wrong_entity_type() {
318
1
    let acct_data = AccountData {
319
1
        parent_account_id: [0u8; 16],
320
1
        name_offset: 0,
321
1
        path_offset: 0,
322
1
        tag_count: 0,
323
1
        name_len: 0,
324
1
        path_len: 0,
325
1
        reserved: [0; 16],
326
1
    }
327
1
    .to_bytes();
328
1
    let entity = EntityRef {
329
1
        header: empty_header(1, 0),
330
1
        data: &acct_data,
331
1
        strings_pool: &[],
332
1
    };
333
1
    assert!(matches!(entity.as_account(), Err(Error::InvalidEntityType)));
334
1
}
335

            
336
#[test]
337
1
fn account_path_oob_returns_out_of_bounds_error() {
338
1
    let pool = b"abc";
339
1
    let acct_data = AccountData {
340
1
        parent_account_id: [0u8; 16],
341
1
        name_offset: 0,
342
1
        path_offset: 0,
343
1
        tag_count: 0,
344
1
        name_len: 0,
345
1
        path_len: 50,
346
1
        reserved: [0; 16],
347
1
    }
348
1
    .to_bytes();
349
1
    let entity = EntityRef {
350
1
        header: empty_header(3, 0),
351
1
        data: &acct_data,
352
1
        strings_pool: pool,
353
1
    };
354
1
    let acct = entity.as_account().unwrap();
355
1
    assert_eq!(acct.path().unwrap_err(), Error::OutOfBounds);
356
1
}
357

            
358
#[test]
359
1
fn as_commodity_reads_symbol_and_name() {
360
1
    let pool = b"USDDollar";
361
1
    let cmd_data = CommodityData {
362
1
        symbol_offset: 0,
363
1
        name_offset: 3,
364
1
        tag_count: 2,
365
1
        symbol_len: 3,
366
1
        name_len: 6,
367
1
        reserved: [0; 16],
368
1
    }
369
1
    .to_bytes();
370
1
    let entity = EntityRef {
371
1
        header: empty_header(4, 0),
372
1
        data: &cmd_data,
373
1
        strings_pool: pool,
374
1
    };
375
1
    let cmd = entity.as_commodity().unwrap();
376
1
    assert_eq!(cmd.symbol().unwrap(), "USD");
377
1
    assert_eq!(cmd.name().unwrap(), "Dollar");
378
1
    assert_eq!(cmd.tag_count(), 2);
379
1
}
380

            
381
#[test]
382
1
fn as_commodity_rejects_wrong_entity_type() {
383
1
    let cmd_data = CommodityData {
384
1
        symbol_offset: 0,
385
1
        name_offset: 0,
386
1
        tag_count: 0,
387
1
        symbol_len: 0,
388
1
        name_len: 0,
389
1
        reserved: [0; 16],
390
1
    }
391
1
    .to_bytes();
392
1
    let entity = EntityRef {
393
1
        header: empty_header(0, 0),
394
1
        data: &cmd_data,
395
1
        strings_pool: &[],
396
1
    };
397
1
    assert!(matches!(
398
1
        entity.as_commodity(),
399
        Err(Error::InvalidEntityType)
400
    ));
401
1
}
402

            
403
#[test]
404
1
fn commodity_symbol_oob_returns_out_of_bounds_error() {
405
1
    let pool = b"x";
406
1
    let cmd_data = CommodityData {
407
1
        symbol_offset: 0,
408
1
        name_offset: 0,
409
1
        tag_count: 0,
410
1
        symbol_len: 10,
411
1
        name_len: 0,
412
1
        reserved: [0; 16],
413
1
    }
414
1
    .to_bytes();
415
1
    let entity = EntityRef {
416
1
        header: empty_header(4, 0),
417
1
        data: &cmd_data,
418
1
        strings_pool: pool,
419
1
    };
420
1
    let cmd = entity.as_commodity().unwrap();
421
1
    assert_eq!(cmd.symbol().unwrap_err(), Error::OutOfBounds);
422
1
}
423

            
424
/// Round-trip sanity: header bytes serialize via `to_bytes` and can
425
/// be reconstructed via `from_bytes` — the test catches alignment /
426
/// repr drift between the SDK and the wire format crate.
427
#[test]
428
1
fn entity_header_bytes_round_trip() {
429
1
    let header = EntityHeader {
430
1
        entity_type: 1,
431
1
        operation: 2,
432
1
        flags: 0,
433
1
        reserved: [0; 1],
434
1
        id: [42u8; 16],
435
1
        parent_idx: 3,
436
1
        data_offset: 100,
437
1
        data_size: 64,
438
1
    };
439
1
    let bytes = header.to_bytes();
440
1
    assert_eq!(bytes.len(), ENTITY_HEADER_SIZE);
441
1
    let parsed = EntityHeader::from_bytes(&bytes).unwrap();
442
1
    assert_eq!(parsed.entity_type, 1);
443
1
    assert_eq!(parsed.operation, 2);
444
1
    assert_eq!(parsed.id, [42u8; 16]);
445
1
    assert_eq!(parsed.parent_idx, 3);
446
1
    assert_eq!(parsed.data_offset, 100);
447
1
    assert_eq!(parsed.data_size, 64);
448
1
}