hotfix 0.12.0

Buy-side FIX engine written in pure Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
use crate::common::actions::when;
use crate::common::assertions::{assert_msg_type, then};
use crate::common::cleanup::finally;
use crate::common::setup::{COUNTERPARTY_COMP_ID, OUR_COMP_ID, given_an_active_session};
use crate::common::test_messages::{
    ExecutionReportWithInvalidField, TestMessage, TestReject, build_execution_report_with_comp_id,
    build_execution_report_with_custom_msg_type,
    build_execution_report_with_incorrect_begin_string,
    build_execution_report_with_incorrect_body_length,
    build_execution_report_with_incorrect_orig_sending_time,
    build_execution_report_with_missing_orig_sending_time,
    build_execution_report_with_missing_sending_time,
    build_execution_report_with_sending_time_too_old,
};
use hotfix::session::Status;
use hotfix_message::Part;
use hotfix_message::fix44::{MsgType, SESSION_REJECT_REASON, SessionRejectReason};

/// Tests that when a counterparty sends a message containing an invalid/unrecognised field,
/// the session rejects the message by sending a Reject (MsgType=3) message back.
#[tokio::test]
async fn test_message_with_invalid_field_gets_rejected() {
    let (session, mut counterparty) = given_an_active_session().await;

    when(&mut counterparty)
        .sends_message(ExecutionReportWithInvalidField::default())
        .await;
    then(&mut counterparty)
        .receives(|msg| assert_msg_type(msg, MsgType::Reject))
        .await;

    finally(&session, &mut counterparty).disconnect().await;
}

/// Tests that when a counterparty sends a garbled message with an invalid body length,
/// the session silently ignores it and detects a sequence gap when the next valid message arrives.
#[tokio::test]
async fn test_garbled_message_with_invalid_target_comp_id_gets_ignored() {
    let (mut session, mut counterparty) = given_an_active_session().await;

    // counterparty sends a message with invalid body length, which constitutes a garbled message
    let garbled_message_seq_num = counterparty.next_target_sequence_number();
    let garbled_message =
        build_execution_report_with_incorrect_body_length(garbled_message_seq_num);
    when(&mut counterparty)
        .sends_raw_message(garbled_message)
        .await;

    // they then send a valid message
    when(&mut counterparty)
        .sends_message(TestMessage::dummy_execution_report())
        .await;

    // we then initiate a resend, having skipped the garbled message
    then(&mut counterparty)
        .receives(|msg| assert_msg_type(msg, MsgType::ResendRequest))
        .await;
    then(&mut session)
        .status_changes_to(Status::AwaitingResend {
            begin: garbled_message_seq_num,
            end: garbled_message_seq_num + 1,
            attempts: 1,
        })
        .await;

    finally(&session, &mut counterparty).disconnect().await;
}

/// Tests that when a counterparty sends a message with an invalid BeginString,
/// the session logs out and disconnects.
#[tokio::test]
async fn test_message_with_invalid_begin_string() {
    let (_session, mut counterparty) = given_an_active_session().await;

    // a message with invalid BeginString is sent by the counterparty
    let invalid_message = build_execution_report_with_incorrect_begin_string(
        counterparty.next_target_sequence_number(),
    );
    when(&mut counterparty)
        .sends_raw_message(invalid_message)
        .await;

    // then we log out and disconnect
    then(&mut counterparty)
        .receives(|msg| assert_msg_type(msg, MsgType::Logout))
        .await;
    then(&mut counterparty).gets_disconnected().await;
}

