fix: make event assertions in tests wait for async event consumption

The mock event consumer waited a fixed 10ms before returning consumed
events, which was not always enough on slow CI runners and caused flaky
failures (e.g. TestMarkSettled_App_BudgetWarning missing its
nwc_budget_warning event). It also appended to the events slice from
concurrent goroutines without synchronization, a data race that could
drop events.

- guard the consumed events slice with a mutex and return copies
- add WaitForConsumedEvents which polls until the expected number of
  events arrived (up to 5s) instead of relying on a fixed sleep
- use it in tests that assert on consumed events; tests asserting that
  no event was published keep the short grace period
- normalize event order in the keysend self-payment test, matching the
  existing approach in the self-payment test, since async publishing
  does not guarantee ordering

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland Bewick 2026-08-10 23:44:39 +07:00
parent 037765794d
commit 1cc3a31c33
7 changed files with 92 additions and 49 deletions

View file

@ -2,12 +2,14 @@ package tests
import (
"context"
"sync"
"time"
"github.com/getAlby/hub/events"
)
type mockEventConsumer struct {
mtx sync.Mutex
consumedEvents []*events.Event
}
@ -18,11 +20,33 @@ func NewMockEventConsumer() *mockEventConsumer {
}
func (e *mockEventConsumer) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
e.mtx.Lock()
defer e.mtx.Unlock()
e.consumedEvents = append(e.consumedEvents, event)
}
func (e *mockEventConsumer) GetConsumedEvents() []*events.Event {
// events are consumed async - give it a bit of time for tests
time.Sleep(10 * time.Millisecond)
return e.consumedEvents
return e.snapshotConsumedEvents()
}
// WaitForConsumedEvents waits until at least count events have been consumed
// (events are consumed async) and returns them. On timeout it returns the
// events consumed so far, so the caller's assertions fail with a useful message.
func (e *mockEventConsumer) WaitForConsumedEvents(count int) []*events.Event {
deadline := time.Now().Add(5 * time.Second)
for {
consumedEvents := e.snapshotConsumedEvents()
if len(consumedEvents) >= count || time.Now().After(deadline) {
return consumedEvents
}
time.Sleep(10 * time.Millisecond)
}
}
func (e *mockEventConsumer) snapshotConsumedEvents() []*events.Event {
e.mtx.Lock()
defer e.mtx.Unlock()
return append([]*events.Event{}, e.consumedEvents...)
}

View file

