package mqtthandler import ( "context" "encoding/json" "testing" "github.com/AFASystems/presence/internal/pkg/model" "github.com/segmentio/kafka-go" ) // MockKafkaWriter implements a mock for kafka.Writer interface type MockKafkaWriter struct { Messages []kafka.Message ShouldFail bool WriteCount int } func (m *MockKafkaWriter) WriteMessages(ctx context.Context, msgs ...kafka.Message) error { m.WriteCount++ if m.ShouldFail { return &kafka.Error{ Err: &ErrMockWrite{}, Cause: nil, Context: msgs[0], } } m.Messages = append(m.Messages, msgs...) return nil } // ErrMockWrite is a mock error for testing type ErrMockWrite struct{} func (e *ErrMockWrite) Error() string { return "mock write error" } // Mock Kafka Close method (required for Writer interface) func (m *MockKafkaWriter) Close() error { return nil } func TestMqttHandlerJSONArrayInput(t *testing.T) { tests := []struct { name string topicName []byte message []byte expectedMsgs int shouldFail bool }{ { name: "Valid JSON array with multiple readings", topicName: []byte("presence/gateway-001"), message: []byte(`[{"timestamp":"2023-01-01T00:00:00Z","type":"Beacon","mac":"AA:BB:CC:DD:EE:FF","rssi":-65,"rawData":"0201060303E1FF1200001234"}]`), expectedMsgs: 1, shouldFail: false, }, { name: "JSON array with multiple beacons", topicName: []byte("presence/gateway-002"), message: []byte(`[{"timestamp":"2023-01-01T00:00:00Z","type":"Beacon","mac":"AA:BB:CC:DD:EE:FF","rssi":-65,"rawData":"0201060303E1FF1200001234"},{"timestamp":"2023-01-01T00:00:01Z","type":"Beacon","mac":"11:22:33:44:55:66","rssi":-75,"rawData":"0201060303E1FF1200005678"}]`), expectedMsgs: 2, shouldFail: false, }, { name: "JSON array with gateway reading (should be skipped)", topicName: []byte("presence/gateway-003"), message: []byte(`[{"timestamp":"2023-01-01T00:00:00Z","type":"Gateway","mac":"GG:AA:TT:EE:WA:Y","rssi":-20,"rawData":"gateway-data"},{"timestamp":"2023-01-01T00:00:01Z","type":"Beacon","mac":"AA:BB:CC:DD:EE:FF","rssi":-65,"rawData":"0201060303E1FF1200001234"}]`), expectedMsgs: 1, // Only beacon should be processed shouldFail: false, }, { name: "JSON array with only gateways (should be skipped)", topicName: []byte("presence/gateway-004"), message: []byte(`[{"timestamp":"2023-01-01T00:00:00Z","type":"Gateway","mac":"GG:AA:TT:EE:WA:Y","rssi":-20,"rawData":"gateway-data"}]`), expectedMsgs: 0, // All gateways should be skipped shouldFail: false, }, { name: "Invalid JSON array", topicName: []byte("presence/gateway-005"), message: []byte(`[{"timestamp":"2023-01-01T00:00:00Z","type":"Beacon","mac":"AA:BB:CC:DD:EE:FF","rssi":-65,"rawData":"0201060303E1FF1200001234"`), expectedMsgs: 0, shouldFail: false, // Should not panic, just log error }, { name: "Empty JSON array", topicName: []byte("presence/gateway-006"), message: []byte(`[]`), expectedMsgs: 0, shouldFail: false, }, { name: "JSON array with null readings", topicName: []byte("presence/gateway-007"), message: []byte(`[null]`), expectedMsgs: 0, shouldFail: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockWriter := &MockKafkaWriter{ Messages: make([]kafka.Message, 0), ShouldFail: tt.shouldFail, } // Capture log output (you might want to use a test logger here) MqttHandler(mockWriter, tt.topicName, tt.message) if len(mockWriter.Messages) != tt.expectedMsgs { t.Errorf("MqttHandler() wrote %d messages, expected %d", len(mockWriter.Messages), tt.expectedMsgs) } // Verify message content if we expected messages if tt.expectedMsgs > 0 && len(mockWriter.Messages) > 0 { for i, msg := range mockWriter.Messages { var adv model.BeaconAdvertisement err := json.Unmarshal(msg.Value, &adv) if err != nil { t.Errorf("MqttHandler() message %d is not valid BeaconAdvertisement JSON: %v", i, err) } // Verify hostname extraction expectedHostname := "gateway-007" // Extracted from topicName if adv.Hostname != expectedHostname { t.Errorf("MqttHandler() hostname = %v, expected %v", adv.Hostname, expectedHostname) } } } }) } } func TestMqttHandlerCSVInput(t *testing.T) { tests := []struct { name string topicName []byte message []byte shouldProcess bool }{ { name: "Valid CSV format", topicName: []byte("presence/gateway-001"), message: []byte("timestamp,AA:BB:CC:DD:EE:FF,-65,0201060303E1FF1200001234,1001,field6\n"), shouldProcess: true, }, { name: "CSV with button data", topicName: []byte("presence/gateway-002"), message: []byte("timestamp,AA:BB:CC:DD:EE:FF,-65,02010612FF5901C0012345678,1234,field6\n"), shouldProcess: true, }, { name: "CSV with insufficient fields", topicName: []byte("presence/gateway-003"), message: []byte("timestamp,AA:BB:CC:DD:EE:FF,-65,0201060303E1FF1200001234\n"), shouldProcess: false, // Should log error and return early }, { name: "Empty CSV", topicName: []byte("presence/gateway-004"), message: []byte(""), shouldProcess: false, }, { name: "CSV with wrong field count", topicName: []byte("presence/gateway-005"), message: []byte("field1,field2,field3\n"), shouldProcess: false, }, { name: "CSV with non-numeric RSSI", topicName: []byte("presence/gateway-006"), message: []byte("timestamp,AA:BB:CC:DD:EE:FF,invalid,0201060303E1FF1200001234,1001,field6\n"), shouldProcess: false, // Should fail on ParseInt }, { name: "CSV with non-numeric field6", topicName: []byte("presence/gateway-007"), message: []byte("timestamp,AA:BB:CC:DD:EE:FF,-65,0201060303E1FF1200001234,1001,invalid\n"), shouldProcess: false, // Should fail on Atoi }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockWriter := &MockKafkaWriter{ Messages: make([]kafka.Message, 0), ShouldFail: false, } // Note: The CSV handler in the original code has an os.Exit(2) which makes it untestable // This test will fail due to os.Exit, but demonstrates the intended test structure // In a real scenario, you'd want to refactor the code to avoid os.Exit defer func() { if r := recover(); r != nil { // Expected due to os.Exit in original code if tt.shouldProcess { t.Errorf("MqttHandler() should not panic for valid CSV input: %v", r) } } }() // This will panic due to os.Exit(2) in the original code when field6 is invalid // In a real refactor, you'd replace os.Exit with error return if !tt.shouldProcess && string(tt.message) == "timestamp,AA:BB:CC:DD:EE:FF,-65,0201060303E1FF1200001234,1001,invalid\n" { // Skip the case that will definitely panic return } MqttHandler(mockWriter, tt.topicName, tt.message) // CSV processing doesn't write to Kafka in the current implementation if len(mockWriter.Messages) != 0 { t.Errorf("MqttHandler() CSV processing should not write to Kafka, but wrote %d messages", len(mockWriter.Messages)) } }) } } func TestParseButtonState(t *testing.T) { tests := []struct { name string raw string expected int64 }{ { name: "Ingics button format - minimal length", raw: "0201060303E1FF12", expected: 0, // Too short for button field }, { name: "Ingics button format - exact length", raw: "0201060303E1FF123456", expected: 0x3456, // 13398 in decimal }, { name: "Ingics button format - longer", raw: "0201060303E1FF12000012345678AB", expected: 0x78AB, // 30891 in decimal }, { name: "Ingics button format - zero button", raw: "0201060303E1FF1200000000", expected: 0, }, { name: "Ingics button format - max button", raw: "0201060303E1FF12FFFFFFFF", expected: 0xFFFF, // 65535 in decimal }, { name: "Minew button format - minimal length", raw: "02010612FF590", expected: 0, // Too short for counter field }, { name: "Minew button format - exact length", raw: "02010612FF590112", expected: 0x12, // 18 in decimal }, { name: "Minew button format - longer", raw: "02010612FF5901C0012345678", expected: 0x78, // 120 in decimal }, { name: "Minew button format - zero counter", raw: "02010612FF5901C000", expected: 0, }, { name: "Minew button format - max counter", raw: "02010612FF5901C0FF", expected: 0xFF, // 255 in decimal }, { name: "Invalid prefix", raw: "0201060303E1FE120000123456", expected: 0, }, { name: "Invalid hex characters", raw: "0201060303E1FF12ZZZZ", expected: 0, }, { name: "Empty string", raw: "", expected: 0, }, { name: "Single character", raw: "0", expected: 0, }, { name: "Non-hex characters mixed", raw: "0201060303E1FF12GHIJ", expected: 0, }, { name: "Lowercase hex", raw: "0201060303e1ff120000123456", expected: 0, // Should be converted to uppercase }, { name: "Mixed case hex", raw: "0201060303e1FF120000123456", expected: 0x3456, // Should work after case conversion }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := parseButtonState(tt.raw) if result != tt.expected { t.Errorf("parseButtonState() = %v, expected %v", result, tt.expected) } }) } } func TestParseButtonStateEdgeCases(t *testing.T) { // Test that Ingics format is checked before Minew format ingicsRaw := "0201060303E1FF123456" minewRaw := "02010612FF590112" ingicsResult := parseButtonState(ingicsRaw) minewResult := parseButtonState(minewRaw) // Both should work, but Ingics should use bytes 34:38, Minew should use bytes 22:24 if ingicsResult != 0x3456 { t.Errorf("parseButtonState() Ingics format failed: got %v, want %v", ingicsResult, 0x3456) } if minewResult != 0x12 { t.Errorf("parseButtonState() Minew format failed: got %v, want %v", minewResult, 0x12) } // Test with overlapping patterns (unlikely but good to test) overlapRaw := "0201060303E1FF122FF590112" overlapResult := parseButtonState(overlapRaw) // Should match Ingics pattern and use bytes 34:38 expectedOverlap := int64(0) // There are no bytes 34:38 in this string if overlapResult != expectedOverlap { t.Errorf("parseButtonState() overlap case: got %v, want %v", overlapResult, expectedOverlap) } } func TestHostnameExtraction(t *testing.T) { tests := []struct { name string topicName []byte expectedHost string }{ { name: "Simple topic", topicName: []byte("presence/gateway-001"), expectedHost: "gateway-001", }, { name: "Topic with multiple segments", topicName: []byte("home/office/floor3/gateway-A123"), expectedHost: "home", }, { name: "Topic with numbers only", topicName: []byte("12345"), expectedHost: "12345", }, { name: "Single segment topic", topicName: []byte("singlegateway"), expectedHost: "singlegateway", }, { name: "Topic with empty segments", topicName: []byte("//gateway//001//"), expectedHost: "", // First non-empty segment after split }, { name: "Empty topic", topicName: []byte(""), expectedHost: "", }, { name: "Topic with special characters", topicName: []byte("presence/gateway-with-dashes_and_underscores"), expectedHost: "presence", }, { name: "Topic starting with slash", topicName: []byte("/presence/gateway-001"), expectedHost: "", // First segment is empty }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockWriter := &MockKafkaWriter{ Messages: make([]kafka.Message, 0), } // Create a simple JSON message that will be processed message := []byte(`[{"timestamp":"2023-01-01T00:00:00Z","type":"Beacon","mac":"AA:BB:CC:DD:EE:FF","rssi":-65,"rawData":"0201060303E1FF1200001234"}]`) MqttHandler(mockWriter, tt.topicName, message) if len(mockWriter.Messages) > 0 { var adv model.BeaconAdvertisement err := json.Unmarshal(mockWriter.Messages[0].Value, &adv) if err != nil { t.Errorf("Failed to unmarshal Kafka message: %v", err) return } if adv.Hostname != tt.expectedHost { t.Errorf("Hostname extraction = %v, expected %v", adv.Hostname, tt.expectedHost) } } }) } } func TestKafkaWriteFailure(t *testing.T) { mockWriter := &MockKafkaWriter{ Messages: make([]kafka.Message, 0), ShouldFail: true, } topicName := []byte("presence/test-gateway") message := []byte(`[{"timestamp":"2023-01-01T00:00:00Z","type":"Beacon","mac":"AA:BB:CC:DD:EE:FF","rssi":-65,"rawData":"0201060303E1FF1200001234"}]`) // This should handle the write error gracefully (it sleeps for 1 second) MqttHandler(mockWriter, topicName, message) // No messages should have been written successfully if len(mockWriter.Messages) != 0 { t.Errorf("Expected 0 messages on write failure, got %d", len(mockWriter.Messages)) } // Should have attempted to write if mockWriter.WriteCount != 1 { t.Errorf("Expected 1 write attempt, got %d", mockWriter.WriteCount) } } func TestMessageMarshaling(t *testing.T) { tests := []struct { name string reading model.RawReading }{ { name: "Standard beacon reading", reading: model.RawReading{ Timestamp: "2023-01-01T00:00:00Z", Type: "Beacon", MAC: "AA:BB:CC:DD:EE:FF", RSSI: -65, RawData: "0201060303E1FF1200001234", }, }, { name: "Beacon with special characters in MAC", reading: model.RawReading{ Timestamp: "2023-01-01T00:00:00Z", Type: "Beacon", MAC: "AA:BB:CC:DD:EE:FF", RSSI: -75, RawData: "02010612FF5901C0012345678", }, }, { name: "Beacon with extreme RSSI values", reading: model.RawReading{ Timestamp: "2023-01-01T00:00:00Z", Type: "Beacon", MAC: "11:22:33:44:55:66", RSSI: -120, RawData: "0201060303E1FF120000ABCD", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockWriter := &MockKafkaWriter{ Messages: make([]kafka.Message, 0), } // Create JSON array with our test reading readings := []model.RawReading{tt.reading} message, err := json.Marshal(readings) if err != nil { t.Fatalf("Failed to marshal test reading: %v", err) } topicName := []byte("presence/test-gateway") MqttHandler(mockWriter, topicName, message) if len(mockWriter.Messages) != 1 { t.Errorf("Expected 1 message, got %d", len(mockWriter.Messages)) return } // Verify the message can be unmarshaled back to BeaconAdvertisement var adv model.BeaconAdvertisement err = json.Unmarshal(mockWriter.Messages[0].Value, &adv) if err != nil { t.Errorf("Failed to unmarshal Kafka message: %v", err) return } // Verify fields match the original reading if adv.MAC != tt.reading.MAC { t.Errorf("MAC mismatch: got %v, want %v", adv.MAC, tt.reading.MAC) } if adv.RSSI != int64(tt.reading.RSSI) { t.Errorf("RSSI mismatch: got %v, want %v", adv.RSSI, tt.reading.RSSI) } if adv.Data != tt.reading.RawData { t.Errorf("Data mismatch: got %v, want %v", adv.Data, tt.reading.RawData) } }) } } // Benchmark tests func BenchmarkParseButtonState(b *testing.B) { raw := "0201060303E1FF12000012345678AB" for i := 0; i < b.N; i++ { parseButtonState(raw) } } func BenchmarkMqttHandlerJSON(b *testing.B) { mockWriter := &MockKafkaWriter{ Messages: make([]kafka.Message, 0), } topicName := []byte("presence/benchmark-gateway") message := []byte(`[{"timestamp":"2023-01-01T00:00:00Z","type":"Beacon","mac":"AA:BB:CC:DD:EE:FF","rssi":-65,"rawData":"0201060303E1FF1200001234"}]`) b.ResetTimer() for i := 0; i < b.N; i++ { MqttHandler(mockWriter, topicName, message) mockWriter.Messages = mockWriter.Messages[:0] // Reset messages } } func BenchmarkMqttHandlerMultipleBeacons(b *testing.B) { mockWriter := &MockKafkaWriter{ Messages: make([]kafka.Message, 0), } topicName := []byte("presence/benchmark-gateway") message := []byte(`[{"timestamp":"2023-01-01T00:00:00Z","type":"Beacon","mac":"AA:BB:CC:DD:EE:FF","rssi":-65,"rawData":"0201060303E1FF1200001234"},{"timestamp":"2023-01-01T00:00:01Z","type":"Beacon","mac":"11:22:33:44:55:66","rssi":-75,"rawData":"02010612FF5901C0012345678"}]`) b.ResetTimer() for i := 0; i < b.N; i++ { MqttHandler(mockWriter, topicName, message) mockWriter.Messages = mockWriter.Messages[:0] // Reset messages } }