/// Tests that when a counterparty sends a message with an invalid TargetCompId,
/// the session sends a Reject (MsgType=3) and logs out and disconnects.
#[tokio::test]
async fn test_message_with_invalid_target_comp_id() {
    let (_session, mut counterparty) = given_an_active_session().await;

    // a message with incorrect TargetCompId is sent by the counterparty
    let invalid_message = build_execution_report_with_comp_id(
        counterparty.next_target_sequence_number(),
        COUNTERPARTY_COMP_ID,
        "WRONG_COMP_ID",
    );
    when(&mut counterparty)
        .sends_raw_message(invalid_message)
        .await;

    // then we send a reject, log out and disconnect
    then(&mut counterparty)
        .receives(|msg| assert_msg_type(msg, MsgType::Reject))
        .await;
    then(&mut counterparty)
        .receives(|msg| assert_msg_type(msg, MsgType::Logout))
        .await;
    then(&mut counterparty).gets_disconnected().await;
}

/// Tests that when a counterparty sends a message with an invalid SenderCompId,
/// the session sends a Reject (MsgType=3) and logs out and disconnects.
#[tokio::test]
async fn test_message_with_invalid_sender_comp_id() {
    let (_session, mut counterparty) = given_an_active_session().await;

    // a message with incorrect SenderCompId is sent by the counterparty
    let invalid_message = build_execution_report_with_comp_id(
        counterparty.next_target_sequence_number(),
        "WRONG_COMP_ID",
        OUR_COMP_ID,
    );
    when(&mut counterparty)
        .sends_raw_message(invalid_message)
        .await;

    // then we send a reject, log out and disconnect
    then(&mut counterparty)
        .receives(|msg| assert_msg_type(msg, MsgType::Reject))
        .await;
    then(&mut counterparty)
        .receives(|msg| assert_msg_type(msg, MsgType::Logout))
        .await;
    then(&mut counterparty).gets_disconnected().await;
}

/// Tests that when the counterparty sends a message with an invalid MsgType,
/// the session sends a Reject (MsgType=3) with the appropriate reject reason.
#[tokio::test]
async fn test_message_with_invalid_msg_type() {
    let (mut session, mut counterparty) = given_an_active_session().await;

    // a message with invalid MsgType is sent by the counterparty
    let sequence_number = counterparty.next_target_sequence_number();
    let invalid_message = build_execution_report_with_custom_msg_type(sequence_number, "ZZ");
    when(&mut counterparty)
        .sends_raw_message(invalid_message)
        .await;

    // then we send a reject
    then(&mut counterparty)
        .receives(|msg| {
            assert_msg_type(msg, MsgType::Reject);
            assert_eq!(msg.get::<u32>(SESSION_REJECT_REASON).unwrap(), 11);
        })
        .await;
    // our target sequence number should be incremented
    then(&mut session)
        .target_sequence_number_reaches(sequence_number)
        .await;

    finally(&session, &mut counterparty).disconnect().await;
}

/// Tests that a message with a sequence number lower than the expected one
/// causes the session to log out and disconnect the counterparty.
#[tokio::test]
async fn test_message_with_sequence_number_too_low() {
    let (mut session, mut counterparty) = given_an_active_session().await;

    let sequence_number = counterparty.next_target_sequence_number();
    when(&mut counterparty)
        .sends_message(TestMessage::dummy_execution_report())
        .await;
    then(&mut session)
        .target_sequence_number_reaches(sequence_number)
        .await;

    // another message is sent, but due to a failure in the message store, it gets assigned the same sequence number
    counterparty.delete_last_message_from_store();
    when(&mut counterparty)
        .sends_message(TestMessage::dummy_execution_report())
        .await;
    then(&mut counterparty)
        .receives(|msg| {
            // we log them out
            assert_msg_type(msg, MsgType::Logout);
        })
        .await;
    then(&mut counterparty).gets_disconnected().await;
}