@ -95,7 +95,7 @@ func TestMarkSettled_App_BudgetWarning(t *testing.T) {
_, err = transactionsService.markTransactionSettled(&dbTransaction, "test", 0, false)
assert.NoError(t, err)
consumedEvents := mockEventConsumer.GetConsumedEvents()
consumedEvents := mockEventConsumer.WaitForConsumedEvents(2)
assert.Equal(t, 2, len(consumedEvents))
eventNames := []string{}
for _, consumedEvent := range consumedEvents {
@ -136,12 +136,13 @@ func TestSendPaymentSync_App_BudgetExceeded(t *testing.T) {
assert.ErrorIs(t, err, NewQuotaExceededError())
assert.Nil(t, transaction)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
assert.Equal(t, "nwc_permission_denied", mockEventConsumer.GetConsumedEvents()[0].Event)
assert.Equal(t, app.Name, mockEventConsumer.GetConsumedEvents()[0].Properties.(map[string]interface{})["app_name"])
assert.Equal(t, constants.ERROR_QUOTA_EXCEEDED, mockEventConsumer.GetConsumedEvents()[0].Properties.(map[string]interface{})["code"])
consumedEvents := mockEventConsumer.WaitForConsumedEvents(1)
assert.Equal(t, 1, len(consumedEvents))
assert.Equal(t, "nwc_permission_denied", consumedEvents[0].Event)
assert.Equal(t, app.Name, consumedEvents[0].Properties.(map[string]interface{})["app_name"])
assert.Equal(t, constants.ERROR_QUOTA_EXCEEDED, consumedEvents[0].Properties.(map[string]interface{})["code"])
expectedMessage := NewQuotaExceededError().Error() + " te" // invoice description is "te" in the mock invoice
assert.Equal(t, expectedMessage, mockEventConsumer.GetConsumedEvents()[0].Properties.(map[string]interface{})["message"])
assert.Equal(t, expectedMessage, consumedEvents[0].Properties.(map[string]interface{})["message"])
}
func TestSendPaymentSync_App_BudgetExceeded_SettledPayment(t *testing.T) {

View file

@ -45,9 +45,10 @@ func TestCheckUnsettledTransaction(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, dbTransaction.State)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumedEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumedEvents()[0].Properties.(*db.Transaction)
consumedEvents := mockEventConsumer.WaitForConsumedEvents(1)
assert.Equal(t, 1, len(consumedEvents))
assert.Equal(t, "nwc_payment_sent", consumedEvents[0].Event)
settledTransaction := consumedEvents[0].Properties.(*db.Transaction)
assert.Equal(t, &dbTransaction, settledTransaction)
}
@ -91,8 +92,9 @@ func TestCheckUnsettledTransactions(t *testing.T) {
})
assert.NoError(t, err)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, dbTransaction.State)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumedEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumedEvents()[0].Properties.(*db.Transaction)
consumedEvents := mockEventConsumer.WaitForConsumedEvents(1)
assert.Equal(t, 1, len(consumedEvents))
assert.Equal(t, "nwc_payment_sent", consumedEvents[0].Event)
settledTransaction := consumedEvents[0].Properties.(*db.Transaction)
assert.Equal(t, dbTransaction.ID, settledTransaction.ID)
}

View file

