diff --git a/cmd/server/main.go b/cmd/server/main.go index e040a82..797fc9e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/go.mod b/go.mod index 027a42a..2424dcd 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 502348d..97f0d4b 100644 --- a/go.sum +++ b/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= diff --git a/internal/handler/handlers.go b/internal/handler/handlers.go index c44f8dd..9fca862 100644 --- a/internal/handler/handlers.go +++ b/internal/handler/handlers.go @@ -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"}) +} diff --git a/internal/handler/handlers_integration_test.go b/internal/handler/handlers_integration_test.go new file mode 100644 index 0000000..5f08199 --- /dev/null +++ b/internal/handler/handlers_integration_test.go @@ -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) + } +} diff --git a/readme.md b/readme.md index a178222..2b258ef 100644 --- a/readme.md +++ b/readme.md @@ -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)