package fix import ( "errors" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "quantex.com/qfixdpl/quickfix" "quantex.com/qfixdpl/src/app" "quantex.com/qfixdpl/src/domain" ) // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // newTestManager builds a Manager with the given store without calling Start(). func newTestManager(store domain.PersistenceStore) *Manager { notify := &MockNotifier{} return NewManager(app.FIXConfig{}, store, notify) } // makeMsg builds a TradeMessage with the given quoteReqID, msgType, and body. func makeMsg(quoteReqID, msgType string, body map[string]interface{}) domain.TradeMessage { return domain.TradeMessage{ ID: "test-id-" + quoteReqID + "-" + msgType, QuoteReqID: quoteReqID, JMessage: domain.FixMessageJSON{ MsgType: msgType, QuoteReqID: quoteReqID, Body: body, ReceiveTime: time.Now(), }, CreatedAt: time.Now(), } } // makeQuoteRequest builds a "R" (QuoteRequest) TradeMessage. func makeQuoteRequest(quoteReqID, listID, nt string, extras map[string]interface{}) domain.TradeMessage { body := map[string]interface{}{ "NegotiationType": nt, "ListID": listID, } for k, v := range extras { body[k] = v } return makeMsg(quoteReqID, "R", body) } // makeQuoteAck builds a "CW" (QuoteAck) TradeMessage. func makeQuoteAck(quoteReqID, status string) domain.TradeMessage { return makeMsg(quoteReqID, "CW", map[string]interface{}{ "QuoteAckStatus": status, }) } // makeQuoteResponse builds an "AJ" (QuoteResponse) TradeMessage. func makeQuoteResponse(quoteReqID, quoteRespID string) domain.TradeMessage { return makeMsg(quoteReqID, "AJ", map[string]interface{}{ "QuoteRespID": quoteRespID, }) } // makeExecutionReport builds an "8" (ExecutionReport) TradeMessage. // clOrdID maps to body["ClOrdID"]; it is also set as TradeMessage.QuoteReqID // to mirror how persistMessage works in handleExecutionReport. func makeExecutionReport(clOrdID, execID string) domain.TradeMessage { return makeMsg(clOrdID, "8", map[string]interface{}{ "ClOrdID": clOrdID, "ExecID": execID, }) } // makeOutgoing builds an outgoing message (AI, S, BN) for a given quoteReqID. func makeOutgoing(quoteReqID, msgType string) domain.TradeMessage { return makeMsg(quoteReqID, msgType, map[string]interface{}{}) } // --------------------------------------------------------------------------- // Group 1 — DB vacia // --------------------------------------------------------------------------- func TestLoadTrades_EmptyDB(t *testing.T) { store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.NotNil(t, m.trades) assert.Len(t, m.trades, 0) store.AssertExpectations(t) } // --------------------------------------------------------------------------- // Group 2 — Interrupcion despues de QuoteRequest // --------------------------------------------------------------------------- func TestLoadTrades_SingleR_CreatesOneTrade(t *testing.T) { r := makeQuoteRequest("LST_ABC123", "LIST_1", "RFQ", map[string]interface{}{ "SecurityID": "US1234567890", "Currency": "USD", "Side": "1", "OrderQty": "1000000", "SettlDate": "20260320", "OwnerTraderID": "trader1", }) store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) require.Len(t, m.trades, 1) trade := m.trades["LST_ABC123"] require.NotNil(t, trade) assert.Equal(t, "LST_ABC123", trade.QuoteReqID) assert.Equal(t, "LIST_1", trade.ListID) assert.Equal(t, "US1234567890", trade.Symbol) assert.Equal(t, "USD", trade.Currency) assert.Equal(t, "1", string(trade.Side)) assert.Equal(t, "20260320", trade.SettlDate) assert.Equal(t, "trader1", trade.OwnerTraderID) assert.Equal(t, domain.TradeStatusActive, trade.Status) store.AssertExpectations(t) } func TestLoadTrades_R_WithOutgoingMsgs_IgnoresAI_S(t *testing.T) { r := makeQuoteRequest("LST_ABC123", "LIST_1", "RFQ", nil) ai := makeOutgoing("LST_ABC123", "AI") s := makeOutgoing("LST_ABC123", "S") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r, ai, s}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 1) assert.Equal(t, domain.TradeStatusActive, m.trades["LST_ABC123"].Status) store.AssertExpectations(t) } func TestLoadTrades_R_NonLSTPrefix_Ignored(t *testing.T) { r := makeQuoteRequest("TRD_ABC123", "LIST_1", "RFQ", nil) store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 0) store.AssertExpectations(t) } func TestLoadTrades_R_NonRFQ_NegotiationType_Ignored(t *testing.T) { r := makeQuoteRequest("LST_X", "LIST_1", "DEALER", nil) store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 0) store.AssertExpectations(t) } func TestLoadTrades_R_EmptyListID_Ignored(t *testing.T) { r := makeQuoteRequest("LST_X", "", "RFQ", nil) store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 0) store.AssertExpectations(t) } func TestLoadTrades_R_MissingOptionalFields_TradeCreated(t *testing.T) { r := makeQuoteRequest("LST_MIN", "LIST_MIN", "RFQ", nil) store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) require.Len(t, m.trades, 1) trade := m.trades["LST_MIN"] require.NotNil(t, trade) assert.Equal(t, "LST_MIN", trade.QuoteReqID) assert.Equal(t, "LIST_MIN", trade.ListID) assert.Equal(t, "", trade.Symbol) assert.Equal(t, "", trade.Currency) assert.Equal(t, "", trade.SettlDate) assert.Equal(t, "", trade.OwnerTraderID) assert.Equal(t, domain.TradeStatusActive, trade.Status) store.AssertExpectations(t) } // --------------------------------------------------------------------------- // Group 3 — Interrupcion despues de QuoteAck // --------------------------------------------------------------------------- func TestLoadTrades_CW_Accepted_TradeRemains(t *testing.T) { r := makeQuoteRequest("LST_Q1", "LIST_1", "RFQ", nil) cw := makeQuoteAck("LST_Q1", "1") // ACCEPTED store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r, cw}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 1) assert.Equal(t, domain.TradeStatusActive, m.trades["LST_Q1"].Status) store.AssertExpectations(t) } func TestLoadTrades_CW_Rejected_TradeMarkedRejected(t *testing.T) { r := makeQuoteRequest("LST_Q1", "LIST_1", "RFQ", nil) cw := makeQuoteAck("LST_Q1", "2") // REJECTED store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r, cw}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 1) assert.Equal(t, domain.TradeStatusRejected, m.trades["LST_Q1"].Status) store.AssertExpectations(t) } // QuoteAckStatus "0" is RECEIVED_NOT_YET_PROCESSED — not a rejection. // Only status "2" (Rejected) should mark the trade as rejected. func TestLoadTrades_CW_ReceivedNotYetProcessed_TradeStaysActive(t *testing.T) { r := makeQuoteRequest("LST_Q1", "LIST_1", "RFQ", nil) cw := makeQuoteAck("LST_Q1", "0") // RECEIVED_NOT_YET_PROCESSED store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r, cw}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 1) assert.Equal(t, domain.TradeStatusActive, m.trades["LST_Q1"].Status) store.AssertExpectations(t) } func TestLoadTrades_CW_WithoutPriorR_NoCrash(t *testing.T) { cw := makeQuoteAck("LST_ORPHAN", "2") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{cw}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 0) store.AssertExpectations(t) } // --------------------------------------------------------------------------- // Group 4 — Interrupcion despues de QuoteResponse // --------------------------------------------------------------------------- func TestLoadTrades_AJ_TRDREQ_TradeRemains(t *testing.T) { r := makeQuoteRequest("LST_Q1", "LIST_1", "RFQ", nil) cw := makeQuoteAck("LST_Q1", "1") aj := makeQuoteResponse("LST_Q1", "LST_Q1_TRDREQ") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r, cw, aj}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 1) assert.Equal(t, domain.TradeStatusActive, m.trades["LST_Q1"].Status) store.AssertExpectations(t) } func TestLoadTrades_AJ_TRDEND_TradeRemains(t *testing.T) { r := makeQuoteRequest("LST_Q1", "LIST_1", "RFQ", nil) cw := makeQuoteAck("LST_Q1", "1") aj := makeQuoteResponse("LST_Q1", "LST_Q1_TRDEND") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r, cw, aj}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 1) assert.Equal(t, domain.TradeStatusActive, m.trades["LST_Q1"].Status) store.AssertExpectations(t) } func TestLoadTrades_AJ_LISTEND_TradeRemains(t *testing.T) { r := makeQuoteRequest("LST_Q1", "LIST_1", "RFQ", nil) cw := makeQuoteAck("LST_Q1", "1") aj := makeQuoteResponse("LST_Q1", "LST_Q1_LISTEND") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r, cw, aj}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 1) assert.Equal(t, domain.TradeStatusActive, m.trades["LST_Q1"].Status) store.AssertExpectations(t) } func TestLoadTrades_AJ_TRDSUMM_TradeMarkedCompleted(t *testing.T) { r := makeQuoteRequest("LST_Q1", "LIST_1", "RFQ", nil) cw := makeQuoteAck("LST_Q1", "1") aj := makeQuoteResponse("LST_Q1", "LST_Q1_TRDSUMM") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r, cw, aj}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 1) assert.Equal(t, domain.TradeStatusCompleted, m.trades["LST_Q1"].Status) store.AssertExpectations(t) } func TestLoadTrades_AJ_TRDSUMM_WithoutPriorR_NoCrash(t *testing.T) { aj := makeQuoteResponse("LST_ORPHAN", "LST_ORPHAN_TRDSUMM") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{aj}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 0) store.AssertExpectations(t) } // --------------------------------------------------------------------------- // Group 5 — Interrupcion despues de ExecutionReport // --------------------------------------------------------------------------- func TestLoadTrades_ExecReport_LISTEND_TradeRemains(t *testing.T) { r := makeQuoteRequest("LST_ABC123", "LIST_1", "RFQ", nil) cw := makeQuoteAck("LST_ABC123", "1") aj := makeQuoteResponse("LST_ABC123", "LST_ABC123_TRDREQ") exec := makeExecutionReport("LST_ABC123", "LST_ABC123_LISTEND") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r, cw, aj, exec}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 1) assert.Equal(t, domain.TradeStatusActive, m.trades["LST_ABC123"].Status) store.AssertExpectations(t) } func TestLoadTrades_ExecReport_TRDEND_TradeRemains(t *testing.T) { r := makeQuoteRequest("LST_ABC123", "LIST_1", "RFQ", nil) cw := makeQuoteAck("LST_ABC123", "1") aj := makeQuoteResponse("LST_ABC123", "LST_ABC123_TRDREQ") execListEnd := makeExecutionReport("LST_ABC123", "LST_ABC123_LISTEND") execTrdEnd := makeExecutionReport("LST_ABC123", "LST_ABC123_TRDEND") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r, cw, aj, execListEnd, execTrdEnd}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 1) assert.Equal(t, domain.TradeStatusActive, m.trades["LST_ABC123"].Status) store.AssertExpectations(t) } func TestLoadTrades_ExecReport_TRDSUMM_TradeMarkedCompleted(t *testing.T) { r := makeQuoteRequest("LST_ABC123", "LIST_1", "RFQ", nil) cw := makeQuoteAck("LST_ABC123", "1") aj := makeQuoteResponse("LST_ABC123", "LST_ABC123_TRDREQ") exec := makeExecutionReport("LST_ABC123", "LST_ABC123_TRDSUMM") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r, cw, aj, exec}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 1) assert.Equal(t, domain.TradeStatusCompleted, m.trades["LST_ABC123"].Status) store.AssertExpectations(t) } func TestLoadTrades_ExecReport_TRDSUMM_WithoutPriorR_NoCrash(t *testing.T) { exec := makeExecutionReport("LST_ORPHAN", "LST_ORPHAN_TRDSUMM") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{exec}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 0) store.AssertExpectations(t) } // --------------------------------------------------------------------------- // Group 6 — Multiples trades en paralelo // --------------------------------------------------------------------------- func TestLoadTrades_TwoTrades_BothActive(t *testing.T) { r1 := makeQuoteRequest("LST_TRADE1", "LIST_1", "RFQ", nil) r2 := makeQuoteRequest("LST_TRADE2", "LIST_2", "RFQ", nil) cw1 := makeQuoteAck("LST_TRADE1", "1") cw2 := makeQuoteAck("LST_TRADE2", "1") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r1, r2, cw1, cw2}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 2) assert.Equal(t, domain.TradeStatusActive, m.trades["LST_TRADE1"].Status) assert.Equal(t, domain.TradeStatusActive, m.trades["LST_TRADE2"].Status) store.AssertExpectations(t) } func TestLoadTrades_TwoTrades_OneCompleted_OneActive(t *testing.T) { // TRADE1: fully completed via flow 8.4 (_TRDSUMM execution report) r1 := makeQuoteRequest("LST_TRADE1", "LIST_1", "RFQ", nil) cw1 := makeQuoteAck("LST_TRADE1", "1") aj1 := makeQuoteResponse("LST_TRADE1", "LST_TRADE1_TRDREQ") exec1 := makeExecutionReport("LST_TRADE1", "LST_TRADE1_TRDSUMM") // TRADE2: still active r2 := makeQuoteRequest("LST_TRADE2", "LIST_2", "RFQ", nil) cw2 := makeQuoteAck("LST_TRADE2", "1") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return( []domain.TradeMessage{r1, cw1, aj1, exec1, r2, cw2}, nil, ) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 2) assert.Equal(t, domain.TradeStatusCompleted, m.trades["LST_TRADE1"].Status) assert.Equal(t, domain.TradeStatusActive, m.trades["LST_TRADE2"].Status) store.AssertExpectations(t) } func TestLoadTrades_TwoTrades_BothCompleted(t *testing.T) { r1 := makeQuoteRequest("LST_TRADE1", "LIST_1", "RFQ", nil) cw1 := makeQuoteAck("LST_TRADE1", "1") exec1 := makeExecutionReport("LST_TRADE1", "LST_TRADE1_TRDSUMM") r2 := makeQuoteRequest("LST_TRADE2", "LIST_2", "RFQ", nil) cw2 := makeQuoteAck("LST_TRADE2", "1") exec2 := makeExecutionReport("LST_TRADE2", "LST_TRADE2_TRDSUMM") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return( []domain.TradeMessage{r1, cw1, exec1, r2, cw2, exec2}, nil, ) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) assert.Len(t, m.trades, 2) assert.Equal(t, domain.TradeStatusCompleted, m.trades["LST_TRADE1"].Status) assert.Equal(t, domain.TradeStatusCompleted, m.trades["LST_TRADE2"].Status) store.AssertExpectations(t) } // --------------------------------------------------------------------------- // Group 7 — SessionID se asigna en onLogon (solo a trades activos) // --------------------------------------------------------------------------- func TestLoadTrades_SessionID_IsZeroAfterRecovery(t *testing.T) { r := makeQuoteRequest("LST_X", "LIST_1", "RFQ", nil) store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r}, nil) m := newTestManager(store) err := m.loadTrades() require.NoError(t, err) require.Len(t, m.trades, 1) trade := m.trades["LST_X"] require.NotNil(t, trade) assert.Equal(t, quickfix.SessionID{}, trade.SessionID) store.AssertExpectations(t) } func TestOnLogon_AssignsSessionToRecoveredActiveTrades(t *testing.T) { r := makeQuoteRequest("LST_X", "LIST_1", "RFQ", nil) store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r}, nil) m := newTestManager(store) require.NoError(t, m.loadTrades()) sessionID := quickfix.SessionID{ BeginString: "FIXT.1.1", SenderCompID: "QFIXDPL", TargetCompID: "TRADEWEB", } m.onLogon(sessionID) trade := m.trades["LST_X"] require.NotNil(t, trade) assert.Equal(t, sessionID, trade.SessionID) } func TestOnLogon_DoesNotAssignSessionToCompletedTrades(t *testing.T) { r := makeQuoteRequest("LST_X", "LIST_1", "RFQ", nil) exec := makeExecutionReport("LST_X", "LST_X_TRDSUMM") store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r, exec}, nil) m := newTestManager(store) require.NoError(t, m.loadTrades()) sessionID := quickfix.SessionID{ BeginString: "FIXT.1.1", SenderCompID: "QFIXDPL", TargetCompID: "TRADEWEB", } m.onLogon(sessionID) trade := m.trades["LST_X"] require.NotNil(t, trade) assert.Equal(t, quickfix.SessionID{}, trade.SessionID, "completed trades should not get session assigned") } // --------------------------------------------------------------------------- // Group 8 — GetTrades filtra por active, GetAllTrades devuelve todos // --------------------------------------------------------------------------- func TestGetTrades_ReturnsOnlyActive(t *testing.T) { r1 := makeQuoteRequest("LST_ACTIVE", "LIST_1", "RFQ", nil) r2 := makeQuoteRequest("LST_DONE", "LIST_2", "RFQ", nil) exec := makeExecutionReport("LST_DONE", "LST_DONE_TRDSUMM") r3 := makeQuoteRequest("LST_REJ", "LIST_3", "RFQ", nil) cw := makeQuoteAck("LST_REJ", "2") // REJECTED store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r1, r2, exec, r3, cw}, nil) m := newTestManager(store) require.NoError(t, m.loadTrades()) trades := m.GetTrades() assert.Len(t, trades, 1) assert.Equal(t, "LST_ACTIVE", trades[0].QuoteReqID) assert.Equal(t, domain.TradeStatusActive, trades[0].Status) } func TestGetAllTrades_ReturnsAll(t *testing.T) { r1 := makeQuoteRequest("LST_ACTIVE", "LIST_1", "RFQ", nil) r2 := makeQuoteRequest("LST_DONE", "LIST_2", "RFQ", nil) exec := makeExecutionReport("LST_DONE", "LST_DONE_TRDSUMM") r3 := makeQuoteRequest("LST_REJ", "LIST_3", "RFQ", nil) cw := makeQuoteAck("LST_REJ", "2") // REJECTED store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage{r1, r2, exec, r3, cw}, nil) m := newTestManager(store) require.NoError(t, m.loadTrades()) trades := m.GetAllTrades() assert.Len(t, trades, 3) statusMap := map[string]domain.TradeStatus{} for _, tr := range trades { statusMap[tr.QuoteReqID] = tr.Status } assert.Equal(t, domain.TradeStatusActive, statusMap["LST_ACTIVE"]) assert.Equal(t, domain.TradeStatusCompleted, statusMap["LST_DONE"]) assert.Equal(t, domain.TradeStatusRejected, statusMap["LST_REJ"]) } // --------------------------------------------------------------------------- // Group 9 — Error en store // --------------------------------------------------------------------------- func TestLoadTrades_StoreError_ReturnsError_MapEmpty(t *testing.T) { store := &MockPersistenceStore{} store.On("GetTodayMessages").Return([]domain.TradeMessage(nil), errors.New("db connection failed")) m := newTestManager(store) err := m.loadTrades() assert.Error(t, err) assert.NotNil(t, m.trades) assert.Len(t, m.trades, 0) store.AssertExpectations(t) }