diff --git a/pkg/xmlparser/benchmark_test.go b/pkg/xmlparser/benchmark_test.go new file mode 100644 index 0000000..ac23948 --- /dev/null +++ b/pkg/xmlparser/benchmark_test.go @@ -0,0 +1,614 @@ +package xmlparser + +import ( + "bytes" + "fmt" + "strings" + "sync" + "testing" +) + +// Benchmark data of different sizes +var ( + smallXML = ` + + Small Test + Small value +` + + mediumXML = ` + + Medium Test Document + This is a medium-sized XML document for benchmarking purposes +
+ Medium XML document with nested elements + benchmark + medium + + xml + benchmark + test + +
+ + 2025-01-09 + Test Suite + 1.0 + +
` + + // Generate large XML dynamically in benchmarks to avoid bloating the source + largeXMLTemplate = ` + + Large Test Document + This is a large XML document for benchmarking performance + %s +` +) + +// generateLargeXML creates a large XML document for benchmarking +func generateLargeXML() string { + var buffer strings.Builder + + // Generate repetitive data to create a large document + for i := 0; i < 1000; i++ { + buffer.WriteString(fmt.Sprintf(` + + Item %d Title + This is item number %d with detailed description for benchmarking purposes. It contains enough text to make the document significantly larger. + category-%d + + tag1 + tag2 + tag3 + + `, i, i, i, i%10)) + } + + return fmt.Sprintf(largeXMLTemplate, buffer.String()) +} + +// BenchmarkParseString benchmarks parsing from string +func BenchmarkParseString(b *testing.B) { + parser := NewParser[MockParsable](nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := parser.ParseString(smallXML) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } +} + +// BenchmarkParseBytes benchmarks parsing from byte slice +func BenchmarkParseBytes(b *testing.B) { + parser := NewParser[MockParsable](nil) + xmlData := []byte(smallXML) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := parser.ParseBytes(xmlData) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } +} + +// BenchmarkParse benchmarks parsing from reader +func BenchmarkParse(b *testing.B) { + parser := NewParser[MockParsable](nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + reader := strings.NewReader(smallXML) + _, err := parser.Parse(reader) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } +} + +// BenchmarkParseSmallXML benchmarks parsing small XML documents +func BenchmarkParseSmallXML(b *testing.B) { + parser := NewParser[MockParsable](nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := parser.ParseString(smallXML) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } +} + +// BenchmarkParseMediumXML benchmarks parsing medium XML documents +func BenchmarkParseMediumXML(b *testing.B) { + parser := NewParser[MockParsable](nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := parser.ParseString(mediumXML) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } +} + +// BenchmarkParseLargeXML benchmarks parsing large XML documents +func BenchmarkParseLargeXML(b *testing.B) { + parser := NewParser[MockParsable](nil) + largeXML := generateLargeXML() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := parser.ParseString(largeXML) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } +} + +// BenchmarkParseWithResult benchmarks parsing with detailed results +func BenchmarkParseWithResult(b *testing.B) { + parser := NewParser[MockParsable](nil) + genericParser := parser.(*GenericParser[MockParsable]) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + reader := strings.NewReader(mediumXML) + _, err := genericParser.ParseWithResult(reader) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } +} + +// BenchmarkValidation benchmarks XML validation +func BenchmarkValidation(b *testing.B) { + parser := NewParser[MockParsable](nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + reader := strings.NewReader(mediumXML) + err := parser.Validate(reader) + if err != nil { + b.Errorf("Validation error: %v", err) + } + } +} + +// BenchmarkConcurrentParsing benchmarks concurrent parsing performance +func BenchmarkConcurrentParsing(b *testing.B) { + parser := NewParser[MockParsable](nil) + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _, err := parser.ParseString(smallXML) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } + }) +} + +// BenchmarkConcurrentParsingWithWaitGroup benchmarks coordinated concurrent parsing +func BenchmarkConcurrentParsingWithWaitGroup(b *testing.B) { + parser := NewParser[MockParsable](nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + var wg sync.WaitGroup + numGoroutines := 4 + + for j := 0; j < numGoroutines; j++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := parser.ParseString(smallXML) + if err != nil { + b.Errorf("Parse error: %v", err) + } + }() + } + + wg.Wait() + } +} + +// BenchmarkMemoryAllocation benchmarks memory allocation patterns +func BenchmarkMemoryAllocation(b *testing.B) { + parser := NewParser[MockParsable](nil) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + result, err := parser.ParseString(mediumXML) + if err != nil { + b.Errorf("Parse error: %v", err) + } + // Use result to prevent optimization + _ = result.GetID() + } +} + +// BenchmarkMemoryAllocationsLarge benchmarks memory allocations for large documents +func BenchmarkMemoryAllocationsLarge(b *testing.B) { + parser := NewParser[MockParsable](nil) + largeXML := generateLargeXML() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + result, err := parser.ParseString(largeXML) + if err != nil { + b.Errorf("Parse error: %v", err) + } + // Use result to prevent optimization + _ = result.GetID() + } +} + +// BenchmarkSanitizeXMLString benchmarks XML string sanitization +func BenchmarkSanitizeXMLString(b *testing.B) { + input := "Test string with\x00invalid\x01characters\x02and normal text" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + result := SanitizeXMLString(input) + // Use result to prevent optimization + _ = len(result) + } +} + +// BenchmarkValidateXMLName benchmarks XML name validation +func BenchmarkValidateXMLName(b *testing.B) { + testNames := []string{ + "ValidElement", + "invalid-element-123", + "_privateElement", + "123Invalid", + "element-with-hyphen", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + name := testNames[i%len(testNames)] + result := ValidateXMLName(name) + // Use result to prevent optimization + _ = result + } +} + +// BenchmarkIsValidNamespace benchmarks namespace validation +func BenchmarkIsValidNamespace(b *testing.B) { + testNamespaces := []string{ + "http://example.com", + "https://ted.europa.eu/schema", + "urn:oasis:names:specification:ubl", + "invalid-namespace", + "", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + namespace := testNamespaces[i%len(testNamespaces)] + result := IsValidNamespace(namespace) + // Use result to prevent optimization + _ = result + } +} + +// BenchmarkNormalizeWhitespace benchmarks whitespace normalization +func BenchmarkNormalizeWhitespace(b *testing.B) { + input := " Multiple \t\n whitespace\r\n characters " + + b.ResetTimer() + for i := 0; i < b.N; i++ { + result := NormalizeWhitespace(input) + // Use result to prevent optimization + _ = len(result) + } +} + +// BenchmarkTruncateString benchmarks string truncation +func BenchmarkTruncateString(b *testing.B) { + input := "This is a very long string that needs to be truncated for display purposes" + maxLength := 50 + + b.ResetTimer() + for i := 0; i < b.N; i++ { + result := TruncateString(input, maxLength) + // Use result to prevent optimization + _ = len(result) + } +} + +// BenchmarkParserCreation benchmarks parser creation +func BenchmarkParserCreation(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + parser := NewParser[MockParsable](nil) + // Use parser to prevent optimization + _ = parser + } +} + +// BenchmarkParserCreationWithOptions benchmarks parser creation with custom options +func BenchmarkParserCreationWithOptions(b *testing.B) { + options := &ParserOptions{ + StrictValidation: false, + AllowEmptyElements: true, + PreserveWhitespace: false, + IgnoreUnknownElements: true, + MaxDepth: 100, + MaxSize: 10 * 1024 * 1024, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + parser := NewParser[MockParsable](options) + // Use parser to prevent optimization + _ = parser + } +} + +// BenchmarkValidationError benchmarks validation error creation +func BenchmarkValidationError(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := ValidationError{ + Field: "testField", + Message: "test error message", + Value: "test value", + } + // Use error to prevent optimization + _ = err.Error() + } +} + +// BenchmarkValidationErrors benchmarks multiple validation errors +func BenchmarkValidationErrors(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + var errors ValidationErrors + errors.Add("field1", "error1", "value1") + errors.Add("field2", "error2", "value2") + errors.Add("field3", "error3", "value3") + + // Use errors to prevent optimization + _ = errors.Error() + } +} + +// BenchmarkMockValidation benchmarks mock object validation +func BenchmarkMockValidation(b *testing.B) { + mock := MockParsable{ + ID: "test-123", + Name: "Test Mock", + Value: "test value", + Valid: true, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := mock.Validate() + // Use error to prevent optimization + _ = err + } +} + +// BenchmarkDifferentXMLSizes compares performance across different document sizes +func BenchmarkDifferentXMLSizes(b *testing.B) { + parser := NewParser[MockParsable](nil) + + testCases := []struct { + name string + xml string + }{ + {"Small", smallXML}, + {"Medium", mediumXML}, + } + + // Add large XML dynamically to avoid memory overhead during compilation + largeXML := generateLargeXML() + testCases = append(testCases, struct { + name string + xml string + }{"Large", largeXML}) + + for _, tc := range testCases { + b.Run(tc.name, func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := parser.ParseString(tc.xml) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } + }) + } +} + +// BenchmarkParsingMethods compares different parsing methods +func BenchmarkParsingMethods(b *testing.B) { + parser := NewParser[MockParsable](nil) + xmlData := []byte(mediumXML) + + b.Run("ParseString", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := parser.ParseString(mediumXML) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } + }) + + b.Run("ParseBytes", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := parser.ParseBytes(xmlData) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } + }) + + b.Run("Parse", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + reader := bytes.NewReader(xmlData) + _, err := parser.Parse(reader) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } + }) +} + +// BenchmarkUtilityFunctions benchmarks all utility functions together +func BenchmarkUtilityFunctions(b *testing.B) { + testData := struct { + dirtyString string + xmlName string + namespace string + whitespace string + longString string + }{ + dirtyString: "Dirty\x00string\x01with\x02control\x03characters", + xmlName: "ValidElementName", + namespace: "http://example.com/namespace", + whitespace: " Multiple \t\n spaces ", + longString: "This is a very long string that needs truncation for testing purposes", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Run all utility functions + _ = SanitizeXMLString(testData.dirtyString) + _ = ValidateXMLName(testData.xmlName) + _ = IsValidNamespace(testData.namespace) + _ = NormalizeWhitespace(testData.whitespace) + _ = TruncateString(testData.longString, 50) + } +} + +// BenchmarkParserOptionsEffects benchmarks parsing with different options +func BenchmarkParserOptionsEffects(b *testing.B) { + testCases := []struct { + name string + options *ParserOptions + }{ + { + name: "Default", + options: nil, + }, + { + name: "StrictDisabled", + options: &ParserOptions{ + StrictValidation: false, + }, + }, + { + name: "PreserveWhitespace", + options: &ParserOptions{ + PreserveWhitespace: true, + }, + }, + { + name: "IgnoreUnknown", + options: &ParserOptions{ + IgnoreUnknownElements: true, + }, + }, + } + + for _, tc := range testCases { + b.Run(tc.name, func(b *testing.B) { + parser := NewParser[MockParsable](tc.options) + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _, err := parser.ParseString(mediumXML) + if err != nil { + b.Errorf("Parse error: %v", err) + } + } + }) + } +} + +// BenchmarkErrorHandling benchmarks error creation and handling +func BenchmarkErrorHandling(b *testing.B) { + b.Run("ValidationError", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := ValidationError{ + Field: "testField", + Message: "test message", + Value: "test value", + } + _ = err.Error() + } + }) + + b.Run("XMLParseError", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := XMLParseError{ + Line: 10, + Column: 5, + Element: "TestElement", + Message: "test parse error", + } + _ = err.Error() + } + }) + + b.Run("ValidationErrors", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + var errors ValidationErrors + errors.Add("field1", "message1", "value1") + errors.Add("field2", "message2", "value2") + _ = errors.Error() + } + }) +} + +// BenchmarkMemoryEfficiency measures memory efficiency across operations +func BenchmarkMemoryEfficiency(b *testing.B) { + parser := NewParser[MockParsable](nil) + + b.Run("SmallDocuments", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + result, err := parser.ParseString(smallXML) + if err != nil { + b.Errorf("Parse error: %v", err) + } + // Ensure result is used + _ = result.GetID() + } + }) + + b.Run("MediumDocuments", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + result, err := parser.ParseString(mediumXML) + if err != nil { + b.Errorf("Parse error: %v", err) + } + // Ensure result is used + _ = result.GetID() + } + }) +} diff --git a/pkg/xmlparser/interfaces_test.go b/pkg/xmlparser/interfaces_test.go new file mode 100644 index 0000000..1d2c545 --- /dev/null +++ b/pkg/xmlparser/interfaces_test.go @@ -0,0 +1,373 @@ +package xmlparser + +import ( + "encoding/xml" + "testing" +) + +// MockParsable is a test implementation of the Parsable interface +type MockParsable struct { + XMLName xml.Name `xml:"MockElement"` + ID string `xml:"id,attr"` + Name string `xml:"name"` + Value string `xml:"value"` + Valid bool `xml:"-"` // Controls validation behavior +} + +// Validate implements the Parsable interface +func (m MockParsable) Validate() error { + // Only fail validation if explicitly set to false in tests + // When parsed from XML, Valid will be false (default), but we want validation to pass + // unless specifically testing validation failures + if m.ID == "" { + return ValidationError{ + Field: "id", + Message: "ID is required", + } + } + if m.Name == "" { + return ValidationError{ + Field: "name", + Message: "Name is required", + } + } + // Only check the Valid field if this appears to be a test scenario + // where Valid was explicitly set to false (we can detect this by checking + // if both ID and Name are populated but Valid is false) + if m.ID != "" && m.Name != "" && !m.Valid { + // This is likely a test case where Valid was explicitly set to false + // In real XML parsing scenarios, Valid will be false by default but validation should pass + // We'll use a different approach - check if Value is specifically set to indicate test failure + if m.Value == "test-validation-failure" { + return ValidationError{ + Field: "valid", + Message: "mock validation failed", + Value: "false", + } + } + } + return nil +} + +// GetID implements the Parsable interface +func (m MockParsable) GetID() string { + return m.ID +} + +func TestDefaultParserOptions(t *testing.T) { + options := DefaultParserOptions() + + // Test default values + if !options.StrictValidation { + t.Error("Expected StrictValidation to be true by default") + } + + if !options.AllowEmptyElements { + t.Error("Expected AllowEmptyElements to be true by default") + } + + if options.PreserveWhitespace { + t.Error("Expected PreserveWhitespace to be false by default") + } + + if options.IgnoreUnknownElements { + t.Error("Expected IgnoreUnknownElements to be false by default") + } + + if options.MaxDepth != 100 { + t.Errorf("Expected MaxDepth to be 100, got %d", options.MaxDepth) + } + + expectedMaxSize := int64(10 * 1024 * 1024) // 10MB + if options.MaxSize != expectedMaxSize { + t.Errorf("Expected MaxSize to be %d, got %d", expectedMaxSize, options.MaxSize) + } +} + +func TestXMLMetadata(t *testing.T) { + metadata := &XMLMetadata{ + XMLName: xml.Name{Local: "TestElement", Space: "http://test.com"}, + Namespace: "http://test.com", + Version: "1.0", + Encoding: "UTF-8", + } + + if metadata.XMLName.Local != "TestElement" { + t.Errorf("Expected XMLName.Local to be 'TestElement', got '%s'", metadata.XMLName.Local) + } + + if metadata.XMLName.Space != "http://test.com" { + t.Errorf("Expected XMLName.Space to be 'http://test.com', got '%s'", metadata.XMLName.Space) + } + + if metadata.Namespace != "http://test.com" { + t.Errorf("Expected Namespace to be 'http://test.com', got '%s'", metadata.Namespace) + } + + if metadata.Version != "1.0" { + t.Errorf("Expected Version to be '1.0', got '%s'", metadata.Version) + } + + if metadata.Encoding != "UTF-8" { + t.Errorf("Expected Encoding to be 'UTF-8', got '%s'", metadata.Encoding) + } +} + +func TestParseResult(t *testing.T) { + mockData := &MockParsable{ + ID: "test-123", + Name: "Test Mock", + Value: "test value", + Valid: true, + } + + metadata := &XMLMetadata{ + XMLName: xml.Name{Local: "MockElement"}, + Namespace: "http://test.com", + Version: "1.0", + Encoding: "UTF-8", + } + + result := &ParseResult[MockParsable]{ + Data: mockData, + Metadata: metadata, + Errors: []string{"test error"}, + Warnings: []string{"test warning"}, + } + + // Test data + if result.Data == nil { + t.Error("Expected Data to not be nil") + } else { + if result.Data.GetID() != "test-123" { + t.Errorf("Expected Data.GetID() to be 'test-123', got '%s'", result.Data.GetID()) + } + } + + // Test metadata + if result.Metadata == nil { + t.Error("Expected Metadata to not be nil") + } else { + if result.Metadata.Version != "1.0" { + t.Errorf("Expected Metadata.Version to be '1.0', got '%s'", result.Metadata.Version) + } + } + + // Test errors + if len(result.Errors) != 1 { + t.Errorf("Expected 1 error, got %d", len(result.Errors)) + } else if result.Errors[0] != "test error" { + t.Errorf("Expected first error to be 'test error', got '%s'", result.Errors[0]) + } + + // Test warnings + if len(result.Warnings) != 1 { + t.Errorf("Expected 1 warning, got %d", len(result.Warnings)) + } else if result.Warnings[0] != "test warning" { + t.Errorf("Expected first warning to be 'test warning', got '%s'", result.Warnings[0]) + } +} + +func TestMockParsable(t *testing.T) { + tests := []struct { + name string + mock MockParsable + expectError bool + errorField string + }{ + { + name: "Valid mock", + mock: MockParsable{ + ID: "test-123", + Name: "Test Name", + Value: "test value", + Valid: true, + }, + expectError: false, + }, + { + name: "Invalid mock - Valid flag false", + mock: MockParsable{ + ID: "test-123", + Name: "Test Name", + Value: "test-validation-failure", + Valid: false, + }, + expectError: true, + errorField: "valid", + }, + { + name: "Invalid mock - Missing ID", + mock: MockParsable{ + Name: "Test Name", + Value: "test value", + Valid: true, + }, + expectError: true, + errorField: "id", + }, + { + name: "Invalid mock - Missing Name", + mock: MockParsable{ + ID: "test-123", + Value: "test value", + Valid: true, + }, + expectError: true, + errorField: "name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.mock.Validate() + + if tt.expectError { + if err == nil { + t.Error("Expected validation error but got none") + return + } + + if validationErr, ok := err.(ValidationError); ok { + if validationErr.Field != tt.errorField { + t.Errorf("Expected error field '%s', got '%s'", tt.errorField, validationErr.Field) + } + } else { + t.Errorf("Expected ValidationError, got %T", err) + } + } else { + if err != nil { + t.Errorf("Unexpected validation error: %v", err) + } + } + + // Test GetID method + expectedID := tt.mock.ID + if tt.mock.GetID() != expectedID { + t.Errorf("Expected GetID() to return '%s', got '%s'", expectedID, tt.mock.GetID()) + } + }) + } +} + +func TestParserInterface(t *testing.T) { + // Test that our MockParsable implements the Parsable interface + var _ Parsable = MockParsable{} + + // Test that our parser implements the Parser interface + parser := NewParser[MockParsable](nil) + var _ Parser[MockParsable] = parser + + // Test parser creation with default options + if parser == nil { + t.Error("Expected parser to not be nil") + } + + // Test parser creation with custom options + customOptions := &ParserOptions{ + StrictValidation: false, + AllowEmptyElements: false, + PreserveWhitespace: true, + IgnoreUnknownElements: true, + MaxDepth: 50, + MaxSize: 1024 * 1024, // 1MB + } + + customParser := NewParser[MockParsable](customOptions) + if customParser == nil { + t.Error("Expected custom parser to not be nil") + } + + // Verify the parser can be cast to GenericParser to access options + if genericParser, ok := customParser.(*GenericParser[MockParsable]); ok { + if genericParser.options.MaxDepth != 50 { + t.Errorf("Expected custom MaxDepth to be 50, got %d", genericParser.options.MaxDepth) + } + if genericParser.options.StrictValidation { + t.Error("Expected custom StrictValidation to be false") + } + } else { + t.Error("Expected parser to be of type *GenericParser[MockParsable]") + } +} + +func TestParserOptionsCustomization(t *testing.T) { + options := &ParserOptions{ + StrictValidation: false, + AllowEmptyElements: false, + PreserveWhitespace: true, + IgnoreUnknownElements: true, + MaxDepth: 200, + MaxSize: 5 * 1024 * 1024, // 5MB + } + + // Test each option individually + if options.StrictValidation { + t.Error("Expected StrictValidation to be false") + } + + if options.AllowEmptyElements { + t.Error("Expected AllowEmptyElements to be false") + } + + if !options.PreserveWhitespace { + t.Error("Expected PreserveWhitespace to be true") + } + + if !options.IgnoreUnknownElements { + t.Error("Expected IgnoreUnknownElements to be true") + } + + if options.MaxDepth != 200 { + t.Errorf("Expected MaxDepth to be 200, got %d", options.MaxDepth) + } + + expectedMaxSize := int64(5 * 1024 * 1024) + if options.MaxSize != expectedMaxSize { + t.Errorf("Expected MaxSize to be %d, got %d", expectedMaxSize, options.MaxSize) + } +} + +func TestEmptyParseResult(t *testing.T) { + result := &ParseResult[MockParsable]{} + + if result.Data != nil { + t.Error("Expected empty result Data to be nil") + } + + if result.Metadata != nil { + t.Error("Expected empty result Metadata to be nil") + } + + if result.Errors != nil { + t.Error("Expected empty result Errors to be nil") + } + + if result.Warnings != nil { + t.Error("Expected empty result Warnings to be nil") + } +} + +func TestXMLMetadataEmpty(t *testing.T) { + metadata := &XMLMetadata{} + + if metadata.XMLName.Local != "" { + t.Errorf("Expected empty XMLName.Local, got '%s'", metadata.XMLName.Local) + } + + if metadata.XMLName.Space != "" { + t.Errorf("Expected empty XMLName.Space, got '%s'", metadata.XMLName.Space) + } + + if metadata.Namespace != "" { + t.Errorf("Expected empty Namespace, got '%s'", metadata.Namespace) + } + + if metadata.Version != "" { + t.Errorf("Expected empty Version, got '%s'", metadata.Version) + } + + if metadata.Encoding != "" { + t.Errorf("Expected empty Encoding, got '%s'", metadata.Encoding) + } +} diff --git a/pkg/xmlparser/parser.go b/pkg/xmlparser/parser.go index 18a2031..26915d2 100644 --- a/pkg/xmlparser/parser.go +++ b/pkg/xmlparser/parser.go @@ -61,6 +61,13 @@ func (p *GenericParser[T]) ParseBytes(xmlData []byte) (*T, error) { targetPtr = reflect.New(resultType).Interface() } + // Check depth limit before parsing if specified + if p.options.MaxDepth > 0 { + if err := p.checkDepthLimit(xmlData); err != nil { + return nil, err + } + } + // Create XML decoder decoder := xml.NewDecoder(bytes.NewReader(xmlData)) @@ -142,6 +149,38 @@ func (p *GenericParser[T]) Validate(reader io.Reader) error { return nil } +// checkDepthLimit validates that XML doesn't exceed the maximum depth limit +func (p *GenericParser[T]) checkDepthLimit(xmlData []byte) error { + decoder := xml.NewDecoder(bytes.NewReader(xmlData)) + depth := 0 + maxDepthReached := 0 + + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("XML depth validation failed: %w", err) + } + + switch token.(type) { + case xml.StartElement: + depth++ + if depth > maxDepthReached { + maxDepthReached = depth + } + if depth > p.options.MaxDepth { + return fmt.Errorf("XML depth %d exceeds maximum allowed depth %d", depth, p.options.MaxDepth) + } + case xml.EndElement: + depth-- + } + } + + return nil +} + // ParseWithResult parses XML and returns detailed result with metadata func (p *GenericParser[T]) ParseWithResult(reader io.Reader) (*ParseResult[T], error) { result := &ParseResult[T]{ diff --git a/pkg/xmlparser/parser_test.go b/pkg/xmlparser/parser_test.go new file mode 100644 index 0000000..45879cd --- /dev/null +++ b/pkg/xmlparser/parser_test.go @@ -0,0 +1,814 @@ +package xmlparser + +import ( + "bytes" + "fmt" + "io" + "strings" + "sync" + "testing" +) + +// Mock XML data for testing +const ( + validMockXML = ` + + Test Mock Name + Test mock value +` + + validMockXMLWithNamespace = ` + + Namespaced Mock + Namespaced value +` + + invalidMockXML = ` + + + Missing name +` + + malformedXML = ` + + Unclosed tag + This will fail +` + + emptyXML = `` + + largeMockXML = ` + + Large Mock + This is a large XML document with nested elements + + + + + + Deep nesting test + + + + + +` + + deeplyNestedXML = ` + + Deep Nesting Test + Testing depth limits + + + + Deep + + + +` +) + +// errorReader is a mock reader that always returns an error +type errorReader struct{} + +func (er errorReader) Read(p []byte) (n int, err error) { + return 0, io.ErrUnexpectedEOF +} + +func TestNewParser(t *testing.T) { + tests := []struct { + name string + options *ParserOptions + }{ + { + name: "Default options", + options: nil, + }, + { + name: "Custom options", + options: &ParserOptions{ + StrictValidation: false, + AllowEmptyElements: false, + PreserveWhitespace: true, + IgnoreUnknownElements: true, + MaxDepth: 50, + MaxSize: 1024 * 1024, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewParser[MockParsable](tt.options) + + if parser == nil { + t.Error("Expected parser to not be nil") + return + } + + // Verify parser type + if _, ok := parser.(*GenericParser[MockParsable]); !ok { + t.Error("Expected parser to be of type *GenericParser[MockParsable]") + } + }) + } +} + +func TestParseString(t *testing.T) { + tests := []struct { + name string + xmlData string + expectError bool + expectedID string + options *ParserOptions + }{ + { + name: "Valid XML", + xmlData: validMockXML, + expectError: false, + expectedID: "test-123", + options: nil, + }, + { + name: "Valid XML with namespace", + xmlData: validMockXMLWithNamespace, + expectError: false, + expectedID: "test-456", + options: nil, + }, + { + name: "Invalid XML (validation failure)", + xmlData: invalidMockXML, + expectError: true, + expectedID: "", + options: nil, + }, + { + name: "Malformed XML", + xmlData: malformedXML, + expectError: true, + expectedID: "", + options: nil, + }, + { + name: "Empty XML", + xmlData: emptyXML, + expectError: true, + expectedID: "", + options: nil, + }, + { + name: "Large XML", + xmlData: largeMockXML, + expectError: false, + expectedID: "large-test", + options: nil, + }, + { + name: "XML exceeds size limit", + xmlData: largeMockXML, + expectError: true, + expectedID: "", + options: &ParserOptions{ + MaxSize: 100, // Very small limit + }, + }, + { + name: "Deeply nested XML exceeds depth", + xmlData: deeplyNestedXML, + expectError: true, + expectedID: "", + options: &ParserOptions{ + MaxDepth: 10, // Shallow depth limit + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewParser[MockParsable](tt.options) + result, err := parser.ParseString(tt.xmlData) + + if tt.expectError { + if err == nil { + t.Error("Expected parsing error but got none") + } + if result != nil { + t.Error("Expected result to be nil when error occurs") + } + return + } + + if err != nil { + t.Errorf("Unexpected parsing error: %v", err) + return + } + + if result == nil { + t.Error("Expected result to not be nil") + return + } + + if result.GetID() != tt.expectedID { + t.Errorf("Expected ID '%s', got '%s'", tt.expectedID, result.GetID()) + } + + // Verify the mock is valid (should pass validation) + if err := result.Validate(); err != nil { + t.Errorf("Parsed result failed validation: %v", err) + } + }) + } +} + +func TestParseBytes(t *testing.T) { + xmlData := []byte(validMockXML) + parser := NewParser[MockParsable](nil) + + result, err := parser.ParseBytes(xmlData) + if err != nil { + t.Errorf("Unexpected parsing error: %v", err) + return + } + + if result == nil { + t.Error("Expected result to not be nil") + return + } + + if result.GetID() != "test-123" { + t.Errorf("Expected ID 'test-123', got '%s'", result.GetID()) + } +} + +func TestParse(t *testing.T) { + tests := []struct { + name string + reader io.Reader + expectError bool + expectedID string + }{ + { + name: "Valid reader", + reader: strings.NewReader(validMockXML), + expectError: false, + expectedID: "test-123", + }, + { + name: "Error reader", + reader: errorReader{}, + expectError: true, + expectedID: "", + }, + { + name: "Empty reader", + reader: strings.NewReader(""), + expectError: true, + expectedID: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewParser[MockParsable](nil) + result, err := parser.Parse(tt.reader) + + if tt.expectError { + if err == nil { + t.Error("Expected parsing error but got none") + } + if result != nil { + t.Error("Expected result to be nil when error occurs") + } + return + } + + if err != nil { + t.Errorf("Unexpected parsing error: %v", err) + return + } + + if result == nil { + t.Error("Expected result to not be nil") + return + } + + if result.GetID() != tt.expectedID { + t.Errorf("Expected ID '%s', got '%s'", tt.expectedID, result.GetID()) + } + }) + } +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + reader io.Reader + expectError bool + options *ParserOptions + }{ + { + name: "Valid XML", + reader: strings.NewReader(validMockXML), + expectError: false, + options: nil, + }, + { + name: "Malformed XML", + reader: strings.NewReader(malformedXML), + expectError: true, + options: nil, + }, + { + name: "Empty XML", + reader: strings.NewReader(""), + expectError: true, + options: nil, + }, + { + name: "Error reader", + reader: errorReader{}, + expectError: true, + options: nil, + }, + { + name: "Deeply nested XML exceeds depth", + reader: strings.NewReader(deeplyNestedXML), + expectError: true, + options: &ParserOptions{ + MaxDepth: 10, + }, + }, + { + name: "Valid deeply nested XML within limits", + reader: strings.NewReader(deeplyNestedXML), + expectError: false, + options: &ParserOptions{ + MaxDepth: 100, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewParser[MockParsable](tt.options) + err := parser.Validate(tt.reader) + + if tt.expectError { + if err == nil { + t.Error("Expected validation error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected validation error: %v", err) + } + } + }) + } +} + +func TestParseWithResult(t *testing.T) { + tests := []struct { + name string + reader io.Reader + expectError bool + expectedID string + expectMetadata bool + expectedVersion string + expectedEncoding string + }{ + { + name: "Valid XML with metadata", + reader: strings.NewReader(validMockXML), + expectError: false, + expectedID: "test-123", + expectMetadata: true, + expectedVersion: "1.0", + expectedEncoding: "UTF-8", + }, + { + name: "Valid XML with namespace", + reader: strings.NewReader(validMockXMLWithNamespace), + expectError: false, + expectedID: "test-456", + expectMetadata: true, + expectedVersion: "1.0", + expectedEncoding: "UTF-8", + }, + { + name: "Error reader", + reader: errorReader{}, + expectError: true, + expectedID: "", + expectMetadata: false, + }, + { + name: "Invalid XML (validation failure)", + reader: strings.NewReader(invalidMockXML), + expectError: true, + expectedID: "", + expectMetadata: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewParser[MockParsable](nil) + + // Cast to GenericParser to access ParseWithResult + genericParser, ok := parser.(*GenericParser[MockParsable]) + if !ok { + t.Error("Expected parser to be of type *GenericParser[MockParsable]") + return + } + + result, err := genericParser.ParseWithResult(tt.reader) + + if result == nil { + t.Error("Expected result to not be nil") + return + } + + if tt.expectError { + if err == nil { + t.Error("Expected parsing error but got none") + } + if len(result.Errors) == 0 { + t.Error("Expected errors in result") + } + } else { + if err != nil { + t.Errorf("Unexpected parsing error: %v", err) + } + if len(result.Errors) > 0 { + t.Errorf("Unexpected errors in result: %v", result.Errors) + } + } + + if tt.expectMetadata { + if result.Metadata == nil { + t.Error("Expected metadata to not be nil") + } else { + if tt.expectedVersion != "" && result.Metadata.Version != tt.expectedVersion { + t.Errorf("Expected version '%s', got '%s'", tt.expectedVersion, result.Metadata.Version) + } + if tt.expectedEncoding != "" && result.Metadata.Encoding != tt.expectedEncoding { + t.Errorf("Expected encoding '%s', got '%s'", tt.expectedEncoding, result.Metadata.Encoding) + } + } + } + + if !tt.expectError && result.Data != nil { + if result.Data.GetID() != tt.expectedID { + t.Errorf("Expected ID '%s', got '%s'", tt.expectedID, result.Data.GetID()) + } + } + }) + } +} + +func TestParseWithInvalidMockData(t *testing.T) { + // Create invalid mock data that will pass XML parsing but fail validation + invalidMockData := MockParsable{ + ID: "test-invalid", + Name: "Test Name", + Value: "test-validation-failure", // This will cause validation to fail + Valid: false, // This will cause validation to fail + } + + // This test ensures that even if XML parsing succeeds, validation errors are caught + xmlData := ` + + Test Name + test-validation-failure +` + + parser := NewParser[MockParsable](nil) + result, err := parser.ParseString(xmlData) + + // This should fail during validation, not XML parsing + if err == nil { + t.Error("Expected validation error but got none") + } + + if result != nil { + t.Error("Expected result to be nil when validation fails") + } + + // Verify the error message contains validation information + if !strings.Contains(err.Error(), "validation failed") { + t.Errorf("Expected validation error message, got: %v", err) + } + + // Test the mock data validation directly + validationErr := invalidMockData.Validate() + if validationErr == nil { + t.Error("Expected direct validation to fail") + } +} + +func TestConcurrentParsing(t *testing.T) { + parser := NewParser[MockParsable](nil) + numGoroutines := 10 + numOperations := 5 + + var wg sync.WaitGroup + errors := make(chan error, numGoroutines*numOperations) + results := make(chan *MockParsable, numGoroutines*numOperations) + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(goroutineID int) { + defer wg.Done() + + for j := 0; j < numOperations; j++ { + // Use different XML data for each operation + xmlData := strings.Replace(validMockXML, "test-123", + fmt.Sprintf("test-%d-%d", goroutineID, j), 1) + + result, err := parser.ParseString(xmlData) + + if err != nil { + errors <- err + } else { + results <- result + } + } + }(i) + } + + wg.Wait() + close(errors) + close(results) + + // Check for errors + errorCount := 0 + for err := range errors { + t.Errorf("Concurrent parsing error: %v", err) + errorCount++ + } + + // Check results + resultCount := 0 + for range results { + resultCount++ + } + + expectedResults := numGoroutines * numOperations + if resultCount != expectedResults { + t.Errorf("Expected %d results, got %d (errors: %d)", expectedResults, resultCount, errorCount) + } +} + +func TestParserOptionsEffects(t *testing.T) { + tests := []struct { + name string + options *ParserOptions + xmlData string + expectError bool + }{ + { + name: "Strict validation enabled", + options: &ParserOptions{ + StrictValidation: true, + }, + xmlData: validMockXML, + expectError: false, + }, + { + name: "Strict validation disabled", + options: &ParserOptions{ + StrictValidation: false, + }, + xmlData: validMockXML, + expectError: false, + }, + { + name: "Very small size limit", + options: &ParserOptions{ + MaxSize: 10, // Very small + }, + xmlData: validMockXML, + expectError: true, + }, + { + name: "Very small depth limit", + options: &ParserOptions{ + MaxDepth: 1, // Very shallow + }, + xmlData: largeMockXML, // Contains nested elements + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewParser[MockParsable](tt.options) + _, err := parser.ParseString(tt.xmlData) + + if tt.expectError { + if err == nil { + t.Error("Expected parsing error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected parsing error: %v", err) + } + } + }) + } +} + +func TestExtractMetadata(t *testing.T) { + parser := NewParser[MockParsable](nil) + genericParser, ok := parser.(*GenericParser[MockParsable]) + if !ok { + t.Error("Expected parser to be of type *GenericParser[MockParsable]") + return + } + + tests := []struct { + name string + xmlData string + expectedVersion string + expectedEncoding string + expectedNamespace string + }{ + { + name: "Standard XML with version and encoding", + xmlData: validMockXML, + expectedVersion: "1.0", + expectedEncoding: "UTF-8", + expectedNamespace: "", + }, + { + name: "XML with namespace", + xmlData: validMockXMLWithNamespace, + expectedVersion: "1.0", + expectedEncoding: "UTF-8", + expectedNamespace: "http://test.example.com", + }, + { + name: "XML with single quotes", + xmlData: ` + + Test + Value +`, + expectedVersion: "1.1", + expectedEncoding: "ISO-8859-1", + expectedNamespace: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + metadata := &XMLMetadata{} + err := genericParser.extractMetadata([]byte(tt.xmlData), metadata) + + if err != nil { + t.Errorf("Unexpected metadata extraction error: %v", err) + return + } + + if metadata.Version != tt.expectedVersion { + t.Errorf("Expected version '%s', got '%s'", tt.expectedVersion, metadata.Version) + } + + if metadata.Encoding != tt.expectedEncoding { + t.Errorf("Expected encoding '%s', got '%s'", tt.expectedEncoding, metadata.Encoding) + } + + if metadata.Namespace != tt.expectedNamespace { + t.Errorf("Expected namespace '%s', got '%s'", tt.expectedNamespace, metadata.Namespace) + } + }) + } +} + +func TestParseEdgeCases(t *testing.T) { + parser := NewParser[MockParsable](nil) + + tests := []struct { + name string + xmlData string + expectError bool + description string + }{ + { + name: "XML with comments", + xmlData: ` + + + + Test with comments + Comments should be ignored +`, + expectError: false, + description: "XML with comments should parse successfully", + }, + { + name: "XML with CDATA", + xmlData: ` + + CDATA Test + characters]]> +`, + expectError: false, + description: "XML with CDATA should parse successfully", + }, + { + name: "XML with special characters", + xmlData: ` + + Special & Characters + <tag> "quoted" 'text' +`, + expectError: false, + description: "XML with escaped special characters should parse successfully", + }, + { + name: "Minimal valid XML", + xmlData: ` + Minimal + Test +`, + expectError: false, + description: "Minimal XML without declaration should parse successfully", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parser.ParseString(tt.xmlData) + + if tt.expectError { + if err == nil { + t.Errorf("%s: Expected error but got none", tt.description) + } + } else { + if err != nil { + t.Errorf("%s: Unexpected error: %v", tt.description, err) + return + } + + if result == nil { + t.Errorf("%s: Expected result to not be nil", tt.description) + return + } + + // Verify the result is valid + if err := result.Validate(); err != nil { + t.Errorf("%s: Result validation failed: %v", tt.description, err) + } + } + }) + } +} + +func TestParserMemoryUsage(t *testing.T) { + // Test parsing multiple documents to ensure no memory leaks + parser := NewParser[MockParsable](nil) + + for i := 0; i < 100; i++ { + xmlData := strings.Replace(validMockXML, "test-123", fmt.Sprintf("test-%d", i), 1) + result, err := parser.ParseString(xmlData) + + if err != nil { + t.Errorf("Parse error on iteration %d: %v", i, err) + break + } + + if result == nil { + t.Errorf("Nil result on iteration %d", i) + break + } + + // Explicitly trigger validation to ensure full processing + if err := result.Validate(); err != nil { + t.Errorf("Validation error on iteration %d: %v", i, err) + break + } + } +} + +func TestParseFromBuffer(t *testing.T) { + // Test parsing from a bytes.Buffer + buffer := bytes.NewBufferString(validMockXML) + parser := NewParser[MockParsable](nil) + + result, err := parser.Parse(buffer) + if err != nil { + t.Errorf("Unexpected error parsing from buffer: %v", err) + return + } + + if result == nil { + t.Error("Expected result to not be nil") + return + } + + if result.GetID() != "test-123" { + t.Errorf("Expected ID 'test-123', got '%s'", result.GetID()) + } +} diff --git a/pkg/xmlparser/run_tests.sh b/pkg/xmlparser/run_tests.sh new file mode 100755 index 0000000..4de72c4 --- /dev/null +++ b/pkg/xmlparser/run_tests.sh @@ -0,0 +1,302 @@ +#!/bin/bash + +# Comprehensive Test Runner for XML Parser Package +# This script runs the complete test suite with coverage analysis and benchmarks + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +PACKAGE_PATH="./..." +COVERAGE_FILE="coverage.out" +COVERAGE_HTML="coverage.html" +BENCHMARK_OUTPUT="benchmark.txt" +TEST_TIMEOUT="10m" +MIN_COVERAGE=80 + +echo -e "${BLUE}πŸš€ Starting XML Parser Test Suite${NC}" +echo "==================================" + +# Function to print section headers +print_section() { + echo -e "\n${BLUE}πŸ“‹ $1${NC}" + echo "----------------------------------------" +} + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Clean up previous test artifacts +print_section "Cleanup" +rm -f ${COVERAGE_FILE} ${COVERAGE_HTML} ${BENCHMARK_OUTPUT} +echo "βœ… Cleaned up previous test artifacts" + +# Verify Go installation +if ! command_exists go; then + echo -e "${RED}❌ Go is not installed or not in PATH${NC}" + exit 1 +fi + +GO_VERSION=$(go version) +echo "βœ… Go version: ${GO_VERSION}" + +# Check if we're in the right directory +if [ ! -f "interfaces.go" ]; then + echo -e "${RED}❌ Not in xmlparser package directory${NC}" + echo "Please run this script from pkg/xmlparser directory" + exit 1 +fi + +echo "βœ… Running from correct directory" + +# Run go mod tidy to ensure dependencies are clean +print_section "Dependency Check" +if [ -f "../../go.mod" ]; then + cd ../.. + go mod tidy + cd pkg/xmlparser + echo "βœ… Dependencies are clean" +else + echo "⚠️ No go.mod found, skipping dependency check" +fi + +# Run linting if golangci-lint is available +print_section "Code Quality Check" +if command_exists golangci-lint; then + echo "Running golangci-lint..." + if golangci-lint run --timeout=5m 2>/dev/null; then + echo "βœ… Linting passed" + else + echo "⚠️ golangci-lint encountered issues, falling back to go vet" + if go vet ${PACKAGE_PATH}; then + echo "βœ… go vet passed" + else + echo -e "${RED}❌ go vet failed${NC}" + exit 1 + fi + fi +else + echo "⚠️ golangci-lint not found, using go vet" + + # Fallback to basic go vet + if go vet ${PACKAGE_PATH}; then + echo "βœ… go vet passed" + else + echo -e "${RED}❌ go vet failed${NC}" + exit 1 + fi +fi + +# Run go fmt check +echo "Checking code formatting..." +UNFORMATTED=$(gofmt -l .) +if [ -n "$UNFORMATTED" ]; then + echo -e "${RED}❌ Code formatting issues found:${NC}" + echo "$UNFORMATTED" + echo "Run 'gofmt -w .' to fix formatting" + exit 1 +else + echo "βœ… Code formatting is correct" +fi + +# Run unit tests with coverage +print_section "Unit Tests" +echo "Running unit tests with coverage analysis..." + +if go test -v -race -timeout=${TEST_TIMEOUT} -coverprofile=${COVERAGE_FILE} ${PACKAGE_PATH}; then + echo -e "${GREEN}βœ… All unit tests passed${NC}" +else + echo -e "${RED}❌ Unit tests failed${NC}" + exit 1 +fi + +# Generate and display coverage report +print_section "Coverage Analysis" +if [ -f ${COVERAGE_FILE} ]; then + # Generate HTML coverage report + go tool cover -html=${COVERAGE_FILE} -o ${COVERAGE_HTML} + echo "βœ… HTML coverage report generated: ${COVERAGE_HTML}" + + # Extract coverage percentage + COVERAGE=$(go tool cover -func=${COVERAGE_FILE} | grep total | awk '{print $3}' | sed 's/%//') + + echo "πŸ“Š Coverage Summary:" + go tool cover -func=${COVERAGE_FILE} | tail -10 + + echo "" + echo "πŸ“ˆ Total Coverage: ${COVERAGE}%" + + # Check if coverage meets minimum requirement + if command_exists bc; then + if (( $(echo "$COVERAGE >= $MIN_COVERAGE" | bc -l) )); then + echo -e "${GREEN}βœ… Coverage meets minimum requirement (${MIN_COVERAGE}%)${NC}" + else + echo -e "${YELLOW}⚠️ Coverage (${COVERAGE}%) is below minimum requirement (${MIN_COVERAGE}%)${NC}" + echo "Consider adding more tests to improve coverage" + fi + else + # Use shell arithmetic as fallback + if [ $(echo "$COVERAGE" | cut -d. -f1) -ge $MIN_COVERAGE ]; then + echo -e "${GREEN}βœ… Coverage meets minimum requirement (${MIN_COVERAGE}%)${NC}" + else + echo -e "${YELLOW}⚠️ Coverage (${COVERAGE}%) is below minimum requirement (${MIN_COVERAGE}%)${NC}" + echo "Consider adding more tests to improve coverage" + fi + fi +else + echo -e "${RED}❌ Coverage file not generated${NC}" +fi + +# Run benchmarks +print_section "Performance Benchmarks" +echo "Running performance benchmarks..." + +if go test -bench=. -benchmem -run=^$ -timeout=${TEST_TIMEOUT} > ${BENCHMARK_OUTPUT} 2>&1; then + echo -e "${GREEN}βœ… Benchmarks completed successfully${NC}" + + echo "" + echo "πŸ“Š Benchmark Results:" + echo "=====================" + cat ${BENCHMARK_OUTPUT} | grep -E "(Benchmark|PASS|FAIL)" + + echo "" + echo "πŸ† Top 5 Fastest Operations:" + cat ${BENCHMARK_OUTPUT} | grep "^Benchmark" | sort -k3 -n | head -5 + + echo "" + echo "⚠️ Top 5 Memory Intensive Operations:" + cat ${BENCHMARK_OUTPUT} | grep "^Benchmark" | grep "allocs/op" | sort -k5 -nr | head -5 + +else + echo -e "${YELLOW}⚠️ Some benchmarks may have failed${NC}" + echo "Benchmark output:" + cat ${BENCHMARK_OUTPUT} +fi + +# Run specific test categories +print_section "Test Categories" + +echo "Running interface tests..." +if go test -v -run=TestDefaultParserOptions,TestXMLMetadata,TestParseResult,TestMockParsable,TestParserInterface -timeout=30s; then + echo "βœ… Interface tests passed" +else + echo -e "${RED}❌ Interface tests failed${NC}" +fi + +echo "" +echo "Running parser tests..." +if go test -v -run=TestNewParser,TestParseString,TestParseBytes,TestParse,TestValidate,TestParseWithResult -timeout=1m; then + echo "βœ… Parser tests passed" +else + echo -e "${RED}❌ Parser tests failed${NC}" +fi + +echo "" +echo "Running utility tests..." +if go test -v -run=TestValidationError,TestSanitizeXMLString,TestValidateXMLName,TestIsValidNamespace,TestNormalizeWhitespace,TestTruncateString -timeout=30s; then + echo "βœ… Utility tests passed" +else + echo -e "${RED}❌ Utility tests failed${NC}" +fi + +# Test with different Go build tags if needed +print_section "Build Verification" +echo "Verifying package builds correctly..." + +if go build -v ${PACKAGE_PATH}; then + echo "βœ… Package builds successfully" +else + echo -e "${RED}❌ Package build failed${NC}" + exit 1 +fi + +# Run race condition detection +print_section "Race Condition Detection" +echo "Running tests with race detector..." + +if go test -race -run=TestConcurrent -timeout=2m; then + echo "βœ… No race conditions detected" +else + echo -e "${YELLOW}⚠️ Race condition tests completed with warnings${NC}" +fi + +# Memory leak detection (basic) +print_section "Memory Analysis" +echo "Running memory allocation tests..." + +if go test -run=TestParserMemoryUsage -timeout=1m; then + echo "βœ… Memory tests passed" +else + echo -e "${YELLOW}⚠️ Memory tests completed with warnings${NC}" +fi + +# Run TED model tests if they exist +if [ -d "ted" ]; then + print_section "TED Model Tests" + echo "Running TED XML model tests..." + + if go test -v ./ted -timeout=1m; then + echo "βœ… TED model tests passed" + else + echo -e "${RED}❌ TED model tests failed${NC}" + fi +fi + +# Performance regression check (if baseline exists) +if [ -f "benchmark_baseline.txt" ]; then + print_section "Performance Regression Check" + echo "Comparing with baseline performance..." + + if command_exists benchcmp; then + benchcmp benchmark_baseline.txt ${BENCHMARK_OUTPUT} + echo "βœ… Performance comparison completed" + else + echo "⚠️ benchcmp not available, skipping performance regression check" + echo "Install with: go install golang.org/x/tools/cmd/benchcmp@latest" + fi +else + echo "πŸ’‘ Consider creating a performance baseline:" + echo " cp ${BENCHMARK_OUTPUT} benchmark_baseline.txt" +fi + +# Test summary +print_section "Test Summary" +echo "πŸ“‹ Test Suite Completed" +echo "=======================" + +echo "βœ… Unit Tests: PASSED" +echo "πŸ“Š Coverage: ${COVERAGE}%" +echo "πŸ† Benchmarks: COMPLETED" +echo "πŸ” Race Detection: PASSED" +echo "πŸ“ Generated Files:" +echo " - ${COVERAGE_HTML} (HTML coverage report)" +echo " - ${COVERAGE_FILE} (Coverage data)" +echo " - ${BENCHMARK_OUTPUT} (Benchmark results)" + +if [ -f ${COVERAGE_HTML} ]; then + echo "" + echo "🌐 To view coverage report in browser:" + echo " open ${COVERAGE_HTML}" +fi + +echo "" +echo -e "${GREEN}πŸŽ‰ All tests completed successfully!${NC}" + +# Cleanup intermediate files (optional) +read -p "πŸ—‘οΈ Remove intermediate files? (y/N): " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + rm -f ${COVERAGE_FILE} + echo "βœ… Intermediate files cleaned up" +fi + +echo "==================================" +echo -e "${BLUE}🏁 XML Parser Test Suite Complete${NC}" \ No newline at end of file diff --git a/pkg/xmlparser/utils_test.go b/pkg/xmlparser/utils_test.go new file mode 100644 index 0000000..1f12552 --- /dev/null +++ b/pkg/xmlparser/utils_test.go @@ -0,0 +1,763 @@ +package xmlparser + +import ( + "errors" + "strings" + "testing" +) + +func TestValidationError(t *testing.T) { + tests := []struct { + name string + field string + message string + value string + expected string + }{ + { + name: "Error with value", + field: "name", + message: "is required", + value: "empty", + expected: "validation error in field 'name': is required (value: empty)", + }, + { + name: "Error without value", + field: "id", + message: "must be unique", + value: "", + expected: "validation error in field 'id': must be unique", + }, + { + name: "Complex error message", + field: "email", + message: "must be a valid email address", + value: "invalid-email", + expected: "validation error in field 'email': must be a valid email address (value: invalid-email)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidationError{ + Field: tt.field, + Message: tt.message, + Value: tt.value, + } + + if err.Error() != tt.expected { + t.Errorf("Expected error message '%s', got '%s'", tt.expected, err.Error()) + } + }) + } +} + +func TestValidationErrors(t *testing.T) { + tests := []struct { + name string + errors ValidationErrors + expected string + }{ + { + name: "Empty validation errors", + errors: ValidationErrors{}, + expected: "no validation errors", + }, + { + name: "Single validation error", + errors: ValidationErrors{ + {Field: "name", Message: "is required"}, + }, + expected: "validation failed with 1 error(s): validation error in field 'name': is required", + }, + { + name: "Multiple validation errors", + errors: ValidationErrors{ + {Field: "name", Message: "is required"}, + {Field: "email", Message: "must be valid", Value: "invalid"}, + }, + expected: "validation failed with 2 error(s): validation error in field 'name': is required; validation error in field 'email': must be valid (value: invalid)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.errors.Error() != tt.expected { + t.Errorf("Expected error message '%s', got '%s'", tt.expected, tt.errors.Error()) + } + }) + } +} + +func TestValidationErrorsIsEmpty(t *testing.T) { + tests := []struct { + name string + errors ValidationErrors + expected bool + }{ + { + name: "Empty errors", + errors: ValidationErrors{}, + expected: true, + }, + { + name: "Nil errors", + errors: nil, + expected: true, + }, + { + name: "Non-empty errors", + errors: ValidationErrors{ + {Field: "test", Message: "test error"}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.errors.IsEmpty() != tt.expected { + t.Errorf("Expected IsEmpty() to return %v, got %v", tt.expected, tt.errors.IsEmpty()) + } + }) + } +} + +func TestValidationErrorsAdd(t *testing.T) { + var errors ValidationErrors + + // Test adding errors + errors.Add("field1", "message1", "value1") + errors.Add("field2", "message2", "") + + if len(errors) != 2 { + t.Errorf("Expected 2 errors, got %d", len(errors)) + } + + // Test first error + if errors[0].Field != "field1" { + t.Errorf("Expected first error field 'field1', got '%s'", errors[0].Field) + } + if errors[0].Message != "message1" { + t.Errorf("Expected first error message 'message1', got '%s'", errors[0].Message) + } + if errors[0].Value != "value1" { + t.Errorf("Expected first error value 'value1', got '%s'", errors[0].Value) + } + + // Test second error + if errors[1].Field != "field2" { + t.Errorf("Expected second error field 'field2', got '%s'", errors[1].Field) + } + if errors[1].Message != "message2" { + t.Errorf("Expected second error message 'message2', got '%s'", errors[1].Message) + } + if errors[1].Value != "" { + t.Errorf("Expected second error value to be empty, got '%s'", errors[1].Value) + } +} + +func TestXMLParseError(t *testing.T) { + tests := []struct { + name string + error XMLParseError + expected string + }{ + { + name: "Complete error information", + error: XMLParseError{ + Line: 10, + Column: 5, + Element: "TestElement", + Message: "unexpected end of element", + Original: errors.New("original error"), + }, + expected: "XML parse error (element 'TestElement', line 10, column 5): unexpected end of element", + }, + { + name: "Error with line only", + error: XMLParseError{ + Line: 15, + Message: "syntax error", + }, + expected: "XML parse error (line 15): syntax error", + }, + { + name: "Error with element only", + error: XMLParseError{ + Element: "BadElement", + Message: "invalid element", + }, + expected: "XML parse error (element 'BadElement'): invalid element", + }, + { + name: "Minimal error", + error: XMLParseError{ + Message: "general parse error", + }, + expected: "XML parse error: general parse error", + }, + { + name: "Error with line and column", + error: XMLParseError{ + Line: 20, + Column: 8, + Message: "missing closing tag", + }, + expected: "XML parse error (line 20, column 8): missing closing tag", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.error.Error() != tt.expected { + t.Errorf("Expected error message '%s', got '%s'", tt.expected, tt.error.Error()) + } + }) + } +} + +func TestXMLParseErrorUnwrap(t *testing.T) { + originalErr := errors.New("original error") + xmlErr := XMLParseError{ + Message: "wrapper error", + Original: originalErr, + } + + unwrapped := xmlErr.Unwrap() + if unwrapped != originalErr { + t.Errorf("Expected unwrapped error to be original error, got %v", unwrapped) + } + + // Test with no original error + xmlErrNoOriginal := XMLParseError{ + Message: "standalone error", + } + + unwrappedNil := xmlErrNoOriginal.Unwrap() + if unwrappedNil != nil { + t.Errorf("Expected unwrapped error to be nil, got %v", unwrappedNil) + } +} + +func TestSanitizeXMLString(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Clean string", + input: "Hello World", + expected: "Hello World", + }, + { + name: "String with valid whitespace", + input: "Hello\tWorld\nNew Line\rCarriage Return", + expected: "Hello\tWorld\nNew Line\rCarriage Return", + }, + { + name: "String with control characters", + input: "Hello\x00\x01\x02World", + expected: "HelloWorld", + }, + { + name: "String with high Unicode", + input: "Hello δΈ–η•Œ 🌍", + expected: "Hello δΈ–η•Œ 🌍", + }, + { + name: "Empty string", + input: "", + expected: "", + }, + { + name: "String with only control characters", + input: "\x00\x01\x02\x03", + expected: "", + }, + { + name: "Mixed valid and invalid characters", + input: "Valid\x00Invalid\tTab\x01More\nNewline", + expected: "ValidInvalid\tTabMore\nNewline", + }, + { + name: "XML special characters", + input: "<>&\"'", + expected: "<>&\"'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeXMLString(tt.input) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestValidateXMLName(t *testing.T) { + tests := []struct { + name string + xmlName string + expected bool + }{ + // Valid names + { + name: "Simple name", + xmlName: "element", + expected: true, + }, + { + name: "Name with underscore", + xmlName: "_element", + expected: true, + }, + { + name: "Name with uppercase", + xmlName: "Element", + expected: true, + }, + { + name: "Name with numbers", + xmlName: "element123", + expected: true, + }, + { + name: "Name with hyphen", + xmlName: "my-element", + expected: true, + }, + { + name: "Name with period", + xmlName: "my.element", + expected: true, + }, + { + name: "Complex valid name", + xmlName: "My_Element-123.test", + expected: true, + }, + // Invalid names + { + name: "Empty name", + xmlName: "", + expected: false, + }, + { + name: "Name starting with number", + xmlName: "123element", + expected: false, + }, + { + name: "Name starting with hyphen", + xmlName: "-element", + expected: false, + }, + { + name: "Name starting with period", + xmlName: ".element", + expected: false, + }, + { + name: "Name with space", + xmlName: "my element", + expected: false, + }, + { + name: "Name with special characters", + xmlName: "element@test", + expected: false, + }, + { + name: "Name with colon", + xmlName: "element:test", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ValidateXMLName(tt.xmlName) + if result != tt.expected { + t.Errorf("Expected ValidateXMLName('%s') to return %v, got %v", tt.xmlName, tt.expected, result) + } + }) + } +} + +func TestIsValidNamespace(t *testing.T) { + tests := []struct { + name string + namespace string + expected bool + }{ + // Valid namespaces + { + name: "Empty namespace", + namespace: "", + expected: true, + }, + { + name: "HTTP namespace", + namespace: "http://example.com", + expected: true, + }, + { + name: "HTTPS namespace", + namespace: "https://example.com", + expected: true, + }, + { + name: "URN namespace", + namespace: "urn:example:namespace", + expected: true, + }, + { + name: "Complex HTTP namespace", + namespace: "http://www.example.com/schema/2023", + expected: true, + }, + { + name: "Complex HTTPS namespace", + namespace: "https://schemas.example.org/xml/namespace", + expected: true, + }, + { + name: "Complex URN namespace", + namespace: "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2", + expected: true, + }, + // Invalid namespaces + { + name: "Invalid protocol", + namespace: "ftp://example.com", + expected: false, + }, + { + name: "No protocol", + namespace: "example.com", + expected: false, + }, + { + name: "Invalid format", + namespace: "not-a-namespace", + expected: false, + }, + { + name: "Partial HTTP", + namespace: "http:", + expected: false, + }, + { + name: "Partial URN", + namespace: "urn", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsValidNamespace(tt.namespace) + if result != tt.expected { + t.Errorf("Expected IsValidNamespace('%s') to return %v, got %v", tt.namespace, tt.expected, result) + } + }) + } +} + +func TestNormalizeWhitespace(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Single spaces", + input: "hello world", + expected: "hello world", + }, + { + name: "Multiple spaces", + input: "hello world", + expected: "hello world", + }, + { + name: "Mixed whitespace", + input: "hello\t\n\r world", + expected: "hello world", + }, + { + name: "Leading whitespace", + input: " hello world", + expected: "hello world", + }, + { + name: "Trailing whitespace", + input: "hello world ", + expected: "hello world", + }, + { + name: "Leading and trailing whitespace", + input: " hello world ", + expected: "hello world", + }, + { + name: "Multiple words with mixed whitespace", + input: "one\ttwo\n\nthree\r\r\rfour five", + expected: "one two three four five", + }, + { + name: "Empty string", + input: "", + expected: "", + }, + { + name: "Only whitespace", + input: " \t\n\r ", + expected: "", + }, + { + name: "Single word", + input: "hello", + expected: "hello", + }, + { + name: "Single word with whitespace", + input: " hello ", + expected: "hello", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeWhitespace(tt.input) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestTruncateString(t *testing.T) { + tests := []struct { + name string + input string + maxLength int + expected string + }{ + { + name: "Short string", + input: "hello", + maxLength: 10, + expected: "hello", + }, + { + name: "Exact length", + input: "hello", + maxLength: 5, + expected: "hello", + }, + { + name: "Truncate with ellipsis", + input: "hello world", + maxLength: 8, + expected: "hello...", + }, + { + name: "Very short max length", + input: "hello", + maxLength: 3, + expected: "hel", + }, + { + name: "Max length 0", + input: "hello", + maxLength: 0, + expected: "", + }, + { + name: "Max length 1", + input: "hello", + maxLength: 1, + expected: "h", + }, + { + name: "Max length 2", + input: "hello", + maxLength: 2, + expected: "he", + }, + { + name: "Empty string", + input: "", + maxLength: 5, + expected: "", + }, + { + name: "Long string truncation", + input: "This is a very long string that needs to be truncated", + maxLength: 20, + expected: "This is a very lo...", + }, + { + name: "Unicode string", + input: "Hello δΈ–η•Œ", + maxLength: 8, + expected: "Hello...", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TruncateString(tt.input, tt.maxLength) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + + // Verify result length doesn't exceed maxLength + if len(result) > tt.maxLength { + t.Errorf("Result length %d exceeds maxLength %d", len(result), tt.maxLength) + } + }) + } +} + +func TestUtilityFunctionsWithRealWorldData(t *testing.T) { + // Test with real-world XML-like data + t.Run("Real world XML sanitization", func(t *testing.T) { + input := "Contract Notice\x00 for\x01 Public\x02 Procurement\t\nProject" + expected := "Contract Notice for Public Procurement\t\nProject" + result := SanitizeXMLString(input) + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } + }) + + t.Run("Real world XML name validation", func(t *testing.T) { + validNames := []string{ + "ContractNotice", + "ProcurementProject", + "TenderingTerms", + "_privateElement", + "xml-element", + "element.version", + } + + for _, name := range validNames { + if !ValidateXMLName(name) { + t.Errorf("Expected '%s' to be valid XML name", name) + } + } + + invalidNames := []string{ + "123Contract", + "-element", + ".element", + "element with spaces", + "element@symbol", + } + + for _, name := range invalidNames { + if ValidateXMLName(name) { + t.Errorf("Expected '%s' to be invalid XML name", name) + } + } + }) + + t.Run("Real world namespace validation", func(t *testing.T) { + validNamespaces := []string{ + "", + "http://www.w3.org/2001/XMLSchema", + "https://ted.europa.eu/schema/eforms", + "urn:oasis:names:specification:ubl:schema:xsd:ContractNotice-2", + } + + for _, ns := range validNamespaces { + if !IsValidNamespace(ns) { + t.Errorf("Expected '%s' to be valid namespace", ns) + } + } + }) + + t.Run("Real world whitespace normalization", func(t *testing.T) { + input := " Contract\t\tNotice\n\n\nfor\r\r\rPublic Procurement " + expected := "Contract Notice for Public Procurement" + result := NormalizeWhitespace(input) + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } + }) + + t.Run("Real world string truncation", func(t *testing.T) { + longDescription := "This is a very long contract description that contains detailed information about the procurement project including all requirements, specifications, and conditions that need to be met by potential contractors." + truncated := TruncateString(longDescription, 50) + + if len(truncated) > 50 { + t.Errorf("Truncated string length %d exceeds limit 50", len(truncated)) + } + + if !strings.HasSuffix(truncated, "...") && len(longDescription) > 50 { + t.Error("Expected truncated string to end with '...'") + } + }) +} + +func TestErrorTypes(t *testing.T) { + // Test that our error types implement the error interface + var _ error = ValidationError{} + var _ error = ValidationErrors{} + var _ error = XMLParseError{} + + // Test error interface methods + ve := ValidationError{Field: "test", Message: "test error"} + if ve.Error() == "" { + t.Error("ValidationError.Error() should return non-empty string") + } + + ves := ValidationErrors{ve} + if ves.Error() == "" { + t.Error("ValidationErrors.Error() should return non-empty string") + } + + xpe := XMLParseError{Message: "test parse error"} + if xpe.Error() == "" { + t.Error("XMLParseError.Error() should return non-empty string") + } +} + +func TestValidationErrorsChaining(t *testing.T) { + var errors ValidationErrors + + // Build up errors + errors.Add("field1", "error1", "value1") + errors.Add("field2", "error2", "") + errors.Add("field3", "error3", "value3") + + if len(errors) != 3 { + t.Errorf("Expected 3 errors, got %d", len(errors)) + } + + if errors.IsEmpty() { + t.Error("Expected errors to not be empty") + } + + errorMessage := errors.Error() + if !strings.Contains(errorMessage, "validation failed with 3 error(s)") { + t.Errorf("Error message should contain count, got: %s", errorMessage) + } + + if !strings.Contains(errorMessage, "field1") { + t.Error("Error message should contain field1") + } + + if !strings.Contains(errorMessage, "field2") { + t.Error("Error message should contain field2") + } + + if !strings.Contains(errorMessage, "field3") { + t.Error("Error message should contain field3") + } +}