routing: refactor update payment state tests

This commit refactors the resumePayment to extract some logics back to
paymentState so that the code is more testable. It also adds unit tests
for paymentState, and breaks the original MPPayment tests into independent tests
so that it's easier to maintain and debug. All the new tests are built
using mock so that the control flow is eaiser to setup and change.
This commit is contained in:
yyforyongyu
2021-05-21 19:02:49 +08:00
parent e79e46ed21
commit cd35981569
4 changed files with 1262 additions and 358 deletions

View File

@@ -195,14 +195,6 @@ func TestRouterPaymentStateMachine(t *testing.T) {
t.Fatalf("unable to create route: %v", err)
}
halfShard, err := createTestRoute(paymentAmt/2, testGraph.aliasMap)
require.NoError(t, err, "unable to create half route")
shard, err := createTestRoute(paymentAmt/4, testGraph.aliasMap)
if err != nil {
t.Fatalf("unable to create route: %v", err)
}
tests := []paymentLifecycleTestCase{
{
// Tests a normal payment flow that succeeds.
@@ -425,280 +417,6 @@ func TestRouterPaymentStateMachine(t *testing.T) {
routes: []*route.Route{rt},
paymentErr: channeldb.FailureReasonNoRoute,
},
// =====================================
// || MPP scenarios ||
// =====================================
{
// Tests a simple successful MP payment of 4 shards.
name: "MP success",
steps: []string{
routerInitPayment,
// shard 0
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 1
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 2
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 3
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// All shards succeed.
getPaymentResultSuccess,
getPaymentResultSuccess,
getPaymentResultSuccess,
getPaymentResultSuccess,
// Router should settle them all.
routerSettleAttempt,
routerSettleAttempt,
routerSettleAttempt,
routerSettleAttempt,
// And the final result is obviously
// successful.
paymentSuccess,
},
routes: []*route.Route{shard, shard, shard, shard},
},
{
// An MP payment scenario where we need several extra
// attempts before the payment finally settle.
name: "MP failed shards",
steps: []string{
routerInitPayment,
// shard 0
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 1
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 2
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 3
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// First two shards fail, two new ones are sent.
getPaymentResultTempFailure,
getPaymentResultTempFailure,
routerFailAttempt,
routerFailAttempt,
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// The four shards settle.
getPaymentResultSuccess,
getPaymentResultSuccess,
getPaymentResultSuccess,
getPaymentResultSuccess,
routerSettleAttempt,
routerSettleAttempt,
routerSettleAttempt,
routerSettleAttempt,
// Overall payment succeeds.
paymentSuccess,
},
routes: []*route.Route{
shard, shard, shard, shard, shard, shard,
},
},
{
// An MP payment scenario where one of the shards fails,
// but we still receive a single success shard.
name: "MP one shard success",
steps: []string{
routerInitPayment,
// shard 0
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 1
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 0 fails, and should be failed by the
// router.
getPaymentResultTempFailure,
routerFailAttempt,
// We will try one more shard because we haven't
// sent the full payment amount.
routeRelease,
// The second shard succeed against all odds,
// making the overall payment succeed.
getPaymentResultSuccess,
routerSettleAttempt,
paymentSuccess,
},
routes: []*route.Route{halfShard, halfShard},
},
{
// An MP payment scenario a shard fail with a terminal
// error, causing the router to stop attempting.
name: "MP terminal",
steps: []string{
routerInitPayment,
// shard 0
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 1
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 2
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 3
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// The first shard fail with a terminal error.
getPaymentResultTerminalFailure,
routerFailAttempt,
routerFailPayment,
// Remaining 3 shards fail.
getPaymentResultTempFailure,
getPaymentResultTempFailure,
getPaymentResultTempFailure,
routerFailAttempt,
routerFailAttempt,
routerFailAttempt,
// Payment fails.
paymentError,
},
routes: []*route.Route{
shard, shard, shard, shard, shard, shard,
},
paymentErr: channeldb.FailureReasonPaymentDetails,
},
{
// A MP payment scenario when our path finding returns
// after we've just received a terminal failure, and
// attempts to dispatch a new shard. Testing that we
// correctly abandon the shard and conclude the payment.
name: "MP path found after failure",
steps: []string{
routerInitPayment,
// shard 0
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// The first shard fail with a terminal error.
getPaymentResultTerminalFailure,
routerFailAttempt,
routerFailPayment,
// shard 1 fails because we've had a terminal
// failure.
routeRelease,
routerRegisterAttempt,
// Payment fails.
paymentError,
},
routes: []*route.Route{
shard, shard,
},
paymentErr: channeldb.FailureReasonPaymentDetails,
},
{
// A MP payment scenario when our path finding returns
// after we've just received a terminal failure, and
// we have another shard still in flight.
name: "MP shard in flight after terminal",
steps: []string{
routerInitPayment,
// shard 0
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 1
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// shard 2
routeRelease,
routerRegisterAttempt,
sendToSwitchSuccess,
// We find a path for another shard.
routeRelease,
// shard 0 fails with a terminal error.
getPaymentResultTerminalFailure,
routerFailAttempt,
routerFailPayment,
// We try to register our final shard after
// processing a terminal failure.
routerRegisterAttempt,
// Our in-flight shards fail.
getPaymentResultTempFailure,
getPaymentResultTempFailure,
routerFailAttempt,
routerFailAttempt,
// Payment fails.
paymentError,
},
routes: []*route.Route{
shard, shard, shard, shard,
},
paymentErr: channeldb.FailureReasonPaymentDetails,
},
}
for _, test := range tests {
@@ -1080,3 +798,343 @@ func testPaymentLifecycle(t *testing.T, test paymentLifecycleTestCase,
t.Fatalf("SendPayment didn't exit")
}
}
// TestPaymentState tests that the logics implemented on paymentState struct
// are as expected. In particular, that the method terminated and
// needWaitForShards return the right values.
func TestPaymentState(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
// Use the following three params, each is equivalent to a bool
// statement, to construct 8 test cases so that we can
// exhaustively catch all possible states.
numShardsInFlight int
remainingAmt lnwire.MilliSatoshi
terminate bool
expectedTerminated bool
expectedNeedWaitForShards bool
}{
{
// If we have active shards and terminate is marked
// false, the state is not terminated. Since the
// remaining amount is zero, we need to wait for shards
// to be finished and launch no more shards.
name: "state 100",
numShardsInFlight: 1,
remainingAmt: lnwire.MilliSatoshi(0),
terminate: false,
expectedTerminated: false,
expectedNeedWaitForShards: true,
},
{
// If we have active shards while terminate is marked
// true, the state is not terminated, and we need to
// wait for shards to be finished and launch no more
// shards.
name: "state 101",
numShardsInFlight: 1,
remainingAmt: lnwire.MilliSatoshi(0),
terminate: true,
expectedTerminated: false,
expectedNeedWaitForShards: true,
},
{
// If we have active shards and terminate is marked
// false, the state is not terminated. Since the
// remaining amount is not zero, we don't need to wait
// for shards outcomes and should launch more shards.
name: "state 110",
numShardsInFlight: 1,
remainingAmt: lnwire.MilliSatoshi(1),
terminate: false,
expectedTerminated: false,
expectedNeedWaitForShards: false,
},
{
// If we have active shards and terminate is marked
// true, the state is not terminated. Even the
// remaining amount is not zero, we need to wait for
// shards outcomes because state is terminated.
name: "state 111",
numShardsInFlight: 1,
remainingAmt: lnwire.MilliSatoshi(1),
terminate: true,
expectedTerminated: false,
expectedNeedWaitForShards: true,
},
{
// If we have no active shards while terminate is marked
// false, the state is not terminated, and we don't
// need to wait for more shard outcomes because there
// are no active shards.
name: "state 000",
numShardsInFlight: 0,
remainingAmt: lnwire.MilliSatoshi(0),
terminate: false,
expectedTerminated: false,
expectedNeedWaitForShards: false,
},
{
// If we have no active shards while terminate is marked
// true, the state is terminated, and we don't need to
// wait for shards to be finished.
name: "state 001",
numShardsInFlight: 0,
remainingAmt: lnwire.MilliSatoshi(0),
terminate: true,
expectedTerminated: true,
expectedNeedWaitForShards: false,
},
{
// If we have no active shards while terminate is marked
// false, the state is not terminated. Since the
// remaining amount is not zero, we don't need to wait
// for shards outcomes and should launch more shards.
name: "state 010",
numShardsInFlight: 0,
remainingAmt: lnwire.MilliSatoshi(1),
terminate: false,
expectedTerminated: false,
expectedNeedWaitForShards: false,
},
{
// If we have no active shards while terminate is marked
// true, the state is terminated, and we don't need to
// wait for shards outcomes.
name: "state 011",
numShardsInFlight: 0,
remainingAmt: lnwire.MilliSatoshi(1),
terminate: true,
expectedTerminated: true,
expectedNeedWaitForShards: false,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ps := &paymentState{
numShardsInFlight: tc.numShardsInFlight,
remainingAmt: tc.remainingAmt,
terminate: tc.terminate,
}
require.Equal(
t, tc.expectedTerminated, ps.terminated(),
"terminated returned wrong value",
)
require.Equal(
t, tc.expectedNeedWaitForShards,
ps.needWaitForShards(),
"needWaitForShards returned wrong value",
)
})
}
}
// TestUpdatePaymentState checks that the method updatePaymentState updates the
// paymentState as expected.
func TestUpdatePaymentState(t *testing.T) {
t.Parallel()
// paymentHash is the identifier on paymentLifecycle.
paymentHash := lntypes.Hash{}
// TODO(yy): make MPPayment into an interface so we can mock it. The
// current design implicitly tests the methods SendAmt, TerminalInfo,
// and InFlightHTLCs on channeldb.MPPayment, which is not good. Once
// MPPayment becomes an interface, we can then mock these methods here.
// SentAmt returns 90, 10
// TerminalInfo returns non-nil, nil
// InFlightHTLCs returns 0
var preimage lntypes.Preimage
paymentSettled := &channeldb.MPPayment{
HTLCs: []channeldb.HTLCAttempt{
makeSettledAttempt(100, 10, preimage),
},
}
// SentAmt returns 0, 0
// TerminalInfo returns nil, non-nil
// InFlightHTLCs returns 0
reason := channeldb.FailureReasonError
paymentFailed := &channeldb.MPPayment{
FailureReason: &reason,
}
// SentAmt returns 90, 10
// TerminalInfo returns nil, nil
// InFlightHTLCs returns 1
paymentActive := &channeldb.MPPayment{
HTLCs: []channeldb.HTLCAttempt{
makeActiveAttempt(100, 10),
makeFailedAttempt(100, 10),
},
}
testCases := []struct {
name string
payment *channeldb.MPPayment
totalAmt int
feeLimit int
expectedState *paymentState
shouldReturnError bool
}{
{
// Test that the error returned from FetchPayment is
// handled properly. We use a nil payment to indicate
// we want to return an error.
name: "fetch payment error",
payment: nil,
shouldReturnError: true,
},
{
// Test that when the sentAmt exceeds totalAmount, the
// error is returned.
name: "amount exceeded error",
payment: paymentSettled,
totalAmt: 1,
shouldReturnError: true,
},
{
// Test that when the fee budget is reached, the
// remaining fee should be zero.
name: "fee budget reached",
payment: paymentActive,
totalAmt: 1000,
feeLimit: 1,
expectedState: &paymentState{
numShardsInFlight: 1,
remainingAmt: 1000 - 90,
remainingFees: 0,
terminate: false,
},
},
{
// Test when the payment is settled, the state should
// be marked as terminated.
name: "payment settled",
payment: paymentSettled,
totalAmt: 1000,
feeLimit: 100,
expectedState: &paymentState{
numShardsInFlight: 0,
remainingAmt: 1000 - 90,
remainingFees: 100 - 10,
terminate: true,
},
},
{
// Test when the payment is failed, the state should be
// marked as terminated.
name: "payment failed",
payment: paymentFailed,
totalAmt: 1000,
feeLimit: 100,
expectedState: &paymentState{
numShardsInFlight: 0,
remainingAmt: 1000,
remainingFees: 100,
terminate: true,
},
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Create mock control tower and assign it to router.
// We will then use the router and the paymentHash
// above to create our paymentLifecycle for this test.
ct := &mockControlTower{}
rt := &ChannelRouter{cfg: &Config{Control: ct}}
pl := &paymentLifecycle{
router: rt,
identifier: paymentHash,
totalAmount: lnwire.MilliSatoshi(tc.totalAmt),
feeLimit: lnwire.MilliSatoshi(tc.feeLimit),
}
if tc.payment == nil {
// A nil payment indicates we want to test an
// error returned from FetchPayment.
dummyErr := errors.New("dummy")
ct.On("FetchPayment", paymentHash).Return(
nil, dummyErr,
)
} else {
// Otherwise we will return the payment.
ct.On("FetchPayment", paymentHash).Return(
tc.payment, nil,
)
}
// Call the method that updates the payment state.
_, state, err := pl.updatePaymentState()
// Assert that the mock method is called as
// intended.
ct.AssertExpectations(t)
if tc.shouldReturnError {
require.Error(t, err, "expect an error")
return
}
require.NoError(t, err, "unexpected error")
require.Equal(
t, tc.expectedState, state,
"state not updated as expected",
)
})
}
}
func makeActiveAttempt(total, fee int) channeldb.HTLCAttempt {
return channeldb.HTLCAttempt{
HTLCAttemptInfo: makeAttemptInfo(total, total-fee),
}
}
func makeSettledAttempt(total, fee int,
preimage lntypes.Preimage) channeldb.HTLCAttempt {
return channeldb.HTLCAttempt{
HTLCAttemptInfo: makeAttemptInfo(total, total-fee),
Settle: &channeldb.HTLCSettleInfo{Preimage: preimage},
}
}
func makeFailedAttempt(total, fee int) channeldb.HTLCAttempt {
return channeldb.HTLCAttempt{
HTLCAttemptInfo: makeAttemptInfo(total, total-fee),
Failure: &channeldb.HTLCFailInfo{
Reason: channeldb.HTLCFailInternal,
},
}
}
func makeAttemptInfo(total, amtForwarded int) channeldb.HTLCAttemptInfo {
hop := &route.Hop{AmtToForward: lnwire.MilliSatoshi(amtForwarded)}
return channeldb.HTLCAttemptInfo{
Route: route.Route{
TotalAmount: lnwire.MilliSatoshi(total),
Hops: []*route.Hop{hop},
},
}
}