feat: add CRUD endpoints for sensors, actors, and sensor readings; update README and integration tests
This commit is contained in:
@@ -124,7 +124,23 @@ func main() {
|
||||
auth.GET("/protected", h.Protected)
|
||||
auth.GET("/mqttping", h.MQTTPing)
|
||||
auth.GET("/messages", handler.GetMessages)
|
||||
auth.GET("/sensors", h.GetSensors)
|
||||
auth.GET("/sensors/:id", h.GetSensor)
|
||||
auth.POST("/sensors", h.CreateSensor)
|
||||
auth.PUT("/sensors/:id", h.UpdateSensor)
|
||||
auth.DELETE("/sensors/:id", h.DeleteSensor)
|
||||
|
||||
auth.GET("/actors", h.GetActors)
|
||||
auth.GET("/actors/:id", h.GetActor)
|
||||
auth.POST("/actors", h.CreateActor)
|
||||
auth.PUT("/actors/:id", h.UpdateActor)
|
||||
auth.DELETE("/actors/:id", h.DeleteActor)
|
||||
|
||||
auth.GET("/sensor-readings", h.GetSensorReadings)
|
||||
auth.GET("/sensor-readings/:id", h.GetSensorReading)
|
||||
auth.POST("/sensor-readings", h.CreateSensorReading)
|
||||
auth.PUT("/sensor-readings/:id", h.UpdateSensorReading)
|
||||
auth.DELETE("/sensor-readings/:id", h.DeleteSensorReading)
|
||||
|
||||
// Device CRUD routes
|
||||
auth.POST("/devices", h.CreateDevice)
|
||||
|
||||
1
go.mod
1
go.mod
@@ -7,6 +7,7 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pelletier/go-toml/v2 v2.0.6
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/mattn/go-sqlite3 v1.14.20
|
||||
)
|
||||
|
||||
|
||||
3
go.sum
3
go.sum
@@ -1,5 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA=
|
||||
github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
@@ -41,6 +43,7 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
|
||||
@@ -28,6 +28,33 @@ type Device struct {
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type Sensor struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
DeviceID uuid.UUID `json:"device_id" db:"device_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Type string `json:"type" db:"type"`
|
||||
DataTypeID int `json:"data_type_id" db:"data_type_id"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type Actor struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
DeviceID uuid.UUID `json:"device_id" db:"device_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Type string `json:"type" db:"type"`
|
||||
DataTypeID int `json:"data_type_id" db:"data_type_id"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type SensorReading struct {
|
||||
ID int64 `json:"id" db:"id"`
|
||||
SensorID uuid.UUID `json:"sensor_id" db:"sensor_id"`
|
||||
Value float64 `json:"value" db:"value"`
|
||||
ValueAt time.Time `json:"value_at" db:"value_at"`
|
||||
}
|
||||
|
||||
// Health returns basic service health
|
||||
func (h *Handler) Health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
@@ -95,6 +122,121 @@ func (h *Handler) CreateSensor(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"id": sensorID.String()})
|
||||
}
|
||||
|
||||
// GetSensors lists all sensors
|
||||
func (h *Handler) GetSensors(c *gin.Context) {
|
||||
rows, err := h.DB.Query("SELECT BIN_TO_UUID(id), BIN_TO_UUID(device_id), name, type, data_type_id, created_at, updated_at FROM sensors")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch sensors"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sensors []Sensor
|
||||
for rows.Next() {
|
||||
var s Sensor
|
||||
if err := rows.Scan(&s.ID, &s.DeviceID, &s.Name, &s.Type, &s.DataTypeID, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan sensor"})
|
||||
return
|
||||
}
|
||||
sensors = append(sensors, s)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, sensors)
|
||||
}
|
||||
|
||||
// GetSensor returns a single sensor
|
||||
func (h *Handler) GetSensor(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sensor id"})
|
||||
return
|
||||
}
|
||||
|
||||
var s Sensor
|
||||
err = h.DB.QueryRow("SELECT BIN_TO_UUID(id), BIN_TO_UUID(device_id), name, type, data_type_id, created_at, updated_at FROM sensors WHERE id = UUID_TO_BIN(?)", id.String()).Scan(&s.ID, &s.DeviceID, &s.Name, &s.Type, &s.DataTypeID, &s.CreatedAt, &s.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch sensor"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
// UpdateSensor updates sensor fields
|
||||
func (h *Handler) UpdateSensor(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sensor id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DeviceID *string `json:"device_id"`
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
DataTypeID *int `json:"data_type_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
setParts := []string{}
|
||||
args := []interface{}{}
|
||||
if req.DeviceID != nil {
|
||||
devID, err := uuid.Parse(*req.DeviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid device id"})
|
||||
return
|
||||
}
|
||||
setParts = append(setParts, "device_id = UUID_TO_BIN(?)")
|
||||
args = append(args, devID.String())
|
||||
}
|
||||
if req.Name != nil {
|
||||
setParts = append(setParts, "name = ?")
|
||||
args = append(args, *req.Name)
|
||||
}
|
||||
if req.Type != nil {
|
||||
setParts = append(setParts, "type = ?")
|
||||
args = append(args, *req.Type)
|
||||
}
|
||||
if req.DataTypeID != nil {
|
||||
setParts = append(setParts, "data_type_id = ?")
|
||||
args = append(args, *req.DataTypeID)
|
||||
}
|
||||
if len(setParts) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
query := "UPDATE sensors SET " + strings.Join(setParts, ", ") + " WHERE id = UUID_TO_BIN(?)"
|
||||
args = append(args, id.String())
|
||||
|
||||
if _, err := h.DB.Exec(query, args...); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update sensor"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "sensor updated"})
|
||||
}
|
||||
|
||||
// DeleteSensor removes a sensor
|
||||
func (h *Handler) DeleteSensor(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sensor id"})
|
||||
return
|
||||
}
|
||||
if _, err := h.DB.Exec("DELETE FROM sensors WHERE id = UUID_TO_BIN(?)", id.String()); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete sensor"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "sensor deleted"})
|
||||
}
|
||||
|
||||
// CreateDevice creates a new device
|
||||
func (h *Handler) CreateDevice(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -285,3 +427,289 @@ func GetMessages(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": msgs})
|
||||
}
|
||||
|
||||
// Actor CRUD
|
||||
func (h *Handler) CreateActor(c *gin.Context) {
|
||||
var req struct {
|
||||
DeviceID string `json:"device_id" binding:"required,uuid"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
DataTypeID int `json:"data_type_id" binding:"required,min=1"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
devID, err := uuid.Parse(req.DeviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid device id"})
|
||||
return
|
||||
}
|
||||
actorID := uuid.New()
|
||||
if _, err := h.DB.Exec("INSERT INTO actors (id, device_id, name, type, data_type_id) VALUES (UUID_TO_BIN(?), UUID_TO_BIN(?), ?, ?, ?)", actorID.String(), devID.String(), req.Name, req.Type, req.DataTypeID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create actor"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"id": actorID.String()})
|
||||
}
|
||||
|
||||
func (h *Handler) GetActors(c *gin.Context) {
|
||||
rows, err := h.DB.Query("SELECT BIN_TO_UUID(id), BIN_TO_UUID(device_id), name, type, data_type_id, created_at, updated_at FROM actors")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch actors"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var actors []Actor
|
||||
for rows.Next() {
|
||||
var a Actor
|
||||
if err := rows.Scan(&a.ID, &a.DeviceID, &a.Name, &a.Type, &a.DataTypeID, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan actor"})
|
||||
return
|
||||
}
|
||||
actors = append(actors, a)
|
||||
}
|
||||
c.JSON(http.StatusOK, actors)
|
||||
}
|
||||
|
||||
func (h *Handler) GetActor(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid actor id"})
|
||||
return
|
||||
}
|
||||
var a Actor
|
||||
err = h.DB.QueryRow("SELECT BIN_TO_UUID(id), BIN_TO_UUID(device_id), name, type, data_type_id, created_at, updated_at FROM actors WHERE id = UUID_TO_BIN(?)", id.String()).Scan(&a.ID, &a.DeviceID, &a.Name, &a.Type, &a.DataTypeID, &a.CreatedAt, &a.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "actor not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch actor"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateActor(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid actor id"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DeviceID *string `json:"device_id"`
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
DataTypeID *int `json:"data_type_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
setParts := []string{}
|
||||
args := []interface{}{}
|
||||
if req.DeviceID != nil {
|
||||
devID, err := uuid.Parse(*req.DeviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid device id"})
|
||||
return
|
||||
}
|
||||
setParts = append(setParts, "device_id = UUID_TO_BIN(?)")
|
||||
args = append(args, devID.String())
|
||||
}
|
||||
if req.Name != nil {
|
||||
setParts = append(setParts, "name = ?")
|
||||
args = append(args, *req.Name)
|
||||
}
|
||||
if req.Type != nil {
|
||||
setParts = append(setParts, "type = ?")
|
||||
args = append(args, *req.Type)
|
||||
}
|
||||
if req.DataTypeID != nil {
|
||||
setParts = append(setParts, "data_type_id = ?")
|
||||
args = append(args, *req.DataTypeID)
|
||||
}
|
||||
if len(setParts) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
query := "UPDATE actors SET " + strings.Join(setParts, ", ") + " WHERE id = UUID_TO_BIN(?)"
|
||||
args = append(args, id.String())
|
||||
if _, err := h.DB.Exec(query, args...); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update actor"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "actor updated"})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteActor(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid actor id"})
|
||||
return
|
||||
}
|
||||
if _, err := h.DB.Exec("DELETE FROM actors WHERE id = UUID_TO_BIN(?)", id.String()); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete actor"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "actor deleted"})
|
||||
}
|
||||
|
||||
// Sensor readings CRUD
|
||||
func (h *Handler) CreateSensorReading(c *gin.Context) {
|
||||
var req struct {
|
||||
SensorID string `json:"sensor_id" binding:"required,uuid"`
|
||||
Value float64 `json:"value" binding:"required"`
|
||||
ValueAt *string `json:"value_at"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sensorID, err := uuid.Parse(req.SensorID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sensor id"})
|
||||
return
|
||||
}
|
||||
valueAt := time.Now()
|
||||
if req.ValueAt != nil && *req.ValueAt != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, *req.ValueAt); err == nil {
|
||||
valueAt = parsed
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid value_at; use RFC3339"})
|
||||
return
|
||||
}
|
||||
}
|
||||
res, err := h.DB.Exec("INSERT INTO sensor_readings (sensor_id, value, value_at) VALUES (UUID_TO_BIN(?), ?, ?)", sensorID.String(), req.Value, valueAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create sensor reading"})
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
c.JSON(http.StatusCreated, gin.H{"id": id})
|
||||
}
|
||||
|
||||
func (h *Handler) GetSensorReadings(c *gin.Context) {
|
||||
limit := 100
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 1000 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
page := 0
|
||||
if p := c.Query("page"); p != "" {
|
||||
if n, err := strconv.Atoi(p); err == nil && n >= 0 {
|
||||
page = n
|
||||
}
|
||||
}
|
||||
offset := page * limit
|
||||
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
|
||||
if sensorIDStr := c.Query("sensor_id"); sensorIDStr != "" {
|
||||
sensorID, err := uuid.Parse(sensorIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sensor id"})
|
||||
return
|
||||
}
|
||||
rows, err = h.DB.Query("SELECT id, BIN_TO_UUID(sensor_id), value, value_at FROM sensor_readings WHERE sensor_id = UUID_TO_BIN(?) ORDER BY id DESC LIMIT ? OFFSET ?", sensorID.String(), limit, offset)
|
||||
} else {
|
||||
rows, err = h.DB.Query("SELECT id, BIN_TO_UUID(sensor_id), value, value_at FROM sensor_readings ORDER BY id DESC LIMIT ? OFFSET ?", limit, offset)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch sensor readings"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var readings []SensorReading
|
||||
for rows.Next() {
|
||||
var r SensorReading
|
||||
if err := rows.Scan(&r.ID, &r.SensorID, &r.Value, &r.ValueAt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan sensor reading"})
|
||||
return
|
||||
}
|
||||
readings = append(readings, r)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, readings)
|
||||
}
|
||||
|
||||
func (h *Handler) GetSensorReading(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reading id"})
|
||||
return
|
||||
}
|
||||
var r SensorReading
|
||||
err = h.DB.QueryRow("SELECT id, BIN_TO_UUID(sensor_id), value, value_at FROM sensor_readings WHERE id = ?", id).Scan(&r.ID, &r.SensorID, &r.Value, &r.ValueAt)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sensor reading not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch sensor reading"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, r)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateSensorReading(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reading id"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Value *float64 `json:"value"`
|
||||
ValueAt *string `json:"value_at"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
setParts := []string{}
|
||||
args := []interface{}{}
|
||||
if req.Value != nil {
|
||||
setParts = append(setParts, "value = ?")
|
||||
args = append(args, *req.Value)
|
||||
}
|
||||
if req.ValueAt != nil {
|
||||
parsed, err := time.Parse(time.RFC3339, *req.ValueAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid value_at; use RFC3339"})
|
||||
return
|
||||
}
|
||||
setParts = append(setParts, "value_at = ?")
|
||||
args = append(args, parsed)
|
||||
}
|
||||
if len(setParts) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
query := "UPDATE sensor_readings SET " + strings.Join(setParts, ", ") + " WHERE id = ?"
|
||||
args = append(args, id)
|
||||
if _, err := h.DB.Exec(query, args...); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update sensor reading"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "sensor reading updated"})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteSensorReading(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reading id"})
|
||||
return
|
||||
}
|
||||
if _, err := h.DB.Exec("DELETE FROM sensor_readings WHERE id = ?", id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete sensor reading"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "sensor reading deleted"})
|
||||
}
|
||||
|
||||
202
internal/handler/handlers_integration_test.go
Normal file
202
internal/handler/handlers_integration_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// helper to build gin engine with given handler without auth for focused handler tests
|
||||
func newTestRouter(h *Handler, register func(*gin.Engine)) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
register(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// helper to set up sqlmock-backed handler
|
||||
func newMockHandler(t *testing.T) (*Handler, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
h := &Handler{DB: db, JWTSecret: "secret"}
|
||||
cleanup := func() { db.Close() }
|
||||
return h, mock, cleanup
|
||||
}
|
||||
|
||||
func TestCreateSensor(t *testing.T) {
|
||||
h, mock, cleanup := newMockHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec("INSERT INTO sensors").
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "temp", "bool", 1).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
r := newTestRouter(h, func(r *gin.Engine) {
|
||||
r.POST("/sensors", h.CreateSensor)
|
||||
})
|
||||
|
||||
body := map[string]interface{}{
|
||||
"device_id": "11111111-1111-1111-1111-111111111111",
|
||||
"name": "temp",
|
||||
"type": "bool",
|
||||
"data_type_id": 1,
|
||||
}
|
||||
buf, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, "/sensors", bytes.NewReader(buf))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 got %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSensors(t *testing.T) {
|
||||
h, mock, cleanup := newMockHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "device_id", "name", "type", "data_type_id", "created_at", "updated_at"}).
|
||||
AddRow("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "temp", "bool", 1, time.Now(), time.Now())
|
||||
|
||||
mock.ExpectQuery(`SELECT BIN_TO_UUID\(id\)`).WillReturnRows(rows)
|
||||
|
||||
r := newTestRouter(h, func(r *gin.Engine) {
|
||||
r.GET("/sensors", h.GetSensors)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/sensors", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateActor(t *testing.T) {
|
||||
h, mock, cleanup := newMockHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec("INSERT INTO actors").
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "led", "switch", 1).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
r := newTestRouter(h, func(r *gin.Engine) {
|
||||
r.POST("/actors", h.CreateActor)
|
||||
})
|
||||
|
||||
body := map[string]interface{}{
|
||||
"device_id": "11111111-1111-1111-1111-111111111111",
|
||||
"name": "led",
|
||||
"type": "switch",
|
||||
"data_type_id": 1,
|
||||
}
|
||||
buf, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, "/actors", bytes.NewReader(buf))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 got %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActors(t *testing.T) {
|
||||
h, mock, cleanup := newMockHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "device_id", "name", "type", "data_type_id", "created_at", "updated_at"}).
|
||||
AddRow("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "led", "switch", 1, time.Now(), time.Now())
|
||||
|
||||
mock.ExpectQuery(`SELECT BIN_TO_UUID\(id\).*FROM actors`).WillReturnRows(rows)
|
||||
|
||||
r := newTestRouter(h, func(r *gin.Engine) {
|
||||
r.GET("/actors", h.GetActors)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/actors", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSensorReading(t *testing.T) {
|
||||
h, mock, cleanup := newMockHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec("INSERT INTO sensor_readings").
|
||||
WithArgs(sqlmock.AnyArg(), 42.0, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(10, 1))
|
||||
|
||||
r := newTestRouter(h, func(r *gin.Engine) {
|
||||
r.POST("/sensor-readings", h.CreateSensorReading)
|
||||
})
|
||||
|
||||
body := map[string]interface{}{
|
||||
"sensor_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"value": 42.0,
|
||||
}
|
||||
buf, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, "/sensor-readings", bytes.NewReader(buf))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 got %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSensorReadings(t *testing.T) {
|
||||
h, mock, cleanup := newMockHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "sensor_id", "value", "value_at"}).
|
||||
AddRow(int64(1), "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", 1.23, time.Now())
|
||||
|
||||
mock.ExpectQuery(`SELECT id, BIN_TO_UUID\(sensor_id\)`).WillReturnRows(rows)
|
||||
|
||||
r := newTestRouter(h, func(r *gin.Engine) {
|
||||
r.GET("/sensor-readings", h.GetSensorReadings)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/sensor-readings", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,9 @@ The compose file seeds the database from `ai-improved.sql` and exposes:
|
||||
- `POST /login` — demo login, body `{ "username": "admin", "password": "password" }`, returns `{ "token": "..." }`
|
||||
- `GET /protected` — JWT required
|
||||
- `GET /devices` — list devices (public) / `POST /devices`, `GET/PUT/DELETE /devices/:id` (JWT)
|
||||
- `POST /sensors` — create sensor (JWT)
|
||||
- Sensors (JWT): `GET /sensors`, `GET /sensors/:id`, `POST /sensors`, `PUT /sensors/:id`, `DELETE /sensors/:id`
|
||||
- Actors (JWT): `GET /actors`, `GET /actors/:id`, `POST /actors`, `PUT /actors/:id`, `DELETE /actors/:id`
|
||||
- Sensor readings (JWT): `GET /sensor-readings`, `GET /sensor-readings/:id`, `POST /sensor-readings`, `PUT /sensor-readings/:id`, `DELETE /sensor-readings/:id` (optional `sensor_id` filter and pagination via `limit`, `page`)
|
||||
- `GET /mqttping` — publish timestamp to MQTT default topic (JWT)
|
||||
- `GET /messages` — last stored MQTT state messages from SQLite (JWT)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user