/// Tests that a duplicate sequence number (too low) carrying PossDupFlag=Y is
/// treated as a safe retransmission and therefore ignored (no logout / reject),
/// and that subsequent in-sequence messages continue processing normally.
#[tokio::test]
async fn test_message_with_sequence_number_too_low_possdup_ignored() {
    let (mut session, mut counterparty) = given_an_active_session().await;

    // A valid execution report is sent and processed normally
    let first_seq = counterparty.next_target_sequence_number();
    when(&mut counterparty)
        .sends_message(TestMessage::dummy_execution_report())
        .await;
    then(&mut session)
        .target_sequence_number_reaches(first_seq)
        .await;

    // The message is resent with PossDupFlag=Y
    // We expect the session to ignore this duplicate (no logout / no reject)
    when(&mut counterparty).resends_message(first_seq).await;

    // A second message is sent, which should be accepted normally
    let second_seq = counterparty.next_target_sequence_number();
    when(&mut counterparty)
        .sends_message(TestMessage::dummy_execution_report())
        .await;
    then(&mut session)
        .target_sequence_number_reaches(second_seq)
        .await;

    finally(&session, &mut counterparty).disconnect().await;
}

/// Tests that a message with `OrigSendingTime` after `SendingTime` is rejected
/// with an appropriate rejection reason.
#[tokio::test]
async fn test_message_with_incorrect_orig_sending_time_is_rejected() {
    let (mut session, mut counterparty) = given_an_active_session().await;

    // A valid execution report is sent and processed normally
    let seq_number = counterparty.next_target_sequence_number();
    when(&mut counterparty)
        .sends_message(TestMessage::dummy_execution_report())
        .await;
    then(&mut session)
        .target_sequence_number_reaches(seq_number)
        .await;

    // the same is resent with PossDupFlag=Y, but with OriginalSendingTime after SendingTime
    when(&mut counterparty)
        .sends_raw_message(build_execution_report_with_incorrect_orig_sending_time(
            seq_number,
        ))
        .await;
    then(&mut counterparty)
        .receives(|msg| {
            assert_msg_type(msg, MsgType::Reject);
            assert_eq!(
                msg.get::<SessionRejectReason>(SESSION_REJECT_REASON)
                    .unwrap(),
                SessionRejectReason::SendingtimeAccuracyProblem
            );
        })
        .await;

    finally(&session, &mut counterparty).disconnect().await;
}

/// Tests that a message with missing `OrigSendingTime` is rejected.
///
/// `OrigSendingTime` is required when `PossDupFlag` is set to `Y`.
#[tokio::test]
async fn test_message_with_missing_orig_sending_time_is_rejected() {
    let (mut session, mut counterparty) = given_an_active_session().await;

    // a valid execution report is sent and processed normally
    let seq_number = counterparty.next_target_sequence_number();
    when(&mut counterparty)
        .sends_message(TestMessage::dummy_execution_report())
        .await;
    then(&mut session)
        .target_sequence_number_reaches(seq_number)
        .await;

    // the same is resent with PossDupFlag=Y, but with OriginalSendingTime after SendingTime
    when(&mut counterparty)
        .sends_raw_message(build_execution_report_with_missing_orig_sending_time(
            seq_number,
        ))
        .await;
    then(&mut counterparty)
        .receives(|msg| {
            assert_msg_type(msg, MsgType::Reject);
            assert_eq!(
                msg.get::<SessionRejectReason>(SESSION_REJECT_REASON)
                    .unwrap(),
                SessionRejectReason::RequiredTagMissing
            );
        })
        .await;

    finally(&session, &mut counterparty).disconnect().await;
}

/// Tests that a message with missing `SendingTime` is rejected.
///
/// `SendingTime` is a required field in all FIX messages.
#[tokio::test]
async fn test_message_with_missing_sending_time_is_rejected() {
    let (mut session, mut counterparty) = given_an_active_session().await;

    // a message with missing SendingTime is sent by the counterparty
    let seq_number = counterparty.next_target_sequence_number();
    when(&mut counterparty)
        .sends_raw_message(build_execution_report_with_missing_sending_time(seq_number))
        .await;

    // then we send a reject with the appropriate reason
    then(&mut counterparty)
        .receives(|msg| {
            assert_msg_type(msg, MsgType::Reject);
            assert_eq!(
                msg.get::<SessionRejectReason>(SESSION_REJECT_REASON)
                    .unwrap(),
                SessionRejectReason::SendingtimeAccuracyProblem
            );
        })
        .await;

    // our target sequence number should be incremented
    then(&mut session)
        .target_sequence_number_reaches(seq_number)
        .await;

    finally(&session, &mut counterparty).disconnect().await;
}

