浏览代码

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

master
Blaz Smehov 1周前
父节点
当前提交
d2300aa400
共有 10 个文件被更改,包括 2313 次插入58 次删除
  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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

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

正在加载...
取消
保存