Skip to content

Commit 8418a41

Browse files
authored
Merge pull request #1893 from LerianStudio/fix/ledger-settings
fix: nested ledger-settings persistence
2 parents 3d2d738 + feb1c54 commit 8418a41

File tree

5 files changed

+144
-0
lines changed

5 files changed

+144
-0
lines changed

components/transaction/internal/bootstrap/rabbitmq.multitenant_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
func goleakIgnores() []goleak.Option {
2020
return []goleak.Option{
2121
goleak.IgnoreTopFunction("go.opencensus.io/stats/view.(*worker).start"),
22+
goleak.IgnoreTopFunction("github.com/LerianStudio/lib-commons/v3/commons/tenant-manager/cache.(*InMemoryCache).cleanupLoop"),
2223
goleak.IgnoreAnyFunction("testing.tRunner"),
2324
goleak.IgnoreAnyFunction("testing.tRunner.func1"),
2425
goleak.IgnoreAnyFunction("testing.(*T).Run"),

pkg/constant/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ var (
160160
ErrTenantNotProvisioned = errors.New("0146")
161161
ErrUnknownSettingsField = errors.New("0147")
162162
ErrInvalidSettingsFieldType = errors.New("0148")
163+
ErrSettingsRootLevelField = errors.New("0149")
163164
)
164165

165166
// List of CRM errors.

pkg/errors.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,12 @@ func ValidateBusinessError(err error, entityType string, args ...any) error {
12611261
Title: "Invalid Settings Field Type",
12621262
Message: fmt.Sprintf("The settings field '%v' has an invalid type. Expected %v.", args...),
12631263
},
1264+
constant.ErrSettingsRootLevelField: ValidationError{
1265+
EntityType: entityType,
1266+
Code: constant.ErrSettingsRootLevelField.Error(),
1267+
Title: "Settings Field at Root Level",
1268+
Message: fmt.Sprintf("The settings field '%v' must be nested under '%v'. Expected structure: {\"%v\": {\"%v\": value}}.", args...),
1269+
},
12641270
}
12651271

12661272
if mappedError, found := errorMap[err]; found {

pkg/mmodel/settings.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package mmodel
77
import (
88
"fmt"
99
"maps"
10+
"sort"
1011

1112
"github.com/LerianStudio/midaz/v3/pkg"
1213
"github.com/LerianStudio/midaz/v3/pkg/constant"
@@ -187,6 +188,20 @@ var settingsSchema = map[string]map[string]string{
187188
},
188189
}
189190

191+
// knownNestedFieldNames contains all field names that should be nested under a parent key.
192+
// Used to detect flat structures where these fields appear at the root level.
193+
// Automatically derived from settingsSchema to ensure consistency.
194+
var knownNestedFieldNames = func() map[string]string {
195+
result := make(map[string]string)
196+
for parentKey, nestedFields := range settingsSchema {
197+
for fieldName := range nestedFields {
198+
result[fieldName] = parentKey
199+
}
200+
}
201+
202+
return result
203+
}()
204+
190205
// ValidateSettings validates that the input settings contain only known fields
191206
// with correct types. Returns an error if unknown fields are found or types are invalid.
192207
// This enforces strict schema compliance for settings updates.
@@ -201,6 +216,21 @@ func ValidateSettings(settings map[string]any) error {
201216
return nil
202217
}
203218

219+
// Check for root-level fields that should be nested under a parent key.
220+
// Keys are sorted for deterministic error reporting when multiple root-level fields exist.
221+
keys := make([]string, 0, len(settings))
222+
for key := range settings {
223+
keys = append(keys, key)
224+
}
225+
226+
sort.Strings(keys)
227+
228+
for _, key := range keys {
229+
if parentKey, isNested := knownNestedFieldNames[key]; isNested {
230+
return pkg.ValidateBusinessError(constant.ErrSettingsRootLevelField, "LedgerSettings", key, parentKey, parentKey, key)
231+
}
232+
}
233+
204234
// Check each top-level key
205235
for key, value := range settings {
206236
nestedSchema, knownKey := settingsSchema[key]

pkg/mmodel/settings_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ package mmodel
66

77
import (
88
"encoding/json"
9+
"errors"
910
"testing"
1011

12+
pkg "github.com/LerianStudio/midaz/v3/pkg"
1113
"github.com/stretchr/testify/assert"
1214
"github.com/stretchr/testify/require"
1315
)
@@ -299,6 +301,7 @@ func TestValidateSettings(t *testing.T) {
299301
input map[string]any
300302
wantErr bool
301303
errContains string
304+
wantErrCode string // structured error code to assert, if non-empty
302305
}{
303306
{
304307
name: "nil settings returns no error",
@@ -391,6 +394,46 @@ func TestValidateSettings(t *testing.T) {
391394
wantErr: true,
392395
errContains: "validateAccountType",
393396
},
397+
{
398+
name: "root-level validateAccountType returns error with parent key in message",
399+
input: map[string]any{
400+
"validateAccountType": true,
401+
},
402+
wantErr: true,
403+
errContains: "accounting",
404+
wantErrCode: "0149",
405+
},
406+
{
407+
name: "root-level validateRoutes returns error with field name in message",
408+
input: map[string]any{
409+
"validateRoutes": false,
410+
},
411+
wantErr: true,
412+
errContains: "validateRoutes",
413+
wantErrCode: "0149",
414+
},
415+
{
416+
name: "mixed root-level and nested returns error for root-level",
417+
input: map[string]any{
418+
"validateAccountType": true,
419+
"accounting": map[string]any{
420+
"validateRoutes": true,
421+
},
422+
},
423+
wantErr: true,
424+
errContains: "validateAccountType",
425+
wantErrCode: "0149",
426+
},
427+
{
428+
name: "multiple root-level fields returns error for first alphabetically",
429+
input: map[string]any{
430+
"validateAccountType": false,
431+
"validateRoutes": true,
432+
},
433+
wantErr: true,
434+
errContains: "validateAccountType", // Deterministic: alphabetically first field is reported
435+
wantErrCode: "0149",
436+
},
394437
}
395438

396439
for _, tt := range tests {
@@ -399,6 +442,13 @@ func TestValidateSettings(t *testing.T) {
399442

400443
if tt.wantErr {
401444
require.Error(t, err)
445+
446+
if tt.wantErrCode != "" {
447+
var vErr pkg.ValidationError
448+
require.True(t, errors.As(err, &vErr), "expected ValidationError type, got %T", err)
449+
assert.Equal(t, tt.wantErrCode, vErr.Code, "expected error code %q, got %q", tt.wantErrCode, vErr.Code)
450+
}
451+
402452
assert.Contains(t, err.Error(), tt.errContains)
403453
} else {
404454
require.NoError(t, err)
@@ -591,6 +641,57 @@ func TestDeepMergeSettings(t *testing.T) {
591641
}
592642
}
593643

644+
// TestSettingsSchema_NoDuplicateNestedFieldNames validates that settingsSchema has no duplicate
645+
// nested field names across different parent keys. If two parent keys define the same nested
646+
// field name, knownNestedFieldNames would have nondeterministic behavior due to map iteration order.
647+
// This test catches such issues at CI/CD time before deployment.
648+
func TestSettingsSchema_NoDuplicateNestedFieldNames(t *testing.T) {
649+
// Track which parent key owns each field name
650+
fieldToParent := make(map[string]string)
651+
652+
for parentKey, nestedFields := range settingsSchema {
653+
for fieldName := range nestedFields {
654+
if existingParent, exists := fieldToParent[fieldName]; exists {
655+
// Build suggestion safely, handling empty fieldName
656+
suggestion := parentKey
657+
if fieldName != "" {
658+
suggestion = parentKey + "." + fieldName
659+
}
660+
661+
t.Fatalf(
662+
"settingsSchema has duplicate nested field name %q: defined in both %q and %q. "+
663+
"This causes nondeterministic behavior in knownNestedFieldNames. "+
664+
"Use unique field names or qualified names (e.g., %q instead of %q).",
665+
fieldName,
666+
existingParent,
667+
parentKey,
668+
suggestion,
669+
fieldName,
670+
)
671+
}
672+
673+
fieldToParent[fieldName] = parentKey
674+
}
675+
}
676+
677+
// Also verify knownNestedFieldNames was built correctly
678+
for fieldName, expectedParent := range fieldToParent {
679+
actualParent, exists := knownNestedFieldNames[fieldName]
680+
if !exists {
681+
t.Errorf("knownNestedFieldNames missing field %q (expected parent: %q)", fieldName, expectedParent)
682+
} else if actualParent != expectedParent {
683+
t.Errorf("knownNestedFieldNames[%q] = %q, expected %q", fieldName, actualParent, expectedParent)
684+
}
685+
}
686+
687+
// Verify no extra fields in knownNestedFieldNames
688+
for fieldName := range knownNestedFieldNames {
689+
if _, exists := fieldToParent[fieldName]; !exists {
690+
t.Errorf("knownNestedFieldNames contains unexpected field %q", fieldName)
691+
}
692+
}
693+
}
694+
594695
// FuzzValidateSettings verifies ValidateSettings never panics on arbitrary JSON input.
595696
// It uses JSON strings as fuzz input since Go's fuzzing only supports primitive types.
596697
// The function must either return nil (valid) or an error (invalid) - never panic.
@@ -620,6 +721,11 @@ func FuzzValidateSettings(f *testing.F) {
620721
`123`,
621722
`true`,
622723
`false`,
724+
// Root-level field cases (should be nested under parent key)
725+
`{"validateAccountType": true}`,
726+
`{"validateRoutes": false}`,
727+
`{"validateAccountType": true, "validateRoutes": false}`,
728+
`{"validateAccountType": true, "accounting": {"validateRoutes": true}}`,
623729
}
624730

625731
for _, seed := range seeds {

0 commit comments

Comments
 (0)