Skip to content

Commit 931e5b8

Browse files
authored
test: Add end-to-end test suite for reconciliation service (#846)
1 parent f7d2126 commit 931e5b8

7 files changed

Lines changed: 2527 additions & 0 deletions

File tree

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
//go:build integration
2+
// +build integration
3+
4+
package reconciliatione2e
5+
6+
import (
7+
"testing"
8+
9+
"github.com/meridianhub/meridian/services/reconciliation/domain"
10+
"github.com/meridianhub/meridian/services/reconciliation/service"
11+
"github.com/shopspring/decimal"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// TestBalanceAssertion_Balanced verifies that when debits == credits,
17+
// the assertion passes with PASSED status.
18+
func TestBalanceAssertion_Balanced(t *testing.T) {
19+
infra := setupE2EInfra(t)
20+
ctx := infra.tenantCtx()
21+
22+
// Set up mock PK to return balanced positions
23+
infra.mockPKClient.setSummary("ACC-BAL", "GBP",
24+
decimal.NewFromFloat(5000.00), // total debits
25+
decimal.NewFromFloat(5000.00), // total credits (balanced)
26+
)
27+
28+
result, err := infra.assertor.ExecuteBalanceAssertion(ctx, service.AssertBalanceRequest{
29+
AccountID: "ACC-BAL",
30+
InstrumentCode: "GBP",
31+
Expression: "total_debits == total_credits",
32+
ExpectedBalance: decimal.NewFromFloat(5000.00),
33+
Scope: domain.AssertionScopePositionLedger,
34+
CallerRole: service.CallerRoleTenantAdmin,
35+
})
36+
require.NoError(t, err)
37+
require.NotNil(t, result)
38+
39+
assert.Equal(t, domain.AssertionStatusPassed, result.Assertion.Status)
40+
assert.Nil(t, result.Event, "no imbalance event should be published for balanced positions")
41+
42+
// Verify assertion was persisted
43+
persisted, err := infra.assertionRepo.FindByID(ctx, result.Assertion.AssertionID)
44+
require.NoError(t, err)
45+
assert.Equal(t, domain.AssertionStatusPassed, persisted.Status)
46+
}
47+
48+
// TestBalanceAssertion_Imbalanced verifies that when debits != credits,
49+
// the assertion fails and publishes a P1 critical event.
50+
func TestBalanceAssertion_Imbalanced(t *testing.T) {
51+
infra := setupE2EInfra(t)
52+
ctx := infra.tenantCtx()
53+
54+
// Set up mock PK with imbalance
55+
infra.mockPKClient.setSummary("ACC-IMBAL", "GBP",
56+
decimal.NewFromFloat(5000.00), // total debits
57+
decimal.NewFromFloat(4800.00), // total credits (imbalanced by 200)
58+
)
59+
60+
result, err := infra.assertor.ExecuteBalanceAssertion(ctx, service.AssertBalanceRequest{
61+
AccountID: "ACC-IMBAL",
62+
InstrumentCode: "GBP",
63+
Expression: "total_debits == total_credits",
64+
ExpectedBalance: decimal.NewFromFloat(5000.00),
65+
Scope: domain.AssertionScopePositionLedger,
66+
CallerRole: service.CallerRoleTenantAdmin,
67+
})
68+
require.NoError(t, err)
69+
require.NotNil(t, result)
70+
71+
assert.Equal(t, domain.AssertionStatusFailed, result.Assertion.Status)
72+
assert.Contains(t, result.Assertion.FailureReason, "CRITICAL")
73+
assert.Contains(t, result.Assertion.FailureReason, "imbalance")
74+
75+
// Verify imbalance event was published
76+
require.NotNil(t, result.Event, "imbalance event should be published")
77+
assert.Equal(t, "GBP", result.Event.InstrumentCode)
78+
assert.True(t, result.Event.ImbalanceAmount.Equal(decimal.NewFromFloat(200.00)))
79+
80+
// Verify event was published to mock publisher
81+
imbalanceEvents := infra.mockPublisher.getEventsByTopic("reconciliation.balance.imbalance.detected")
82+
assert.Len(t, imbalanceEvents, 1)
83+
84+
// Verify assertion was persisted
85+
persisted, err := infra.assertionRepo.FindByID(ctx, result.Assertion.AssertionID)
86+
require.NoError(t, err)
87+
assert.Equal(t, domain.AssertionStatusFailed, persisted.Status)
88+
}
89+
90+
// TestBalanceAssertion_ImbalanceTrend verifies that persistent imbalances
91+
// across multiple assertions trigger trend tracking.
92+
func TestBalanceAssertion_ImbalanceTrend(t *testing.T) {
93+
infra := setupE2EInfra(t)
94+
ctx := infra.tenantCtx()
95+
96+
// Set up persistent imbalance
97+
infra.mockPKClient.setSummary("ACC-TREND", "EUR",
98+
decimal.NewFromFloat(10000.00),
99+
decimal.NewFromFloat(9900.00), // 100 EUR imbalance
100+
)
101+
102+
// Run multiple assertions to build a trend
103+
for i := 0; i < 3; i++ {
104+
result, err := infra.assertor.ExecuteBalanceAssertion(ctx, service.AssertBalanceRequest{
105+
AccountID: "ACC-TREND",
106+
InstrumentCode: "EUR",
107+
Expression: "total_debits == total_credits",
108+
ExpectedBalance: decimal.NewFromFloat(10000.00),
109+
Scope: domain.AssertionScopePositionLedger,
110+
CallerRole: service.CallerRoleTenantAdmin,
111+
})
112+
require.NoError(t, err)
113+
assert.Equal(t, domain.AssertionStatusFailed, result.Assertion.Status)
114+
}
115+
116+
// Verify trend was created and tracks consecutive days
117+
trend, err := infra.trendRepo.FindByInstrumentCode(ctx, "EUR")
118+
require.NoError(t, err)
119+
assert.Equal(t, "EUR", trend.InstrumentCode)
120+
assert.Greater(t, trend.ConsecutiveDays, 0, "consecutive days should increase")
121+
assert.True(t, trend.LastImbalanceAmount.Equal(decimal.NewFromFloat(100.00)))
122+
}
123+
124+
// TestBalanceAssertion_TrendResolvesOnBalance verifies that a balanced assertion
125+
// resolves any existing imbalance trend.
126+
func TestBalanceAssertion_TrendResolvesOnBalance(t *testing.T) {
127+
infra := setupE2EInfra(t)
128+
ctx := infra.tenantCtx()
129+
130+
// Create an imbalance first
131+
infra.mockPKClient.setSummary("ACC-RESOLVE", "USD",
132+
decimal.NewFromFloat(1000.00),
133+
decimal.NewFromFloat(900.00),
134+
)
135+
136+
_, err := infra.assertor.ExecuteBalanceAssertion(ctx, service.AssertBalanceRequest{
137+
AccountID: "ACC-RESOLVE",
138+
InstrumentCode: "USD",
139+
Expression: "total_debits == total_credits",
140+
ExpectedBalance: decimal.NewFromFloat(1000.00),
141+
Scope: domain.AssertionScopePositionLedger,
142+
CallerRole: service.CallerRoleTenantAdmin,
143+
})
144+
require.NoError(t, err)
145+
146+
// Verify trend exists
147+
trend, err := infra.trendRepo.FindByInstrumentCode(ctx, "USD")
148+
require.NoError(t, err)
149+
assert.Nil(t, trend.ResolvedAt, "trend should be unresolved")
150+
151+
// Now fix the imbalance
152+
infra.mockPKClient.setSummary("ACC-RESOLVE", "USD",
153+
decimal.NewFromFloat(1000.00),
154+
decimal.NewFromFloat(1000.00), // Balanced now
155+
)
156+
157+
result, err := infra.assertor.ExecuteBalanceAssertion(ctx, service.AssertBalanceRequest{
158+
AccountID: "ACC-RESOLVE",
159+
InstrumentCode: "USD",
160+
Expression: "total_debits == total_credits",
161+
ExpectedBalance: decimal.NewFromFloat(1000.00),
162+
Scope: domain.AssertionScopePositionLedger,
163+
CallerRole: service.CallerRoleTenantAdmin,
164+
})
165+
require.NoError(t, err)
166+
assert.Equal(t, domain.AssertionStatusPassed, result.Assertion.Status)
167+
168+
// Trend should be resolved (FindByInstrumentCode only returns unresolved)
169+
_, err = infra.trendRepo.FindByInstrumentCode(ctx, "USD")
170+
assert.ErrorIs(t, err, domain.ErrNotFound, "trend should be resolved and not found by active query")
171+
}
172+
173+
// TestBalanceAssertion_CrossAccountRequiresSystemRole verifies RBAC for
174+
// cross-account balance assertions.
175+
func TestBalanceAssertion_CrossAccountRequiresSystemRole(t *testing.T) {
176+
infra := setupE2EInfra(t)
177+
ctx := infra.tenantCtx()
178+
179+
// Tenant admin should not be able to do cross-account assertions
180+
_, err := infra.assertor.ExecuteBalanceAssertion(ctx, service.AssertBalanceRequest{
181+
AccountID: "SYSTEM",
182+
InstrumentCode: "GBP",
183+
Expression: "sum(all_accounts.debits) == sum(all_accounts.credits)",
184+
ExpectedBalance: decimal.Zero,
185+
Scope: domain.AssertionScopeCrossAccount,
186+
CallerRole: service.CallerRoleTenantAdmin,
187+
})
188+
require.Error(t, err)
189+
assert.ErrorIs(t, err, domain.ErrUnauthorized)
190+
191+
// System role should succeed
192+
infra.mockPKClient.setSummary("SYSTEM", "GBP",
193+
decimal.NewFromFloat(100000.00),
194+
decimal.NewFromFloat(100000.00),
195+
)
196+
197+
result, err := infra.assertor.ExecuteBalanceAssertion(ctx, service.AssertBalanceRequest{
198+
AccountID: "SYSTEM",
199+
InstrumentCode: "GBP",
200+
Expression: "sum(all_accounts.debits) == sum(all_accounts.credits)",
201+
ExpectedBalance: decimal.Zero,
202+
Scope: domain.AssertionScopeCrossAccount,
203+
CallerRole: service.CallerRoleSystem,
204+
})
205+
require.NoError(t, err)
206+
assert.Equal(t, domain.AssertionStatusPassed, result.Assertion.Status)
207+
}
208+
209+
// TestBalanceAssertion_PKClientFailure verifies graceful degradation when
210+
// Position Keeping is unavailable.
211+
func TestBalanceAssertion_PKClientFailure(t *testing.T) {
212+
infra := setupE2EInfra(t)
213+
ctx := infra.tenantCtx()
214+
215+
// Mock PK to return error
216+
infra.mockPKClient.setError(assert.AnError)
217+
218+
result, err := infra.assertor.ExecuteBalanceAssertion(ctx, service.AssertBalanceRequest{
219+
AccountID: "ACC-PKFAIL",
220+
InstrumentCode: "GBP",
221+
Expression: "total_debits == total_credits",
222+
ExpectedBalance: decimal.NewFromFloat(1000.00),
223+
Scope: domain.AssertionScopePositionLedger,
224+
CallerRole: service.CallerRoleTenantAdmin,
225+
})
226+
227+
// Should return both result AND error
228+
require.Error(t, err)
229+
require.NotNil(t, result, "result should be returned even on PK failure")
230+
assert.Equal(t, domain.AssertionStatusFailed, result.Assertion.Status)
231+
assert.Contains(t, result.Assertion.FailureReason, "failed to query position keeping")
232+
233+
// Reset PK error for other tests
234+
infra.mockPKClient.setError(nil)
235+
}
236+
237+
// TestBalanceAssertion_OverrideWorkflow verifies that a failed assertion
238+
// can be overridden by an operator.
239+
func TestBalanceAssertion_OverrideWorkflow(t *testing.T) {
240+
infra := setupE2EInfra(t)
241+
ctx := infra.tenantCtx()
242+
243+
// Create failed assertion via imbalance
244+
infra.mockPKClient.setSummary("ACC-OVRD", "GBP",
245+
decimal.NewFromFloat(1000.00),
246+
decimal.NewFromFloat(999.00),
247+
)
248+
249+
result, err := infra.assertor.ExecuteBalanceAssertion(ctx, service.AssertBalanceRequest{
250+
AccountID: "ACC-OVRD",
251+
InstrumentCode: "GBP",
252+
Expression: "total_debits == total_credits",
253+
ExpectedBalance: decimal.NewFromFloat(1000.00),
254+
Scope: domain.AssertionScopePositionLedger,
255+
CallerRole: service.CallerRoleTenantAdmin,
256+
})
257+
require.NoError(t, err)
258+
assert.Equal(t, domain.AssertionStatusFailed, result.Assertion.Status)
259+
260+
// Override the assertion via domain model
261+
require.NoError(t, result.Assertion.Override("Approved by risk committee"))
262+
require.NoError(t, infra.assertionRepo.Update(ctx, result.Assertion))
263+
264+
// Verify override persisted
265+
overridden, err := infra.assertionRepo.FindByID(ctx, result.Assertion.AssertionID)
266+
require.NoError(t, err)
267+
assert.Equal(t, domain.AssertionStatusOverride, overridden.Status)
268+
assert.Equal(t, "Approved by risk committee", overridden.OverrideReason)
269+
// Original failure reason preserved
270+
assert.NotEmpty(t, overridden.FailureReason)
271+
}
272+
273+
// TestBalanceAssertion_NostroVostroUnimplemented verifies that NOSTRO_VOSTRO scope
274+
// returns an ErrUnimplemented error.
275+
func TestBalanceAssertion_NostroVostroUnimplemented(t *testing.T) {
276+
infra := setupE2EInfra(t)
277+
ctx := infra.tenantCtx()
278+
279+
_, err := infra.assertor.ExecuteBalanceAssertion(ctx, service.AssertBalanceRequest{
280+
AccountID: "ACC-NV",
281+
InstrumentCode: "GBP",
282+
Expression: "nostro == vostro",
283+
ExpectedBalance: decimal.Zero,
284+
Scope: domain.AssertionScopeNostroVostro,
285+
CallerRole: service.CallerRoleSystem,
286+
})
287+
require.Error(t, err)
288+
assert.ErrorIs(t, err, domain.ErrUnimplemented)
289+
}

0 commit comments

Comments
 (0)