/// Tests that a message with `SendingTime` too far in the past is rejected.
///
/// Messages with `SendingTime` more than 120 seconds in the past should be rejected.
#[tokio::test]
async fn test_message_with_sending_time_too_old_is_rejected() {
    let (mut session, mut counterparty) = given_an_active_session().await;

    // a message with SendingTime 121 seconds in the past is sent by the counterparty
    let seq_number = counterparty.next_target_sequence_number();
    when(&mut counterparty)
        .sends_raw_message(build_execution_report_with_sending_time_too_old(seq_number))
        .await;

    // then we send a reject with the appropriate reason
    then(&mut counterparty)
        .receives(|msg| {
            assert_msg_type(msg, MsgType::Reject);
            assert_eq!(
                msg.get::<SessionRejectReason>(SESSION_REJECT_REASON)
                    .unwrap(),
                SessionRejectReason::SendingtimeAccuracyProblem
            );
        })
        .await;

    // our target sequence number should be incremented
    then(&mut session)
        .target_sequence_number_reaches(seq_number)
        .await;

    finally(&session, &mut counterparty).disconnect().await;
}

/// Tests that a message with PossDupFlag=Y but missing OrigSendingTime is rejected.
///
/// When PossDupFlag is set to Y, OrigSendingTime (tag 122) is required.
/// The session should reject with SessionRejectReason = 1 (RequiredTagMissing).
#[tokio::test]
async fn test_scenario_2g_possdup_without_orig_sending_time() {
    let (mut session, mut counterparty) = given_an_active_session().await;

    // a valid execution report is sent and processed normally
    let seq_number = counterparty.next_target_sequence_number();
    when(&mut counterparty)
        .sends_message(TestMessage::dummy_execution_report())
        .await;
    then(&mut session)
        .target_sequence_number_reaches(seq_number)
        .await;

    // the message is resent with PossDupFlag=Y but without OrigSendingTime
    when(&mut counterparty)
        .sends_raw_message(build_execution_report_with_missing_orig_sending_time(
            seq_number,
        ))
        .await;

    // then we send a reject with SessionRejectReason = 1 (RequiredTagMissing)
    then(&mut counterparty)
        .receives(|msg| {
            assert_msg_type(msg, MsgType::Reject);
            assert_eq!(
                msg.get::<SessionRejectReason>(SESSION_REJECT_REASON)
                    .unwrap(),
                SessionRejectReason::RequiredTagMissing
            );
        })
        .await;

    finally(&session, &mut counterparty).disconnect().await;
}

/// Tests that a Reject (MsgType=3) from the counterparty is processed correctly.
///
/// The session should increment the target sequence number and remain active,
/// continuing to accept subsequent messages.
#[tokio::test]
async fn test_processing_reject_from_counterparty() {
    let (mut session, mut counterparty) = given_an_active_session().await;

    // Counterparty sends a Reject referencing our logon (seq 1)
    let reject_seq_num = counterparty.next_target_sequence_number();
    when(&mut counterparty)
        .sends_message(TestReject { ref_seq_num: 1 })
        .await;

    // The reject should be processed, incrementing the target sequence number
    then(&mut session)
        .target_sequence_number_reaches(reject_seq_num)
        .await;

    // The session should remain active and accept further messages
    let next_seq_num = counterparty.next_target_sequence_number();
    when(&mut counterparty)
        .sends_message(TestMessage::dummy_execution_report())
        .await;
    then(&mut session)
        .target_sequence_number_reaches(next_seq_num)
        .await;

    finally(&session, &mut counterparty).disconnect().await;
}