From a640309e54d6d29089cc5665f7901aa14e7c97cb Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 11:53:04 +0200 Subject: [PATCH 01/11] fix: handle missing TaskPage key in workflow activity mutation When setting PAGE on a workflow activity where the TaskPage BSON key is absent (not just nil), dSet silently failed because it only updates existing keys. Added replaceActivity helper that appends the key to the activity document and replaces it in the BSON tree by match. Three cases now handled correctly: - TaskPage exists with value: update Page field in place - TaskPage exists with nil value: replace via dSet - TaskPage absent: append key, replace activity in tree --- mdl/backend/mpr/workflow_mutator.go | 66 +++++++++++++++++++++--- mdl/backend/mpr/workflow_mutator_test.go | 15 +++--- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/mdl/backend/mpr/workflow_mutator.go b/mdl/backend/mpr/workflow_mutator.go index 01c00238..74926e89 100644 --- a/mdl/backend/mpr/workflow_mutator.go +++ b/mdl/backend/mpr/workflow_mutator.go @@ -162,14 +162,21 @@ func (m *mprWorkflowMutator) SetActivityProperty(activityRef string, atPos int, case "PAGE": taskPage := dGetDoc(actDoc, "TaskPage") if taskPage != nil { + // TaskPage exists and has a value — update the Page field in place. dSet(taskPage, "Page", value) + return nil + } + pageRef := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$PageReference"}, + {Key: "Page", Value: value}, + } + if dSet(actDoc, "TaskPage", pageRef) { + // TaskPage key exists (nil value) — replaced in place via dSet. } else { - pageRef := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$PageReference"}, - {Key: "Page", Value: value}, - } - dSet(actDoc, "TaskPage", pageRef) + // TaskPage key absent — append to activity and replace in BSON tree. + actDoc = append(actDoc, bson.E{Key: "TaskPage", Value: pageRef}) + m.replaceActivity(actDoc) } return nil @@ -555,6 +562,53 @@ func (m *mprWorkflowMutator) Save() error { // Internal helpers — activity search // --------------------------------------------------------------------------- +// replaceActivity replaces an activity document in the workflow's BSON tree +// by matching on $ID. This is needed when appending new keys to an activity +// document, because the slice header returned by findActivityByCaption cannot +// propagate appends back to the parent bson.A. +func (m *mprWorkflowMutator) replaceActivity(updated bson.D) { + actID := extractBinaryIDFromDoc(dGet(updated, "$ID")) + if actID == "" { + return + } + flow := dGetDoc(m.rawData, "Flow") + if flow == nil { + return + } + replaceActivityRecursive(flow, actID, updated) +} + +func replaceActivityRecursive(flow bson.D, actID string, updated bson.D) bool { + activitiesVal := dGet(flow, "Activities") + arr := toBsonA(activitiesVal) + if len(arr) == 0 { + return false + } + // Skip the int32 type marker at index 0 if present. + start := 0 + if _, ok := arr[0].(int32); ok { + start = 1 + } else if _, ok := arr[0].(int); ok { + start = 1 + } + for i := start; i < len(arr); i++ { + actDoc, ok := arr[i].(bson.D) + if !ok { + continue + } + if extractBinaryIDFromDoc(dGet(actDoc, "$ID")) == actID { + arr[i] = updated + return true + } + for _, nestedFlow := range getNestedFlows(actDoc) { + if replaceActivityRecursive(nestedFlow, actID, updated) { + return true + } + } + } + return false +} + // findActivityByCaption searches the workflow for an activity matching caption. func (m *mprWorkflowMutator) findActivityByCaption(caption string, atPosition int) (bson.D, error) { flow := dGetDoc(m.rawData, "Flow") diff --git a/mdl/backend/mpr/workflow_mutator_test.go b/mdl/backend/mpr/workflow_mutator_test.go index d821614f..f6378558 100644 --- a/mdl/backend/mpr/workflow_mutator_test.go +++ b/mdl/backend/mpr/workflow_mutator_test.go @@ -1136,8 +1136,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_NoDelay(t *testing.T) { // --------------------------------------------------------------------------- func TestWorkflowMutator_SetActivityProperty_Page_New(t *testing.T) { - // Note: When TaskPage key doesn't pre-exist in BSON, dSet silently fails. - // The key must be present (even as nil) for PAGE to work on a new activity. + // TaskPage key present with nil value — should be replaced with a new PageReference. act := makeWfActivity("Workflows$UserTask", "Review", "task1") act = append(act, bson.E{Key: "TaskPage", Value: nil}) m := newMutator(makeWorkflowDoc(act)) @@ -1157,21 +1156,23 @@ func TestWorkflowMutator_SetActivityProperty_Page_New(t *testing.T) { } func TestWorkflowMutator_SetActivityProperty_Page_MissingKey(t *testing.T) { - // BUG: dSet silently fails when TaskPage key is absent — pageRef is lost. + // Regression test: dSet silently failed when TaskPage key was absent. + // Fixed by appending the key to the activity and replacing it in the BSON tree. act := makeWfActivity("Workflows$UserTask", "Review", "task1") // No TaskPage field at all m := newMutator(makeWorkflowDoc(act)) - // No error returned, but the set is silently lost if err := m.SetActivityProperty("Review", 0, "PAGE", "MyModule.TaskPage"); err != nil { t.Fatalf("SetActivityProperty PAGE failed: %v", err) } actDoc, _ := m.findActivityByCaption("Review", 0) taskPage := dGetDoc(actDoc, "TaskPage") - // This documents the bug: TaskPage is nil because dSet can't create new keys - if taskPage != nil { - t.Log("BUG FIXED: TaskPage is now set even when key was absent") + if taskPage == nil { + t.Fatal("TaskPage should be set even when key was absent") + } + if got := dGetString(taskPage, "Page"); got != "MyModule.TaskPage" { + t.Errorf("Page = %q, want MyModule.TaskPage", got) } } From 7047e58c532f25371ccd517816f30f9fcb0209fa Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 12:24:37 +0200 Subject: [PATCH 02/11] test: add nested sub-flow test for replaceActivity recursive path --- mdl/backend/mpr/backend.go | 18 ++++++++----- mdl/backend/mpr/convert_roundtrip_test.go | 1 - mdl/backend/mpr/workflow_mutator_test.go | 33 +++++++++++++++++++++++ mdl/executor/widget_registry.go | 4 +-- mdl/types/edmx_test.go | 6 ++--- mdl/types/id_test.go | 2 +- 6 files changed, 51 insertions(+), 13 deletions(-) diff --git a/mdl/backend/mpr/backend.go b/mdl/backend/mpr/backend.go index da20193b..48e5426b 100644 --- a/mdl/backend/mpr/backend.go +++ b/mdl/backend/mpr/backend.go @@ -85,9 +85,11 @@ func (b *MprBackend) Path() string { return b.path } // for new code. func (b *MprBackend) MprReader() *mpr.Reader { return b.reader } -func (b *MprBackend) Version() types.MPRVersion { return convertMPRVersion(b.reader.Version()) } -func (b *MprBackend) ProjectVersion() *types.ProjectVersion { return convertProjectVersion(b.reader.ProjectVersion()) } -func (b *MprBackend) GetMendixVersion() (string, error) { return b.reader.GetMendixVersion() } +func (b *MprBackend) Version() types.MPRVersion { return convertMPRVersion(b.reader.Version()) } +func (b *MprBackend) ProjectVersion() *types.ProjectVersion { + return convertProjectVersion(b.reader.ProjectVersion()) +} +func (b *MprBackend) GetMendixVersion() (string, error) { return b.reader.GetMendixVersion() } // Commit is a no-op — the MPR writer auto-commits on each write operation. func (b *MprBackend) Commit() error { return nil } @@ -112,7 +114,9 @@ func (b *MprBackend) DeleteModuleWithCleanup(id model.ID, moduleName string) err // FolderBackend // --------------------------------------------------------------------------- -func (b *MprBackend) ListFolders() ([]*types.FolderInfo, error) { return convertFolderInfoSlice(b.reader.ListFolders()) } +func (b *MprBackend) ListFolders() ([]*types.FolderInfo, error) { + return convertFolderInfoSlice(b.reader.ListFolders()) +} func (b *MprBackend) CreateFolder(folder *model.Folder) error { return b.writer.CreateFolder(folder) } func (b *MprBackend) DeleteFolder(id model.ID) error { return b.writer.DeleteFolder(id) } func (b *MprBackend) MoveFolder(id model.ID, newContainerID model.ID) error { @@ -678,8 +682,10 @@ func (b *MprBackend) UpdateRawUnit(unitID string, contents []byte) error { // MetadataBackend // --------------------------------------------------------------------------- -func (b *MprBackend) ListAllUnitIDs() ([]string, error) { return b.reader.ListAllUnitIDs() } -func (b *MprBackend) ListUnits() ([]*types.UnitInfo, error) { return convertUnitInfoSlice(b.reader.ListUnits()) } +func (b *MprBackend) ListAllUnitIDs() ([]string, error) { return b.reader.ListAllUnitIDs() } +func (b *MprBackend) ListUnits() ([]*types.UnitInfo, error) { + return convertUnitInfoSlice(b.reader.ListUnits()) +} func (b *MprBackend) GetUnitTypes() (map[string]int, error) { return b.reader.GetUnitTypes() } func (b *MprBackend) GetProjectRootID() (string, error) { return b.reader.GetProjectRootID() } func (b *MprBackend) ContentsDir() string { return b.reader.ContentsDir() } diff --git a/mdl/backend/mpr/convert_roundtrip_test.go b/mdl/backend/mpr/convert_roundtrip_test.go index a10391fc..157572f0 100644 --- a/mdl/backend/mpr/convert_roundtrip_test.go +++ b/mdl/backend/mpr/convert_roundtrip_test.go @@ -648,4 +648,3 @@ func TestFieldCountDrift(t *testing.T) { assertFieldCount(t, "mpr.EntityAccessRevocation", mpr.EntityAccessRevocation{}, 6) assertFieldCount(t, "types.EntityAccessRevocation", types.EntityAccessRevocation{}, 6) } - diff --git a/mdl/backend/mpr/workflow_mutator_test.go b/mdl/backend/mpr/workflow_mutator_test.go index f6378558..aa42a5ec 100644 --- a/mdl/backend/mpr/workflow_mutator_test.go +++ b/mdl/backend/mpr/workflow_mutator_test.go @@ -1176,6 +1176,39 @@ func TestWorkflowMutator_SetActivityProperty_Page_MissingKey(t *testing.T) { } } +func TestWorkflowMutator_SetActivityProperty_Page_MissingKey_NestedSubFlow(t *testing.T) { + // Exercises the recursive replaceActivity path: the target activity lives + // inside an outcome's sub-flow, not at the top level. + nestedAct := makeWfActivity("Workflows$UserTask", "NestedReview", "nested1") + // No TaskPage field at all on the nested activity. + + outcome := bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$BooleanOutcome"}, + {Key: "Flow", Value: bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$Flow"}, + {Key: "Activities", Value: bson.A{int32(3), nestedAct}}, + }}, + } + parentAct := makeWfActivity("Workflows$Decision", "Check", "decision1") + parentAct = append(parentAct, bson.E{Key: "Outcomes", Value: bson.A{int32(3), outcome}}) + m := newMutator(makeWorkflowDoc(parentAct)) + + if err := m.SetActivityProperty("NestedReview", 0, "PAGE", "MyModule.NestedPage"); err != nil { + t.Fatalf("SetActivityProperty PAGE on nested activity failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("NestedReview", 0) + taskPage := dGetDoc(actDoc, "TaskPage") + if taskPage == nil { + t.Fatal("TaskPage should be set on nested activity even when key was absent") + } + if got := dGetString(taskPage, "Page"); got != "MyModule.NestedPage" { + t.Errorf("Page = %q, want MyModule.NestedPage", got) + } +} + func TestWorkflowMutator_SetActivityProperty_Page_Existing(t *testing.T) { act := makeWfActivity("Workflows$UserTask", "Review", "task1") act = append(act, bson.E{Key: "TaskPage", Value: bson.D{ diff --git a/mdl/executor/widget_registry.go b/mdl/executor/widget_registry.go index 71192c50..f65e9b8d 100644 --- a/mdl/executor/widget_registry.go +++ b/mdl/executor/widget_registry.go @@ -16,8 +16,8 @@ import ( // WidgetRegistry holds loaded widget definitions keyed by uppercase MDL name. type WidgetRegistry struct { - byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName - byWidgetID map[string]*WidgetDefinition // keyed by widgetId + byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName + byWidgetID map[string]*WidgetDefinition // keyed by widgetId knownOperations map[string]bool // operations accepted during validation } diff --git a/mdl/types/edmx_test.go b/mdl/types/edmx_test.go index a4159e93..01d2b374 100644 --- a/mdl/types/edmx_test.go +++ b/mdl/types/edmx_test.go @@ -217,9 +217,9 @@ func TestFindEntityType(t *testing.T) { func TestResolveNavType(t *testing.T) { tests := []struct { - input string - typeName string - isMany bool + input string + typeName string + isMany bool }{ {"Collection(NS.Order)", "Order", true}, {"NS.Customer", "Customer", false}, diff --git a/mdl/types/id_test.go b/mdl/types/id_test.go index d30a5e39..aa138154 100644 --- a/mdl/types/id_test.go +++ b/mdl/types/id_test.go @@ -156,7 +156,7 @@ func TestValidateID(t *testing.T) { {"AABBCCDD-EEFF-1122-3344-556677889900", true}, {"", false}, {"too-short", false}, - {"a1b2c3d4-e5f6-7890-abcd-ef123456789", false}, // 35 chars + {"a1b2c3d4-e5f6-7890-abcd-ef123456789", false}, // 35 chars {"a1b2c3d4-e5f6-7890-abcd-ef12345678901", false}, // 37 chars {"a1b2c3d4xe5f6-7890-abcd-ef1234567890", false}, // wrong separator {"g1b2c3d4-e5f6-7890-abcd-ef1234567890", false}, // invalid hex From 767b9f8397040a239c8d913f95a043b8d9910e62 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 12:37:35 +0200 Subject: [PATCH 03/11] test: use distinct IDs in nested sub-flow test to ensure recursive path --- mdl/backend/mpr/workflow_mutator_test.go | 29 +++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/mdl/backend/mpr/workflow_mutator_test.go b/mdl/backend/mpr/workflow_mutator_test.go index aa42a5ec..6ffec5e0 100644 --- a/mdl/backend/mpr/workflow_mutator_test.go +++ b/mdl/backend/mpr/workflow_mutator_test.go @@ -1179,7 +1179,16 @@ func TestWorkflowMutator_SetActivityProperty_Page_MissingKey(t *testing.T) { func TestWorkflowMutator_SetActivityProperty_Page_MissingKey_NestedSubFlow(t *testing.T) { // Exercises the recursive replaceActivity path: the target activity lives // inside an outcome's sub-flow, not at the top level. - nestedAct := makeWfActivity("Workflows$UserTask", "NestedReview", "nested1") + // Use distinct $IDs so replaceActivity cannot accidentally match the parent. + parentID := primitive.Binary{Subtype: 0x04, Data: []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}} + nestedID := primitive.Binary{Subtype: 0x04, Data: []byte{2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}} + + nestedAct := bson.D{ + {Key: "$ID", Value: nestedID}, + {Key: "$Type", Value: "Workflows$UserTask"}, + {Key: "Caption", Value: "NestedReview"}, + {Key: "Name", Value: "nested1"}, + } // No TaskPage field at all on the nested activity. outcome := bson.D{ @@ -1191,8 +1200,13 @@ func TestWorkflowMutator_SetActivityProperty_Page_MissingKey_NestedSubFlow(t *te {Key: "Activities", Value: bson.A{int32(3), nestedAct}}, }}, } - parentAct := makeWfActivity("Workflows$Decision", "Check", "decision1") - parentAct = append(parentAct, bson.E{Key: "Outcomes", Value: bson.A{int32(3), outcome}}) + parentAct := bson.D{ + {Key: "$ID", Value: parentID}, + {Key: "$Type", Value: "Workflows$Decision"}, + {Key: "Caption", Value: "Check"}, + {Key: "Name", Value: "decision1"}, + {Key: "Outcomes", Value: bson.A{int32(3), outcome}}, + } m := newMutator(makeWorkflowDoc(parentAct)) if err := m.SetActivityProperty("NestedReview", 0, "PAGE", "MyModule.NestedPage"); err != nil { @@ -1207,6 +1221,15 @@ func TestWorkflowMutator_SetActivityProperty_Page_MissingKey_NestedSubFlow(t *te if got := dGetString(taskPage, "Page"); got != "MyModule.NestedPage" { t.Errorf("Page = %q, want MyModule.NestedPage", got) } + + // Verify parent decision still has its Outcomes intact. + parentDoc, _ := m.findActivityByCaption("Check", 0) + if parentDoc == nil { + t.Fatal("parent decision activity should still exist") + } + if outcomes := dGet(parentDoc, "Outcomes"); outcomes == nil { + t.Fatal("parent decision Outcomes should still be present") + } } func TestWorkflowMutator_SetActivityProperty_Page_Existing(t *testing.T) { From cddd4222d5745f8c3a9b353d834bb275d9932d79 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 12:43:09 +0200 Subject: [PATCH 04/11] refactor: use dGetArrayElements in replaceActivityRecursive to reduce duplication --- mdl/backend/mpr/workflow_mutator.go | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/mdl/backend/mpr/workflow_mutator.go b/mdl/backend/mpr/workflow_mutator.go index 74926e89..e888d530 100644 --- a/mdl/backend/mpr/workflow_mutator.go +++ b/mdl/backend/mpr/workflow_mutator.go @@ -579,25 +579,14 @@ func (m *mprWorkflowMutator) replaceActivity(updated bson.D) { } func replaceActivityRecursive(flow bson.D, actID string, updated bson.D) bool { - activitiesVal := dGet(flow, "Activities") - arr := toBsonA(activitiesVal) - if len(arr) == 0 { - return false - } - // Skip the int32 type marker at index 0 if present. - start := 0 - if _, ok := arr[0].(int32); ok { - start = 1 - } else if _, ok := arr[0].(int); ok { - start = 1 - } - for i := start; i < len(arr); i++ { - actDoc, ok := arr[i].(bson.D) + elements := dGetArrayElements(dGet(flow, "Activities")) + for i, elem := range elements { + actDoc, ok := elem.(bson.D) if !ok { continue } if extractBinaryIDFromDoc(dGet(actDoc, "$ID")) == actID { - arr[i] = updated + elements[i] = updated return true } for _, nestedFlow := range getNestedFlows(actDoc) { From 34d7a58fa43cf71e180ae237e046fe486470203f Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 12:48:15 +0200 Subject: [PATCH 05/11] style: invert dSet condition to eliminate empty branch --- mdl/backend/mpr/workflow_mutator.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mdl/backend/mpr/workflow_mutator.go b/mdl/backend/mpr/workflow_mutator.go index e888d530..5a1b78ba 100644 --- a/mdl/backend/mpr/workflow_mutator.go +++ b/mdl/backend/mpr/workflow_mutator.go @@ -171,9 +171,7 @@ func (m *mprWorkflowMutator) SetActivityProperty(activityRef string, atPos int, {Key: "$Type", Value: "Workflows$PageReference"}, {Key: "Page", Value: value}, } - if dSet(actDoc, "TaskPage", pageRef) { - // TaskPage key exists (nil value) — replaced in place via dSet. - } else { + if !dSet(actDoc, "TaskPage", pageRef) { // TaskPage key absent — append to activity and replace in BSON tree. actDoc = append(actDoc, bson.E{Key: "TaskPage", Value: pageRef}) m.replaceActivity(actDoc) From d52a43c1e0538b153d41b85a5811a0a3d883b85a Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 12:56:18 +0200 Subject: [PATCH 06/11] Add MockPageMutator, MockWorkflowMutator, and fix registry completeness test Add mock implementations of PageMutator (16 methods) and WorkflowMutator (15 methods) using the same Func-field delegation pattern as MockBackend. Both include compile-time interface checks. Fix allKnownStatements() to include 8 missing agent editor statement types (CreateAgent, DropAgent, CreateModel, DropModel, CreateConsumedMCPService, DropConsumedMCPService, CreateKnowledgeBase, DropKnowledgeBase). Add handler count snapshot test. --- mdl/backend/mock/mock_page_mutator.go | 145 ++++++++++++++++++++++ mdl/backend/mock/mock_workflow_mutator.go | 136 ++++++++++++++++++++ mdl/executor/registry_test.go | 19 +++ 3 files changed, 300 insertions(+) create mode 100644 mdl/backend/mock/mock_page_mutator.go create mode 100644 mdl/backend/mock/mock_workflow_mutator.go diff --git a/mdl/backend/mock/mock_page_mutator.go b/mdl/backend/mock/mock_page_mutator.go new file mode 100644 index 00000000..868a97e8 --- /dev/null +++ b/mdl/backend/mock/mock_page_mutator.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mock + +import ( + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +var _ backend.PageMutator = (*MockPageMutator)(nil) + +// MockPageMutator implements backend.PageMutator. Every interface method is +// backed by a public function field. If the field is nil the method returns +// zero values / nil error (never panics). +type MockPageMutator struct { + ContainerTypeFunc func() backend.ContainerKind + SetWidgetPropertyFunc func(widgetRef string, prop string, value any) error + SetWidgetDataSourceFunc func(widgetRef string, ds pages.DataSource) error + SetColumnPropertyFunc func(gridRef string, columnRef string, prop string, value any) error + InsertWidgetFunc func(widgetRef string, columnRef string, position backend.InsertPosition, widgets []pages.Widget) error + DropWidgetFunc func(refs []backend.WidgetRef) error + ReplaceWidgetFunc func(widgetRef string, columnRef string, widgets []pages.Widget) error + FindWidgetFunc func(name string) bool + AddVariableFunc func(name, dataType, defaultValue string) error + DropVariableFunc func(name string) error + SetLayoutFunc func(newLayout string, paramMappings map[string]string) error + SetPluggablePropertyFunc func(widgetRef string, propKey string, op backend.PluggablePropertyOp, ctx backend.PluggablePropertyContext) error + EnclosingEntityFunc func(widgetRef string) string + WidgetScopeFunc func() map[string]model.ID + ParamScopeFunc func() (map[string]model.ID, map[string]string) + SaveFunc func() error +} + +func (m *MockPageMutator) ContainerType() backend.ContainerKind { + if m.ContainerTypeFunc != nil { + return m.ContainerTypeFunc() + } + return backend.ContainerPage +} + +func (m *MockPageMutator) SetWidgetProperty(widgetRef string, prop string, value any) error { + if m.SetWidgetPropertyFunc != nil { + return m.SetWidgetPropertyFunc(widgetRef, prop, value) + } + return nil +} + +func (m *MockPageMutator) SetWidgetDataSource(widgetRef string, ds pages.DataSource) error { + if m.SetWidgetDataSourceFunc != nil { + return m.SetWidgetDataSourceFunc(widgetRef, ds) + } + return nil +} + +func (m *MockPageMutator) SetColumnProperty(gridRef string, columnRef string, prop string, value any) error { + if m.SetColumnPropertyFunc != nil { + return m.SetColumnPropertyFunc(gridRef, columnRef, prop, value) + } + return nil +} + +func (m *MockPageMutator) InsertWidget(widgetRef string, columnRef string, position backend.InsertPosition, widgets []pages.Widget) error { + if m.InsertWidgetFunc != nil { + return m.InsertWidgetFunc(widgetRef, columnRef, position, widgets) + } + return nil +} + +func (m *MockPageMutator) DropWidget(refs []backend.WidgetRef) error { + if m.DropWidgetFunc != nil { + return m.DropWidgetFunc(refs) + } + return nil +} + +func (m *MockPageMutator) ReplaceWidget(widgetRef string, columnRef string, widgets []pages.Widget) error { + if m.ReplaceWidgetFunc != nil { + return m.ReplaceWidgetFunc(widgetRef, columnRef, widgets) + } + return nil +} + +func (m *MockPageMutator) FindWidget(name string) bool { + if m.FindWidgetFunc != nil { + return m.FindWidgetFunc(name) + } + return false +} + +func (m *MockPageMutator) AddVariable(name, dataType, defaultValue string) error { + if m.AddVariableFunc != nil { + return m.AddVariableFunc(name, dataType, defaultValue) + } + return nil +} + +func (m *MockPageMutator) DropVariable(name string) error { + if m.DropVariableFunc != nil { + return m.DropVariableFunc(name) + } + return nil +} + +func (m *MockPageMutator) SetLayout(newLayout string, paramMappings map[string]string) error { + if m.SetLayoutFunc != nil { + return m.SetLayoutFunc(newLayout, paramMappings) + } + return nil +} + +func (m *MockPageMutator) SetPluggableProperty(widgetRef string, propKey string, op backend.PluggablePropertyOp, ctx backend.PluggablePropertyContext) error { + if m.SetPluggablePropertyFunc != nil { + return m.SetPluggablePropertyFunc(widgetRef, propKey, op, ctx) + } + return nil +} + +func (m *MockPageMutator) EnclosingEntity(widgetRef string) string { + if m.EnclosingEntityFunc != nil { + return m.EnclosingEntityFunc(widgetRef) + } + return "" +} + +func (m *MockPageMutator) WidgetScope() map[string]model.ID { + if m.WidgetScopeFunc != nil { + return m.WidgetScopeFunc() + } + return nil +} + +func (m *MockPageMutator) ParamScope() (map[string]model.ID, map[string]string) { + if m.ParamScopeFunc != nil { + return m.ParamScopeFunc() + } + return nil, nil +} + +func (m *MockPageMutator) Save() error { + if m.SaveFunc != nil { + return m.SaveFunc() + } + return nil +} diff --git a/mdl/backend/mock/mock_workflow_mutator.go b/mdl/backend/mock/mock_workflow_mutator.go new file mode 100644 index 00000000..b184953b --- /dev/null +++ b/mdl/backend/mock/mock_workflow_mutator.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mock + +import ( + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +var _ backend.WorkflowMutator = (*MockWorkflowMutator)(nil) + +// MockWorkflowMutator implements backend.WorkflowMutator. Every interface +// method is backed by a public function field. If the field is nil the +// method returns zero values / nil error (never panics). +type MockWorkflowMutator struct { + SetPropertyFunc func(prop string, value string) error + SetPropertyWithEntityFunc func(prop string, value string, entity string) error + SetActivityPropertyFunc func(activityRef string, atPos int, prop string, value string) error + InsertAfterActivityFunc func(activityRef string, atPos int, activities []workflows.WorkflowActivity) error + DropActivityFunc func(activityRef string, atPos int) error + ReplaceActivityFunc func(activityRef string, atPos int, activities []workflows.WorkflowActivity) error + InsertOutcomeFunc func(activityRef string, atPos int, outcomeName string, activities []workflows.WorkflowActivity) error + DropOutcomeFunc func(activityRef string, atPos int, outcomeName string) error + InsertPathFunc func(activityRef string, atPos int, pathCaption string, activities []workflows.WorkflowActivity) error + DropPathFunc func(activityRef string, atPos int, pathCaption string) error + InsertBranchFunc func(activityRef string, atPos int, condition string, activities []workflows.WorkflowActivity) error + DropBranchFunc func(activityRef string, atPos int, branchName string) error + InsertBoundaryEventFunc func(activityRef string, atPos int, eventType string, delay string, activities []workflows.WorkflowActivity) error + DropBoundaryEventFunc func(activityRef string, atPos int) error + SaveFunc func() error +} + +func (m *MockWorkflowMutator) SetProperty(prop string, value string) error { + if m.SetPropertyFunc != nil { + return m.SetPropertyFunc(prop, value) + } + return nil +} + +func (m *MockWorkflowMutator) SetPropertyWithEntity(prop string, value string, entity string) error { + if m.SetPropertyWithEntityFunc != nil { + return m.SetPropertyWithEntityFunc(prop, value, entity) + } + return nil +} + +func (m *MockWorkflowMutator) SetActivityProperty(activityRef string, atPos int, prop string, value string) error { + if m.SetActivityPropertyFunc != nil { + return m.SetActivityPropertyFunc(activityRef, atPos, prop, value) + } + return nil +} + +func (m *MockWorkflowMutator) InsertAfterActivity(activityRef string, atPos int, activities []workflows.WorkflowActivity) error { + if m.InsertAfterActivityFunc != nil { + return m.InsertAfterActivityFunc(activityRef, atPos, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropActivity(activityRef string, atPos int) error { + if m.DropActivityFunc != nil { + return m.DropActivityFunc(activityRef, atPos) + } + return nil +} + +func (m *MockWorkflowMutator) ReplaceActivity(activityRef string, atPos int, activities []workflows.WorkflowActivity) error { + if m.ReplaceActivityFunc != nil { + return m.ReplaceActivityFunc(activityRef, atPos, activities) + } + return nil +} + +func (m *MockWorkflowMutator) InsertOutcome(activityRef string, atPos int, outcomeName string, activities []workflows.WorkflowActivity) error { + if m.InsertOutcomeFunc != nil { + return m.InsertOutcomeFunc(activityRef, atPos, outcomeName, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropOutcome(activityRef string, atPos int, outcomeName string) error { + if m.DropOutcomeFunc != nil { + return m.DropOutcomeFunc(activityRef, atPos, outcomeName) + } + return nil +} + +func (m *MockWorkflowMutator) InsertPath(activityRef string, atPos int, pathCaption string, activities []workflows.WorkflowActivity) error { + if m.InsertPathFunc != nil { + return m.InsertPathFunc(activityRef, atPos, pathCaption, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropPath(activityRef string, atPos int, pathCaption string) error { + if m.DropPathFunc != nil { + return m.DropPathFunc(activityRef, atPos, pathCaption) + } + return nil +} + +func (m *MockWorkflowMutator) InsertBranch(activityRef string, atPos int, condition string, activities []workflows.WorkflowActivity) error { + if m.InsertBranchFunc != nil { + return m.InsertBranchFunc(activityRef, atPos, condition, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropBranch(activityRef string, atPos int, branchName string) error { + if m.DropBranchFunc != nil { + return m.DropBranchFunc(activityRef, atPos, branchName) + } + return nil +} + +func (m *MockWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, activities []workflows.WorkflowActivity) error { + if m.InsertBoundaryEventFunc != nil { + return m.InsertBoundaryEventFunc(activityRef, atPos, eventType, delay, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropBoundaryEvent(activityRef string, atPos int) error { + if m.DropBoundaryEventFunc != nil { + return m.DropBoundaryEventFunc(activityRef, atPos) + } + return nil +} + +func (m *MockWorkflowMutator) Save() error { + if m.SaveFunc != nil { + return m.SaveFunc() + } + return nil +} diff --git a/mdl/executor/registry_test.go b/mdl/executor/registry_test.go index 4641412f..ac7957b0 100644 --- a/mdl/executor/registry_test.go +++ b/mdl/executor/registry_test.go @@ -172,9 +172,11 @@ func allKnownStatements() []ast.Statement { &ast.AlterUserRoleStmt{}, &ast.AlterWorkflowStmt{}, &ast.ConnectStmt{}, + &ast.CreateAgentStmt{}, &ast.CreateAssociationStmt{}, &ast.CreateBusinessEventServiceStmt{}, &ast.CreateConfigurationStmt{}, + &ast.CreateConsumedMCPServiceStmt{}, &ast.CreateConstantStmt{}, &ast.CreateDatabaseConnectionStmt{}, &ast.CreateDataTransformerStmt{}, @@ -188,7 +190,9 @@ func allKnownStatements() []ast.Statement { &ast.CreateImportMappingStmt{}, &ast.CreateJavaActionStmt{}, &ast.CreateJsonStructureStmt{}, + &ast.CreateKnowledgeBaseStmt{}, &ast.CreateMicroflowStmt{}, + &ast.CreateModelStmt{}, &ast.CreateModuleRoleStmt{}, &ast.CreateModuleStmt{}, &ast.CreateODataClientStmt{}, @@ -206,9 +210,11 @@ func allKnownStatements() []ast.Statement { &ast.DescribeStmt{}, &ast.DescribeStylingStmt{}, &ast.DisconnectStmt{}, + &ast.DropAgentStmt{}, &ast.DropAssociationStmt{}, &ast.DropBusinessEventServiceStmt{}, &ast.DropConfigurationStmt{}, + &ast.DropConsumedMCPServiceStmt{}, &ast.DropConstantStmt{}, &ast.DropDataTransformerStmt{}, &ast.DropDemoUserStmt{}, @@ -220,7 +226,9 @@ func allKnownStatements() []ast.Statement { &ast.DropImportMappingStmt{}, &ast.DropJavaActionStmt{}, &ast.DropJsonStructureStmt{}, + &ast.DropKnowledgeBaseStmt{}, &ast.DropMicroflowStmt{}, + &ast.DropModelStmt{}, &ast.DropModuleRoleStmt{}, &ast.DropModuleStmt{}, &ast.DropODataClientStmt{}, @@ -286,3 +294,14 @@ func TestNewRegistry_Completeness(t *testing.T) { t.Fatalf("registry is incomplete: %v", err) } } + +// TestNewRegistry_HandlerCountSnapshot validates the expected number of +// registered handlers. Update the expected count when adding new handlers. +func TestNewRegistry_HandlerCountSnapshot(t *testing.T) { + r := NewRegistry() + known := allKnownStatements() + + if got := r.HandlerCount(); got != len(known) { + t.Errorf("handler count mismatch: registry has %d, allKnownStatements has %d — update allKnownStatements or register missing handlers", got, len(known)) + } +} From 4d970a6e365455abfb9c08c637b5c72e769729c9 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 13:01:24 +0200 Subject: [PATCH 07/11] Clarify handler count snapshot test comment per review --- mdl/executor/registry_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mdl/executor/registry_test.go b/mdl/executor/registry_test.go index ac7957b0..932e441b 100644 --- a/mdl/executor/registry_test.go +++ b/mdl/executor/registry_test.go @@ -295,8 +295,9 @@ func TestNewRegistry_Completeness(t *testing.T) { } } -// TestNewRegistry_HandlerCountSnapshot validates the expected number of -// registered handlers. Update the expected count when adding new handlers. +// TestNewRegistry_HandlerCountSnapshot verifies that the number of registered +// handlers matches allKnownStatements(). Keep allKnownStatements() in sync with +// known statement types and handler registrations. func TestNewRegistry_HandlerCountSnapshot(t *testing.T) { r := NewRegistry() known := allKnownStatements() From ad2c0e50726ddf233379b92f8f111c26da1f9c93 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 13:24:55 +0200 Subject: [PATCH 08/11] test: add visitor tests for 20 untested visitor source files Cover security (24 tests), odata (4), agent editor (4), REST (2), settings (4), module (1), association (3), connection (2), business events (1), enumeration (5), JSON structure (1), data transformer (1), widgets (2), image collection (1), navigation (1), lint (4), import/export mapping (2), move (2), misc queries (4), and database connection (1). Visitor test function count: 128 -> 197 (+69). --- mdl/visitor/visitor_agenteditor_test.go | 142 +++++ mdl/visitor/visitor_association_test.go | 75 +++ mdl/visitor/visitor_businessevents_test.go | 44 ++ mdl/visitor/visitor_connection_test.go | 42 ++ mdl/visitor/visitor_datatransformer_test.go | 38 ++ mdl/visitor/visitor_dbconnection_test.go | 39 ++ mdl/visitor/visitor_enumeration_test.go | 108 ++++ mdl/visitor/visitor_export_mapping_test.go | 38 ++ mdl/visitor/visitor_imagecollection_test.go | 41 ++ mdl/visitor/visitor_import_mapping_test.go | 41 ++ mdl/visitor/visitor_jsonstructure_test.go | 30 + mdl/visitor/visitor_lint_test.go | 82 +++ mdl/visitor/visitor_misc_test.go | 75 +++ mdl/visitor/visitor_module_test.go | 30 + mdl/visitor/visitor_move_test.go | 51 ++ mdl/visitor/visitor_navigation_test.go | 53 ++ mdl/visitor/visitor_odata_test.go | 135 +++++ mdl/visitor/visitor_rest_test.go | 90 +++ mdl/visitor/visitor_security_test.go | 609 ++++++++++++++++++++ mdl/visitor/visitor_settings_test.go | 84 +++ mdl/visitor/visitor_widgets_test.go | 54 ++ 21 files changed, 1901 insertions(+) create mode 100644 mdl/visitor/visitor_agenteditor_test.go create mode 100644 mdl/visitor/visitor_association_test.go create mode 100644 mdl/visitor/visitor_businessevents_test.go create mode 100644 mdl/visitor/visitor_connection_test.go create mode 100644 mdl/visitor/visitor_datatransformer_test.go create mode 100644 mdl/visitor/visitor_dbconnection_test.go create mode 100644 mdl/visitor/visitor_enumeration_test.go create mode 100644 mdl/visitor/visitor_export_mapping_test.go create mode 100644 mdl/visitor/visitor_imagecollection_test.go create mode 100644 mdl/visitor/visitor_import_mapping_test.go create mode 100644 mdl/visitor/visitor_jsonstructure_test.go create mode 100644 mdl/visitor/visitor_lint_test.go create mode 100644 mdl/visitor/visitor_misc_test.go create mode 100644 mdl/visitor/visitor_module_test.go create mode 100644 mdl/visitor/visitor_move_test.go create mode 100644 mdl/visitor/visitor_navigation_test.go create mode 100644 mdl/visitor/visitor_odata_test.go create mode 100644 mdl/visitor/visitor_rest_test.go create mode 100644 mdl/visitor/visitor_security_test.go create mode 100644 mdl/visitor/visitor_settings_test.go create mode 100644 mdl/visitor/visitor_widgets_test.go diff --git a/mdl/visitor/visitor_agenteditor_test.go b/mdl/visitor/visitor_agenteditor_test.go new file mode 100644 index 00000000..50117826 --- /dev/null +++ b/mdl/visitor/visitor_agenteditor_test.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateModel(t *testing.T) { + input := `CREATE MODEL MyModule.GPT4 ( + Provider: MxCloudGenAI, + Key: MyModule.APIKey, + DisplayName: 'GPT-4 Turbo' + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateModelStmt) + if !ok { + t.Fatalf("Expected CreateModelStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Module != "MyModule" || stmt.Name.Name != "GPT4" { + t.Errorf("Expected MyModule.GPT4, got %s.%s", stmt.Name.Module, stmt.Name.Name) + } + if stmt.Provider != "MxCloudGenAI" { + t.Errorf("Got Provider %q", stmt.Provider) + } + if stmt.Key == nil || stmt.Key.Name != "APIKey" { + t.Error("Key mismatch") + } + if stmt.DisplayName != "GPT-4 Turbo" { + t.Errorf("Got DisplayName %q", stmt.DisplayName) + } +} + +func TestCreateConsumedMCPService(t *testing.T) { + input := `CREATE CONSUMED MCP SERVICE MyModule.ToolService ( + ProtocolVersion: v2025_03_26, + Version: '0.0.1', + ConnectionTimeoutSeconds: 30, + Documentation: 'MCP tool service' + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateConsumedMCPServiceStmt) + if !ok { + t.Fatalf("Expected CreateConsumedMCPServiceStmt, got %T", prog.Statements[0]) + } + if stmt.ProtocolVersion != "v2025_03_26" { + t.Errorf("Got ProtocolVersion %q", stmt.ProtocolVersion) + } + if stmt.Version != "0.0.1" { + t.Errorf("Got Version %q", stmt.Version) + } + if stmt.ConnectionTimeoutSeconds != 30 { + t.Errorf("Got ConnectionTimeoutSeconds %d", stmt.ConnectionTimeoutSeconds) + } + if stmt.InnerDocumentation != "MCP tool service" { + t.Errorf("Got InnerDocumentation %q", stmt.InnerDocumentation) + } +} + +func TestCreateKnowledgeBase(t *testing.T) { + input := `CREATE KNOWLEDGE BASE MyModule.ProductKB ( + Provider: MxCloudGenAI, + Key: MyModule.KBKey, + ModelDisplayName: 'Product Knowledge', + ModelName: 'product-embeddings' + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateKnowledgeBaseStmt) + if !ok { + t.Fatalf("Expected CreateKnowledgeBaseStmt, got %T", prog.Statements[0]) + } + if stmt.Provider != "MxCloudGenAI" { + t.Errorf("Got Provider %q", stmt.Provider) + } + if stmt.Key == nil || stmt.Key.Name != "KBKey" { + t.Error("Key mismatch") + } + if stmt.ModelDisplayName != "Product Knowledge" { + t.Errorf("Got ModelDisplayName %q", stmt.ModelDisplayName) + } +} + +func TestCreateAgent_Basic(t *testing.T) { + input := `CREATE AGENT MyModule.OrderAgent ( + UsageType: Task, + Model: MyModule.GPT4, + Entity: MyModule.OrderContext, + SystemPrompt: 'You are an order processing assistant.', + ToolChoice: auto, + MaxTokens: 4096, + Temperature: 0.7 + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateAgentStmt) + if !ok { + t.Fatalf("Expected CreateAgentStmt, got %T", prog.Statements[0]) + } + if stmt.UsageType != "Task" { + t.Errorf("Got UsageType %q", stmt.UsageType) + } + if stmt.Model == nil || stmt.Model.Name != "GPT4" { + t.Error("Model mismatch") + } + if stmt.Entity == nil || stmt.Entity.Name != "OrderContext" { + t.Error("Entity mismatch") + } + if stmt.MaxTokens == nil || *stmt.MaxTokens != 4096 { + t.Error("MaxTokens mismatch") + } + if stmt.Temperature == nil || *stmt.Temperature != 0.7 { + t.Error("Temperature mismatch") + } + if stmt.ToolChoice != "auto" { + t.Errorf("Got ToolChoice %q", stmt.ToolChoice) + } +} diff --git a/mdl/visitor/visitor_association_test.go b/mdl/visitor/visitor_association_test.go new file mode 100644 index 00000000..34469deb --- /dev/null +++ b/mdl/visitor/visitor_association_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateAssociation(t *testing.T) { + input := `CREATE ASSOCIATION MyModule.Order_Customer FROM MyModule.Order TO MyModule.Customer TYPE REFERENCE OWNER DEFAULT;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateAssociationStmt) + if !ok { + t.Fatalf("Expected CreateAssociationStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Module != "MyModule" || stmt.Name.Name != "Order_Customer" { + t.Errorf("Got name %s.%s", stmt.Name.Module, stmt.Name.Name) + } + if stmt.Parent.Name != "Order" { + t.Errorf("Got parent %s", stmt.Parent.Name) + } + if stmt.Child.Name != "Customer" { + t.Errorf("Got child %s", stmt.Child.Name) + } + if stmt.Type != ast.AssocReference { + t.Errorf("Expected Reference type, got %v", stmt.Type) + } +} + +func TestCreateAssociation_ReferenceSet(t *testing.T) { + input := `CREATE ASSOCIATION MyModule.Order_Product FROM MyModule.Order TO MyModule.Product TYPE REFERENCE_SET OWNER BOTH;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.CreateAssociationStmt) + if stmt.Type != ast.AssocReferenceSet { + t.Errorf("Expected ReferenceSet, got %v", stmt.Type) + } + if stmt.Owner != ast.OwnerBoth { + t.Errorf("Expected OwnerBoth, got %v", stmt.Owner) + } +} + +func TestAlterAssociation_SetOwner(t *testing.T) { + input := `ALTER ASSOCIATION MyModule.Order_Customer SET OWNER BOTH;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterAssociationStmt) + if !ok { + t.Fatalf("Expected AlterAssociationStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "Order_Customer" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.Owner != ast.OwnerBoth { + t.Errorf("Expected OwnerBoth, got %v", stmt.Owner) + } +} diff --git a/mdl/visitor/visitor_businessevents_test.go b/mdl/visitor/visitor_businessevents_test.go new file mode 100644 index 00000000..cd3ca293 --- /dev/null +++ b/mdl/visitor/visitor_businessevents_test.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateBusinessEventService(t *testing.T) { + input := `CREATE BUSINESS EVENT SERVICE MyModule.OrderEvents ( + ServiceName: 'OrderService', + EventNamePrefix: 'com.example.orders' + ) { + MESSAGE OrderCreated (OrderId: Long) PUBLISH ENTITY MyModule.Order; + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateBusinessEventServiceStmt) + if !ok { + t.Fatalf("Expected CreateBusinessEventServiceStmt, got %T", prog.Statements[0]) + } + if stmt.ServiceName != "OrderService" { + t.Errorf("Got ServiceName %q", stmt.ServiceName) + } + if stmt.EventNamePrefix != "com.example.orders" { + t.Errorf("Got EventNamePrefix %q", stmt.EventNamePrefix) + } + if len(stmt.Messages) != 1 { + t.Fatalf("Expected 1 message, got %d", len(stmt.Messages)) + } + if stmt.Messages[0].MessageName != "OrderCreated" { + t.Errorf("Got MessageName %q", stmt.Messages[0].MessageName) + } + if stmt.Messages[0].Operation != "PUBLISH" { + t.Errorf("Got Operation %q", stmt.Messages[0].Operation) + } +} diff --git a/mdl/visitor/visitor_connection_test.go b/mdl/visitor/visitor_connection_test.go new file mode 100644 index 00000000..fe69424f --- /dev/null +++ b/mdl/visitor/visitor_connection_test.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestConnect_Local(t *testing.T) { + input := `CONNECT LOCAL '/path/to/project';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.ConnectStmt) + if !ok { + t.Fatalf("Expected ConnectStmt, got %T", prog.Statements[0]) + } + if stmt.Path != "/path/to/project" { + t.Errorf("Expected /path/to/project, got %q", stmt.Path) + } +} + +func TestDisconnect(t *testing.T) { + input := `DISCONNECT;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + _, ok := prog.Statements[0].(*ast.DisconnectStmt) + if !ok { + t.Fatalf("Expected DisconnectStmt, got %T", prog.Statements[0]) + } +} diff --git a/mdl/visitor/visitor_datatransformer_test.go b/mdl/visitor/visitor_datatransformer_test.go new file mode 100644 index 00000000..245e5526 --- /dev/null +++ b/mdl/visitor/visitor_datatransformer_test.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateDataTransformer(t *testing.T) { + input := `CREATE DATA TRANSFORMER MyModule.LatExtract SOURCE JSON '{"lat": 51.9}' { + JSLT '{ "latitude": .lat }'; + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateDataTransformerStmt) + if !ok { + t.Fatalf("Expected CreateDataTransformerStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "LatExtract" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.SourceType != "JSON" { + t.Errorf("Got SourceType %q", stmt.SourceType) + } + if len(stmt.Steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(stmt.Steps)) + } + if stmt.Steps[0].Technology != "JSLT" { + t.Errorf("Got Technology %q", stmt.Steps[0].Technology) + } +} diff --git a/mdl/visitor/visitor_dbconnection_test.go b/mdl/visitor/visitor_dbconnection_test.go new file mode 100644 index 00000000..a9fb7aaa --- /dev/null +++ b/mdl/visitor/visitor_dbconnection_test.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateDatabaseConnection(t *testing.T) { + input := `CREATE DATABASE CONNECTION MyModule.MyDB TYPE 'PostgreSQL' HOST 'localhost' PORT 5432 DATABASE 'mydb' USERNAME 'user' PASSWORD 'pass';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateDatabaseConnectionStmt) + if !ok { + t.Fatalf("Expected CreateDatabaseConnectionStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "MyDB" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.DatabaseType != "PostgreSQL" { + t.Errorf("Got DatabaseType %q", stmt.DatabaseType) + } + if stmt.Host != "localhost" { + t.Errorf("Got Host %q", stmt.Host) + } + if stmt.Port != 5432 { + t.Errorf("Got Port %d", stmt.Port) + } + if stmt.UserName != "user" { + t.Errorf("Got UserName %q", stmt.UserName) + } +} diff --git a/mdl/visitor/visitor_enumeration_test.go b/mdl/visitor/visitor_enumeration_test.go new file mode 100644 index 00000000..33121a12 --- /dev/null +++ b/mdl/visitor/visitor_enumeration_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestAlterEnumeration_AddValue(t *testing.T) { + input := `ALTER ENUMERATION MyModule.Status ADD VALUE Pending CAPTION 'Pending';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterEnumerationStmt) + if !ok { + t.Fatalf("Expected AlterEnumerationStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "Status" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.Operation != ast.AlterEnumAdd { + t.Errorf("Expected Add, got %v", stmt.Operation) + } + if stmt.ValueName != "Pending" { + t.Errorf("Got ValueName %q", stmt.ValueName) + } + if stmt.Caption != "Pending" { + t.Errorf("Got Caption %q", stmt.Caption) + } +} + +func TestAlterEnumeration_RenameValue(t *testing.T) { + input := `ALTER ENUMERATION MyModule.Status RENAME VALUE Active TO Enabled;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.AlterEnumerationStmt) + if stmt.Operation != ast.AlterEnumRename { + t.Errorf("Expected Rename, got %v", stmt.Operation) + } + if stmt.ValueName != "Active" { + t.Errorf("Got ValueName %q", stmt.ValueName) + } + if stmt.NewName != "Enabled" { + t.Errorf("Got NewName %q", stmt.NewName) + } +} + +func TestAlterEnumeration_DropValue(t *testing.T) { + input := `ALTER ENUMERATION MyModule.Status DROP VALUE Obsolete;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.AlterEnumerationStmt) + if stmt.Operation != ast.AlterEnumDrop { + t.Errorf("Expected Drop, got %v", stmt.Operation) + } +} + +func TestCreateConstant(t *testing.T) { + input := `CREATE CONSTANT MyModule.MaxRetries TYPE Integer DEFAULT 3 COMMENT 'Max retry count';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateConstantStmt) + if !ok { + t.Fatalf("Expected CreateConstantStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "MaxRetries" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.Comment != "Max retry count" { + t.Errorf("Got Comment %q", stmt.Comment) + } +} + +func TestCreateConstant_ExposedToClient(t *testing.T) { + input := `CREATE CONSTANT MyModule.AppUrl TYPE String DEFAULT 'https://example.com' EXPOSED TO CLIENT;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.CreateConstantStmt) + if !stmt.ExposedToClient { + t.Error("Expected ExposedToClient true") + } +} diff --git a/mdl/visitor/visitor_export_mapping_test.go b/mdl/visitor/visitor_export_mapping_test.go new file mode 100644 index 00000000..a59e0363 --- /dev/null +++ b/mdl/visitor/visitor_export_mapping_test.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateExportMapping(t *testing.T) { + input := `CREATE EXPORT MAPPING MyModule.PetExport WITH JSON STRUCTURE MyModule.PetSchema { + MyModule.Pet { + name = Name, + id = PetId + } + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateExportMappingStmt) + if !ok { + t.Fatalf("Expected CreateExportMappingStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "PetExport" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.SchemaKind != "JSON_STRUCTURE" { + t.Errorf("Got SchemaKind %q", stmt.SchemaKind) + } + if stmt.RootElement == nil { + t.Fatal("Expected non-nil RootElement") + } +} diff --git a/mdl/visitor/visitor_imagecollection_test.go b/mdl/visitor/visitor_imagecollection_test.go new file mode 100644 index 00000000..c8ac39f5 --- /dev/null +++ b/mdl/visitor/visitor_imagecollection_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateImageCollection(t *testing.T) { + input := `CREATE IMAGE COLLECTION MyModule.Icons EXPORT LEVEL 'Public' ( + IMAGE MyIcon FROM FILE '/images/icon.png' + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateImageCollectionStmt) + if !ok { + t.Fatalf("Expected CreateImageCollectionStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "Icons" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.ExportLevel != "Public" { + t.Errorf("Got ExportLevel %q", stmt.ExportLevel) + } + if len(stmt.Images) != 1 { + t.Fatalf("Expected 1 image, got %d", len(stmt.Images)) + } + if stmt.Images[0].Name != "MyIcon" { + t.Errorf("Got image name %q", stmt.Images[0].Name) + } + if stmt.Images[0].FilePath != "/images/icon.png" { + t.Errorf("Got FilePath %q", stmt.Images[0].FilePath) + } +} diff --git a/mdl/visitor/visitor_import_mapping_test.go b/mdl/visitor/visitor_import_mapping_test.go new file mode 100644 index 00000000..d73eaa10 --- /dev/null +++ b/mdl/visitor/visitor_import_mapping_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateImportMapping(t *testing.T) { + input := `CREATE IMPORT MAPPING MyModule.PetMapping WITH JSON STRUCTURE MyModule.PetSchema { + CREATE MyModule.Pet { + PetId = id KEY, + Name = name + } + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateImportMappingStmt) + if !ok { + t.Fatalf("Expected CreateImportMappingStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "PetMapping" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.SchemaKind != "JSON_STRUCTURE" { + t.Errorf("Got SchemaKind %q", stmt.SchemaKind) + } + if stmt.SchemaRef.Name != "PetSchema" { + t.Errorf("Got SchemaRef %s", stmt.SchemaRef.Name) + } + if stmt.RootElement == nil { + t.Fatal("Expected non-nil RootElement") + } +} diff --git a/mdl/visitor/visitor_jsonstructure_test.go b/mdl/visitor/visitor_jsonstructure_test.go new file mode 100644 index 00000000..2743bc95 --- /dev/null +++ b/mdl/visitor/visitor_jsonstructure_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateJsonStructure(t *testing.T) { + input := `CREATE JSON STRUCTURE MyModule.PetSchema SNIPPET '{"id": 1, "name": "test"}';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateJsonStructureStmt) + if !ok { + t.Fatalf("Expected CreateJsonStructureStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "PetSchema" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.JsonSnippet != `{"id": 1, "name": "test"}` { + t.Errorf("Got JsonSnippet %q", stmt.JsonSnippet) + } +} diff --git a/mdl/visitor/visitor_lint_test.go b/mdl/visitor/visitor_lint_test.go new file mode 100644 index 00000000..cd171f8a --- /dev/null +++ b/mdl/visitor/visitor_lint_test.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestLint_All(t *testing.T) { + input := `LINT;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.LintStmt) + if !ok { + t.Fatalf("Expected LintStmt, got %T", prog.Statements[0]) + } + if stmt.Target != nil { + t.Error("Expected nil Target") + } + if stmt.ShowRules { + t.Error("Expected ShowRules false") + } +} + +func TestLint_ModuleOnly(t *testing.T) { + input := `LINT MyModule.*;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.LintStmt) + if !stmt.ModuleOnly { + t.Error("Expected ModuleOnly true") + } + if stmt.Target == nil { + t.Fatal("Expected non-nil Target") + } + // For "LINT MyModule.*", qualifiedName is just "MyModule" (no module prefix) + if stmt.Target.Name != "MyModule" && stmt.Target.Module != "MyModule" { + t.Errorf("Target mismatch: got Module=%q Name=%q", stmt.Target.Module, stmt.Target.Name) + } +} + +func TestLint_WithFormat(t *testing.T) { + input := `LINT * FORMAT JSON;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.LintStmt) + if stmt.Format != "json" { + t.Errorf("Expected json format, got %q", stmt.Format) + } +} + +func TestShowLintRules(t *testing.T) { + input := `SHOW LINT RULES;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.LintStmt) + if !stmt.ShowRules { + t.Error("Expected ShowRules true") + } +} diff --git a/mdl/visitor/visitor_misc_test.go b/mdl/visitor/visitor_misc_test.go new file mode 100644 index 00000000..97ef1c74 --- /dev/null +++ b/mdl/visitor/visitor_misc_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestSearch(t *testing.T) { + input := `SEARCH 'customer';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.SearchStmt) + if !ok { + t.Fatalf("Expected SearchStmt, got %T", prog.Statements[0]) + } + if stmt.Query != "customer" { + t.Errorf("Got Query %q", stmt.Query) + } +} + +func TestExecuteScript(t *testing.T) { + input := `EXECUTE SCRIPT 'myscript.mdl';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.ExecuteScriptStmt) + if !ok { + t.Fatalf("Expected ExecuteScriptStmt, got %T", prog.Statements[0]) + } + if stmt.Path != "myscript.mdl" { + t.Errorf("Got Path %q", stmt.Path) + } +} + +func TestHelp(t *testing.T) { + input := `help;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + _, ok := prog.Statements[0].(*ast.HelpStmt) + if !ok { + t.Fatalf("Expected HelpStmt, got %T", prog.Statements[0]) + } +} + +func TestUpdate(t *testing.T) { + input := `UPDATE;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + _, ok := prog.Statements[0].(*ast.UpdateStmt) + if !ok { + t.Fatalf("Expected UpdateStmt, got %T", prog.Statements[0]) + } +} diff --git a/mdl/visitor/visitor_module_test.go b/mdl/visitor/visitor_module_test.go new file mode 100644 index 00000000..a43a2fe7 --- /dev/null +++ b/mdl/visitor/visitor_module_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateModule(t *testing.T) { + input := `CREATE MODULE OrderManagement;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + if len(prog.Statements) != 1 { + t.Fatalf("Expected 1 statement, got %d", len(prog.Statements)) + } + stmt, ok := prog.Statements[0].(*ast.CreateModuleStmt) + if !ok { + t.Fatalf("Expected CreateModuleStmt, got %T", prog.Statements[0]) + } + if stmt.Name != "OrderManagement" { + t.Errorf("Expected OrderManagement, got %q", stmt.Name) + } +} diff --git a/mdl/visitor/visitor_move_test.go b/mdl/visitor/visitor_move_test.go new file mode 100644 index 00000000..7225b852 --- /dev/null +++ b/mdl/visitor/visitor_move_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestMovePage_ToFolder(t *testing.T) { + input := `MOVE PAGE MyModule.OldPage TO FOLDER 'Admin/Pages';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.MoveStmt) + if !ok { + t.Fatalf("Expected MoveStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "OldPage" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.Folder != "Admin/Pages" { + t.Errorf("Got Folder %q", stmt.Folder) + } +} + +func TestMoveEntity_ToModule(t *testing.T) { + input := `MOVE ENTITY MyModule.Customer TO OtherModule;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.MoveStmt) + if !ok { + t.Fatalf("Expected MoveStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Name != "Customer" { + t.Errorf("Got name %s", stmt.Name.Name) + } + if stmt.TargetModule != "OtherModule" { + t.Errorf("Got TargetModule %q", stmt.TargetModule) + } +} diff --git a/mdl/visitor/visitor_navigation_test.go b/mdl/visitor/visitor_navigation_test.go new file mode 100644 index 00000000..af1bf127 --- /dev/null +++ b/mdl/visitor/visitor_navigation_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateNavigation(t *testing.T) { + input := `CREATE NAVIGATION Responsive + HOME PAGE MyModule.HomePage + LOGIN PAGE MyModule.LoginPage + MENU ( + MENU ITEM 'Dashboard' PAGE MyModule.Dashboard; + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterNavigationStmt) + if !ok { + t.Fatalf("Expected AlterNavigationStmt, got %T", prog.Statements[0]) + } + if stmt.ProfileName != "Responsive" { + t.Errorf("Got ProfileName %q", stmt.ProfileName) + } + if len(stmt.HomePages) != 1 { + t.Fatalf("Expected 1 home page, got %d", len(stmt.HomePages)) + } + if !stmt.HomePages[0].IsPage { + t.Error("Expected IsPage true") + } + if stmt.HomePages[0].Target.Name != "HomePage" { + t.Errorf("Got target %s", stmt.HomePages[0].Target.Name) + } + if stmt.LoginPage == nil || stmt.LoginPage.Name != "LoginPage" { + t.Error("LoginPage mismatch") + } + if !stmt.HasMenuBlock { + t.Error("Expected HasMenuBlock true") + } + if len(stmt.MenuItems) != 1 { + t.Fatalf("Expected 1 menu item, got %d", len(stmt.MenuItems)) + } + if stmt.MenuItems[0].Caption != "Dashboard" { + t.Errorf("Got caption %q", stmt.MenuItems[0].Caption) + } +} diff --git a/mdl/visitor/visitor_odata_test.go b/mdl/visitor/visitor_odata_test.go new file mode 100644 index 00000000..8b92e28d --- /dev/null +++ b/mdl/visitor/visitor_odata_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateODataClient_Basic(t *testing.T) { + input := `CREATE ODATA CLIENT MyModule.PetStore ( + Version: '1.0', + ODataVersion: OData4, + MetadataUrl: 'https://petstore.example.com/odata/$metadata' + );` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + if len(prog.Statements) != 1 { + t.Fatalf("Expected 1 statement, got %d", len(prog.Statements)) + } + stmt, ok := prog.Statements[0].(*ast.CreateODataClientStmt) + if !ok { + t.Fatalf("Expected CreateODataClientStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Module != "MyModule" || stmt.Name.Name != "PetStore" { + t.Errorf("Expected MyModule.PetStore, got %s.%s", stmt.Name.Module, stmt.Name.Name) + } + if stmt.MetadataUrl != "https://petstore.example.com/odata/$metadata" { + t.Errorf("Got MetadataUrl %q", stmt.MetadataUrl) + } + if stmt.Version != "1.0" { + t.Errorf("Got Version %q", stmt.Version) + } +} + +func TestCreateODataService(t *testing.T) { + input := `CREATE ODATA SERVICE MyModule.ProductAPI ( + Path: '/odata/v1/products', + Version: '1.0.0', + ODataVersion: OData4 + ) AUTHENTICATION Basic, Session + { + PUBLISH ENTITY MyModule.Product AS 'Products' + (ReadMode: 'FromDatabase') + EXPOSE (Name, Price); + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateODataServiceStmt) + if !ok { + t.Fatalf("Expected CreateODataServiceStmt, got %T", prog.Statements[0]) + } + if stmt.Path != "/odata/v1/products" { + t.Errorf("Got Path %q", stmt.Path) + } + if len(stmt.AuthenticationTypes) != 2 { + t.Fatalf("Expected 2 auth types, got %d", len(stmt.AuthenticationTypes)) + } + if len(stmt.Entities) != 1 { + t.Fatalf("Expected 1 entity, got %d", len(stmt.Entities)) + } + if stmt.Entities[0].ExposedName != "Products" { + t.Errorf("Got ExposedName %q", stmt.Entities[0].ExposedName) + } + if len(stmt.Entities[0].Members) != 2 { + t.Errorf("Expected 2 members, got %d", len(stmt.Entities[0].Members)) + } +} + +func TestCreateExternalEntity(t *testing.T) { + input := `CREATE EXTERNAL ENTITY MyModule.RemoteProduct FROM ODATA CLIENT MyModule.PetStore + (EntitySet: 'Products', RemoteName: 'Product', Countable: true) + (Name: String(200), Price: Decimal);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateExternalEntityStmt) + if !ok { + t.Fatalf("Expected CreateExternalEntityStmt, got %T", prog.Statements[0]) + } + if stmt.ServiceRef.Name != "PetStore" { + t.Errorf("Got ServiceRef %s", stmt.ServiceRef.Name) + } + if stmt.EntitySet != "Products" { + t.Errorf("Got EntitySet %q", stmt.EntitySet) + } + if !stmt.Countable { + t.Error("Expected Countable true") + } + if len(stmt.Attributes) != 2 { + t.Errorf("Expected 2 attributes, got %d", len(stmt.Attributes)) + } +} + +func TestCreateExternalEntities(t *testing.T) { + input := `CREATE EXTERNAL ENTITIES FROM MyModule.PetStore INTO Integration ENTITIES (Product, Category);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateExternalEntitiesStmt) + if !ok { + t.Fatalf("Expected CreateExternalEntitiesStmt, got %T", prog.Statements[0]) + } + if stmt.ServiceRef.Name != "PetStore" { + t.Errorf("Got ServiceRef %s", stmt.ServiceRef.Name) + } + if stmt.TargetModule != "Integration" { + t.Errorf("Got TargetModule %q", stmt.TargetModule) + } + if len(stmt.EntityNames) != 2 { + t.Fatalf("Expected 2 entity names, got %d", len(stmt.EntityNames)) + } + if stmt.EntityNames[0] != "Product" || stmt.EntityNames[1] != "Category" { + t.Errorf("Got %v", stmt.EntityNames) + } +} diff --git a/mdl/visitor/visitor_rest_test.go b/mdl/visitor/visitor_rest_test.go new file mode 100644 index 00000000..781b368f --- /dev/null +++ b/mdl/visitor/visitor_rest_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateRestClient_Basic(t *testing.T) { + input := `CREATE REST CLIENT MyModule.PetAPI ( + BaseUrl: 'https://api.example.com/v1' + ) { + OPERATION GetPets { + Method: GET, + Path: '/pets', + Response: JSON AS $PetList + } + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + if len(prog.Statements) != 1 { + t.Fatalf("Expected 1 statement, got %d", len(prog.Statements)) + } + stmt, ok := prog.Statements[0].(*ast.CreateRestClientStmt) + if !ok { + t.Fatalf("Expected CreateRestClientStmt, got %T", prog.Statements[0]) + } + if stmt.BaseUrl != "https://api.example.com/v1" { + t.Errorf("Got BaseUrl %q", stmt.BaseUrl) + } + if stmt.Authentication != nil { + t.Error("Expected nil Authentication") + } + if len(stmt.Operations) != 1 { + t.Fatalf("Expected 1 operation, got %d", len(stmt.Operations)) + } + op := stmt.Operations[0] + if op.Name != "GetPets" { + t.Errorf("Got Name %q", op.Name) + } + if op.Method != "GET" { + t.Errorf("Got Method %q", op.Method) + } +} + +func TestCreatePublishedRestService(t *testing.T) { + input := `CREATE PUBLISHED REST SERVICE MyModule.OrderAPI ( + Path: '/api/v1', + Version: '1.0.0', + ServiceName: 'Orders' + ) { + RESOURCE 'orders' { + GET '/{id}' MICROFLOW MyModule.GetOrder; + POST '/' MICROFLOW MyModule.CreateOrder; + } + };` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreatePublishedRestServiceStmt) + if !ok { + t.Fatalf("Expected CreatePublishedRestServiceStmt, got %T", prog.Statements[0]) + } + if stmt.Path != "/api/v1" { + t.Errorf("Got Path %q", stmt.Path) + } + if stmt.ServiceName != "Orders" { + t.Errorf("Got ServiceName %q", stmt.ServiceName) + } + if len(stmt.Resources) != 1 { + t.Fatalf("Expected 1 resource, got %d", len(stmt.Resources)) + } + if stmt.Resources[0].Name != "orders" { + t.Errorf("Got resource name %q", stmt.Resources[0].Name) + } + if len(stmt.Resources[0].Operations) != 2 { + t.Errorf("Expected 2 operations, got %d", len(stmt.Resources[0].Operations)) + } +} diff --git a/mdl/visitor/visitor_security_test.go b/mdl/visitor/visitor_security_test.go new file mode 100644 index 00000000..59ce6f9a --- /dev/null +++ b/mdl/visitor/visitor_security_test.go @@ -0,0 +1,609 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestCreateModuleRole(t *testing.T) { + input := `CREATE MODULE ROLE MyModule.Editor DESCRIPTION 'Can edit content';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + if len(prog.Statements) != 1 { + t.Fatalf("Expected 1 statement, got %d", len(prog.Statements)) + } + stmt, ok := prog.Statements[0].(*ast.CreateModuleRoleStmt) + if !ok { + t.Fatalf("Expected CreateModuleRoleStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Module != "MyModule" || stmt.Name.Name != "Editor" { + t.Errorf("Expected MyModule.Editor, got %s.%s", stmt.Name.Module, stmt.Name.Name) + } + if stmt.Description != "Can edit content" { + t.Errorf("Expected description 'Can edit content', got %q", stmt.Description) + } +} + +func TestCreateModuleRole_NoDescription(t *testing.T) { + input := `CREATE MODULE ROLE MyModule.Viewer;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.CreateModuleRoleStmt) + if stmt.Description != "" { + t.Errorf("Expected empty description, got %q", stmt.Description) + } +} + +func TestDropModuleRole(t *testing.T) { + input := `DROP MODULE ROLE MyModule.Editor;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.DropModuleRoleStmt) + if !ok { + t.Fatalf("Expected DropModuleRoleStmt, got %T", prog.Statements[0]) + } + if stmt.Name.Module != "MyModule" || stmt.Name.Name != "Editor" { + t.Errorf("Expected MyModule.Editor, got %s.%s", stmt.Name.Module, stmt.Name.Name) + } +} + +func TestCreateUserRole(t *testing.T) { + input := `CREATE USER ROLE Administrator (MyModule.Admin, OtherModule.FullAccess) MANAGE ALL ROLES;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateUserRoleStmt) + if !ok { + t.Fatalf("Expected CreateUserRoleStmt, got %T", prog.Statements[0]) + } + if stmt.Name != "Administrator" { + t.Errorf("Expected Administrator, got %s", stmt.Name) + } + if len(stmt.ModuleRoles) != 2 { + t.Fatalf("Expected 2 module roles, got %d", len(stmt.ModuleRoles)) + } + if stmt.ModuleRoles[0].Module != "MyModule" || stmt.ModuleRoles[0].Name != "Admin" { + t.Errorf("Expected MyModule.Admin, got %s.%s", stmt.ModuleRoles[0].Module, stmt.ModuleRoles[0].Name) + } + if !stmt.ManageAllRoles { + t.Error("Expected ManageAllRoles true") + } + if stmt.CreateOrModify { + t.Error("Expected CreateOrModify false") + } +} + +func TestCreateUserRole_OrModify(t *testing.T) { + input := `CREATE OR MODIFY USER ROLE Editor (MyModule.Editor);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.CreateUserRoleStmt) + if !stmt.CreateOrModify { + t.Error("Expected CreateOrModify true") + } + if stmt.ManageAllRoles { + t.Error("Expected ManageAllRoles false") + } +} + +func TestAlterUserRole_Add(t *testing.T) { + input := `ALTER USER ROLE Administrator ADD MODULE ROLES (NewModule.Admin);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterUserRoleStmt) + if !ok { + t.Fatalf("Expected AlterUserRoleStmt, got %T", prog.Statements[0]) + } + if stmt.Name != "Administrator" { + t.Errorf("Expected Administrator, got %s", stmt.Name) + } + if !stmt.Add { + t.Error("Expected Add true") + } + if len(stmt.ModuleRoles) != 1 { + t.Fatalf("Expected 1 module role, got %d", len(stmt.ModuleRoles)) + } +} + +func TestAlterUserRole_Remove(t *testing.T) { + input := `ALTER USER ROLE Administrator REMOVE MODULE ROLES (OldModule.Legacy);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.AlterUserRoleStmt) + if stmt.Add { + t.Error("Expected Add false") + } +} + +func TestDropUserRole(t *testing.T) { + input := `DROP USER ROLE Administrator;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.DropUserRoleStmt) + if !ok { + t.Fatalf("Expected DropUserRoleStmt, got %T", prog.Statements[0]) + } + if stmt.Name != "Administrator" { + t.Errorf("Expected Administrator, got %s", stmt.Name) + } +} + +// Table-driven tests for grant/revoke statements — these follow repetitive patterns. + +func TestGrantEntityAccess(t *testing.T) { + input := `GRANT MyModule.Admin ON MyModule.Customer (CREATE, DELETE, READ *, WRITE (Name, Price)) WHERE '[Active = true]';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantEntityAccessStmt) + if !ok { + t.Fatalf("Expected GrantEntityAccessStmt, got %T", prog.Statements[0]) + } + if len(stmt.Roles) != 1 { + t.Fatalf("Expected 1 role, got %d", len(stmt.Roles)) + } + if stmt.Roles[0].Module != "MyModule" || stmt.Roles[0].Name != "Admin" { + t.Errorf("Expected MyModule.Admin role, got %s.%s", stmt.Roles[0].Module, stmt.Roles[0].Name) + } + if stmt.Entity.Module != "MyModule" || stmt.Entity.Name != "Customer" { + t.Errorf("Expected MyModule.Customer entity, got %s.%s", stmt.Entity.Module, stmt.Entity.Name) + } + if len(stmt.Rights) != 4 { + t.Fatalf("Expected 4 rights, got %d", len(stmt.Rights)) + } + if stmt.Rights[0].Type != ast.EntityAccessCreate { + t.Errorf("Expected CREATE, got %v", stmt.Rights[0].Type) + } + if stmt.Rights[1].Type != ast.EntityAccessDelete { + t.Errorf("Expected DELETE, got %v", stmt.Rights[1].Type) + } + if stmt.Rights[2].Type != ast.EntityAccessReadAll { + t.Errorf("Expected READ *, got %v", stmt.Rights[2].Type) + } + if stmt.Rights[3].Type != ast.EntityAccessWriteMembers { + t.Errorf("Expected WRITE members, got %v", stmt.Rights[3].Type) + } + if len(stmt.Rights[3].Members) != 2 { + t.Fatalf("Expected 2 write members, got %d", len(stmt.Rights[3].Members)) + } + if stmt.Rights[3].Members[0] != "Name" || stmt.Rights[3].Members[1] != "Price" { + t.Errorf("Expected [Name, Price], got %v", stmt.Rights[3].Members) + } + if stmt.XPathConstraint != "[Active = true]" { + t.Errorf("Expected '[Active = true]', got %q", stmt.XPathConstraint) + } +} + +func TestGrantEntityAccess_MultipleRoles(t *testing.T) { + input := `GRANT MyModule.Admin, MyModule.Editor ON MyModule.Customer (READ *);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.GrantEntityAccessStmt) + if len(stmt.Roles) != 2 { + t.Fatalf("Expected 2 roles, got %d", len(stmt.Roles)) + } + if stmt.XPathConstraint != "" { + t.Errorf("Expected empty XPath, got %q", stmt.XPathConstraint) + } +} + +func TestRevokeEntityAccess_Full(t *testing.T) { + input := `REVOKE MyModule.Admin ON MyModule.Customer;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokeEntityAccessStmt) + if !ok { + t.Fatalf("Expected RevokeEntityAccessStmt, got %T", prog.Statements[0]) + } + if len(stmt.Rights) != 0 { + t.Errorf("Expected 0 rights for full revoke, got %d", len(stmt.Rights)) + } +} + +func TestRevokeEntityAccess_Partial(t *testing.T) { + input := `REVOKE MyModule.Admin ON MyModule.Customer (WRITE *);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.RevokeEntityAccessStmt) + if len(stmt.Rights) != 1 { + t.Fatalf("Expected 1 right, got %d", len(stmt.Rights)) + } + if stmt.Rights[0].Type != ast.EntityAccessWriteAll { + t.Errorf("Expected WRITE *, got %v", stmt.Rights[0].Type) + } +} + +type grantRevokeTest struct { + name string + input string + stmtType string +} + +func TestGrantRevokeMicroflow(t *testing.T) { + t.Run("grant", func(t *testing.T) { + input := `GRANT EXECUTE ON MICROFLOW MyModule.ProcessOrder TO MyModule.Admin;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantMicroflowAccessStmt) + if !ok { + t.Fatalf("Expected GrantMicroflowAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Microflow.Module != "MyModule" || stmt.Microflow.Name != "ProcessOrder" { + t.Errorf("Expected MyModule.ProcessOrder, got %s.%s", stmt.Microflow.Module, stmt.Microflow.Name) + } + if len(stmt.Roles) != 1 || stmt.Roles[0].Name != "Admin" { + t.Errorf("Expected 1 role (Admin), got %v", stmt.Roles) + } + }) + + t.Run("revoke", func(t *testing.T) { + input := `REVOKE EXECUTE ON MICROFLOW MyModule.ProcessOrder FROM MyModule.Admin;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokeMicroflowAccessStmt) + if !ok { + t.Fatalf("Expected RevokeMicroflowAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Microflow.Name != "ProcessOrder" { + t.Errorf("Expected ProcessOrder, got %s", stmt.Microflow.Name) + } + }) +} + +func TestGrantRevokePage(t *testing.T) { + t.Run("grant", func(t *testing.T) { + input := `GRANT VIEW ON PAGE MyModule.HomePage TO MyModule.User;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantPageAccessStmt) + if !ok { + t.Fatalf("Expected GrantPageAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Page.Name != "HomePage" { + t.Errorf("Expected HomePage, got %s", stmt.Page.Name) + } + if len(stmt.Roles) != 1 || stmt.Roles[0].Name != "User" { + t.Errorf("Expected 1 role (User), got %v", stmt.Roles) + } + }) + + t.Run("revoke", func(t *testing.T) { + input := `REVOKE VIEW ON PAGE MyModule.AdminPage FROM MyModule.User;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokePageAccessStmt) + if !ok { + t.Fatalf("Expected RevokePageAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Page.Name != "AdminPage" { + t.Errorf("Expected AdminPage, got %s", stmt.Page.Name) + } + }) +} + +func TestGrantRevokeWorkflow(t *testing.T) { + t.Run("grant", func(t *testing.T) { + input := `GRANT EXECUTE ON WORKFLOW MyModule.ApprovalWF TO MyModule.Manager;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantWorkflowAccessStmt) + if !ok { + t.Fatalf("Expected GrantWorkflowAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Workflow.Name != "ApprovalWF" { + t.Errorf("Expected ApprovalWF, got %s", stmt.Workflow.Name) + } + }) + + t.Run("revoke", func(t *testing.T) { + input := `REVOKE EXECUTE ON WORKFLOW MyModule.ApprovalWF FROM MyModule.Manager;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokeWorkflowAccessStmt) + if !ok { + t.Fatalf("Expected RevokeWorkflowAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Workflow.Name != "ApprovalWF" { + t.Errorf("Expected ApprovalWF, got %s", stmt.Workflow.Name) + } + }) +} + +func TestGrantRevokeODataService(t *testing.T) { + t.Run("grant", func(t *testing.T) { + input := `GRANT ACCESS ON ODATA SERVICE MyModule.OrderAPI TO MyModule.APIUser;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantODataServiceAccessStmt) + if !ok { + t.Fatalf("Expected GrantODataServiceAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Service.Name != "OrderAPI" { + t.Errorf("Expected OrderAPI, got %s", stmt.Service.Name) + } + }) + + t.Run("revoke", func(t *testing.T) { + input := `REVOKE ACCESS ON ODATA SERVICE MyModule.OrderAPI FROM MyModule.APIUser;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokeODataServiceAccessStmt) + if !ok { + t.Fatalf("Expected RevokeODataServiceAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Service.Name != "OrderAPI" { + t.Errorf("Expected OrderAPI, got %s", stmt.Service.Name) + } + }) +} + +func TestGrantRevokePublishedRestService(t *testing.T) { + t.Run("grant", func(t *testing.T) { + input := `GRANT ACCESS ON PUBLISHED REST SERVICE MyModule.RestAPI TO MyModule.APIUser;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.GrantPublishedRestServiceAccessStmt) + if !ok { + t.Fatalf("Expected GrantPublishedRestServiceAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Service.Name != "RestAPI" { + t.Errorf("Expected RestAPI, got %s", stmt.Service.Name) + } + }) + + t.Run("revoke", func(t *testing.T) { + input := `REVOKE ACCESS ON PUBLISHED REST SERVICE MyModule.RestAPI FROM MyModule.APIUser;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.RevokePublishedRestServiceAccessStmt) + if !ok { + t.Fatalf("Expected RevokePublishedRestServiceAccessStmt, got %T", prog.Statements[0]) + } + if stmt.Service.Name != "RestAPI" { + t.Errorf("Expected RestAPI, got %s", stmt.Service.Name) + } + }) +} + +func TestAlterProjectSecurity_Level(t *testing.T) { + input := `ALTER PROJECT SECURITY LEVEL PRODUCTION;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterProjectSecurityStmt) + if !ok { + t.Fatalf("Expected AlterProjectSecurityStmt, got %T", prog.Statements[0]) + } + if stmt.SecurityLevel != "Production" { + t.Errorf("Expected Production, got %q", stmt.SecurityLevel) + } + if stmt.DemoUsersEnabled != nil { + t.Error("Expected nil DemoUsersEnabled") + } +} + +func TestAlterProjectSecurity_DemoUsers(t *testing.T) { + input := `ALTER PROJECT SECURITY DEMO USERS ON;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.AlterProjectSecurityStmt) + if stmt.SecurityLevel != "" { + t.Errorf("Expected empty security level, got %q", stmt.SecurityLevel) + } + if stmt.DemoUsersEnabled == nil || !*stmt.DemoUsersEnabled { + t.Error("Expected DemoUsersEnabled true") + } +} + +func TestCreateDemoUser(t *testing.T) { + input := `CREATE DEMO USER 'demo_admin' PASSWORD '1' ENTITY MyModule.Account (Administrator);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateDemoUserStmt) + if !ok { + t.Fatalf("Expected CreateDemoUserStmt, got %T", prog.Statements[0]) + } + if stmt.UserName != "demo_admin" { + t.Errorf("Expected demo_admin, got %q", stmt.UserName) + } + if stmt.Password != "1" { + t.Errorf("Expected '1', got %q", stmt.Password) + } + if stmt.Entity != "MyModule.Account" { + t.Errorf("Expected MyModule.Account, got %q", stmt.Entity) + } + if len(stmt.UserRoles) != 1 || stmt.UserRoles[0] != "Administrator" { + t.Errorf("Expected [Administrator], got %v", stmt.UserRoles) + } +} + +func TestCreateDemoUser_OrModify(t *testing.T) { + input := `CREATE OR MODIFY DEMO USER 'demo_admin' PASSWORD '1' (Administrator);` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.CreateDemoUserStmt) + if !stmt.CreateOrModify { + t.Error("Expected CreateOrModify true") + } +} + +func TestDropDemoUser(t *testing.T) { + input := `DROP DEMO USER 'demo_admin';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.DropDemoUserStmt) + if !ok { + t.Fatalf("Expected DropDemoUserStmt, got %T", prog.Statements[0]) + } + if stmt.UserName != "demo_admin" { + t.Errorf("Expected demo_admin, got %q", stmt.UserName) + } +} + +func TestUpdateSecurity(t *testing.T) { + input := `UPDATE SECURITY;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.UpdateSecurityStmt) + if !ok { + t.Fatalf("Expected UpdateSecurityStmt, got %T", prog.Statements[0]) + } + if stmt.Module != "" { + t.Errorf("Expected empty module, got %q", stmt.Module) + } +} + +func TestUpdateSecurity_InModule(t *testing.T) { + input := `UPDATE SECURITY IN MyModule;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.UpdateSecurityStmt) + if stmt.Module != "MyModule" { + t.Errorf("Expected MyModule, got %q", stmt.Module) + } +} diff --git a/mdl/visitor/visitor_settings_test.go b/mdl/visitor/visitor_settings_test.go new file mode 100644 index 00000000..0504796f --- /dev/null +++ b/mdl/visitor/visitor_settings_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestAlterSettings_Model(t *testing.T) { + input := `ALTER SETTINGS MODEL DefaultLanguage = 'en_US';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.AlterSettingsStmt) + if !ok { + t.Fatalf("Expected AlterSettingsStmt, got %T", prog.Statements[0]) + } + if stmt.Section != "MODEL" { + t.Errorf("Got Section %q", stmt.Section) + } + if stmt.Properties["DefaultLanguage"] != "en_US" { + t.Errorf("Got %v", stmt.Properties["DefaultLanguage"]) + } +} + +func TestAlterSettings_Constant(t *testing.T) { + input := `ALTER SETTINGS CONSTANT 'MyModule.APIEndpoint' VALUE 'https://prod.example.com';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.AlterSettingsStmt) + if stmt.ConstantId != "MyModule.APIEndpoint" { + t.Errorf("Got ConstantId %q", stmt.ConstantId) + } + if stmt.Value != "https://prod.example.com" { + t.Errorf("Got Value %q", stmt.Value) + } +} + +func TestAlterSettings_DropConstant(t *testing.T) { + input := `ALTER SETTINGS DROP CONSTANT 'MyModule.OldConst';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.AlterSettingsStmt) + if !stmt.DropConstant { + t.Error("Expected DropConstant true") + } +} + +func TestCreateConfiguration(t *testing.T) { + input := `CREATE CONFIGURATION 'Acceptance' DatabaseHost = 'db.acc.example.com', DatabaseName = 'myapp_acc';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.CreateConfigurationStmt) + if !ok { + t.Fatalf("Expected CreateConfigurationStmt, got %T", prog.Statements[0]) + } + if stmt.Name != "Acceptance" { + t.Errorf("Got Name %q", stmt.Name) + } + if stmt.Properties["DatabaseHost"] != "db.acc.example.com" { + t.Errorf("Got %v", stmt.Properties["DatabaseHost"]) + } +} diff --git a/mdl/visitor/visitor_widgets_test.go b/mdl/visitor/visitor_widgets_test.go new file mode 100644 index 00000000..7ed1c9d2 --- /dev/null +++ b/mdl/visitor/visitor_widgets_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestUpdateWidgets(t *testing.T) { + input := `UPDATE WIDGETS SET 'showLabel' = false WHERE WidgetType LIKE '%textbox%';` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt, ok := prog.Statements[0].(*ast.UpdateWidgetsStmt) + if !ok { + t.Fatalf("Expected UpdateWidgetsStmt, got %T", prog.Statements[0]) + } + if len(stmt.Assignments) != 1 { + t.Fatalf("Expected 1 assignment, got %d", len(stmt.Assignments)) + } + if stmt.Assignments[0].PropertyPath != "showLabel" { + t.Errorf("Got PropertyPath %q", stmt.Assignments[0].PropertyPath) + } + if len(stmt.Filters) != 1 { + t.Fatalf("Expected 1 filter, got %d", len(stmt.Filters)) + } + if stmt.DryRun { + t.Error("Expected DryRun false") + } +} + +func TestUpdateWidgets_DryRun(t *testing.T) { + input := `UPDATE WIDGETS SET 'editable' = true WHERE WidgetType = 'TextBox' IN MyModule DRY RUN;` + prog, errs := Build(input) + if len(errs) > 0 { + for _, e := range errs { + t.Errorf("Parse error: %v", e) + } + return + } + stmt := prog.Statements[0].(*ast.UpdateWidgetsStmt) + if !stmt.DryRun { + t.Error("Expected DryRun true") + } + if stmt.InModule != "MyModule" { + t.Errorf("Got InModule %q", stmt.InModule) + } +} From 8a43c788bfd16af0d638ff5610c6eb677734a67f Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 13:48:38 +0200 Subject: [PATCH 09/11] Address Copilot review: precise assertions, guarded type checks, fix comment - visitor_lint_test.go: assert Target.Name and Target.Module separately to prevent false positives - visitor_widgets_test.go: use guarded type assertion with len check instead of direct assertion that would panic - mock_page_mutator.go: clarify doc comment that ContainerType defaults to ContainerPage, not zero value --- mdl/backend/mock/mock_page_mutator.go | 3 ++- mdl/visitor/visitor_lint_test.go | 8 +++++--- mdl/visitor/visitor_widgets_test.go | 8 +++++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/mdl/backend/mock/mock_page_mutator.go b/mdl/backend/mock/mock_page_mutator.go index 868a97e8..ddcd0b2d 100644 --- a/mdl/backend/mock/mock_page_mutator.go +++ b/mdl/backend/mock/mock_page_mutator.go @@ -12,7 +12,8 @@ var _ backend.PageMutator = (*MockPageMutator)(nil) // MockPageMutator implements backend.PageMutator. Every interface method is // backed by a public function field. If the field is nil the method returns -// zero values / nil error (never panics). +// nil error (never panics). ContainerType defaults to ContainerPage when unset; +// all other methods return zero values. type MockPageMutator struct { ContainerTypeFunc func() backend.ContainerKind SetWidgetPropertyFunc func(widgetRef string, prop string, value any) error diff --git a/mdl/visitor/visitor_lint_test.go b/mdl/visitor/visitor_lint_test.go index cd171f8a..00033752 100644 --- a/mdl/visitor/visitor_lint_test.go +++ b/mdl/visitor/visitor_lint_test.go @@ -45,9 +45,11 @@ func TestLint_ModuleOnly(t *testing.T) { if stmt.Target == nil { t.Fatal("Expected non-nil Target") } - // For "LINT MyModule.*", qualifiedName is just "MyModule" (no module prefix) - if stmt.Target.Name != "MyModule" && stmt.Target.Module != "MyModule" { - t.Errorf("Target mismatch: got Module=%q Name=%q", stmt.Target.Module, stmt.Target.Name) + if stmt.Target.Name != "MyModule" { + t.Errorf("Expected Target.Name %q, got %q", "MyModule", stmt.Target.Name) + } + if stmt.Target.Module != "" { + t.Errorf("Expected Target.Module %q, got %q", "", stmt.Target.Module) } } diff --git a/mdl/visitor/visitor_widgets_test.go b/mdl/visitor/visitor_widgets_test.go index 7ed1c9d2..47b2a1e7 100644 --- a/mdl/visitor/visitor_widgets_test.go +++ b/mdl/visitor/visitor_widgets_test.go @@ -44,7 +44,13 @@ func TestUpdateWidgets_DryRun(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.UpdateWidgetsStmt) + if len(prog.Statements) == 0 { + t.Fatal("Expected at least one statement") + } + stmt, ok := prog.Statements[0].(*ast.UpdateWidgetsStmt) + if !ok { + t.Fatalf("Expected *ast.UpdateWidgetsStmt, got %T", prog.Statements[0]) + } if !stmt.DryRun { t.Error("Expected DryRun true") } From 94891e8798bd2a0bae31d26ada740ca812d59b63 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 14:00:35 +0200 Subject: [PATCH 10/11] Remove unused grantRevokeTest struct per review --- mdl/visitor/visitor_security_test.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/mdl/visitor/visitor_security_test.go b/mdl/visitor/visitor_security_test.go index 59ce6f9a..fd346e8a 100644 --- a/mdl/visitor/visitor_security_test.go +++ b/mdl/visitor/visitor_security_test.go @@ -274,12 +274,6 @@ func TestRevokeEntityAccess_Partial(t *testing.T) { } } -type grantRevokeTest struct { - name string - input string - stmtType string -} - func TestGrantRevokeMicroflow(t *testing.T) { t.Run("grant", func(t *testing.T) { input := `GRANT EXECUTE ON MICROFLOW MyModule.ProcessOrder TO MyModule.Admin;` From a0e2a1c2148ebe9d945f84b2846cf28e80cbb4a0 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 15:06:19 +0200 Subject: [PATCH 11/11] Guard all type assertions in visitor tests with ok check Replace 13 unguarded prog.Statements[0].(*ast.XxxStmt) direct assertions with guarded two-value form + t.Fatalf across visitor_settings_test.go, visitor_security_test.go, and visitor_lint_test.go to prevent panics on unexpected types. --- mdl/visitor/visitor_lint_test.go | 15 ++++++++--- mdl/visitor/visitor_security_test.go | 40 ++++++++++++++++++++++------ mdl/visitor/visitor_settings_test.go | 10 +++++-- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/mdl/visitor/visitor_lint_test.go b/mdl/visitor/visitor_lint_test.go index 00033752..6a2e1cab 100644 --- a/mdl/visitor/visitor_lint_test.go +++ b/mdl/visitor/visitor_lint_test.go @@ -38,7 +38,10 @@ func TestLint_ModuleOnly(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.LintStmt) + stmt, ok := prog.Statements[0].(*ast.LintStmt) + if !ok { + t.Fatalf("Expected LintStmt, got %T", prog.Statements[0]) + } if !stmt.ModuleOnly { t.Error("Expected ModuleOnly true") } @@ -62,7 +65,10 @@ func TestLint_WithFormat(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.LintStmt) + stmt, ok := prog.Statements[0].(*ast.LintStmt) + if !ok { + t.Fatalf("Expected LintStmt, got %T", prog.Statements[0]) + } if stmt.Format != "json" { t.Errorf("Expected json format, got %q", stmt.Format) } @@ -77,7 +83,10 @@ func TestShowLintRules(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.LintStmt) + stmt, ok := prog.Statements[0].(*ast.LintStmt) + if !ok { + t.Fatalf("Expected LintStmt, got %T", prog.Statements[0]) + } if !stmt.ShowRules { t.Error("Expected ShowRules true") } diff --git a/mdl/visitor/visitor_security_test.go b/mdl/visitor/visitor_security_test.go index fd346e8a..0179bdfe 100644 --- a/mdl/visitor/visitor_security_test.go +++ b/mdl/visitor/visitor_security_test.go @@ -41,7 +41,10 @@ func TestCreateModuleRole_NoDescription(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.CreateModuleRoleStmt) + stmt, ok := prog.Statements[0].(*ast.CreateModuleRoleStmt) + if !ok { + t.Fatalf("Expected CreateModuleRoleStmt, got %T", prog.Statements[0]) + } if stmt.Description != "" { t.Errorf("Expected empty description, got %q", stmt.Description) } @@ -104,7 +107,10 @@ func TestCreateUserRole_OrModify(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.CreateUserRoleStmt) + stmt, ok := prog.Statements[0].(*ast.CreateUserRoleStmt) + if !ok { + t.Fatalf("Expected CreateUserRoleStmt, got %T", prog.Statements[0]) + } if !stmt.CreateOrModify { t.Error("Expected CreateOrModify true") } @@ -146,7 +152,10 @@ func TestAlterUserRole_Remove(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.AlterUserRoleStmt) + stmt, ok := prog.Statements[0].(*ast.AlterUserRoleStmt) + if !ok { + t.Fatalf("Expected AlterUserRoleStmt, got %T", prog.Statements[0]) + } if stmt.Add { t.Error("Expected Add false") } @@ -229,7 +238,10 @@ func TestGrantEntityAccess_MultipleRoles(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.GrantEntityAccessStmt) + stmt, ok := prog.Statements[0].(*ast.GrantEntityAccessStmt) + if !ok { + t.Fatalf("Expected GrantEntityAccessStmt, got %T", prog.Statements[0]) + } if len(stmt.Roles) != 2 { t.Fatalf("Expected 2 roles, got %d", len(stmt.Roles)) } @@ -265,7 +277,10 @@ func TestRevokeEntityAccess_Partial(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.RevokeEntityAccessStmt) + stmt, ok := prog.Statements[0].(*ast.RevokeEntityAccessStmt) + if !ok { + t.Fatalf("Expected RevokeEntityAccessStmt, got %T", prog.Statements[0]) + } if len(stmt.Rights) != 1 { t.Fatalf("Expected 1 right, got %d", len(stmt.Rights)) } @@ -500,7 +515,10 @@ func TestAlterProjectSecurity_DemoUsers(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.AlterProjectSecurityStmt) + stmt, ok := prog.Statements[0].(*ast.AlterProjectSecurityStmt) + if !ok { + t.Fatalf("Expected AlterProjectSecurityStmt, got %T", prog.Statements[0]) + } if stmt.SecurityLevel != "" { t.Errorf("Expected empty security level, got %q", stmt.SecurityLevel) } @@ -545,7 +563,10 @@ func TestCreateDemoUser_OrModify(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.CreateDemoUserStmt) + stmt, ok := prog.Statements[0].(*ast.CreateDemoUserStmt) + if !ok { + t.Fatalf("Expected CreateDemoUserStmt, got %T", prog.Statements[0]) + } if !stmt.CreateOrModify { t.Error("Expected CreateOrModify true") } @@ -596,7 +617,10 @@ func TestUpdateSecurity_InModule(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.UpdateSecurityStmt) + stmt, ok := prog.Statements[0].(*ast.UpdateSecurityStmt) + if !ok { + t.Fatalf("Expected UpdateSecurityStmt, got %T", prog.Statements[0]) + } if stmt.Module != "MyModule" { t.Errorf("Expected MyModule, got %q", stmt.Module) } diff --git a/mdl/visitor/visitor_settings_test.go b/mdl/visitor/visitor_settings_test.go index 0504796f..5d3b4bf7 100644 --- a/mdl/visitor/visitor_settings_test.go +++ b/mdl/visitor/visitor_settings_test.go @@ -38,7 +38,10 @@ func TestAlterSettings_Constant(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.AlterSettingsStmt) + stmt, ok := prog.Statements[0].(*ast.AlterSettingsStmt) + if !ok { + t.Fatalf("Expected AlterSettingsStmt, got %T", prog.Statements[0]) + } if stmt.ConstantId != "MyModule.APIEndpoint" { t.Errorf("Got ConstantId %q", stmt.ConstantId) } @@ -56,7 +59,10 @@ func TestAlterSettings_DropConstant(t *testing.T) { } return } - stmt := prog.Statements[0].(*ast.AlterSettingsStmt) + stmt, ok := prog.Statements[0].(*ast.AlterSettingsStmt) + if !ok { + t.Fatalf("Expected AlterSettingsStmt, got %T", prog.Statements[0]) + } if !stmt.DropConstant { t.Error("Expected DropConstant true") }