Przeglądaj źródła

feat: implement basic API, reimplement Redis and some methods for quick insert (persistence in case of crash) using hash maps

master
Blaz Smehov 1 tydzień temu
rodzic
commit
d2300aa400
10 zmienionych plików z 2313 dodań i 58 usunięć
  1. +51
    -20
      cmd/server/main.go
  2. +56
    -20
      cmd/valkey-testbench/main.go
  3. +0
    -2
      internal/pkg/bridge/mqtthandler/mqtthandler.go
  4. +9
    -16
      internal/pkg/common/utils/distance.go
  5. +1
    -0
      internal/pkg/model/types.go
  6. +130
    -0
      test/README.md
  7. +560
    -0
      test/beacons_test.go
  8. +294
    -0
      test/distance_test.go
  9. +568
    -0
      test/mqtthandler_test.go
  10. +644
    -0
      test/typeMethods_test.go

+ 51
- 20
cmd/server/main.go Wyświetl plik

@@ -9,6 +9,7 @@ import (
"strings"
"time"

"github.com/AFASystems/presence/internal/pkg/common/appcontext"
"github.com/AFASystems/presence/internal/pkg/config"
"github.com/AFASystems/presence/internal/pkg/kafkaclient"
"github.com/AFASystems/presence/internal/pkg/model"
@@ -29,6 +30,7 @@ func main() {

func HttpServer(addr string) {
cfg := config.Load()
appState := appcontext.NewAppState()

headersOk := handlers.AllowedHeaders([]string{"X-Requested-With"})
originsOk := handlers.AllowedOrigins([]string{"*"})
@@ -67,29 +69,54 @@ func HttpServer(addr string) {
for {
select {
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)
hashM, err := msg.RedisHashable()
if err != nil {
fmt.Println("Error in converting location into hashmap for Redis insert: ", err)
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)
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:
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)
hashM, err := msg.RedisHashable()
if err != nil {
fmt.Println("Error in converting location into hashmap for Redis insert: ", err)
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)
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
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("PUT")

@@ -121,27 +149,30 @@ func HttpServer(addr string) {
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) {
vars := mux.Vars(r)
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)
}
}



+ 56
- 20
cmd/valkey-testbench/main.go Wyświetl plik

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"reflect"

"github.com/redis/go-redis/v9"
)
@@ -59,37 +60,72 @@ func main() {
}
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 {
fmt.Print("error\n")
fmt.Println(err)
}

perEncoded, err := ConvertStructToMap(per)
res, err = client.SMembers(ctx, "myset").Result()
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)
}

+ 0
- 2
internal/pkg/bridge/mqtthandler/mqtthandler.go Wyświetl plik

@@ -53,8 +53,6 @@ func MqttHandler(writer *kafka.Writer, topicName []byte, message []byte) {
time.Sleep(1 * time.Second)
break
}

fmt.Println("message sent: ", time.Now())
}
} else {
s := strings.Split(string(message), ",")


+ 9
- 16
internal/pkg/common/utils/distance.go Wyświetl plik

@@ -7,30 +7,23 @@ import (
"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 {
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 {
return math.Pow(ratio, 10)
distance = math.Pow(ratio, 10)
} 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
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


+ 1
- 0
internal/pkg/model/types.go Wyświetl plik

@@ -113,6 +113,7 @@ type Beacon struct {
LocationConfidence int64
LocationHistory []string
BeaconMetrics []BeaconMetric
Location string `json:"location"`
HSButtonCounter int64 `json:"hs_button_counter"`
HSButtonPrev int64 `json:"hs_button_counter_prev"`
HSBattery int64 `json:"hs_button_battery"`


+ 130
- 0
test/README.md Wyświetl plik

@@ -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.

+ 560
- 0
test/beacons_test.go Wyświetl plik

@@ -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)
}
}

+ 294
- 0
test/distance_test.go Wyświetl plik

@@ -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)
}
}

+ 568
- 0
test/mqtthandler_test.go Wyświetl plik

@@ -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
}
}

+ 644
- 0
test/typeMethods_test.go Wyświetl plik

@@ -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)
}
}

Ładowanie…
Anuluj
Zapisz