@ -80,12 +80,13 @@ func TestSendPaymentSync_IsolatedApp_BalanceInsufficient(t *testing.T) {
assert.ErrorIs(t, err, NewInsufficientBalanceError())
assert.Nil(t, transaction)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
assert.Equal(t, "nwc_permission_denied", mockEventConsumer.GetConsumedEvents()[0].Event)
assert.Equal(t, app.Name, mockEventConsumer.GetConsumedEvents()[0].Properties.(map[string]interface{})["app_name"])
assert.Equal(t, constants.ERROR_INSUFFICIENT_BALANCE, mockEventConsumer.GetConsumedEvents()[0].Properties.(map[string]interface{})["code"])
consumedEvents := mockEventConsumer.WaitForConsumedEvents(1)
assert.Equal(t, 1, len(consumedEvents))
assert.Equal(t, "nwc_permission_denied", consumedEvents[0].Event)
assert.Equal(t, app.Name, consumedEvents[0].Properties.(map[string]interface{})["app_name"])
assert.Equal(t, constants.ERROR_INSUFFICIENT_BALANCE, consumedEvents[0].Properties.(map[string]interface{})["code"])
expectedMessage := NewInsufficientBalanceError().Error() + " te" // invoice description is "te" in the mock invoice
assert.Equal(t, expectedMessage, mockEventConsumer.GetConsumedEvents()[0].Properties.(map[string]interface{})["message"])
assert.Equal(t, expectedMessage, consumedEvents[0].Properties.(map[string]interface{})["message"])
}
func TestSendPaymentSync_IsolatedApp_BalanceSufficient(t *testing.T) {

View file

@ -43,9 +43,10 @@ func TestSendKeysend(t *testing.T) {
assert.NotNil(t, transaction.Preimage)
assert.Equal(t, 64, len(*transaction.Preimage))
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumedEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumedEvents()[0].Properties.(*db.Transaction)
consumedEvents := mockEventConsumer.WaitForConsumedEvents(1)
assert.Equal(t, 1, len(consumedEvents))
assert.Equal(t, "nwc_payment_sent", consumedEvents[0].Event)
settledTransaction := consumedEvents[0].Properties.(*db.Transaction)
assert.Equal(t, transaction, settledTransaction)
}
func TestSendKeysend_FailedRemovesFeeReserve(t *testing.T) {
@ -72,8 +73,9 @@ func TestSendKeysend_FailedRemovesFeeReserve(t *testing.T) {
assert.Zero(t, failedTransaction.FeeReserveMsat)
assert.Equal(t, "Some error", failedTransaction.FailureReason)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
assert.Equal(t, "nwc_payment_failed", mockEventConsumer.GetConsumedEvents()[0].Event)
consumedEvents := mockEventConsumer.WaitForConsumedEvents(1)
assert.Equal(t, 1, len(consumedEvents))
assert.Equal(t, "nwc_payment_failed", consumedEvents[0].Event)
}
func TestSendKeysend_CustomPreimage(t *testing.T) {
@ -190,11 +192,12 @@ func TestSendKeysend_App_BudgetExceeded(t *testing.T) {
assert.ErrorIs(t, err, NewQuotaExceededError())
assert.Nil(t, transaction)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
assert.Equal(t, "nwc_permission_denied", mockEventConsumer.GetConsumedEvents()[0].Event)
assert.Equal(t, app.Name, mockEventConsumer.GetConsumedEvents()[0].Properties.(map[string]interface{})["app_name"])
assert.Equal(t, constants.ERROR_QUOTA_EXCEEDED, mockEventConsumer.GetConsumedEvents()[0].Properties.(map[string]interface{})["code"])
assert.Equal(t, NewQuotaExceededError().Error(), mockEventConsumer.GetConsumedEvents()[0].Properties.(map[string]interface{})["message"])
consumedEvents := mockEventConsumer.WaitForConsumedEvents(1)
assert.Equal(t, 1, len(consumedEvents))
assert.Equal(t, "nwc_permission_denied", consumedEvents[0].Event)
assert.Equal(t, app.Name, consumedEvents[0].Properties.(map[string]interface{})["app_name"])
assert.Equal(t, constants.ERROR_QUOTA_EXCEEDED, consumedEvents[0].Properties.(map[string]interface{})["code"])
assert.Equal(t, NewQuotaExceededError().Error(), consumedEvents[0].Properties.(map[string]interface{})["message"])
}
func TestSendKeysend_App_BudgetNotExceeded(t *testing.T) {
svc, err := tests.CreateTestService(t)
@ -530,13 +533,20 @@ func TestSendKeysend_IsolatedAppToIsolatedApp(t *testing.T) {
assert.Equal(t, int64(123000), balanceMsat)
// check notifications
assert.Equal(t, 2, len(mockEventConsumer.GetConsumedEvents()))
consumedEvents := mockEventConsumer.WaitForConsumedEvents(2)
assert.Equal(t, 2, len(consumedEvents))
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumedEvents()[1].Event)
settledTransaction := mockEventConsumer.GetConsumedEvents()[1].Properties.(*db.Transaction)
// we can't guarantee which notification was processed first because events are published async
// so swap them if they are back to front
if consumedEvents[1].Event == "nwc_payment_received" {
consumedEvents[0], consumedEvents[1] = consumedEvents[1], consumedEvents[0]
}
assert.Equal(t, "nwc_payment_sent", consumedEvents[1].Event)
settledTransaction := consumedEvents[1].Properties.(*db.Transaction)
assert.Equal(t, transaction.ID, settledTransaction.ID)
assert.Equal(t, "nwc_payment_received", mockEventConsumer.GetConsumedEvents()[0].Event)
receivedTransaction := mockEventConsumer.GetConsumedEvents()[0].Properties.(*db.Transaction)
assert.Equal(t, "nwc_payment_received", consumedEvents[0].Event)
receivedTransaction := consumedEvents[0].Properties.(*db.Transaction)
assert.Equal(t, incomingTransaction.ID, receivedTransaction.ID)
}

View file

@ -184,9 +184,10 @@ func TestMarkSettled_Sent(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, dbTransaction.State)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumedEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumedEvents()[0].Properties.(*db.Transaction)
consumedEvents := mockEventConsumer.WaitForConsumedEvents(1)
assert.Equal(t, 1, len(consumedEvents))
assert.Equal(t, "nwc_payment_sent", consumedEvents[0].Event)
settledTransaction := consumedEvents[0].Properties.(*db.Transaction)
assert.Equal(t, &dbTransaction, settledTransaction)
}
@ -233,9 +234,10 @@ func TestMarkSettled_Twice(t *testing.T) {
var reloadedTransaction db.Transaction
require.NoError(t, svc.DB.First(&reloadedTransaction, dbTransaction.ID).Error)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, reloadedTransaction.State)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumedEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumedEvents()[0].Properties.(*db.Transaction)
consumedEvents := mockEventConsumer.WaitForConsumedEvents(1)
assert.Equal(t, 1, len(consumedEvents))
assert.Equal(t, "nwc_payment_sent", consumedEvents[0].Event)
settledTransaction := consumedEvents[0].Properties.(*db.Transaction)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, settledTransaction.State)
assert.Equal(t, dbTransaction.PaymentHash, settledTransaction.PaymentHash)
}
@ -260,9 +262,10 @@ func TestMarkSettled_Received(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, dbTransaction.State)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
assert.Equal(t, "nwc_payment_received", mockEventConsumer.GetConsumedEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumedEvents()[0].Properties.(*db.Transaction)
consumedEvents := mockEventConsumer.WaitForConsumedEvents(1)
assert.Equal(t, 1, len(consumedEvents))
assert.Equal(t, "nwc_payment_received", consumedEvents[0].Event)
settledTransaction := consumedEvents[0].Properties.(*db.Transaction)
assert.Equal(t, &dbTransaction, settledTransaction)
}
@ -311,9 +314,10 @@ func TestMarkFailed(t *testing.T) {
assert.NoError(t, err)
assert.True(t, markedFailed)
assert.Equal(t, constants.TRANSACTION_STATE_FAILED, dbTransaction.State)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
assert.Equal(t, "nwc_payment_failed", mockEventConsumer.GetConsumedEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumedEvents()[0].Properties.(*db.Transaction)
consumedEvents := mockEventConsumer.WaitForConsumedEvents(1)
assert.Equal(t, 1, len(consumedEvents))
assert.Equal(t, "nwc_payment_failed", consumedEvents[0].Event)
settledTransaction := consumedEvents[0].Properties.(*db.Transaction)
assert.Equal(t, &dbTransaction, settledTransaction)
assert.Equal(t, "some routing error", settledTransaction.FailureReason)
}
@ -399,8 +403,9 @@ func TestSendPaymentSync_FailedRemovesFeeReserve(t *testing.T) {
assert.Zero(t, transaction.FeeReserveMsat)
assert.Nil(t, transaction.Preimage)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
assert.Equal(t, "nwc_payment_failed", mockEventConsumer.GetConsumedEvents()[0].Event)
consumedEvents := mockEventConsumer.WaitForConsumedEvents(1)
assert.Equal(t, 1, len(consumedEvents))
assert.Equal(t, "nwc_payment_failed", consumedEvents[0].Event)
}
func TestSendPaymentSync_PendingHasFeeReserve(t *testing.T) {

View file

@ -399,11 +399,11 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToIsolatedApp(t *testing.T) {
assert.Equal(t, int64(0), balanceMsat)
// check notifications
assert.Equal(t, 2, len(mockEventConsumer.GetConsumedEvents()))
consumedEvents := mockEventConsumer.WaitForConsumedEvents(2)
assert.Equal(t, 2, len(consumedEvents))
// we can't guarantee which notification was processed first because events are published async
// so swap them if they are back to front
consumedEvents := mockEventConsumer.GetConsumedEvents()
if consumedEvents[1].Event == "nwc_payment_received" {
consumedEvents[0], consumedEvents[1] = consumedEvents[1], consumedEvents[0]
}