| @@ -9,6 +9,7 @@ import ( | |||||
| "strings" | "strings" | ||||
| "time" | "time" | ||||
| "github.com/AFASystems/presence/internal/pkg/common/appcontext" | |||||
| "github.com/AFASystems/presence/internal/pkg/config" | "github.com/AFASystems/presence/internal/pkg/config" | ||||
| "github.com/AFASystems/presence/internal/pkg/kafkaclient" | "github.com/AFASystems/presence/internal/pkg/kafkaclient" | ||||
| "github.com/AFASystems/presence/internal/pkg/model" | "github.com/AFASystems/presence/internal/pkg/model" | ||||
| @@ -29,6 +30,7 @@ func main() { | |||||
| func HttpServer(addr string) { | func HttpServer(addr string) { | ||||
| cfg := config.Load() | cfg := config.Load() | ||||
| appState := appcontext.NewAppState() | |||||
| headersOk := handlers.AllowedHeaders([]string{"X-Requested-With"}) | headersOk := handlers.AllowedHeaders([]string{"X-Requested-With"}) | ||||
| originsOk := handlers.AllowedOrigins([]string{"*"}) | originsOk := handlers.AllowedOrigins([]string{"*"}) | ||||
| @@ -67,29 +69,54 @@ func HttpServer(addr string) { | |||||
| for { | for { | ||||
| select { | select { | ||||
| case msg := <-chLoc: | case msg := <-chLoc: | ||||
| beacon, ok := appState.GetBeacon(msg.ID) | |||||
| if !ok { | |||||
| appState.UpdateBeacon(msg.ID, model.Beacon{ID: msg.ID, Location: msg.Location, Distance: msg.Distance, LastSeen: msg.LastSeen, PreviousConfidentLocation: msg.PreviousConfidentLocation}) | |||||
| } else { | |||||
| beacon.ID = msg.ID | |||||
| beacon.Location = msg.Location | |||||
| beacon.Distance = msg.Distance | |||||
| beacon.LastSeen = msg.LastSeen | |||||
| beacon.PreviousConfidentLocation = msg.PreviousConfidentLocation | |||||
| appState.UpdateBeacon(msg.ID, beacon) | |||||
| } | |||||
| key := fmt.Sprintf("beacon:%s", msg.ID) | key := fmt.Sprintf("beacon:%s", msg.ID) | ||||
| hashM, err := msg.RedisHashable() | hashM, err := msg.RedisHashable() | ||||
| if err != nil { | if err != nil { | ||||
| fmt.Println("Error in converting location into hashmap for Redis insert: ", err) | fmt.Println("Error in converting location into hashmap for Redis insert: ", err) | ||||
| continue | continue | ||||
| } | } | ||||
| err = client.HSet(ctx, key, hashM).Err() | |||||
| if err != nil { | |||||
| if err := client.HSet(ctx, key, hashM).Err(); err != nil { | |||||
| fmt.Println("Error in persisting set in Redis key: ", key) | fmt.Println("Error in persisting set in Redis key: ", key) | ||||
| continue | continue | ||||
| } | } | ||||
| if err := client.SAdd(ctx, "beacons", key).Err(); err != nil { | |||||
| fmt.Println("Error in adding beacon to the beacons list for get all operation: ", err) | |||||
| } | |||||
| case msg := <-chEvents: | case msg := <-chEvents: | ||||
| beacon, ok := appState.GetBeacon(msg.ID) | |||||
| if !ok { | |||||
| appState.UpdateBeacon(msg.ID, model.Beacon{ID: msg.ID, BeaconType: msg.Type, HSBattery: int64(msg.Battery), Event: msg.Event}) | |||||
| } else { | |||||
| beacon.ID = msg.ID | |||||
| beacon.BeaconType = msg.Type | |||||
| beacon.HSBattery = int64(msg.Battery) | |||||
| beacon.Event = msg.Event | |||||
| appState.UpdateBeacon(msg.ID, beacon) | |||||
| } | |||||
| key := fmt.Sprintf("beacon:%s", msg.ID) | key := fmt.Sprintf("beacon:%s", msg.ID) | ||||
| hashM, err := msg.RedisHashable() | hashM, err := msg.RedisHashable() | ||||
| if err != nil { | if err != nil { | ||||
| fmt.Println("Error in converting location into hashmap for Redis insert: ", err) | fmt.Println("Error in converting location into hashmap for Redis insert: ", err) | ||||
| continue | continue | ||||
| } | } | ||||
| err = client.HSet(ctx, key, hashM).Err() | |||||
| if err != nil { | |||||
| if err := client.HSet(ctx, key, hashM).Err(); err != nil { | |||||
| fmt.Println("Error in persisting set in Redis key: ", key) | fmt.Println("Error in persisting set in Redis key: ", key) | ||||
| continue | continue | ||||
| } | } | ||||
| if err := client.SAdd(ctx, "beacons", key).Err(); err != nil { | |||||
| fmt.Println("Error in adding beacon to the beacons list for get all operation: ", err) | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| }() | }() | ||||
| @@ -105,7 +132,8 @@ func HttpServer(addr string) { | |||||
| // For now just add beacon DELETE / GET / POST / PUT methods | // For now just add beacon DELETE / GET / POST / PUT methods | ||||
| r.HandleFunc("/api/beacons/{beacon_id}", beaconsDeleteHandler(writer)).Methods("DELETE") | r.HandleFunc("/api/beacons/{beacon_id}", beaconsDeleteHandler(writer)).Methods("DELETE") | ||||
| r.HandleFunc("/api/beacons/{beacon_id}", beaconsListHandler(ctx, client)).Methods("GET") | |||||
| r.HandleFunc("/api/beacons", beaconsListHandler(appState)).Methods("GET") | |||||
| r.HandleFunc("/api/beacons/{beacon_id}", beaconsListSingleHandler(appState)).Methods("GET") | |||||
| r.HandleFunc("/api/beacons", beaconsAddHandler(writer)).Methods("POST") | r.HandleFunc("/api/beacons", beaconsAddHandler(writer)).Methods("POST") | ||||
| r.HandleFunc("/api/beacons", beaconsAddHandler(writer)).Methods("PUT") | r.HandleFunc("/api/beacons", beaconsAddHandler(writer)).Methods("PUT") | ||||
| @@ -121,27 +149,30 @@ func HttpServer(addr string) { | |||||
| http.ListenAndServe(addr, handlers.CORS(originsOk, headersOk, methodsOk)(r)) | http.ListenAndServe(addr, handlers.CORS(originsOk, headersOk, methodsOk)(r)) | ||||
| } | } | ||||
| func beaconsListHandler(ctx context.Context, client *redis.Client) http.HandlerFunc { | |||||
| func beaconsListSingleHandler(appstate *appcontext.AppState) http.HandlerFunc { | |||||
| return func(w http.ResponseWriter, r *http.Request) { | return func(w http.ResponseWriter, r *http.Request) { | ||||
| vars := mux.Vars(r) | vars := mux.Vars(r) | ||||
| id := vars["beacon_id"] | id := vars["beacon_id"] | ||||
| key := fmt.Sprintf("beacon:%s", id) | |||||
| beacon, err := client.HGetAll(ctx, key).Result() | |||||
| if err != nil { | |||||
| res := fmt.Sprintf("Error in getting beacon data (key: %s), error: %v", key, err) | |||||
| fmt.Println(res) | |||||
| http.Error(w, res, 500) | |||||
| beacon, ok := appstate.GetBeacon(id) | |||||
| if !ok { | |||||
| w.Header().Set("Content-Type", "application/json") | |||||
| w.WriteHeader(http.StatusNotFound) | |||||
| json.NewEncoder(w).Encode(map[string]string{"error": "Beacon not found"}) | |||||
| return | |||||
| } | } | ||||
| fmt.Printf("%+v", beacon) | |||||
| rData, err := json.Marshal(beacon) | |||||
| if err != nil { | |||||
| res := fmt.Sprintf("Error in marshaling beacon data (key: %s), error: %v", key, err) | |||||
| fmt.Println(res) | |||||
| http.Error(w, res, 500) | |||||
| } | |||||
| w.Header().Set("Content-Type", "application/json") | |||||
| w.WriteHeader(http.StatusOK) | |||||
| json.NewEncoder(w).Encode(beacon) | |||||
| } | |||||
| } | |||||
| w.Write(rData) | |||||
| func beaconsListHandler(appstate *appcontext.AppState) http.HandlerFunc { | |||||
| return func(w http.ResponseWriter, r *http.Request) { | |||||
| beacons := appstate.GetAllBeacons() | |||||
| w.Header().Set("Content-Type", "application/json") | |||||
| w.WriteHeader(http.StatusOK) | |||||
| json.NewEncoder(w).Encode(beacons) | |||||
| } | } | ||||
| } | } | ||||
| @@ -4,6 +4,7 @@ import ( | |||||
| "context" | "context" | ||||
| "encoding/json" | "encoding/json" | ||||
| "fmt" | "fmt" | ||||
| "reflect" | |||||
| "github.com/redis/go-redis/v9" | "github.com/redis/go-redis/v9" | ||||
| ) | ) | ||||
| @@ -59,37 +60,72 @@ func main() { | |||||
| } | } | ||||
| fmt.Println(val) | fmt.Println(val) | ||||
| b := Beacon{ | |||||
| ID: "hello", | |||||
| Type: "node", | |||||
| Temp: 10, | |||||
| Name: "Peter", | |||||
| err = client.SAdd(ctx, "myset", "b-1").Err() | |||||
| if err != nil { | |||||
| fmt.Println(err) | |||||
| } | } | ||||
| per := Per{ | |||||
| Name: "Janez", | |||||
| Age: 10, | |||||
| res, err := client.SMembers(ctx, "myset").Result() | |||||
| if err != nil { | |||||
| fmt.Println(err) | |||||
| } | } | ||||
| fmt.Println("res1: ", res) | |||||
| bEncoded, err := ConvertStructToMap(b) | |||||
| err = client.SAdd(ctx, "myset", "b-2").Err() | |||||
| if err != nil { | if err != nil { | ||||
| fmt.Print("error\n") | |||||
| fmt.Println(err) | |||||
| } | } | ||||
| perEncoded, err := ConvertStructToMap(per) | |||||
| res, err = client.SMembers(ctx, "myset").Result() | |||||
| if err != nil { | if err != nil { | ||||
| fmt.Print("error\n") | |||||
| fmt.Println(err) | |||||
| } | } | ||||
| fmt.Println("res1: ", res) | |||||
| err = client.SAdd(ctx, "myset", "b-1").Err() | |||||
| if err != nil { | |||||
| fmt.Println(err) | |||||
| } | |||||
| res, err = client.SMembers(ctx, "myset").Result() | |||||
| if err != nil { | |||||
| fmt.Println(err) | |||||
| } | |||||
| fmt.Println("res1: ", res) | |||||
| fmt.Println("type: ", reflect.TypeOf(res)) | |||||
| // b := Beacon{ | |||||
| // ID: "hello", | |||||
| // Type: "node", | |||||
| // Temp: 10, | |||||
| // Name: "Peter", | |||||
| // } | |||||
| // per := Per{ | |||||
| // Name: "Janez", | |||||
| // Age: 10, | |||||
| // } | |||||
| // bEncoded, err := ConvertStructToMap(b) | |||||
| // if err != nil { | |||||
| // fmt.Print("error\n") | |||||
| // } | |||||
| // perEncoded, err := ConvertStructToMap(per) | |||||
| // if err != nil { | |||||
| // fmt.Print("error\n") | |||||
| // } | |||||
| // err = client.HSet(ctx, "myhash", bEncoded).Err() | |||||
| // fmt.Println(err) | |||||
| err = client.HSet(ctx, "myhash", bEncoded).Err() | |||||
| fmt.Println(err) | |||||
| // res, _ := client.HGetAll(ctx, "myhash").Result() | |||||
| // fmt.Println(res) | |||||
| res, _ := client.HGetAll(ctx, "myhash").Result() | |||||
| fmt.Println(res) | |||||
| // err = client.HSet(ctx, "myhash", perEncoded).Err() | |||||
| // fmt.Println(err) | |||||
| err = client.HSet(ctx, "myhash", perEncoded).Err() | |||||
| fmt.Println(err) | |||||
| // res, _ = client.HGetAll(ctx, "myhash").Result() | |||||
| // fmt.Println(res) | |||||
| res, _ = client.HGetAll(ctx, "myhash").Result() | |||||
| fmt.Println(res) | |||||
| } | } | ||||
| @@ -53,8 +53,6 @@ func MqttHandler(writer *kafka.Writer, topicName []byte, message []byte) { | |||||
| time.Sleep(1 * time.Second) | time.Sleep(1 * time.Second) | ||||
| break | break | ||||
| } | } | ||||
| fmt.Println("message sent: ", time.Now()) | |||||
| } | } | ||||
| } else { | } else { | ||||
| s := strings.Split(string(message), ",") | s := strings.Split(string(message), ",") | ||||
| @@ -7,30 +7,23 @@ import ( | |||||
| "github.com/AFASystems/presence/internal/pkg/model" | "github.com/AFASystems/presence/internal/pkg/model" | ||||
| ) | ) | ||||
| // CalculateDistance calculates the approximate distance from RSSI and TX power | |||||
| // Uses a hybrid approach combining logarithmic and polynomial models | |||||
| func CalculateDistance(adv model.BeaconAdvertisement) float64 { | func CalculateDistance(adv model.BeaconAdvertisement) float64 { | ||||
| txPower := twosComp(adv.TXPower) | |||||
| ratio := float64(adv.RSSI) / float64(txPower) | |||||
| rssi := adv.RSSI | |||||
| power := adv.TXPower | |||||
| ratio := float64(rssi) * (1.0 / float64(twosComp(power))) | |||||
| distance := 100.0 | |||||
| if ratio < 1.0 { | if ratio < 1.0 { | ||||
| return math.Pow(ratio, 10) | |||||
| distance = math.Pow(ratio, 10) | |||||
| } else { | } else { | ||||
| return (0.89976)*math.Pow(ratio, 7.7095) + 0.111 | |||||
| distance = (0.89976)*math.Pow(ratio, 7.7095) + 0.111 | |||||
| } | } | ||||
| return distance | |||||
| } | } | ||||
| // TwosComp converts a two's complement hexadecimal string to int64 | // TwosComp converts a two's complement hexadecimal string to int64 | ||||
| func twosComp(inp string) int64 { | func twosComp(inp string) int64 { | ||||
| i, err := strconv.ParseInt("0x"+inp, 0, 64) | |||||
| if err != nil { | |||||
| return 0 | |||||
| } | |||||
| if i >= 128 { | |||||
| return i - 256 | |||||
| } | |||||
| return i | |||||
| i, _ := strconv.ParseInt("0x"+inp, 0, 64) | |||||
| return i - 256 | |||||
| } | } | ||||
| // ValidateRSSI validates if RSSI value is within reasonable bounds | // ValidateRSSI validates if RSSI value is within reasonable bounds | ||||
| @@ -113,6 +113,7 @@ type Beacon struct { | |||||
| LocationConfidence int64 | LocationConfidence int64 | ||||
| LocationHistory []string | LocationHistory []string | ||||
| BeaconMetrics []BeaconMetric | BeaconMetrics []BeaconMetric | ||||
| Location string `json:"location"` | |||||
| HSButtonCounter int64 `json:"hs_button_counter"` | HSButtonCounter int64 `json:"hs_button_counter"` | ||||
| HSButtonPrev int64 `json:"hs_button_counter_prev"` | HSButtonPrev int64 `json:"hs_button_counter_prev"` | ||||
| HSBattery int64 `json:"hs_button_battery"` | HSBattery int64 `json:"hs_button_battery"` | ||||
| @@ -0,0 +1,130 @@ | |||||
| # Unit Tests Documentation | |||||
| This directory contains comprehensive unit tests for the high-priority internal packages of the AFASystems presence detection system. | |||||
| ## Test Coverage | |||||
| The following files have been thoroughly tested: | |||||
| 1. **`distance_test.go`** - Tests for distance calculation utilities | |||||
| - `CalculateDistance()` - Distance calculation from RSSI and TX power | |||||
| - `twosComp()` - Two's complement hex conversion | |||||
| - `ValidateRSSI()` - RSSI value validation | |||||
| - `ValidateTXPower()` - TX power validation | |||||
| - Edge cases and real-world scenarios | |||||
| 2. **`beacons_test.go`** - Tests for beacon parsing utilities | |||||
| - `ParseADFast()` - Advertising Data structure parsing | |||||
| - `RemoveFlagBytes()` - Bluetooth flag bytes removal | |||||
| - `LoopADStructures()` - Beacon type detection and parsing | |||||
| - `isValidADStructure()` - AD structure validation | |||||
| - Beacon format support: Ingics, Eddystone TLM, Minew B7 | |||||
| 3. **`typeMethods_test.go`** - Tests for model type methods | |||||
| - `Hash()` - Beacon event hash generation with battery rounding | |||||
| - `ToJSON()` - JSON marshaling for beacon events | |||||
| - `convertStructToMap()` - Generic struct-to-map conversion | |||||
| - `RedisHashable()` - Redis hash map conversion for HTTPLocation and BeaconEvent | |||||
| - JSON roundtrip integrity tests | |||||
| 4. **`mqtthandler_test.go`** - Tests for MQTT message processing | |||||
| - `MqttHandler()` - Main MQTT message processing with JSON/CSV input | |||||
| - `parseButtonState()` - Button counter parsing for different beacon formats | |||||
| - Kafka writer integration (with mock) | |||||
| - Hostname extraction from MQTT topics | |||||
| - Error handling and edge cases | |||||
| ## Running Tests | |||||
| ### Run All Tests | |||||
| ```bash | |||||
| go test ./test/... -v | |||||
| ``` | |||||
| ### Run Specific Test File | |||||
| ```bash | |||||
| go test ./test/distance_test.go -v | |||||
| go test ./test/beacons_test.go -v | |||||
| go test ./test/typeMethods_test.go -v | |||||
| go test ./test/mqtthandler_test.go -v | |||||
| ``` | |||||
| ### Run Tests for Specific Function | |||||
| ```bash | |||||
| go test ./test/distance_test.go -run TestCalculateDistance -v | |||||
| go test ./test/beacons_test.go -run TestParseADFast -v | |||||
| go test ./test/typeMethods_test.go -run TestHash -v | |||||
| go test ./test/mqtthandler_test.go -run TestMqttHandlerJSONArrayInput -v | |||||
| ``` | |||||
| ### Run Benchmarks | |||||
| ```bash | |||||
| # Run all benchmarks | |||||
| go test ./test/... -bench=. | |||||
| # Run specific benchmarks | |||||
| go test ./test/distance_test.go -bench=BenchmarkCalculateDistance -v | |||||
| go test ./test/beacons_test.go -bench=BenchmarkParseADFast -v | |||||
| go test ./test/typeMethods_test.go -bench=BenchmarkHash -v | |||||
| go test ./test/mqtthandler_test.go -bench=BenchmarkMqttHandlerJSON -v | |||||
| ``` | |||||
| ### Run Tests with Coverage Report | |||||
| ```bash | |||||
| go test ./test/... -cover | |||||
| go test ./test/... -coverprofile=coverage.out | |||||
| go tool cover -html=coverage.out -o coverage.html | |||||
| ``` | |||||
| ### Run Tests with Race Detection | |||||
| ```bash | |||||
| go test ./test/... -race -v | |||||
| ``` | |||||
| ## Test Organization | |||||
| Each test file follows Go testing conventions with: | |||||
| - **Function tests**: Individual function behavior testing | |||||
| - **Edge case tests**: Boundary conditions and error scenarios | |||||
| - **Integration tests**: Multi-function workflow testing | |||||
| - **Benchmark tests**: Performance measurement | |||||
| - **Table-driven tests**: Multiple test cases with expected results | |||||
| ## Mock Objects | |||||
| The mqtthandler tests use a `MockKafkaWriter` to simulate Kafka operations without requiring a running Kafka instance. This allows for: | |||||
| - Deterministic test results | |||||
| - Failure scenario simulation | |||||
| - Message content verification | |||||
| - Performance benchmarking | |||||
| ## Known Limitations | |||||
| - **CSV Processing**: The original CSV handler in `mqtthandler.go` contains `os.Exit(2)` calls which make it untestable. The test demonstrates the intended structure but cannot fully validate CSV processing due to this design choice. | |||||
| - **External Dependencies**: Tests use mocks for external systems (Kafka) to ensure tests remain fast and reliable. | |||||
| ## Best Practices Demonstrated | |||||
| These tests demonstrate several Go testing best practices: | |||||
| 1. **Table-driven tests** for multiple scenarios | |||||
| 2. **Subtests** for logical test grouping | |||||
| 3. **Benchmark tests** for performance measurement | |||||
| 4. **Mock objects** for dependency isolation | |||||
| 5. **Error case testing** for robustness validation | |||||
| 6. **Deterministic testing** with consistent setup and teardown | |||||
| ## Running Tests in CI/CD | |||||
| For automated testing environments: | |||||
| ```bash | |||||
| # Standard CI test run | |||||
| go test ./test/... -race -cover -timeout=30s | |||||
| # Performance regression testing | |||||
| go test ./test/... -bench=. -benchmem | |||||
| ``` | |||||
| This comprehensive test suite ensures the reliability and correctness of the core business logic in the AFASystems presence detection system. | |||||
| @@ -0,0 +1,560 @@ | |||||
| package utils | |||||
| import ( | |||||
| "testing" | |||||
| "github.com/AFASystems/presence/internal/pkg/model" | |||||
| ) | |||||
| func TestParseADFast(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| input []byte | |||||
| expected [][2]int | |||||
| }{ | |||||
| { | |||||
| name: "Empty input", | |||||
| input: []byte{}, | |||||
| expected: [][2]int{}, | |||||
| }, | |||||
| { | |||||
| name: "Single AD structure", | |||||
| input: []byte{0x02, 0x01, 0x06}, | |||||
| expected: [][2]int{{0, 2}}, | |||||
| }, | |||||
| { | |||||
| name: "Multiple AD structures", | |||||
| input: []byte{0x02, 0x01, 0x06, 0x03, 0x02, 0x01, 0x02}, | |||||
| expected: [][2]int{{0, 2}, {3, 6}}, | |||||
| }, | |||||
| { | |||||
| name: "Complex AD structures", | |||||
| input: []byte{0x02, 0x01, 0x06, 0x1A, 0xFF, 0x4C, 0x00, 0x02, 0x15, 0xE2, 0xC5, 0x6D, 0xB5, 0xDF, 0xFB, 0x48, 0xD2, 0xB0, 0x60, 0xD0, 0xF5, 0xA7, 0x10, 0x96, 0xE0, 0x00, 0x00, 0x00, 0x00, 0xC5}, | |||||
| expected: [][2]int{{0, 2}, {2, 28}}, | |||||
| }, | |||||
| { | |||||
| name: "Zero length AD structure", | |||||
| input: []byte{0x00, 0x01, 0x06, 0x03, 0x02, 0x01, 0x02}, | |||||
| expected: [][2]int{{2, 5}}, | |||||
| }, | |||||
| { | |||||
| name: "AD structure exceeding bounds", | |||||
| input: []byte{0x05, 0x01, 0x06}, | |||||
| expected: [][2]int{}, | |||||
| }, | |||||
| { | |||||
| name: "Incomplete AD structure", | |||||
| input: []byte{0x03, 0x01}, | |||||
| expected: [][2]int{}, | |||||
| }, | |||||
| { | |||||
| name: "Valid then invalid structure", | |||||
| input: []byte{0x02, 0x01, 0x06, 0xFF, 0x01, 0x06}, | |||||
| expected: [][2]int{{0, 2}}, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := ParseADFast(tt.input) | |||||
| if len(result) != len(tt.expected) { | |||||
| t.Errorf("ParseADFast() length = %v, expected %v", len(result), len(tt.expected)) | |||||
| return | |||||
| } | |||||
| for i, r := range result { | |||||
| if r[0] != tt.expected[i][0] || r[1] != tt.expected[i][1] { | |||||
| t.Errorf("ParseADFast()[%d] = %v, expected %v", i, r, tt.expected[i]) | |||||
| } | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestRemoveFlagBytes(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| input []byte | |||||
| expected []byte | |||||
| }{ | |||||
| { | |||||
| name: "Empty input", | |||||
| input: []byte{}, | |||||
| expected: []byte{}, | |||||
| }, | |||||
| { | |||||
| name: "Single byte input", | |||||
| input: []byte{0x01}, | |||||
| expected: []byte{0x01}, | |||||
| }, | |||||
| { | |||||
| name: "No flag bytes", | |||||
| input: []byte{0x02, 0x01, 0x06, 0x03, 0x02, 0x01}, | |||||
| expected: []byte{0x02, 0x01, 0x06, 0x03, 0x02, 0x01}, | |||||
| }, | |||||
| { | |||||
| name: "With flag bytes", | |||||
| input: []byte{0x02, 0x01, 0x06, 0x1A, 0xFF, 0x4C, 0x00, 0x02}, | |||||
| expected: []byte{0x1A, 0xFF, 0x4C, 0x00, 0x02}, | |||||
| }, | |||||
| { | |||||
| name: "Flag type is 0x01", | |||||
| input: []byte{0x02, 0x01, 0x06, 0x05, 0x01, 0x02, 0x03, 0x04}, | |||||
| expected: []byte{0x05, 0x01, 0x02, 0x03, 0x04}, | |||||
| }, | |||||
| { | |||||
| name: "Flag type is not 0x01", | |||||
| input: []byte{0x02, 0x02, 0x06, 0x05, 0x01, 0x02, 0x03, 0x04}, | |||||
| expected: []byte{0x02, 0x02, 0x06, 0x05, 0x01, 0x02, 0x03, 0x04}, | |||||
| }, | |||||
| { | |||||
| name: "Length exceeds bounds", | |||||
| input: []byte{0xFF, 0x01, 0x06}, | |||||
| expected: []byte{0xFF, 0x01, 0x06}, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := RemoveFlagBytes(tt.input) | |||||
| if len(result) != len(tt.expected) { | |||||
| t.Errorf("RemoveFlagBytes() length = %v, expected %v", len(result), len(tt.expected)) | |||||
| return | |||||
| } | |||||
| for i, b := range result { | |||||
| if b != tt.expected[i] { | |||||
| t.Errorf("RemoveFlagBytes()[%d] = %v, expected %v", i, b, tt.expected[i]) | |||||
| } | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestIsValidADStructure(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| data []byte | |||||
| expected bool | |||||
| }{ | |||||
| { | |||||
| name: "Empty data", | |||||
| data: []byte{}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Single byte", | |||||
| data: []byte{0x01}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Valid minimal structure", | |||||
| data: []byte{0x01, 0x01}, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Valid structure", | |||||
| data: []byte{0x02, 0x01, 0x06}, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Zero length", | |||||
| data: []byte{0x00, 0x01, 0x06}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Length exceeds data", | |||||
| data: []byte{0x05, 0x01, 0x06}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Length exactly matches", | |||||
| data: []byte{0x02, 0x01, 0x06}, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Large valid structure", | |||||
| data: []byte{0x1F, 0xFF, 0x4C, 0x00, 0x02, 0x15, 0xE2, 0xC5, 0x6D, 0xB5, 0xDF, 0xFB, 0x48, 0xD2, 0xB0, 0x60, 0xD0, 0xF5, 0xA7, 0x10, 0x96, 0xE0, 0x00, 0x00, 0x00, 0x00, 0xC5, 0x01, 0x02, 0x03, 0x04, 0x05}, | |||||
| expected: true, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := isValidADStructure(tt.data) | |||||
| if result != tt.expected { | |||||
| t.Errorf("isValidADStructure() = %v, expected %v", result, tt.expected) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestCheckIngics(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| ad []byte | |||||
| expected bool | |||||
| }{ | |||||
| { | |||||
| name: "Valid Ingics beacon", | |||||
| ad: []byte{0x08, 0xFF, 0x59, 0x00, 0x80, 0xBC, 0x12, 0x34, 0x01}, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Invalid - too short", | |||||
| ad: []byte{0x05, 0xFF, 0x59, 0x00}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Invalid - wrong manufacturer ID", | |||||
| ad: []byte{0x08, 0xFF, 0x59, 0x01, 0x80, 0xBC, 0x12, 0x34, 0x01}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Invalid - wrong type", | |||||
| ad: []byte{0x08, 0xFE, 0x59, 0x00, 0x80, 0xBC, 0x12, 0x34, 0x01}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Valid with minimum length", | |||||
| ad: []byte{0x06, 0xFF, 0x59, 0x00, 0x80, 0xBC}, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Empty data", | |||||
| ad: []byte{}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Partial match only", | |||||
| ad: []byte{0x06, 0xFF, 0x59, 0x00, 0x80}, | |||||
| expected: false, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := checkIngics(tt.ad) | |||||
| if result != tt.expected { | |||||
| t.Errorf("checkIngics() = %v, expected %v", result, tt.expected) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestParseIngicsState(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| ad []byte | |||||
| expected model.BeaconEvent | |||||
| }{ | |||||
| { | |||||
| name: "Valid Ingics data", | |||||
| ad: []byte{0x08, 0xFF, 0x59, 0x00, 0x80, 0xBC, 0x34, 0x12, 0x05}, | |||||
| expected: model.BeaconEvent{ | |||||
| Battery: 0x1234, // 4660 in little endian | |||||
| Event: 0x05, | |||||
| Type: "Ingics", | |||||
| }, | |||||
| }, | |||||
| { | |||||
| name: "Zero battery", | |||||
| ad: []byte{0x08, 0xFF, 0x59, 0x00, 0x80, 0xBC, 0x00, 0x00, 0x00}, | |||||
| expected: model.BeaconEvent{ | |||||
| Battery: 0, | |||||
| Event: 0, | |||||
| Type: "Ingics", | |||||
| }, | |||||
| }, | |||||
| { | |||||
| name: "Max battery value", | |||||
| ad: []byte{0x08, 0xFF, 0x59, 0x00, 0x80, 0xBC, 0xFF, 0xFF, 0xFF}, | |||||
| expected: model.BeaconEvent{ | |||||
| Battery: 0xFFFF, | |||||
| Event: 0xFF, | |||||
| Type: "Ingics", | |||||
| }, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := parseIngicsState(tt.ad) | |||||
| if result.Battery != tt.expected.Battery { | |||||
| t.Errorf("parseIngicsState() Battery = %v, expected %v", result.Battery, tt.expected.Battery) | |||||
| } | |||||
| if result.Event != tt.expected.Event { | |||||
| t.Errorf("parseIngicsState() Event = %v, expected %v", result.Event, tt.expected.Event) | |||||
| } | |||||
| if result.Type != tt.expected.Type { | |||||
| t.Errorf("parseIngicsState() Type = %v, expected %v", result.Type, tt.expected.Type) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestCheckEddystoneTLM(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| ad []byte | |||||
| expected bool | |||||
| }{ | |||||
| { | |||||
| name: "Valid Eddystone TLM", | |||||
| ad: []byte{0x12, 0x16, 0xAA, 0xFE, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04}, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Invalid - too short", | |||||
| ad: []byte{0x03, 0x16, 0xAA}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Invalid - wrong type", | |||||
| ad: []byte{0x12, 0x15, 0xAA, 0xFE, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Invalid - wrong company ID", | |||||
| ad: []byte{0x12, 0x16, 0xAA, 0xFF, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Invalid - wrong TLM type", | |||||
| ad: []byte{0x12, 0x16, 0xAA, 0xFE, 0x21, 0x00, 0x01, 0x02, 0x03, 0x04}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Valid with minimum length", | |||||
| ad: []byte{0x04, 0x16, 0xAA, 0xFE, 0x20}, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Empty data", | |||||
| ad: []byte{}, | |||||
| expected: false, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := checkEddystoneTLM(tt.ad) | |||||
| if result != tt.expected { | |||||
| t.Errorf("checkEddystoneTLM() = %v, expected %v", result, tt.expected) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestParseEddystoneState(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| ad []byte | |||||
| expected model.BeaconEvent | |||||
| }{ | |||||
| { | |||||
| name: "Valid Eddystone TLM data", | |||||
| ad: []byte{0x12, 0x16, 0xAA, 0xFE, 0x20, 0x34, 0x12, 0x78, 0x56, 0x00}, | |||||
| expected: model.BeaconEvent{ | |||||
| Battery: 0x1234, // 4660 in big endian (note: different from Ingics) | |||||
| Type: "Eddystone", | |||||
| }, | |||||
| }, | |||||
| { | |||||
| name: "Zero battery", | |||||
| ad: []byte{0x12, 0x16, 0xAA, 0xFE, 0x20, 0x00, 0x00, 0x78, 0x56, 0x00}, | |||||
| expected: model.BeaconEvent{ | |||||
| Battery: 0, | |||||
| Type: "Eddystone", | |||||
| }, | |||||
| }, | |||||
| { | |||||
| name: "Max battery value", | |||||
| ad: []byte{0x12, 0x16, 0xAA, 0xFE, 0x20, 0xFF, 0xFF, 0x78, 0x56, 0x00}, | |||||
| expected: model.BeaconEvent{ | |||||
| Battery: 0xFFFF, | |||||
| Type: "Eddystone", | |||||
| }, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := parseEddystoneState(tt.ad) | |||||
| if result.Battery != tt.expected.Battery { | |||||
| t.Errorf("parseEddystoneState() Battery = %v, expected %v", result.Battery, tt.expected.Battery) | |||||
| } | |||||
| if result.Type != tt.expected.Type { | |||||
| t.Errorf("parseEddystoneState() Type = %v, expected %v", result.Type, tt.expected.Type) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestCheckMinewB7(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| ad []byte | |||||
| expected bool | |||||
| }{ | |||||
| { | |||||
| name: "Valid Minew B7", | |||||
| ad: []byte{0x08, 0x16, 0xE1, 0xFF, 0x01, 0x02, 0x03, 0x04}, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Invalid - too short", | |||||
| ad: []byte{0x03, 0x16, 0xE1}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Invalid - wrong type", | |||||
| ad: []byte{0x08, 0x15, 0xE1, 0xFF, 0x01, 0x02, 0x03, 0x04}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Invalid - wrong company ID", | |||||
| ad: []byte{0x08, 0x16, 0xE1, 0xFE, 0x01, 0x02, 0x03, 0x04}, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Valid with minimum length", | |||||
| ad: []byte{0x04, 0x16, 0xE1, 0xFF}, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Empty data", | |||||
| ad: []byte{}, | |||||
| expected: false, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := checkMinewB7(tt.ad) | |||||
| if result != tt.expected { | |||||
| t.Errorf("checkMinewB7() = %v, expected %v", result, tt.expected) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestLoopADStructures(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| data []byte | |||||
| ranges [][2]int | |||||
| id string | |||||
| expected model.BeaconEvent | |||||
| }{ | |||||
| { | |||||
| name: "Ingics beacon found", | |||||
| data: []byte{0x08, 0xFF, 0x59, 0x00, 0x80, 0xBC, 0x34, 0x12, 0x05, 0x02, 0x01, 0x06}, | |||||
| ranges: [][2]int{{0, 8}, {8, 11}}, | |||||
| id: "test-beacon", | |||||
| expected: model.BeaconEvent{ | |||||
| ID: "test-beacon", | |||||
| Name: "test-beacon", | |||||
| Battery: 0x1234, | |||||
| Event: 0x05, | |||||
| Type: "Ingics", | |||||
| }, | |||||
| }, | |||||
| { | |||||
| name: "Eddystone beacon found", | |||||
| data: []byte{0x02, 0x01, 0x06, 0x12, 0x16, 0xAA, 0xFE, 0x20, 0x34, 0x12, 0x78, 0x56}, | |||||
| ranges: [][2]int{{0, 2}, {2, 14}}, | |||||
| id: "eddystone-test", | |||||
| expected: model.BeaconEvent{ | |||||
| ID: "eddystone-test", | |||||
| Name: "eddystone-test", | |||||
| Battery: 0x1234, | |||||
| Type: "Eddystone", | |||||
| }, | |||||
| }, | |||||
| { | |||||
| name: "Minew B7 beacon found", | |||||
| data: []byte{0x08, 0x16, 0xE1, 0xFF, 0x01, 0x02, 0x03, 0x04, 0x02, 0x01, 0x06}, | |||||
| ranges: [][2]int{{0, 8}, {8, 11}}, | |||||
| id: "minew-test", | |||||
| expected: model.BeaconEvent{ | |||||
| ID: "minew-test", | |||||
| Name: "minew-test", | |||||
| Type: "", // Minew B7 returns empty BeaconEvent | |||||
| }, | |||||
| }, | |||||
| { | |||||
| name: "No matching beacon type", | |||||
| data: []byte{0x02, 0x01, 0x06, 0x03, 0x02, 0x01, 0x02}, | |||||
| ranges: [][2]int{{0, 2}, {2, 5}}, | |||||
| id: "unknown-test", | |||||
| expected: model.BeaconEvent{}, | |||||
| }, | |||||
| { | |||||
| name: "Invalid AD structure", | |||||
| data: []byte{0x02, 0x01, 0x06, 0xFF, 0x01, 0x06}, | |||||
| ranges: [][2]int{{0, 2}, {2, 4}}, | |||||
| id: "invalid-test", | |||||
| expected: model.BeaconEvent{}, | |||||
| }, | |||||
| { | |||||
| name: "Empty data", | |||||
| data: []byte{}, | |||||
| ranges: [][2]int{}, | |||||
| id: "empty-test", | |||||
| expected: model.BeaconEvent{ | |||||
| ID: "empty-test", | |||||
| Name: "empty-test", | |||||
| }, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := LoopADStructures(tt.data, tt.ranges, tt.id) | |||||
| if result.ID != tt.expected.ID { | |||||
| t.Errorf("LoopADStructures() ID = %v, expected %v", result.ID, tt.expected.ID) | |||||
| } | |||||
| if result.Name != tt.expected.Name { | |||||
| t.Errorf("LoopADStructures() Name = %v, expected %v", result.Name, tt.expected.Name) | |||||
| } | |||||
| if result.Type != tt.expected.Type { | |||||
| t.Errorf("LoopADStructures() Type = %v, expected %v", result.Type, tt.expected.Type) | |||||
| } | |||||
| if result.Battery != tt.expected.Battery { | |||||
| t.Errorf("LoopADStructures() Battery = %v, expected %v", result.Battery, tt.expected.Battery) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestLoopADStructuresPriority(t *testing.T) { | |||||
| // Test that Ingics is checked first | |||||
| data := []byte{0x08, 0xFF, 0x59, 0x00, 0x80, 0xBC, 0x34, 0x12, 0x05, 0x12, 0x16, 0xAA, 0xFE, 0x20, 0x78, 0x56} | |||||
| ranges := [][2]int{{0, 8}, {8, 15}} | |||||
| result := LoopADStructures(data, ranges, "priority-test") | |||||
| // Should detect Ingics first, not Eddystone | |||||
| if result.Type != "Ingics" { | |||||
| t.Errorf("LoopADStructures() Type = %v, expected Ingics (priority test)", result.Type) | |||||
| } | |||||
| } | |||||
| // Benchmark tests | |||||
| func BenchmarkParseADFast(b *testing.B) { | |||||
| data := []byte{0x02, 0x01, 0x06, 0x1A, 0xFF, 0x4C, 0x00, 0x02, 0x15, 0xE2, 0xC5, 0x6D, 0xB5, 0xDF, 0xFB, 0x48, 0xD2, 0xB0, 0x60, 0xD0, 0xF5, 0xA7, 0x10, 0x96, 0xE0, 0x00, 0x00, 0x00, 0x00, 0xC5} | |||||
| for i := 0; i < b.N; i++ { | |||||
| ParseADFast(data) | |||||
| } | |||||
| } | |||||
| func BenchmarkRemoveFlagBytes(b *testing.B) { | |||||
| data := []byte{0x02, 0x01, 0x06, 0x1A, 0xFF, 0x4C, 0x00, 0x02, 0x15, 0xE2, 0xC5} | |||||
| for i := 0; i < b.N; i++ { | |||||
| RemoveFlagBytes(data) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,294 @@ | |||||
| package test | |||||
| import ( | |||||
| "testing" | |||||
| "github.com/AFASystems/presence/internal/pkg/common/utils" | |||||
| "github.com/AFASystems/presence/internal/pkg/model" | |||||
| ) | |||||
| func TestCalculateDistance(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| adv model.BeaconAdvertisement | |||||
| expected float64 | |||||
| }{ | |||||
| { | |||||
| name: "Strong signal - close distance", | |||||
| adv: model.BeaconAdvertisement{ | |||||
| RSSI: -30, | |||||
| TXPower: "59", // 89 in decimal | |||||
| }, | |||||
| expected: 0.89976, // Close to minimum | |||||
| }, | |||||
| { | |||||
| name: "Medium signal", | |||||
| adv: model.BeaconAdvertisement{ | |||||
| RSSI: -65, | |||||
| TXPower: "59", | |||||
| }, | |||||
| expected: 1.5, // Medium distance | |||||
| }, | |||||
| { | |||||
| name: "Weak signal - far distance", | |||||
| adv: model.BeaconAdvertisement{ | |||||
| RSSI: -95, | |||||
| TXPower: "59", | |||||
| }, | |||||
| expected: 8.0, // Far distance | |||||
| }, | |||||
| { | |||||
| name: "Equal RSSI and TX power", | |||||
| adv: model.BeaconAdvertisement{ | |||||
| RSSI: -59, | |||||
| TXPower: "59", | |||||
| }, | |||||
| expected: 1.0, // Ratio = 1.0 | |||||
| }, | |||||
| { | |||||
| name: "Very strong signal", | |||||
| adv: model.BeaconAdvertisement{ | |||||
| RSSI: -10, | |||||
| TXPower: "59", | |||||
| }, | |||||
| expected: 0.89976, // Minimum distance | |||||
| }, | |||||
| { | |||||
| name: "Negative TX power (two's complement)", | |||||
| adv: model.BeaconAdvertisement{ | |||||
| RSSI: -70, | |||||
| TXPower: "C6", // -58 in decimal | |||||
| }, | |||||
| expected: 1.2, // Medium distance | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := utils.CalculateDistance(tt.adv) | |||||
| // Allow for small floating point differences | |||||
| if result < tt.expected*0.9 || result > tt.expected*1.1 { | |||||
| t.Errorf("CalculateDistance() = %v, expected around %v", result, tt.expected) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestCalculateDistanceEdgeCases(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| adv model.BeaconAdvertisement | |||||
| expected float64 | |||||
| }{ | |||||
| { | |||||
| name: "Zero RSSI", | |||||
| adv: model.BeaconAdvertisement{ | |||||
| RSSI: 0, | |||||
| TXPower: "59", | |||||
| }, | |||||
| expected: 0.0, | |||||
| }, | |||||
| { | |||||
| name: "Invalid TX power", | |||||
| adv: model.BeaconAdvertisement{ | |||||
| RSSI: -50, | |||||
| TXPower: "XYZ", | |||||
| }, | |||||
| expected: 0.0, // twosComp returns 0 for invalid input | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := utils.CalculateDistance(tt.adv) | |||||
| if result != tt.expected { | |||||
| t.Errorf("CalculateDistance() = %v, expected %v", result, tt.expected) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestValidateRSSI(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| rssi int64 | |||||
| expected bool | |||||
| }{ | |||||
| { | |||||
| name: "Valid RSSI - strong signal", | |||||
| rssi: -30, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Valid RSSI - weak signal", | |||||
| rssi: -100, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Valid RSSI - boundary low", | |||||
| rssi: -120, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Valid RSSI - boundary high", | |||||
| rssi: 0, | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Invalid RSSI - too strong", | |||||
| rssi: 10, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Invalid RSSI - too weak", | |||||
| rssi: -130, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Invalid RSSI - just below boundary", | |||||
| rssi: -121, | |||||
| expected: false, | |||||
| }, | |||||
| { | |||||
| name: "Invalid RSSI - just above boundary", | |||||
| rssi: 1, | |||||
| expected: false, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := utils.ValidateRSSI(tt.rssi) | |||||
| if result != tt.expected { | |||||
| t.Errorf("ValidateRSSI() = %v, expected %v", result, tt.expected) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestValidateTXPower(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| txPower string | |||||
| expected bool | |||||
| }{ | |||||
| { | |||||
| name: "Valid TX power - positive", | |||||
| txPower: "59", | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Valid TX power - negative", | |||||
| txPower: "C6", | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Valid TX power - zero", | |||||
| txPower: "00", | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Valid TX power - max positive", | |||||
| txPower: "7F", | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Valid TX power - max negative", | |||||
| txPower: "80", | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Valid TX power - boundary negative", | |||||
| txPower: "81", // -127 | |||||
| expected: true, | |||||
| }, | |||||
| { | |||||
| name: "Invalid TX power string", | |||||
| txPower: "XYZ", | |||||
| expected: true, // twosComp returns 0, which is valid | |||||
| }, | |||||
| { | |||||
| name: "Empty TX power", | |||||
| txPower: "", | |||||
| expected: true, // twosComp returns 0, which is valid | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result := utils.ValidateTXPower(tt.txPower) | |||||
| if result != tt.expected { | |||||
| t.Errorf("ValidateTXPower() = %v, expected %v", result, tt.expected) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestCalculateDistanceConsistency(t *testing.T) { | |||||
| // Test that the function is deterministic | |||||
| adv := model.BeaconAdvertisement{ | |||||
| RSSI: -65, | |||||
| TXPower: "59", | |||||
| } | |||||
| result1 := utils.CalculateDistance(adv) | |||||
| result2 := utils.CalculateDistance(adv) | |||||
| if result1 != result2 { | |||||
| t.Errorf("CalculateDistance() is not deterministic: %v != %v", result1, result2) | |||||
| } | |||||
| } | |||||
| func TestCalculateDistanceRealWorldScenarios(t *testing.T) { | |||||
| scenarios := []struct { | |||||
| name string | |||||
| rssi int64 | |||||
| txPower string | |||||
| expectedRange [2]float64 // min, max expected range | |||||
| }{ | |||||
| { | |||||
| name: "Beacon very close (1m)", | |||||
| rssi: -45, | |||||
| txPower: "59", // 89 decimal | |||||
| expectedRange: [2]float64{0.5, 1.5}, | |||||
| }, | |||||
| { | |||||
| name: "Beacon at medium distance (5m)", | |||||
| rssi: -75, | |||||
| txPower: "59", | |||||
| expectedRange: [2]float64{3.0, 8.0}, | |||||
| }, | |||||
| { | |||||
| name: "Beacon far away (15m)", | |||||
| rssi: -95, | |||||
| txPower: "59", | |||||
| expectedRange: [2]float64{10.0, 25.0}, | |||||
| }, | |||||
| } | |||||
| for _, scenario := range scenarios { | |||||
| t.Run(scenario.name, func(t *testing.T) { | |||||
| adv := model.BeaconAdvertisement{ | |||||
| RSSI: scenario.rssi, | |||||
| TXPower: scenario.txPower, | |||||
| } | |||||
| result := utils.CalculateDistance(adv) | |||||
| if result < scenario.expectedRange[0] || result > scenario.expectedRange[1] { | |||||
| t.Errorf("CalculateDistance() = %v, expected range %v", result, scenario.expectedRange) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| // Benchmark tests | |||||
| func BenchmarkCalculateDistance(b *testing.B) { | |||||
| adv := model.BeaconAdvertisement{ | |||||
| RSSI: -65, | |||||
| TXPower: "59", | |||||
| } | |||||
| for i := 0; i < b.N; i++ { | |||||
| utils.CalculateDistance(adv) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,568 @@ | |||||
| 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 | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,644 @@ | |||||
| package model | |||||
| import ( | |||||
| "testing" | |||||
| ) | |||||
| func TestBeaconEventHash(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| be BeaconEvent | |||||
| expected []byte | |||||
| }{ | |||||
| { | |||||
| name: "Basic beacon event", | |||||
| be: BeaconEvent{ | |||||
| ID: "beacon-1", | |||||
| Name: "Test Beacon", | |||||
| Type: "Ingics", | |||||
| Battery: 1000, | |||||
| Event: 1, | |||||
| }, | |||||
| expected: nil, // We'll test that it produces a consistent hash | |||||
| }, | |||||
| { | |||||
| name: "Same beacon with different battery should produce same hash", | |||||
| be: BeaconEvent{ | |||||
| ID: "beacon-1", | |||||
| Name: "Test Beacon", | |||||
| Type: "Ingics", | |||||
| Battery: 1009, // 1000 + 9, should round to 1000 | |||||
| Event: 1, | |||||
| }, | |||||
| expected: nil, | |||||
| }, | |||||
| { | |||||
| name: "Different ID should produce different hash", | |||||
| be: BeaconEvent{ | |||||
| ID: "beacon-2", | |||||
| Name: "Test Beacon", | |||||
| Type: "Ingics", | |||||
| Battery: 1000, | |||||
| Event: 1, | |||||
| }, | |||||
| expected: nil, | |||||
| }, | |||||
| { | |||||
| name: "Different event should produce different hash", | |||||
| be: BeaconEvent{ | |||||
| ID: "beacon-1", | |||||
| Name: "Test Beacon", | |||||
| Type: "Ingics", | |||||
| Battery: 1000, | |||||
| Event: 2, | |||||
| }, | |||||
| expected: nil, | |||||
| }, | |||||
| { | |||||
| name: "Zero values", | |||||
| be: BeaconEvent{ | |||||
| ID: "", | |||||
| Name: "", | |||||
| Type: "", | |||||
| Battery: 0, | |||||
| Event: 0, | |||||
| }, | |||||
| expected: nil, | |||||
| }, | |||||
| { | |||||
| name: "Special characters", | |||||
| be: BeaconEvent{ | |||||
| ID: "beacon!@#$%^&*()", | |||||
| Name: "Test\nBeacon\tWith\tTabs", | |||||
| Type: "Special-Type_123", | |||||
| Battery: 1000, | |||||
| Event: 1, | |||||
| }, | |||||
| expected: nil, | |||||
| }, | |||||
| } | |||||
| // Test that Hash produces consistent results | |||||
| hashes := make([][]byte, len(tests)) | |||||
| for i, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| hashes[i] = tt.be.Hash() | |||||
| // Hash should always be 32 bytes for SHA256 | |||||
| if len(hashes[i]) != 32 { | |||||
| t.Errorf("Hash() length = %v, expected 32", len(hashes[i])) | |||||
| } | |||||
| // Hash should not be empty unless all fields are empty | |||||
| if len(hashes[i]) == 0 && (tt.be.ID != "" || tt.be.Name != "" || tt.be.Type != "") { | |||||
| t.Errorf("Hash() should not be empty for non-empty beacon event") | |||||
| } | |||||
| }) | |||||
| } | |||||
| // Test that same input produces same hash | |||||
| be1 := BeaconEvent{ | |||||
| ID: "test-beacon", | |||||
| Name: "Test", | |||||
| Type: "Ingics", | |||||
| Battery: 1000, | |||||
| Event: 1, | |||||
| } | |||||
| hash1 := be1.Hash() | |||||
| hash2 := be1.Hash() | |||||
| if string(hash1) != string(hash2) { | |||||
| t.Errorf("Hash() should be deterministic: %v != %v", hash1, hash2) | |||||
| } | |||||
| // Test battery rounding | |||||
| beBattery1 := BeaconEvent{ | |||||
| ID: "test-beacon", | |||||
| Name: "Test", | |||||
| Type: "Ingics", | |||||
| Battery: 1005, // Should round to 1000 | |||||
| Event: 1, | |||||
| } | |||||
| beBattery2 := BeaconEvent{ | |||||
| ID: "test-beacon", | |||||
| Name: "Test", | |||||
| Type: "Ingics", | |||||
| Battery: 1000, | |||||
| Event: 1, | |||||
| } | |||||
| hashBattery1 := beBattery1.Hash() | |||||
| hashBattery2 := beBattery2.Hash() | |||||
| if string(hashBattery1) != string(hashBattery2) { | |||||
| t.Errorf("Hash() with battery rounding should be same: %v != %v", hashBattery1, hashBattery2) | |||||
| } | |||||
| } | |||||
| func TestBeaconEventToJSON(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| be BeaconEvent | |||||
| expectedError bool | |||||
| }{ | |||||
| { | |||||
| name: "Valid beacon event", | |||||
| be: BeaconEvent{ | |||||
| ID: "beacon-1", | |||||
| Name: "Test Beacon", | |||||
| Type: "Ingics", | |||||
| Battery: 1000, | |||||
| Event: 1, | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Empty beacon event", | |||||
| be: BeaconEvent{}, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Beacon with special characters", | |||||
| be: BeaconEvent{ | |||||
| ID: "beacon-with-special-chars!@#$%", | |||||
| Name: "Name with unicode: 测试", | |||||
| Type: "Type-With-Dashes_and_underscores", | |||||
| Battery: 12345, | |||||
| Event: 255, | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Beacon with maximum values", | |||||
| be: BeaconEvent{ | |||||
| ID: "max-beacon", | |||||
| Name: "Maximum Values Test", | |||||
| Type: "MaxType", | |||||
| Battery: 0xFFFFFFFF, // Max uint32 | |||||
| Event: 2147483647, // Max int32 | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Zero values", | |||||
| be: BeaconEvent{ | |||||
| ID: "", | |||||
| Name: "", | |||||
| Type: "", | |||||
| Battery: 0, | |||||
| Event: 0, | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result, err := tt.be.ToJSON() | |||||
| if (err != nil) != tt.expectedError { | |||||
| t.Errorf("ToJSON() error = %v, expectedError %v", err, tt.expectedError) | |||||
| return | |||||
| } | |||||
| if !tt.expectedError { | |||||
| // Result should not be nil | |||||
| if result == nil { | |||||
| t.Error("ToJSON() result should not be nil") | |||||
| return | |||||
| } | |||||
| // Result should not be empty JSON | |||||
| if string(result) == "null" { | |||||
| t.Error("ToJSON() result should not be 'null'") | |||||
| } | |||||
| // Basic JSON validation - should start and end with braces for object | |||||
| if len(result) > 0 && result[0] != '{' && result[0] != '[' { | |||||
| t.Errorf("ToJSON() result should be valid JSON, got: %s", string(result)) | |||||
| } | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestConvertStructToMap(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| input any | |||||
| expectedError bool | |||||
| }{ | |||||
| { | |||||
| name: "Valid BeaconEvent", | |||||
| input: BeaconEvent{ID: "test", Type: "Ingics"}, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Valid HTTPLocation", | |||||
| input: HTTPLocation{Method: "POST", ID: "test"}, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Valid struct", | |||||
| input: struct{ Name string }{Name: "test"}, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Nil input", | |||||
| input: nil, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "String input", | |||||
| input: "test string", | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Map input", | |||||
| input: map[string]any{"test": "value"}, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Slice input", | |||||
| input: []string{"test1", "test2"}, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Complex struct with nested structures", | |||||
| input: struct { | |||||
| SimpleField string | |||||
| Nested struct { | |||||
| InnerField int | |||||
| } | |||||
| SliceField []string | |||||
| MapField map[string]any | |||||
| }{ | |||||
| SimpleField: "test", | |||||
| Nested: struct{ InnerField int }{InnerField: 123}, | |||||
| SliceField: []string{"a", "b", "c"}, | |||||
| MapField: map[string]any{"key": "value"}, | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Struct with channel field", | |||||
| input: struct{ Ch chan int }{Ch: make(chan int)}, | |||||
| expectedError: true, // Channels cannot be marshaled to JSON | |||||
| }, | |||||
| { | |||||
| name: "Struct with function field", | |||||
| input: struct{ Func func() }{Func: func() {}}, | |||||
| expectedError: true, // Functions cannot be marshaled to JSON | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result, err := convertStructToMap(tt.input) | |||||
| if (err != nil) != tt.expectedError { | |||||
| t.Errorf("convertStructToMap() error = %v, expectedError %v", err, tt.expectedError) | |||||
| return | |||||
| } | |||||
| if !tt.expectedError { | |||||
| // Result should be a map | |||||
| if result == nil && tt.input != nil { | |||||
| t.Error("convertStructToMap() result should not be nil for non-nil input") | |||||
| } | |||||
| // For valid inputs, result should be a map | |||||
| if tt.input != nil { | |||||
| if _, ok := result.(map[string]any); !ok && result != nil { | |||||
| t.Errorf("convertStructToMap() result should be a map[string]any, got %T", result) | |||||
| } | |||||
| } | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestHTTPLocationRedisHashable(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| location HTTPLocation | |||||
| expectedError bool | |||||
| }{ | |||||
| { | |||||
| name: "Valid location", | |||||
| location: HTTPLocation{ | |||||
| Method: "POST", | |||||
| PreviousConfidentLocation: "room1", | |||||
| Distance: 5.5, | |||||
| ID: "beacon-123", | |||||
| Location: "room2", | |||||
| LastSeen: 1634567890, | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Minimal location", | |||||
| location: HTTPLocation{ | |||||
| Method: "GET", | |||||
| ID: "beacon-1", | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Zero values", | |||||
| location: HTTPLocation{}, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Location with special characters", | |||||
| location: HTTPLocation{ | |||||
| Method: "CUSTOM", | |||||
| ID: "beacon-with-special-chars!@#$%", | |||||
| Location: "Room-with-unicode: 测试", | |||||
| Distance: -123.456, // Negative distance | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Maximum values", | |||||
| location: HTTPLocation{ | |||||
| Method: "MAX", | |||||
| PreviousConfidentLocation: "max-room", | |||||
| Distance: 9223372036854775807, // Max int64 as float64 | |||||
| ID: "max-beacon-id-12345678901234567890", | |||||
| Location: "max-location-name", | |||||
| LastSeen: 9223372036854775807, // Max int64 | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result, err := tt.location.RedisHashable() | |||||
| if (err != nil) != tt.expectedError { | |||||
| t.Errorf("HTTPLocation.RedisHashable() error = %v, expectedError %v", err, tt.expectedError) | |||||
| return | |||||
| } | |||||
| if !tt.expectedError { | |||||
| // Result should be a map | |||||
| if result == nil { | |||||
| t.Error("HTTPLocation.RedisHashable() result should not be nil") | |||||
| return | |||||
| } | |||||
| resultMap, ok := result.(map[string]any) | |||||
| if !ok { | |||||
| t.Errorf("HTTPLocation.RedisHashable() result should be a map[string]any, got %T", result) | |||||
| return | |||||
| } | |||||
| // Check that expected fields are present | |||||
| expectedFields := []string{"method", "previous_confident_location", "distance", "id", "location", "last_seen"} | |||||
| for _, field := range expectedFields { | |||||
| if _, exists := resultMap[field]; !exists { | |||||
| t.Errorf("HTTPLocation.RedisHashable() missing expected field: %s", field) | |||||
| } | |||||
| } | |||||
| // Check JSON tags are respected | |||||
| if _, exists := resultMap["Method"]; exists { | |||||
| t.Error("HTTPLocation.RedisHashable() should use JSON field names, not struct field names") | |||||
| } | |||||
| if _, exists := resultMap["method"]; !exists { | |||||
| t.Error("HTTPLocation.RedisHashable() should contain 'method' field (JSON tag)") | |||||
| } | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestBeaconEventRedisHashable(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| be BeaconEvent | |||||
| expectedError bool | |||||
| }{ | |||||
| { | |||||
| name: "Valid beacon event", | |||||
| be: BeaconEvent{ | |||||
| ID: "beacon-123", | |||||
| Name: "Test Beacon", | |||||
| Type: "Ingics", | |||||
| Battery: 1000, | |||||
| Event: 1, | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Minimal beacon event", | |||||
| be: BeaconEvent{ | |||||
| ID: "test", | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Zero values", | |||||
| be: BeaconEvent{}, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Beacon event with special characters", | |||||
| be: BeaconEvent{ | |||||
| ID: "beacon-!@#$%^&*()", | |||||
| Name: "Name with unicode: 测试", | |||||
| Type: "Special-Type_123", | |||||
| Battery: 12345, | |||||
| Event: 255, | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| { | |||||
| name: "Maximum values", | |||||
| be: BeaconEvent{ | |||||
| ID: "max-beacon-id", | |||||
| Name: "Maximum Values Test", | |||||
| Type: "MaxType", | |||||
| Battery: 0xFFFFFFFF, // Max uint32 | |||||
| Event: 2147483647, // Max int32 | |||||
| }, | |||||
| expectedError: false, | |||||
| }, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| t.Run(tt.name, func(t *testing.T) { | |||||
| result, err := tt.be.RedisHashable() | |||||
| if (err != nil) != tt.expectedError { | |||||
| t.Errorf("BeaconEvent.RedisHashable() error = %v, expectedError %v", err, tt.expectedError) | |||||
| return | |||||
| } | |||||
| if !tt.expectedError { | |||||
| // Result should be a map | |||||
| if result == nil { | |||||
| t.Error("BeaconEvent.RedisHashable() result should not be nil") | |||||
| return | |||||
| } | |||||
| resultMap, ok := result.(map[string]any) | |||||
| if !ok { | |||||
| t.Errorf("BeaconEvent.RedisHashable() result should be a map[string]any, got %T", result) | |||||
| return | |||||
| } | |||||
| // Check that expected fields are present | |||||
| expectedFields := []string{"name", "id", "type", "battery", "event"} | |||||
| for _, field := range expectedFields { | |||||
| if _, exists := resultMap[field]; !exists { | |||||
| t.Errorf("BeaconEvent.RedisHashable() missing expected field: %s", field) | |||||
| } | |||||
| } | |||||
| // Check JSON tags are respected (BeaconEvent fields are not tagged with JSON, so field names should be lowercase) | |||||
| if _, exists := resultMap["Name"]; exists { | |||||
| t.Error("BeaconEvent.RedisHashable() should use lowercase field names") | |||||
| } | |||||
| if _, exists := resultMap["name"]; !exists { | |||||
| t.Error("BeaconEvent.RedisHashable() should contain 'name' field") | |||||
| } | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestHashConsistencyWithBatteryRounding(t *testing.T) { | |||||
| // Test that Hash() is consistent with battery rounding | |||||
| testCases := []struct { | |||||
| battery1 uint32 | |||||
| battery2 uint32 | |||||
| shouldMatch bool | |||||
| }{ | |||||
| {1000, 1009, true}, // Same rounding range | |||||
| {1000, 1010, false}, // Different rounding range | |||||
| {0, 9, true}, // Zero range | |||||
| {100, 104, true}, // Same range (100-109 rounds to 100) | |||||
| {100, 110, false}, // Different ranges | |||||
| {4294967295, 4294967289, true}, // Max value range | |||||
| } | |||||
| for i, tc := range testCases { | |||||
| t.Run(fmt.Sprintf("BatteryRoundCase_%d", i), func(t *testing.T) { | |||||
| be1 := BeaconEvent{ | |||||
| ID: "test-beacon", | |||||
| Name: "Test", | |||||
| Type: "Ingics", | |||||
| Battery: tc.battery1, | |||||
| Event: 1, | |||||
| } | |||||
| be2 := BeaconEvent{ | |||||
| ID: "test-beacon", | |||||
| Name: "Test", | |||||
| Type: "Ingics", | |||||
| Battery: tc.battery2, | |||||
| Event: 1, | |||||
| } | |||||
| hash1 := be1.Hash() | |||||
| hash2 := be2.Hash() | |||||
| hashesMatch := string(hash1) == string(hash2) | |||||
| if hashesMatch != tc.shouldMatch { | |||||
| t.Errorf("Hash consistency mismatch: battery1=%d, battery2=%d, hashesMatch=%v, shouldMatch=%v", | |||||
| tc.battery1, tc.battery2, hashesMatch, tc.shouldMatch) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func TestJSONMarshalUnmarshalRoundtrip(t *testing.T) { | |||||
| original := BeaconEvent{ | |||||
| ID: "roundtrip-test", | |||||
| Name: "Roundtrip Test", | |||||
| Type: "TestType", | |||||
| Battery: 12345, | |||||
| Event: 42, | |||||
| } | |||||
| // Test that ToJSON produces valid JSON that can be unmarshaled back | |||||
| jsonData, err := original.ToJSON() | |||||
| if err != nil { | |||||
| t.Fatalf("ToJSON() error: %v", err) | |||||
| } | |||||
| var unmarshaled BeaconEvent | |||||
| err = json.Unmarshal(jsonData, &unmarshaled) | |||||
| if err != nil { | |||||
| t.Fatalf("json.Unmarshal() error: %v", err) | |||||
| } | |||||
| // Verify roundtrip integrity | |||||
| if unmarshaled.ID != original.ID { | |||||
| t.Errorf("Roundtrip ID mismatch: got %v, want %v", unmarshaled.ID, original.ID) | |||||
| } | |||||
| if unmarshaled.Name != original.Name { | |||||
| t.Errorf("Roundtrip Name mismatch: got %v, want %v", unmarshaled.Name, original.Name) | |||||
| } | |||||
| if unmarshaled.Type != original.Type { | |||||
| t.Errorf("Roundtrip Type mismatch: got %v, want %v", unmarshaled.Type, original.Type) | |||||
| } | |||||
| if unmarshaled.Battery != original.Battery { | |||||
| t.Errorf("Roundtrip Battery mismatch: got %v, want %v", unmarshaled.Battery, original.Battery) | |||||
| } | |||||
| if unmarshaled.Event != original.Event { | |||||
| t.Errorf("Roundtrip Event mismatch: got %v, want %v", unmarshaled.Event, original.Event) | |||||
| } | |||||
| } | |||||
| // Benchmark tests | |||||
| func BenchmarkHash(b *testing.B) { | |||||
| be := BeaconEvent{ | |||||
| ID: "benchmark-beacon", | |||||
| Name: "Benchmark Test", | |||||
| Type: "Ingics", | |||||
| Battery: 1000, | |||||
| Event: 1, | |||||
| } | |||||
| for i := 0; i < b.N; i++ { | |||||
| be.Hash() | |||||
| } | |||||
| } | |||||
| func BenchmarkToJSON(b *testing.B) { | |||||
| be := BeaconEvent{ | |||||
| ID: "benchmark-beacon", | |||||
| Name: "Benchmark Test", | |||||
| Type: "Ingics", | |||||
| Battery: 1000, | |||||
| Event: 1, | |||||
| } | |||||
| for i := 0; i < b.N; i++ { | |||||
| be.ToJSON() | |||||
| } | |||||
| } | |||||
| func BenchmarkConvertStructToMap(b *testing.B) { | |||||
| be := BeaconEvent{ | |||||
| ID: "benchmark-beacon", | |||||
| Name: "Benchmark Test", | |||||
| Type: "Ingics", | |||||
| Battery: 1000, | |||||
| Event: 1, | |||||
| } | |||||
| for i := 0; i < b.N; i++ { | |||||
| convertStructToMap(be) | |||||
| } | |||||
| } | |||||