1
use chrono::Utc;
2
use finance::split::Split;
3
use finance::transaction::Transaction;
4
use scripting::executor::ScriptExecutor;
5
use scripting::format::{
6
    ContextType, ENTITY_HEADER_SIZE, EntityType, GLOBAL_HEADER_SIZE, Operation,
7
};
8
use scripting::parser::EntityData;
9
use scripting::serializer::MemorySerializer;
10
use uuid::Uuid;
11

            
12
const TEST_WASM: &[u8] = include_bytes!("../../web/static/wasm/groceries_markup.wasm");
13
const TAG_SYNC_WASM: &[u8] = include_bytes!("../../web/static/wasm/tag_sync.wasm");
14

            
15
#[test]
16
1
fn test_groceries_script_tags_splits() {
17
1
    let executor = ScriptExecutor::new();
18

            
19
1
    let tx = Transaction {
20
1
        id: Uuid::new_v4(),
21
1
        post_date: Utc::now(),
22
1
        enter_date: Utc::now(),
23
1
    };
24

            
25
1
    let account1_id = Uuid::new_v4();
26
1
    let account2_id = Uuid::new_v4();
27
1
    let commodity_id = Uuid::new_v4();
28

            
29
1
    let split1 = Split {
30
1
        id: Uuid::new_v4(),
31
1
        tx_id: tx.id,
32
1
        account_id: account1_id,
33
1
        commodity_id,
34
1
        value_num: -5000,
35
1
        value_denom: 100,
36
1
        reconcile_state: None,
37
1
        reconcile_date: None,
38
1
        lot_id: None,
39
1
    };
40

            
41
1
    let split2 = Split {
42
1
        id: Uuid::new_v4(),
43
1
        tx_id: tx.id,
44
1
        account_id: account2_id,
45
1
        commodity_id,
46
1
        value_num: 5000,
47
1
        value_denom: 100,
48
1
        reconcile_state: None,
49
1
        reconcile_date: None,
50
1
        lot_id: None,
51
1
    };
52

            
53
1
    let mut serializer = MemorySerializer::new();
54
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
55

            
56
    // Transaction with 2 splits and 1 tag
57
1
    let tx_idx = serializer.add_transaction_from(&tx, true, 2, 1, false);
58
1
    serializer.set_primary(tx_idx);
59

            
60
1
    let split1_idx = serializer.add_split_from(&split1, tx_idx as i32);
61
1
    let split2_idx = serializer.add_split_from(&split2, tx_idx as i32);
62

            
63
    // Add "note" = "groceries" tag to transaction
64
1
    serializer.add_tag(
65
1
        Uuid::new_v4().into_bytes(),
66
1
        tx_idx as i32,
67
        false,
68
        false,
69
1
        "note",
70
1
        "groceries",
71
    );
72

            
73
1
    let input = serializer.finalize(4096);
74

            
75
1
    println!("Input size: {} bytes", input.len());
76

            
77
1
    let entities = executor
78
1
        .execute(TEST_WASM, &input, Some(4096))
79
1
        .expect("Execution failed");
80

            
81
1
    println!("Output entities: {entities:?}");
82
1
    assert_eq!(
83
1
        entities.len(),
84
        2,
85
        "Expected 2 output entities (category tags for each split)"
86
    );
87

            
88
2
    for (i, entity) in entities.iter().enumerate() {
89
2
        assert_eq!(entity.entity_type, EntityType::Tag);
90
2
        assert_eq!(entity.operation, Operation::Create);
91

            
92
2
        if let EntityData::Tag { name, value } = &entity.data {
93
2
            assert_eq!(name, "category", "Tag {i} name mismatch");
94
2
            assert_eq!(value, "groceries", "Tag {i} value mismatch");
95
        } else {
96
            panic!("Expected Tag entity data, got {:?}", entity.data);
97
        }
98
    }
99

            
100
1
    let parent_indices: Vec<i32> = entities.iter().map(|e| e.parent_idx).collect();
101
1
    assert!(
102
1
        parent_indices.contains(&(split1_idx as i32)),
103
        "Missing tag for split1"
104
    );
105
1
    assert!(
106
1
        parent_indices.contains(&(split2_idx as i32)),
107
        "Missing tag for split2"
108
    );
109
1
}
110

            
111
#[test]
112
1
fn test_groceries_script_skips_non_groceries() {
113
1
    let executor = ScriptExecutor::new();
114

            
115
1
    let tx = Transaction {
116
1
        id: Uuid::new_v4(),
117
1
        post_date: Utc::now(),
118
1
        enter_date: Utc::now(),
119
1
    };
120

            
121
1
    let mut serializer = MemorySerializer::new();
122
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
123

            
124
    // Transaction with tag "note" = "other" (not groceries)
125
1
    let tx_idx = serializer.add_transaction_from(&tx, true, 0, 1, false);
126
1
    serializer.set_primary(tx_idx);
127
1
    serializer.add_tag(
128
1
        Uuid::new_v4().into_bytes(),
129
1
        tx_idx as i32,
130
        false,
131
        false,
132
1
        "note",
133
1
        "other",
134
    );
135

            
136
1
    let input = serializer.finalize(4096);
137

            
138
1
    let entities = executor
139
1
        .execute(TEST_WASM, &input, Some(4096))
140
1
        .expect("Execution failed");
141
1
    assert!(
142
1
        entities.is_empty(),
143
        "Expected 0 output entities for non-groceries transaction"
144
    );
145
1
}
146

            
147
#[test]
148
1
fn test_groceries_script_skips_non_transaction() {
149
1
    let executor = ScriptExecutor::new();
150

            
151
1
    let mut serializer = MemorySerializer::new();
152
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Account);
153

            
154
1
    let account_id = [1u8; 16];
