From 9019199633089cace6a19d137c00afd8001de532 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Wed, 22 Apr 2026 13:59:28 +0200 Subject: [PATCH 1/2] test(catalog): add unit tests for builder pure helper functions Cover getMicroflowObjectType, getMicroflowActionType, getDataTypeName, countMicroflowActivities, calculateMcCabeComplexity, countNanoflowActivities, calculateNanoflowComplexity, extractLayoutRef, extractPageWidgets, extractWidgetsRecursive, extractSnippetWidgets, getBsonArrayElements, toBsonArray, extractString, extractBsonID, decodeBase64GUID, extractBinaryID, formatGUID, bytesToHex, entityAccessFromMemberRights, countWorkflowActivityTypes, countMenuItems, extractAttrName. --- mdl/catalog/builder_microflows_test.go | 318 +++++++++++++++++ mdl/catalog/builder_modules_test.go | 27 ++ mdl/catalog/builder_navigation_test.go | 76 ++++ mdl/catalog/builder_pages_test.go | 449 ++++++++++++++++++++++++ mdl/catalog/builder_permissions_test.go | 116 ++++++ mdl/catalog/builder_workflows_test.go | 149 ++++++++ 6 files changed, 1135 insertions(+) create mode 100644 mdl/catalog/builder_microflows_test.go create mode 100644 mdl/catalog/builder_modules_test.go create mode 100644 mdl/catalog/builder_navigation_test.go create mode 100644 mdl/catalog/builder_pages_test.go create mode 100644 mdl/catalog/builder_permissions_test.go create mode 100644 mdl/catalog/builder_workflows_test.go diff --git a/mdl/catalog/builder_microflows_test.go b/mdl/catalog/builder_microflows_test.go new file mode 100644 index 00000000..ba6e917c --- /dev/null +++ b/mdl/catalog/builder_microflows_test.go @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +func TestGetMicroflowObjectType(t *testing.T) { + tests := []struct { + name string + obj microflows.MicroflowObject + want string + }{ + {"ActionActivity", µflows.ActionActivity{}, "ActionActivity"}, + {"StartEvent", µflows.StartEvent{}, "StartEvent"}, + {"EndEvent", µflows.EndEvent{}, "EndEvent"}, + {"ExclusiveSplit", µflows.ExclusiveSplit{}, "ExclusiveSplit"}, + {"InheritanceSplit", µflows.InheritanceSplit{}, "InheritanceSplit"}, + {"ExclusiveMerge", µflows.ExclusiveMerge{}, "ExclusiveMerge"}, + {"LoopedActivity", µflows.LoopedActivity{}, "LoopedActivity"}, + {"Annotation", µflows.Annotation{}, "Annotation"}, + {"BreakEvent", µflows.BreakEvent{}, "BreakEvent"}, + {"ContinueEvent", µflows.ContinueEvent{}, "ContinueEvent"}, + {"ErrorEvent", µflows.ErrorEvent{}, "ErrorEvent"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getMicroflowObjectType(tt.obj); got != tt.want { + t.Errorf("getMicroflowObjectType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetMicroflowActionType(t *testing.T) { + tests := []struct { + name string + action microflows.MicroflowAction + want string + }{ + {"CreateObjectAction", µflows.CreateObjectAction{}, "CreateObjectAction"}, + {"ChangeObjectAction", µflows.ChangeObjectAction{}, "ChangeObjectAction"}, + {"RetrieveAction", µflows.RetrieveAction{}, "RetrieveAction"}, + {"MicroflowCallAction", µflows.MicroflowCallAction{}, "MicroflowCallAction"}, + {"JavaActionCallAction", µflows.JavaActionCallAction{}, "JavaActionCallAction"}, + {"ShowMessageAction", µflows.ShowMessageAction{}, "ShowMessageAction"}, + {"LogMessageAction", µflows.LogMessageAction{}, "LogMessageAction"}, + {"ValidationFeedbackAction", µflows.ValidationFeedbackAction{}, "ValidationFeedbackAction"}, + {"ChangeVariableAction", µflows.ChangeVariableAction{}, "ChangeVariableAction"}, + {"CreateVariableAction", µflows.CreateVariableAction{}, "CreateVariableAction"}, + {"AggregateListAction", µflows.AggregateListAction{}, "AggregateListAction"}, + {"ListOperationAction", µflows.ListOperationAction{}, "ListOperationAction"}, + {"CastAction", µflows.CastAction{}, "CastAction"}, + {"DownloadFileAction", µflows.DownloadFileAction{}, "DownloadFileAction"}, + {"ClosePageAction", µflows.ClosePageAction{}, "ClosePageAction"}, + {"ShowPageAction", µflows.ShowPageAction{}, "ShowPageAction"}, + {"CallExternalAction", µflows.CallExternalAction{}, "CallExternalAction"}, + {"unknown action falls to default", µflows.UnknownAction{TypeName: "CustomThing"}, "MicroflowAction"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getMicroflowActionType(tt.action); got != tt.want { + t.Errorf("getMicroflowActionType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetDataTypeName(t *testing.T) { + tests := []struct { + name string + dt microflows.DataType + want string + }{ + {"nil", nil, ""}, + {"Boolean", µflows.BooleanType{}, "Boolean"}, + {"Integer", µflows.IntegerType{}, "Integer"}, + {"Long", µflows.LongType{}, "Long"}, + {"Decimal", µflows.DecimalType{}, "Decimal"}, + {"String", µflows.StringType{}, "String"}, + {"DateTime", µflows.DateTimeType{}, "DateTime"}, + {"Date", µflows.DateType{}, "Date"}, + {"Void", µflows.VoidType{}, "Void"}, + {"Object with entity", µflows.ObjectType{EntityQualifiedName: "Module.Entity"}, "Object:Module.Entity"}, + {"List with entity", µflows.ListType{EntityQualifiedName: "Module.Entity"}, "List:Module.Entity"}, + {"Enumeration", µflows.EnumerationType{EnumerationQualifiedName: "Module.Color"}, "Enumeration:Module.Color"}, + {"Binary falls to Unknown", µflows.BinaryType{}, "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getDataTypeName(tt.dt); got != tt.want { + t.Errorf("getDataTypeName() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCountMicroflowActivities(t *testing.T) { + tests := []struct { + name string + mf *microflows.Microflow + want int + }{ + { + name: "nil object collection", + mf: µflows.Microflow{}, + want: 0, + }, + { + name: "empty objects", + mf: µflows.Microflow{ + ObjectCollection: µflows.MicroflowObjectCollection{}, + }, + want: 0, + }, + { + name: "excludes start/end/merge", + mf: µflows.Microflow{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.StartEvent{}, + µflows.ActionActivity{}, + µflows.ExclusiveSplit{}, + µflows.EndEvent{}, + µflows.ExclusiveMerge{}, + }, + }, + }, + want: 2, // ActionActivity + ExclusiveSplit + }, + { + name: "counts loops and annotations", + mf: µflows.Microflow{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.LoopedActivity{}, + µflows.Annotation{}, + µflows.ErrorEvent{}, + }, + }, + }, + want: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := countMicroflowActivities(tt.mf); got != tt.want { + t.Errorf("countMicroflowActivities() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestCalculateMcCabeComplexity(t *testing.T) { + tests := []struct { + name string + mf *microflows.Microflow + want int + }{ + { + name: "nil object collection — base complexity", + mf: µflows.Microflow{}, + want: 1, + }, + { + name: "no decision points", + mf: µflows.Microflow{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.StartEvent{}, + µflows.ActionActivity{}, + µflows.EndEvent{}, + }, + }, + }, + want: 1, + }, + { + name: "exclusive split adds 1", + mf: µflows.Microflow{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.ExclusiveSplit{}, + }, + }, + }, + want: 2, + }, + { + name: "inheritance split adds 1", + mf: µflows.Microflow{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.InheritanceSplit{}, + }, + }, + }, + want: 2, + }, + { + name: "loop adds 1 plus nested decisions", + mf: µflows.Microflow{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.LoopedActivity{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.ExclusiveSplit{}, + }, + }, + }, + }, + }, + }, + want: 3, // 1 base + 1 loop + 1 nested split + }, + { + name: "error event adds 1", + mf: µflows.Microflow{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.ErrorEvent{}, + }, + }, + }, + want: 2, + }, + { + name: "complex flow", + mf: µflows.Microflow{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.ExclusiveSplit{}, + µflows.ExclusiveSplit{}, + µflows.InheritanceSplit{}, + µflows.LoopedActivity{}, + µflows.ErrorEvent{}, + }, + }, + }, + want: 6, // 1 + 2 splits + 1 inheritance + 1 loop + 1 error + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := calculateMcCabeComplexity(tt.mf); got != tt.want { + t.Errorf("calculateMcCabeComplexity() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestCountNanoflowActivities(t *testing.T) { + tests := []struct { + name string + nf *microflows.Nanoflow + want int + }{ + { + name: "nil object collection", + nf: µflows.Nanoflow{}, + want: 0, + }, + { + name: "excludes structural elements", + nf: µflows.Nanoflow{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.StartEvent{}, + µflows.ActionActivity{}, + µflows.EndEvent{}, + µflows.ExclusiveMerge{}, + }, + }, + }, + want: 1, // only ActionActivity + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := countNanoflowActivities(tt.nf); got != tt.want { + t.Errorf("countNanoflowActivities() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestCalculateNanoflowComplexity(t *testing.T) { + nf := µflows.Nanoflow{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.ExclusiveSplit{}, + µflows.LoopedActivity{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.InheritanceSplit{}, + }, + }, + }, + }, + }, + } + + got := calculateNanoflowComplexity(nf) + want := 4 // 1 base + 1 split + 1 loop + 1 nested inheritance + if got != want { + t.Errorf("calculateNanoflowComplexity() = %d, want %d", got, want) + } +} diff --git a/mdl/catalog/builder_modules_test.go b/mdl/catalog/builder_modules_test.go new file mode 100644 index 00000000..f3947006 --- /dev/null +++ b/mdl/catalog/builder_modules_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import "testing" + +func TestExtractAttrName(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"three parts", "Module.Entity.Attribute", "Attribute"}, + {"four parts", "Module.Entity.Attribute.Sub", "Sub"}, + {"two parts", "Module.Entity", ""}, + {"single part", "Single", ""}, + {"empty string", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractAttrName(tt.input); got != tt.want { + t.Errorf("extractAttrName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/mdl/catalog/builder_navigation_test.go b/mdl/catalog/builder_navigation_test.go new file mode 100644 index 00000000..88f49de4 --- /dev/null +++ b/mdl/catalog/builder_navigation_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/types" +) + +func TestCountMenuItems(t *testing.T) { + tests := []struct { + name string + items []*types.NavMenuItem + want int + }{ + { + name: "nil items", + items: nil, + want: 0, + }, + { + name: "empty items", + items: []*types.NavMenuItem{}, + want: 0, + }, + { + name: "flat items", + items: []*types.NavMenuItem{ + {Caption: "Home"}, + {Caption: "About"}, + {Caption: "Contact"}, + }, + want: 3, + }, + { + name: "nested items", + items: []*types.NavMenuItem{ + { + Caption: "Admin", + Items: []*types.NavMenuItem{ + {Caption: "Users"}, + {Caption: "Roles"}, + }, + }, + {Caption: "Home"}, + }, + want: 4, // Admin + Users + Roles + Home + }, + { + name: "deeply nested", + items: []*types.NavMenuItem{ + { + Caption: "L1", + Items: []*types.NavMenuItem{ + { + Caption: "L2", + Items: []*types.NavMenuItem{ + {Caption: "L3"}, + }, + }, + }, + }, + }, + want: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := countMenuItems(tt.items); got != tt.want { + t.Errorf("countMenuItems() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/mdl/catalog/builder_pages_test.go b/mdl/catalog/builder_pages_test.go new file mode 100644 index 00000000..24cb62c9 --- /dev/null +++ b/mdl/catalog/builder_pages_test.go @@ -0,0 +1,449 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestExtractLayoutRef(t *testing.T) { + tests := []struct { + name string + rawData map[string]any + want string + }{ + { + name: "no FormCall", + rawData: map[string]any{}, + want: "", + }, + { + name: "string Form field", + rawData: map[string]any{ + "FormCall": map[string]any{ + "Form": "MyModule.MyLayout", + }, + }, + want: "MyModule.MyLayout", + }, + { + name: "empty Form and no Layout", + rawData: map[string]any{ + "FormCall": map[string]any{ + "Form": "", + }, + }, + want: "", + }, + { + name: "binary Layout field", + rawData: map[string]any{ + "FormCall": map[string]any{ + "Layout": primitive.Binary{ + Data: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}, + }, + }, + }, + want: "04030201-0605-0807-090a-0b0c0d0e0f10", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractLayoutRef(tt.rawData); got != tt.want { + t.Errorf("extractLayoutRef() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractPageWidgets(t *testing.T) { + t.Run("no FormCall", func(t *testing.T) { + got := extractPageWidgets(map[string]any{}, "container-1") + if got != nil { + t.Errorf("expected nil, got %v", got) + } + }) + + t.Run("no Arguments", func(t *testing.T) { + got := extractPageWidgets(map[string]any{ + "FormCall": map[string]any{}, + }, "container-1") + if got != nil { + t.Errorf("expected nil, got %v", got) + } + }) + + t.Run("extracts widgets from arguments", func(t *testing.T) { + rawData := map[string]any{ + "FormCall": map[string]any{ + "Arguments": []any{ + int32(0), // BSON type indicator + map[string]any{ + "Widgets": []any{ + int32(0), + map[string]any{ + "$ID": "widget-1", + "Name": "textBox1", + "$Type": "Forms$TextBox", + }, + }, + }, + }, + }, + } + got := extractPageWidgets(rawData, "container-1") + if len(got) != 1 { + t.Fatalf("expected 1 widget, got %d", len(got)) + } + if got[0].Name != "textBox1" { + t.Errorf("widget name = %q, want %q", got[0].Name, "textBox1") + } + if got[0].WidgetType != "Forms$TextBox" { + t.Errorf("widget type = %q, want %q", got[0].WidgetType, "Forms$TextBox") + } + }) +} + +func TestExtractWidgetsRecursive(t *testing.T) { + t.Run("simple widget", func(t *testing.T) { + w := map[string]any{ + "$ID": "w1", + "Name": "myWidget", + "$Type": "Forms$TextBox", + } + got := extractWidgetsRecursive(w) + if len(got) != 1 { + t.Fatalf("expected 1 widget, got %d", len(got)) + } + if got[0].ID != "w1" || got[0].Name != "myWidget" { + t.Errorf("unexpected widget: %+v", got[0]) + } + }) + + t.Run("skips DivContainer but includes children", func(t *testing.T) { + w := map[string]any{ + "$ID": "div1", + "Name": "divContainer", + "$Type": "Forms$DivContainer", + "Widgets": []any{ + int32(0), + map[string]any{ + "$ID": "child1", + "Name": "childWidget", + "$Type": "Forms$Button", + }, + }, + } + got := extractWidgetsRecursive(w) + if len(got) != 1 { + t.Fatalf("expected 1 widget (child only), got %d", len(got)) + } + if got[0].ID != "child1" { + t.Errorf("expected child1, got %q", got[0].ID) + } + }) + + t.Run("CustomWidget resolves WidgetId", func(t *testing.T) { + w := map[string]any{ + "$ID": "cw1", + "Name": "customWidget", + "$Type": "CustomWidgets$CustomWidget", + "Type": map[string]any{ + "WidgetId": "com.mendix.widget.MyCustom", + }, + } + got := extractWidgetsRecursive(w) + if len(got) != 1 { + t.Fatalf("expected 1 widget, got %d", len(got)) + } + if got[0].WidgetType != "com.mendix.widget.MyCustom" { + t.Errorf("WidgetType = %q, want %q", got[0].WidgetType, "com.mendix.widget.MyCustom") + } + }) + + t.Run("extracts attribute reference", func(t *testing.T) { + w := map[string]any{ + "$ID": "w1", + "Name": "inputWidget", + "$Type": "Forms$TextBox", + "AttributeRef": map[string]any{ + "Attribute": "Module.Entity.Name", + }, + } + got := extractWidgetsRecursive(w) + if len(got) != 1 { + t.Fatalf("expected 1 widget, got %d", len(got)) + } + if got[0].AttributeRef != "Module.Entity.Name" { + t.Errorf("AttributeRef = %q, want %q", got[0].AttributeRef, "Module.Entity.Name") + } + }) + + t.Run("recurses into LayoutGrid rows/columns", func(t *testing.T) { + w := map[string]any{ + "$ID": "grid1", + "Name": "layoutGrid", + "$Type": "Forms$LayoutGrid", + "Rows": []any{ + int32(0), + map[string]any{ + "Columns": []any{ + int32(0), + map[string]any{ + "Widgets": []any{ + int32(0), + map[string]any{ + "$ID": "nested1", + "Name": "nestedWidget", + "$Type": "Forms$Text", + }, + }, + }, + }, + }, + }, + } + got := extractWidgetsRecursive(w) + // grid1 itself + nested1 + if len(got) != 2 { + t.Fatalf("expected 2 widgets, got %d", len(got)) + } + }) +} + +func TestExtractSnippetWidgets(t *testing.T) { + t.Run("Widgets plural format", func(t *testing.T) { + rawData := map[string]any{ + "Widgets": []any{ + int32(0), + map[string]any{ + "$ID": "sw1", + "Name": "snippetWidget", + "$Type": "Forms$TextBox", + }, + }, + } + got := extractSnippetWidgets(rawData) + if len(got) != 1 { + t.Fatalf("expected 1 widget, got %d", len(got)) + } + }) + + t.Run("Widget singular container format", func(t *testing.T) { + rawData := map[string]any{ + "Widget": map[string]any{ + "Widgets": []any{ + int32(0), + map[string]any{ + "$ID": "sw1", + "Name": "innerWidget", + "$Type": "Forms$Button", + }, + }, + }, + } + got := extractSnippetWidgets(rawData) + if len(got) != 1 { + t.Fatalf("expected 1 widget, got %d", len(got)) + } + }) + + t.Run("nil when no widgets", func(t *testing.T) { + got := extractSnippetWidgets(map[string]any{}) + if got != nil { + t.Errorf("expected nil, got %v", got) + } + }) +} + +func TestGetBsonArrayElements(t *testing.T) { + tests := []struct { + name string + v any + want int // expected length, -1 for nil + }{ + {"nil input", nil, -1}, + {"int32 type indicator", []any{int32(0), "a", "b"}, 2}, + {"int type indicator", []any{int(0), "a", "b"}, 2}, + {"no type indicator", []any{"a", "b"}, 2}, + {"primitive.A with indicator", primitive.A{int32(0), "a"}, 1}, + {"empty array", []any{}, -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getBsonArrayElements(tt.v) + if tt.want == -1 { + if got != nil { + t.Errorf("expected nil, got %v", got) + } + } else if len(got) != tt.want { + t.Errorf("len = %d, want %d", len(got), tt.want) + } + }) + } +} + +func TestToBsonArray(t *testing.T) { + t.Run("[]any passthrough", func(t *testing.T) { + input := []any{"a", "b"} + got := toBsonArray(input) + if len(got) != 2 { + t.Errorf("expected 2, got %d", len(got)) + } + }) + + t.Run("primitive.A converted", func(t *testing.T) { + input := primitive.A{"x", "y"} + got := toBsonArray(input) + if len(got) != 2 { + t.Errorf("expected 2, got %d", len(got)) + } + }) + + t.Run("unsupported type returns nil", func(t *testing.T) { + got := toBsonArray("not an array") + if got != nil { + t.Errorf("expected nil, got %v", got) + } + }) +} + +func TestExtractString(t *testing.T) { + if got := extractString("hello"); got != "hello" { + t.Errorf("expected hello, got %q", got) + } + if got := extractString(42); got != "" { + t.Errorf("expected empty, got %q", got) + } + if got := extractString(nil); got != "" { + t.Errorf("expected empty, got %q", got) + } +} + +func TestExtractBsonID(t *testing.T) { + tests := []struct { + name string + v any + want string + }{ + {"nil", nil, ""}, + {"string", "my-id", "my-id"}, + {"binary map with base64 GUID", map[string]any{"Data": "AQIDBAUGBwgJCgsMDQ4PEA=="}, "04030201-0605-0807-090a-0b0c0d0e0f10"}, + {"primitive.Binary", primitive.Binary{Data: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}}, "04030201-0605-0807-090a-0b0c0d0e0f10"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractBsonID(tt.v); got != tt.want { + t.Errorf("extractBsonID() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDecodeBase64GUID(t *testing.T) { + t.Run("short data returned as-is", func(t *testing.T) { + encoded := "AQIDBAUG" // only 6 bytes — too short for GUID + got := decodeBase64GUID(encoded) + if got != encoded { + t.Errorf("expected passthrough for short data, got %q", got) + } + }) + + t.Run("valid 16-byte GUID", func(t *testing.T) { + // base64 of bytes 0x01..0x10 + encoded := "AQIDBAUGBwgJCgsMDQ4PEA==" + got := decodeBase64GUID(encoded) + want := "04030201-0605-0807-090a-0b0c0d0e0f10" + if got != want { + t.Errorf("decodeBase64GUID() = %q, want %q", got, want) + } + }) + + t.Run("invalid base64", func(t *testing.T) { + encoded := "not-valid-base64!!!" + got := decodeBase64GUID(encoded) + if got != encoded { + t.Errorf("expected passthrough for invalid base64, got %q", got) + } + }) +} + +func TestExtractBinaryID(t *testing.T) { + tests := []struct { + name string + v any + want string + }{ + {"string", "my-id", "my-id"}, + {"bytes 16", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}, "04030201-0605-0807-090a-0b0c0d0e0f10"}, + {"primitive.Binary", primitive.Binary{Data: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}}, "04030201-0605-0807-090a-0b0c0d0e0f10"}, + {"nil", nil, ""}, + {"unsupported", 42, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractBinaryID(tt.v); got != tt.want { + t.Errorf("extractBinaryID() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatGUID(t *testing.T) { + tests := []struct { + name string + data []byte + want string + }{ + { + name: "standard 16-byte GUID", + data: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}, + want: "04030201-0605-0807-090a-0b0c0d0e0f10", + }, + { + name: "all zeros", + data: make([]byte, 16), + want: "00000000-0000-0000-0000-000000000000", + }, + { + name: "short data — returns as string", + data: []byte{0x01, 0x02}, + want: "\x01\x02", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatGUID(tt.data); got != tt.want { + t.Errorf("formatGUID() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBytesToHex(t *testing.T) { + tests := []struct { + name string + data []byte + want string + }{ + {"zero byte", []byte{0x00}, "00"}, + {"max byte", []byte{0xff}, "ff"}, + {"two bytes", []byte{0x0a, 0xbc}, "0abc"}, + {"empty", []byte{}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := bytesToHex(tt.data); got != tt.want { + t.Errorf("bytesToHex(%v) = %q, want %q", tt.data, got, tt.want) + } + }) + } +} diff --git a/mdl/catalog/builder_permissions_test.go b/mdl/catalog/builder_permissions_test.go new file mode 100644 index 00000000..f89aaa23 --- /dev/null +++ b/mdl/catalog/builder_permissions_test.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +func TestEntityAccessFromMemberRights(t *testing.T) { + tests := []struct { + name string + rule *domainmodel.AccessRule + wantRead bool + wantWrite bool + }{ + { + name: "no member accesses, default None", + rule: &domainmodel.AccessRule{ + DefaultMemberAccessRights: domainmodel.MemberAccessRightsNone, + }, + wantRead: false, + wantWrite: false, + }, + { + name: "no member accesses, default ReadOnly", + rule: &domainmodel.AccessRule{ + DefaultMemberAccessRights: domainmodel.MemberAccessRightsReadOnly, + }, + wantRead: true, + wantWrite: false, + }, + { + name: "no member accesses, default ReadWrite", + rule: &domainmodel.AccessRule{ + DefaultMemberAccessRights: domainmodel.MemberAccessRightsReadWrite, + }, + wantRead: true, + wantWrite: true, + }, + { + name: "explicit member ReadOnly", + rule: &domainmodel.AccessRule{ + MemberAccesses: []*domainmodel.MemberAccess{ + {AttributeName: "Name", AccessRights: domainmodel.MemberAccessRightsReadOnly}, + }, + }, + wantRead: true, + wantWrite: false, + }, + { + name: "explicit member ReadWrite", + rule: &domainmodel.AccessRule{ + MemberAccesses: []*domainmodel.MemberAccess{ + {AttributeName: "Name", AccessRights: domainmodel.MemberAccessRightsReadWrite}, + }, + }, + wantRead: true, + wantWrite: true, + }, + { + name: "mixed members — one ReadOnly one None", + rule: &domainmodel.AccessRule{ + MemberAccesses: []*domainmodel.MemberAccess{ + {AttributeName: "Name", AccessRights: domainmodel.MemberAccessRightsReadOnly}, + {AttributeName: "Age", AccessRights: domainmodel.MemberAccessRightsNone}, + }, + }, + wantRead: true, + wantWrite: false, + }, + { + name: "mixed members — one ReadOnly one ReadWrite", + rule: &domainmodel.AccessRule{ + MemberAccesses: []*domainmodel.MemberAccess{ + {AttributeName: "Name", AccessRights: domainmodel.MemberAccessRightsReadOnly}, + {AttributeName: "Age", AccessRights: domainmodel.MemberAccessRightsReadWrite}, + }, + }, + wantRead: true, + wantWrite: true, + }, + { + name: "all members None", + rule: &domainmodel.AccessRule{ + MemberAccesses: []*domainmodel.MemberAccess{ + {AttributeName: "Name", AccessRights: domainmodel.MemberAccessRightsNone}, + }, + }, + wantRead: false, + wantWrite: false, + }, + { + name: "empty member accesses falls through to default", + rule: &domainmodel.AccessRule{ + MemberAccesses: []*domainmodel.MemberAccess{}, + DefaultMemberAccessRights: domainmodel.MemberAccessRightsReadOnly, + }, + wantRead: true, + wantWrite: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRead, gotWrite := entityAccessFromMemberRights(tt.rule) + if gotRead != tt.wantRead { + t.Errorf("hasRead = %v, want %v", gotRead, tt.wantRead) + } + if gotWrite != tt.wantWrite { + t.Errorf("hasWrite = %v, want %v", gotWrite, tt.wantWrite) + } + }) + } +} diff --git a/mdl/catalog/builder_workflows_test.go b/mdl/catalog/builder_workflows_test.go new file mode 100644 index 00000000..5dcb3cd6 --- /dev/null +++ b/mdl/catalog/builder_workflows_test.go @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +func TestCountWorkflowActivityTypes(t *testing.T) { + tests := []struct { + name string + wf *workflows.Workflow + wantTotal, wantUT, wantMF, wantDec int + }{ + { + name: "nil flow", + wf: &workflows.Workflow{}, + wantTotal: 0, + }, + { + name: "empty flow", + wf: &workflows.Workflow{ + Flow: &workflows.Flow{}, + }, + wantTotal: 0, + }, + { + name: "user task", + wf: &workflows.Workflow{ + Flow: &workflows.Flow{ + Activities: []workflows.WorkflowActivity{ + &workflows.UserTask{}, + }, + }, + }, + wantTotal: 1, + wantUT: 1, + }, + { + name: "call microflow task", + wf: &workflows.Workflow{ + Flow: &workflows.Flow{ + Activities: []workflows.WorkflowActivity{ + &workflows.CallMicroflowTask{}, + }, + }, + }, + wantTotal: 1, + wantMF: 1, + }, + { + name: "system task counts as microflow call", + wf: &workflows.Workflow{ + Flow: &workflows.Flow{ + Activities: []workflows.WorkflowActivity{ + &workflows.SystemTask{}, + }, + }, + }, + wantTotal: 1, + wantMF: 1, + }, + { + name: "exclusive split counts as decision", + wf: &workflows.Workflow{ + Flow: &workflows.Flow{ + Activities: []workflows.WorkflowActivity{ + &workflows.ExclusiveSplitActivity{}, + }, + }, + }, + wantTotal: 1, + wantDec: 1, + }, + { + name: "nested activities in user task outcomes", + wf: &workflows.Workflow{ + Flow: &workflows.Flow{ + Activities: []workflows.WorkflowActivity{ + &workflows.UserTask{ + Outcomes: []*workflows.UserTaskOutcome{ + { + Flow: &workflows.Flow{ + Activities: []workflows.WorkflowActivity{ + &workflows.CallMicroflowTask{}, + }, + }, + }, + }, + }, + }, + }, + }, + wantTotal: 2, + wantUT: 1, + wantMF: 1, + }, + { + name: "parallel split recurses into outcomes", + wf: &workflows.Workflow{ + Flow: &workflows.Flow{ + Activities: []workflows.WorkflowActivity{ + &workflows.ParallelSplitActivity{ + Outcomes: []*workflows.ParallelSplitOutcome{ + { + Flow: &workflows.Flow{ + Activities: []workflows.WorkflowActivity{ + &workflows.UserTask{}, + }, + }, + }, + { + Flow: &workflows.Flow{ + Activities: []workflows.WorkflowActivity{ + &workflows.ExclusiveSplitActivity{}, + }, + }, + }, + }, + }, + }, + }, + }, + wantTotal: 3, // parallel split + user task + decision + wantUT: 1, + wantDec: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + total, ut, mf, dec := countWorkflowActivityTypes(tt.wf) + if total != tt.wantTotal { + t.Errorf("total = %d, want %d", total, tt.wantTotal) + } + if ut != tt.wantUT { + t.Errorf("userTasks = %d, want %d", ut, tt.wantUT) + } + if mf != tt.wantMF { + t.Errorf("microflowCalls = %d, want %d", mf, tt.wantMF) + } + if dec != tt.wantDec { + t.Errorf("decisions = %d, want %d", dec, tt.wantDec) + } + }) + } +} From 5bd8d682197c6a55ae49a1db346d3a67cf848c3f Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Wed, 22 Apr 2026 15:02:06 +0200 Subject: [PATCH 2/2] test(catalog): address code review feedback from ako - Add default/unknown test case for getMicroflowObjectType - Replace non-printable bytes in formatGUID short-data test with legible ASCII - Add Pages$DivContainer test case to TestExtractWidgetsRecursive --- mdl/catalog/builder_microflows_test.go | 9 ++++++++ mdl/catalog/builder_pages_test.go | 29 +++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/mdl/catalog/builder_microflows_test.go b/mdl/catalog/builder_microflows_test.go index ba6e917c..073bbf60 100644 --- a/mdl/catalog/builder_microflows_test.go +++ b/mdl/catalog/builder_microflows_test.go @@ -6,8 +6,16 @@ import ( "testing" "github.com/mendixlabs/mxcli/sdk/microflows" + "github.com/mendixlabs/mxcli/model" ) +// unknownMicroflowObject satisfies MicroflowObject but is not in the type switch. +type unknownMicroflowObject struct{} + +func (u *unknownMicroflowObject) GetID() model.ID { return "" } +func (u *unknownMicroflowObject) GetPosition() model.Point { return model.Point{} } +func (u *unknownMicroflowObject) SetPosition(model.Point) {} + func TestGetMicroflowObjectType(t *testing.T) { tests := []struct { name string @@ -25,6 +33,7 @@ func TestGetMicroflowObjectType(t *testing.T) { {"BreakEvent", µflows.BreakEvent{}, "BreakEvent"}, {"ContinueEvent", µflows.ContinueEvent{}, "ContinueEvent"}, {"ErrorEvent", µflows.ErrorEvent{}, "ErrorEvent"}, + {"unknown object falls to default", &unknownMicroflowObject{}, "MicroflowObject"}, } for _, tt := range tests { diff --git a/mdl/catalog/builder_pages_test.go b/mdl/catalog/builder_pages_test.go index 24cb62c9..25bf104a 100644 --- a/mdl/catalog/builder_pages_test.go +++ b/mdl/catalog/builder_pages_test.go @@ -146,6 +146,29 @@ func TestExtractWidgetsRecursive(t *testing.T) { } }) + t.Run("skips Pages$DivContainer but includes children", func(t *testing.T) { + w := map[string]any{ + "$ID": "div2", + "Name": "pagesDivContainer", + "$Type": "Pages$DivContainer", + "Widgets": []any{ + int32(0), + map[string]any{ + "$ID": "child2", + "Name": "childWidget2", + "$Type": "Pages$Button", + }, + }, + } + got := extractWidgetsRecursive(w) + if len(got) != 1 { + t.Fatalf("expected 1 widget (child only), got %d", len(got)) + } + if got[0].ID != "child2" { + t.Errorf("expected child2, got %q", got[0].ID) + } + }) + t.Run("CustomWidget resolves WidgetId", func(t *testing.T) { w := map[string]any{ "$ID": "cw1", @@ -412,9 +435,9 @@ func TestFormatGUID(t *testing.T) { want: "00000000-0000-0000-0000-000000000000", }, { - name: "short data — returns as string", - data: []byte{0x01, 0x02}, - want: "\x01\x02", + name: "short data — returns raw string passthrough", + data: []byte{0x41, 0x42}, + want: "AB", }, }