155
1
    let parent_account_id = [0u8; 16];
156
1
    let account_idx = serializer.add_account(
157
1
        account_id,
158
        -1,
159
        true,
160
        false,
161
1
        parent_account_id,
162
1
        "Test Account",
163
1
        "Assets:Test Account",
164
        0,
165
    );
166
1
    serializer.set_primary(account_idx);
167

            
168
1
    let input = serializer.finalize(4096);
169

            
170
1
    let entities = executor
171
1
        .execute(TEST_WASM, &input, Some(4096))
172
1
        .expect("Execution failed");
173
1
    assert!(
174
1
        entities.is_empty(),
175
        "Expected 0 output entities for Account"
176
    );
177
1
}
178

            
179
#[test]
180
1
fn test_groceries_script_skips_no_note_tag() {
181
1
    let executor = ScriptExecutor::new();
182

            
183
1
    let tx = Transaction {
184
1
        id: Uuid::new_v4(),
185
1
        post_date: Utc::now(),
186
1
        enter_date: Utc::now(),
187
1
    };
188

            
189
1
    let mut serializer = MemorySerializer::new();
190
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
191

            
192
    // Transaction without any tags
193
1
    let tx_idx = serializer.add_transaction_from(&tx, true, 0, 0, false);
194
1
    serializer.set_primary(tx_idx);
195

            
196
1
    let input = serializer.finalize(4096);
197

            
198
1
    let entities = executor
199
1
        .execute(TEST_WASM, &input, Some(4096))
200
1
        .expect("Execution failed");
201
1
    assert!(
202
1
        entities.is_empty(),
203
        "Expected 0 output entities for transaction without note tag"
204
    );
205
1
}
206

            
207
#[test]
208
1
fn test_tag_sync_copies_user_tags_to_splits() {
209
1
    let executor = ScriptExecutor::new();
210

            
211
1
    let tx = Transaction {
212
1
        id: Uuid::new_v4(),
213
1
        post_date: Utc::now(),
214
1
        enter_date: Utc::now(),
215
1
    };
216

            
217
1
    let commodity_id = Uuid::new_v4();
218

            
219
1
    let split1 = Split {
220
1
        id: Uuid::new_v4(),
221
1
        tx_id: tx.id,
222
1
        account_id: Uuid::new_v4(),
223
1
        commodity_id,
224
1
        value_num: -5000,
225
1
        value_denom: 100,
226
1
        reconcile_state: None,
227
1
        reconcile_date: None,
228
1
        lot_id: None,
229
1
    };
230

            
231
1
    let split2 = Split {
232
1
        id: Uuid::new_v4(),
233
1
        tx_id: tx.id,
234
1
        account_id: Uuid::new_v4(),
235
1
        commodity_id,
236
1
        value_num: 5000,
237
1
        value_denom: 100,
238
1
        reconcile_state: None,
239
1
        reconcile_date: None,
240
1
        lot_id: None,
241
1
    };
242

            
243
1
    let mut serializer = MemorySerializer::new();
244
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
245

            
246
1
    let tx_idx = serializer.add_transaction_from(&tx, true, 2, 2, false);
247
1
    serializer.set_primary(tx_idx);
248

            
249
1
    let split1_idx = serializer.add_split_from(&split1, tx_idx as i32);
250
1
    let split2_idx = serializer.add_split_from(&split2, tx_idx as i32);
251

            
252
    // "note" is a system tag — excluded from user tag count
253
1
    serializer.add_tag(
254
1
        Uuid::new_v4().into_bytes(),
255
1
        tx_idx as i32,
256
        false,
257
        false,
258
1
        "note",
259
1
        "groceries",
260
    );
261
    // "category" is a user tag
262
1
    serializer.add_tag(
263
1
        Uuid::new_v4().into_bytes(),
264
1
        tx_idx as i32,
265
        false,
266
        false,
267
1
        "category",
268
1
        "food",
269
    );
270

            
271
1
    let input = serializer.finalize(4096);
272

            
273
1
    let entities = executor
274
1
        .execute(TAG_SYNC_WASM, &input, Some(4096))
275
1
        .expect("tag_sync execution failed");
276

            
277
1
    assert_eq!(
278
1
        entities.len(),
279
        2,
280
        "Expected 2 output entities (category tag copied to each split)"
281
    );
282

            
283
2
    for entity in &entities {
284
2
        assert_eq!(entity.entity_type, EntityType::Tag);
285
2
        assert_eq!(entity.operation, Operation::Create);
286

            
287
2
        if let EntityData::Tag { name, value } = &entity.data {
288
2
            assert_eq!(name, "category");
289
2
            assert_eq!(value, "food");
290
        } else {
291
            panic!("Expected Tag entity data, got {:?}", entity.data);
292
        }
293
    }
294

            
295
1
    let parent_indices: Vec<i32> = entities.iter().map(|e| e.parent_idx).collect();
296
1
    assert!(
297
1
        parent_indices.contains(&(split1_idx as i32)),
298
        "Missing tag for split1"
299
    );
300
1
    assert!(
301
1
        parent_indices.contains(&(split2_idx as i32)),
302
        "Missing tag for split2"
303
    );
304
1
}
305

            
306
#[test]
307
1
fn test_tag_sync_copies_split_tags_to_transaction() {
308
1
    let executor = ScriptExecutor::new();
309

            
310
1
    let tx = Transaction {
311
1
        id: Uuid::new_v4(),
312
1
        post_date: Utc::now(),
313
1
        enter_date: Utc::now(),
314
1
    };
315

            
316
1
    let commodity_id = Uuid::new_v4();
317

            
318
1
    let split1 = Split {
319
1
        id: Uuid::new_v4(),
320
1
        tx_id: tx.id,
321
1
        account_id: Uuid::new_v4(),
322
1
        commodity_id,
323
1
        value_num: -5000,
324
1
        value_denom: 100,
325
1
        reconcile_state: None,
326
1
        reconcile_date: None,
327
1
        lot_id: None,
328
1
    };
329

            
330
1
    let split2 = Split {
331
1
        id: Uuid::new_v4(),
332
1
        tx_id: tx.id,
333
1
        account_id: Uuid::new_v4(),
334
1
        commodity_id,
335
1
        value_num: 5000,
336
1
        value_denom: 100,
337
1
        reconcile_state: None,
338
1
        reconcile_date: None,
339
1
        lot_id: None,
340
1
    };
341

            
342
1
    let mut serializer = MemorySerializer::new();
343
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
344

            
345
    // Transaction with only a "note" tag (no user tags)
346
1
    let tx_idx = serializer.add_transaction_from(&tx, true, 2, 1, false);
347
1
    serializer.set_primary(tx_idx);
348

            
349
1
    let split1_idx = serializer.add_split_from(&split1, tx_idx as i32);
350
1
    serializer.add_split_from(&split2, tx_idx as i32);
351

            
352
1
    serializer.add_tag(
353
1
        Uuid::new_v4().into_bytes(),
354
1
        tx_idx as i32,
355
        false,
356
        false,
357
1
        "note",
358
1
        "groceries",
359
    );
360
    // Split1 has a user tag
361
1
    serializer.add_tag(
362
1
        Uuid::new_v4().into_bytes(),
363
1
        split1_idx as i32,
364
        false,
365
        false,
366
1
        "category",
367
1
        "food",
368
    );
369

            
370
1
    let input = serializer.finalize(4096);
371

            
372
1
    let entities = executor
373
1
        .execute(TAG_SYNC_WASM, &input, Some(4096))
374
1
        .expect("tag_sync execution failed");
375

            
376
1
    assert_eq!(
377
1
        entities.len(),
378
        1,
379
        "Expected 1 output entity (category tag copied to transaction)"
380
    );
381

            
382
1
    let entity = &entities[0];
383
1
    assert_eq!(entity.entity_type, EntityType::Tag);
384
1
    assert_eq!(entity.operation, Operation::Create);
385
1
    assert_eq!(entity.parent_idx, tx_idx as i32);
386

            
387
1
    if let EntityData::Tag { name, value } = &entity.data {
388
1
        assert_eq!(name, "category");
389
1
        assert_eq!(value, "food");
390
    } else {
391
        panic!("Expected Tag entity data, got {:?}", entity.data);
392
    }
393
1
}
394

            
395
#[test]
396
1
fn test_tag_sync_noop_when_note_only_no_split_tags() {
397
1
    let executor = ScriptExecutor::new();
398

            
399
1
    let tx = Transaction {
400
1
        id: Uuid::new_v4(),
401
1
        post_date: Utc::now(),
402
1
        enter_date: Utc::now(),
403
1
    };
404

            
405
1
    let commodity_id = Uuid::new_v4();
406

            
407
1
    let split1 = Split {
408
1
        id: Uuid::new_v4(),
409
1
        tx_id: tx.id,
410
1
        account_id: Uuid::new_v4(),
411
1
        commodity_id,
412
1
        value_num: -5000,
413
1
        value_denom: 100,
414
1
        reconcile_state: None,
415
1
        reconcile_date: None,
416
1
        lot_id: None,
417
1
    };
418

            
419
1
    let split2 = Split {
420
1
        id: Uuid::new_v4(),
421
1
        tx_id: tx.id,
422
1
        account_id: Uuid::new_v4(),
423
1
        commodity_id,
424
1
        value_num: 5000,
425
1
        value_denom: 100,
426
1
        reconcile_state: None,
427
1
        reconcile_date: None,
428
1
        lot_id: None,
429
1
    };
430

            
431
1
    let mut serializer = MemorySerializer::new();
432
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
433

            
434
1
    let tx_idx = serializer.add_transaction_from(&tx, true, 2, 1, false);
435
1
    serializer.set_primary(tx_idx);
436

            
437
1
    serializer.add_split_from(&split1, tx_idx as i32);
438
1
    serializer.add_split_from(&split2, tx_idx as i32);
439

            
440
    // Only "note" on tx, no tags on splits — nothing to sync
441
1
    serializer.add_tag(
442
1
        Uuid::new_v4().into_bytes(),
443
1
        tx_idx as i32,
444
        false,
445
        false,
446
1
        "note",
447
1
        "groceries",
448
    );
449

            
450
1
    let input = serializer.finalize(4096);
451

            
452
1
    let entities = executor
453
1
        .execute(TAG_SYNC_WASM, &input, Some(4096))
454
1
        .expect("tag_sync execution failed");
455

            
456
1
    assert!(
457
1
        entities.is_empty(),
458
        "Expected 0 output entities when only note tag on tx and no split tags"
459
    );
460
1
}
461

            
462
5
fn build_valid_transaction_input() -> Vec<u8> {
463
5
    let tx = Transaction {
464
5
        id: Uuid::new_v4(),
465
5
        post_date: Utc::now(),
466
5
        enter_date: Utc::now(),
467
5
    };
468

            
469
5
    let commodity_id = Uuid::new_v4();
470

            
471
5
    let split1 = Split {
472
5
        id: Uuid::new_v4(),
473
5
        tx_id: tx.id,
474
5
        account_id: Uuid::new_v4(),
475
5
        commodity_id,
476
5
        value_num: -5000,
477
5
        value_denom: 100,
478
5
        reconcile_state: None,
479
5
        reconcile_date: None,
480
5
        lot_id: None,
481
5
    };
482

            
483
5
    let split2 = Split {
484
5
        id: Uuid::new_v4(),
485
5
        tx_id: tx.id,
486
5
        account_id: Uuid::new_v4(),
487
5
        commodity_id,
488
5
        value_num: 5000,
489
5
        value_denom: 100,
490
5
        reconcile_state: None,
491
5
        reconcile_date: None,
492
5
        lot_id: None,
493
5
    };
494

            
495
5
    let mut serializer = MemorySerializer::new();
496
5
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
497

            
498
5
    let tx_idx = serializer.add_transaction_from(&tx, true, 2, 1, false);
499
5
    serializer.set_primary(tx_idx);
500
5
    serializer.add_split_from(&split1, tx_idx as i32);
501
5
    serializer.add_split_from(&split2, tx_idx as i32);
502
5
    serializer.add_tag(
503
5
        Uuid::new_v4().into_bytes(),
504
5
        tx_idx as i32,
505
        false,
506
        false,
507
5
        "note",
508
5
        "groceries",
509
    );
510

            
511
5
    serializer.finalize(4096)
512
5
}
513

            
514
#[test]
515
1
fn test_script_handles_truncated_input_without_panic() {
516
1
    let executor = ScriptExecutor::new();
517
1
    let input = build_valid_transaction_input();
518

            
519
1
    let truncated = &input[..GLOBAL_HEADER_SIZE + ENTITY_HEADER_SIZE];
520
1
    let result = executor.execute(TEST_WASM, truncated, Some(4096));
521
1
    assert!(
522
1
        result.is_ok() || result.is_err(),
523
        "Must return a result, not panic"
524
    );
525
1
}
526

            
527
#[test]
528
1
fn test_script_handles_corrupted_entity_data_offset_without_panic() {
529
1
    let executor = ScriptExecutor::new();
530
1
    let mut input = build_valid_transaction_input();
531

            
532
1
    let entity_start = GLOBAL_HEADER_SIZE;
533
1
    let data_offset_field = entity_start + 24;
534
1
    if data_offset_field + 4 <= input.len() {
535
1
        input[data_offset_field..data_offset_field + 4].copy_from_slice(&u32::MAX.to_le_bytes());
536
1
    }
537

            
538
1
    let result = executor.execute(TEST_WASM, &input, Some(4096));
539
1
    assert!(
540
1
        result.is_ok() || result.is_err(),
541
        "Must return a result, not panic"
542
    );
543
1
}
544

            
545
#[test]
546
1
fn test_script_handles_corrupted_entity_data_size_without_panic() {
547
1
    let executor = ScriptExecutor::new();
548
1
    let mut input = build_valid_transaction_input();
549

            
550
1
    let entity_start = GLOBAL_HEADER_SIZE;
551
1
    let data_size_field = entity_start + 28;
552
1
    if data_size_field + 4 <= input.len() {
553
1
        input[data_size_field..data_size_field + 4].copy_from_slice(&u32::MAX.to_le_bytes());
554
1
    }
555

            
556
1
    let result = executor.execute(TEST_WASM, &input, Some(4096));
557
1
    assert!(
558
1
        result.is_ok() || result.is_err(),
559
        "Must return a result, not panic"
560
    );
561
1
}
562

            
563
#[test]
564
1
fn test_tag_sync_script_handles_truncated_input_without_panic() {
565
1
    let executor = ScriptExecutor::new();
566
1
    let input = build_valid_transaction_input();
567

            
568
1
    let truncated = &input[..GLOBAL_HEADER_SIZE + ENTITY_HEADER_SIZE];
569
1
    let result = executor.execute(TAG_SYNC_WASM, truncated, Some(4096));
570
1
    assert!(
571
1
        result.is_ok() || result.is_err(),
572
        "Must return a result, not panic"
573
    );
574
1
}
575

            
576
#[test]
577
1
fn test_tag_sync_script_handles_corrupted_entity_offset_without_panic() {
578
1
    let executor = ScriptExecutor::new();
579
1
    let mut input = build_valid_transaction_input();
580

            
581
1
    let entity_start = GLOBAL_HEADER_SIZE;
582
1
    let data_offset_field = entity_start + 24;
583
1
    if data_offset_field + 4 <= input.len() {
584
1
        input[data_offset_field..data_offset_field + 4].copy_from_slice(&u32::MAX.to_le_bytes());
585
1
    }
586

            
587
1
    let result = executor.execute(TAG_SYNC_WASM, &input, Some(4096));
588
1
    assert!(
589
1
        result.is_ok() || result.is_err(),
590
        "Must return a result, not panic"
591
    );
592
1
}