Compare commits

..

2 Commits

Author SHA1 Message Date
6e89d5a8f5 update readme 2025-10-10 04:53:27 +02:00
7bc9f1c987 fix test error 2025-10-10 03:09:34 +02:00
4 changed files with 503 additions and 15 deletions

View File

@ -33,7 +33,7 @@ mock:
.PHONY: test .PHONY: test
# Run tests # Run tests
tests: tests:
go test ./... go test ./... -cover
.PHONY: run .PHONY: run
# Start app in development environment # Start app in development environment

View File

@ -123,9 +123,9 @@ Por otro lado también hay un sistema de caché muy rudimentario, en memoria que
es un mapa de valores. es un mapa de valores.
Para el registro de valores y mantener ambos se ha usado el patrón decorador que Para el registro de valores y mantener ambos se ha usado el patrón decorador que
bajo un mismo _struct_ se incluye las dos implementaciones y se llama a ambas bajo un mismo _struct_ se incluye las dos implementaciones y registra cambios en
funciones. Desde la capa servicios sólo tiene que llamar al decorador sin saber ambas partes. Desde la capa servicios sólo tiene que llamar al decorador sin
los detalles de la implementación. saber los detalles de la implementación.
### Continuamos con los servicios ### Continuamos con los servicios
@ -151,6 +151,20 @@ documentación me quedé con los conceptos clave:
Esto es todo, entonces los controladores de la entidad _sensors_ están Esto es todo, entonces los controladores de la entidad _sensors_ están
constituidos por una serie de _endpoints_ haciendo las acciones que se solicita. constituidos por una serie de _endpoints_ haciendo las acciones que se solicita.
### El simulador
Basada en _gorutinas_ y canales, cuando se inicia el simulador, se crea un canal
para detener simuladores que están en ejecución para su actualización o
detención.
Cuando se registra un nuevo sensor, está la función SimulateSensor, que se
inicia como una _gorutina_ y usa el `SamplingInterval` para el canal `ticker`,
así llamar a `generateData` cada vez que toque.
Una vez que el dato está generado se hace una publicación al asunto _sensor.data_,
que al mismo tiempo, el _handler_ registerData lo captura al estar registrado
al mismo asunto _sensor.data_.
## Pruebas ## Pruebas
La realización de pruebas unitarias de lo que son los controladores de NATS me La realización de pruebas unitarias de lo que son los controladores de NATS me
@ -161,12 +175,6 @@ he optado por no incorporarlas.
Las pruebas más interesantes son las de reglas de negocio y validación, lo que Las pruebas más interesantes son las de reglas de negocio y validación, lo que
viene a ser los servicios y dominio. viene a ser los servicios y dominio.
## LLMS
He usado Claude para la toma de decisiones y ayuda con el _boilerplate_, que no
es poca cosa, además también se ha usado para la generación de las pruebas
unitarias, además de resolución de algunos problemas complejos.
## Generadores y otras librerías ## Generadores y otras librerías
Existen generadores de código para Golang, de hecho, se fomenta su desarrollo, Existen generadores de código para Golang, de hecho, se fomenta su desarrollo,
@ -196,4 +204,61 @@ sí, pero la prueba no consiste en eso.
También se ha planteado incorporar la librería _testify_, descartado porque para También se ha planteado incorporar la librería _testify_, descartado porque para
comprobar si existe el error y algunas comparaciones no era necesario meter una comprobar si existe el error y algunas comparaciones no era necesario meter una
dependencia más. dependencia más.
## Diagrama
![diagrama app](./assets/app-architecture.jpg)
El diagrama explica básicamente la estrucutra del proyecto en términos generales,
se demuestra que el dominio sensors solo se comunica al exterior mediante el
NATS y el _logger_.
Amarillo: exterior.
Morado: infraestructura.
Verde: dominio
Blanco: simulador, los _handlers_ está conectado sólo para llamar a la
gorutina, pero en la realidad debería ir independiente.
## Conclusión y cierre
Interesante reto donde realmente lo que más me ha costado son la realización
de pruebas unitarias, hay veces que me cuesta coger el concepto. Tengo la teoría
muy clara, pero a la hora de la verdad se me complica un poco las cosas. Además
que _mockear_ el sistema de mensajería debe tener su especial complejidad.
También ha sido algo complejo entender la concurrencia y los canales, no es algo
que haya trabajado en profundidad pero sí que se le ha puesto bastante empeño,
cariño y nivel de detalle.
He puesto mucho en valor la arquitectura limpia con un toque personal, no es DDD
puro ya que hay elementos que no deberían estar en dominio, pero en el proyecto
que estoy trabajando ahora mismo se está diseñando de la misma manera y está
funcionando muy bien.
También se ha evitado todo lo posible el uso de LLMs para la generación de
código, y su uso ha sido para la toma de decisiones arquitectónicas, discusión y
lectura rápida sobre los distintos funcionamientos de algunas librerías. En más
de una ocasión he cuestionado las respuestas que da, teniendo que verificar con
la documentación oficial. Pongo en valor mi capacidad para aprovechar la IA de
la mejor forma posible, verificando la información, además recalco que justo el
proyecto actual es una migración de un código en PHP completamente hecho con IA,
y se puede ver patrones y errores comunes que comete.
Soy consciente de que hay margen de mejora, por ejemplo con los tests o con la
documentación, se ha puesto especial esfuerzo y atención a que los nombres de
las funciones, variables, métodos, estructuras y paquetes sean lo más
autodescriptivos posibles. Se han puesto algunos comentarios. También hay
esfuerzo por permitir ejecutar el proyecto por primera vez con la mínima
intervención.
Un problema interesante que tuve que resolver, que como el sensor puede mandar
un valor ausente, el tipo `float64` al hacer el `unmarshal` se establece a 0.0,
con lo que se puede considerar válido, con lo cual su solución fue el uso de
puntero, si se descubre que es `nil` se considera no válido.
Digamos que este proyecto resuelve el problema que se propone, un sistema que
permite registrar y actualziar un sensor. Se puede ver su estado y los datos que
se recogen (simulados) se guardan en una base de datos.
Espero que el proyecto sea de vuestro agrado y podamos tener una siguiente
reunión.

BIN
assets/app-architecture.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -1,6 +1,7 @@
package sensors package sensors
import ( import (
"errors"
"testing" "testing"
"time" "time"
@ -20,6 +21,7 @@ func Test_RegisterSensor(t *testing.T) {
given Sensor given Sensor
setupMock func(q *MockRepository, params Sensor) setupMock func(q *MockRepository, params Sensor)
expecErr bool expecErr bool
expectErr error
} }
tests := []testCase{ tests := []testCase{
@ -47,9 +49,25 @@ func Test_RegisterSensor(t *testing.T) {
ThresholdBelow: ptr(0.0), ThresholdBelow: ptr(0.0),
}, },
setupMock: func(q *MockRepository, params Sensor) { setupMock: func(q *MockRepository, params Sensor) {
q.EXPECT().CreateSensor(params).Return(ErrSensorAlreadyExists) q.EXPECT().CreateSensor(params).Return(errors.New("duplicate key value"))
}, },
expecErr: true, expecErr: true,
expectErr: ErrSensorAlreadyExists,
},
{
name: "error - some db error",
given: Sensor{
SensorID: "temp-001",
SensorType: Temperature,
SamplingInterval: ptr(time.Minute),
ThresholdAbove: ptr(100.0),
ThresholdBelow: ptr(0.0),
},
setupMock: func(q *MockRepository, params Sensor) {
q.EXPECT().CreateSensor(params).Return(errors.New("some db error"))
},
expecErr: true,
expectErr: ErrRegisteringSensor,
}, },
} }
@ -71,8 +89,413 @@ func Test_RegisterSensor(t *testing.T) {
return return
} }
if tt.expecErr && err != ErrSensorAlreadyExists { if tt.expecErr && tt.expectErr != nil && err != tt.expectErr {
t.Errorf("expected ErrSensorAlreadyExists, got %v", err) t.Errorf("expected error %v, got %v", tt.expectErr, err)
}
})
}
}
func Test_RegisterSensorData(t *testing.T) {
type testCase struct {
name string
given SensorData
setupMock func(q *MockRepository, params SensorData)
expecErr bool
}
timestamp := time.Now()
value := 25.5
tests := []testCase{
{
name: "success - registers sensor data",
given: SensorData{
SensorID: "temp-001",
Value: &value,
Timestamp: &timestamp,
},
setupMock: func(q *MockRepository, params SensorData) {
q.EXPECT().CreateSensorData(params).Return(nil)
},
expecErr: false,
},
{
name: "error - database error",
given: SensorData{
SensorID: "temp-001",
Value: &value,
Timestamp: &timestamp,
},
setupMock: func(q *MockRepository, params SensorData) {
q.EXPECT().CreateSensorData(params).Return(errors.New("database error"))
},
expecErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, q := setup(t)
tt.setupMock(q, tt.given)
err := s.RegisterSensorData(tt.given)
if tt.expecErr && err == nil {
t.Error("expected error, got nil")
return
}
if !tt.expecErr && err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
}
func Test_UpdateSensor(t *testing.T) {
type testCase struct {
name string
given Sensor
setupMock func(q *MockRepository, params Sensor)
expecErr bool
expectErr error
}
tests := []testCase{
{
name: "success - updates sensor",
given: Sensor{
SensorID: "temp-001",
SensorType: Temperature,
SamplingInterval: ptr(time.Minute * 2),
ThresholdAbove: ptr(120.0),
ThresholdBelow: ptr(10.0),
},
setupMock: func(q *MockRepository, params Sensor) {
q.EXPECT().UpdateSensor(params).Return(nil)
},
expecErr: false,
},
{
name: "error - sensor already exists (duplicate)",
given: Sensor{
SensorID: "temp-002",
SensorType: Temperature,
SamplingInterval: ptr(time.Minute),
ThresholdAbove: ptr(100.0),
ThresholdBelow: ptr(0.0),
},
setupMock: func(q *MockRepository, params Sensor) {
q.EXPECT().UpdateSensor(params).Return(errors.New("duplicate key value"))
},
expecErr: true,
expectErr: ErrSensorAlreadyExists,
},
{
name: "error - general database error",
given: Sensor{
SensorID: "temp-001",
SensorType: Temperature,
SamplingInterval: ptr(time.Minute),
ThresholdAbove: ptr(100.0),
ThresholdBelow: ptr(0.0),
},
setupMock: func(q *MockRepository, params Sensor) {
q.EXPECT().UpdateSensor(params).Return(errors.New("connection failed"))
},
expecErr: true,
expectErr: ErrUpdatingSensor,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, q := setup(t)
tt.setupMock(q, tt.given)
err := s.UpdateSensor(tt.given)
if tt.expecErr && err == nil {
t.Error("expected error, got nil")
return
}
if !tt.expecErr && err != nil {
t.Errorf("expected no error, got %v", err)
return
}
if tt.expecErr && tt.expectErr != nil && err != tt.expectErr {
t.Errorf("expected error %v, got %v", tt.expectErr, err)
}
})
}
}
func Test_GetSensor(t *testing.T) {
type testCase struct {
name string
given SensorRequest
setupMock func(q *MockRepository, sensorID string)
expected Sensor
expecErr bool
}
tests := []testCase{
{
name: "success - retrieves sensor",
given: SensorRequest{
SensorID: "temp-001",
},
setupMock: func(q *MockRepository, sensorID string) {
q.EXPECT().ReadSensor(sensorID).Return(Sensor{
SensorID: "temp-001",
SensorType: Temperature,
SamplingInterval: ptr(time.Minute),
ThresholdAbove: ptr(100.0),
ThresholdBelow: ptr(0.0),
}, nil)
},
expected: Sensor{
SensorID: "temp-001",
SensorType: Temperature,
SamplingInterval: ptr(time.Minute),
ThresholdAbove: ptr(100.0),
ThresholdBelow: ptr(0.0),
},
expecErr: false,
},
{
name: "error - sensor not found",
given: SensorRequest{
SensorID: "temp-999",
},
setupMock: func(q *MockRepository, sensorID string) {
q.EXPECT().ReadSensor(sensorID).Return(Sensor{}, ErrSensorNotFound)
},
expected: Sensor{},
expecErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, q := setup(t)
tt.setupMock(q, tt.given.SensorID)
result, err := s.GetSensor(tt.given)
if tt.expecErr && err == nil {
t.Error("expected error, got nil")
return
}
if !tt.expecErr && err != nil {
t.Errorf("expected no error, got %v", err)
return
}
if !tt.expecErr {
if result.SensorID != tt.expected.SensorID {
t.Errorf("expected sensor_id %q, got %q", tt.expected.SensorID, result.SensorID)
}
if result.SensorType != tt.expected.SensorType {
t.Errorf("expected sensor_type %q, got %q", tt.expected.SensorType, result.SensorType)
}
}
})
}
}
func Test_GetValues(t *testing.T) {
type testCase struct {
name string
given SensorDataRequest
setupMock func(q *MockRepository, sensorID string, from, to time.Time)
expected []SensorData
expecErr bool
}
now := time.Now()
weekAgo := now.AddDate(0, 0, -7)
fromStr := weekAgo.Format(time.RFC3339)
toStr := now.Format(time.RFC3339)
value1 := 25.5
value2 := 26.0
tests := []testCase{
{
name: "success - retrieves sensor data",
given: SensorDataRequest{
SensorID: "temp-001",
From: &fromStr,
To: &toStr,
},
setupMock: func(q *MockRepository, sensorID string, from, to time.Time) {
q.EXPECT().ReadSensorValues(sensorID, from, to).Return([]SensorData{
{
SensorID: "temp-001",
Value: &value1,
Timestamp: &weekAgo,
},
{
SensorID: "temp-001",
Value: &value2,
Timestamp: &now,
},
}, nil)
},
expected: []SensorData{
{
SensorID: "temp-001",
Value: &value1,
Timestamp: &weekAgo,
},
{
SensorID: "temp-001",
Value: &value2,
Timestamp: &now,
},
},
expecErr: false,
},
{
name: "error - database error",
given: SensorDataRequest{
SensorID: "temp-001",
From: &fromStr,
To: &toStr,
},
setupMock: func(q *MockRepository, sensorID string, from, to time.Time) {
q.EXPECT().ReadSensorValues(sensorID, from, to).Return([]SensorData{}, errors.New("database error"))
},
expected: []SensorData{},
expecErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, q := setup(t)
from, _ := time.Parse(time.RFC3339, *tt.given.From)
to, _ := time.Parse(time.RFC3339, *tt.given.To)
tt.setupMock(q, tt.given.SensorID, from, to)
result, err := s.GetValues(tt.given)
if tt.expecErr && err == nil {
t.Error("expected error, got nil")
return
}
if !tt.expecErr && err != nil {
t.Errorf("expected no error, got %v", err)
return
}
if !tt.expecErr {
if len(result) != len(tt.expected) {
t.Errorf("expected %d values, got %d", len(tt.expected), len(result))
}
}
})
}
}
func Test_ListSensors(t *testing.T) {
type testCase struct {
name string
setupMock func(q *MockRepository)
expected []Sensor
expecErr bool
}
tests := []testCase{
{
name: "success - retrieves all sensors",
setupMock: func(q *MockRepository) {
q.EXPECT().ReadAllSensors().Return([]Sensor{
{
SensorID: "temp-001",
SensorType: Temperature,
SamplingInterval: ptr(time.Minute),
ThresholdAbove: ptr(100.0),
ThresholdBelow: ptr(0.0),
},
{
SensorID: "hum-001",
SensorType: Humidity,
SamplingInterval: ptr(time.Minute * 2),
ThresholdAbove: ptr(80.0),
ThresholdBelow: ptr(20.0),
},
}, nil)
},
expected: []Sensor{
{
SensorID: "temp-001",
SensorType: Temperature,
SamplingInterval: ptr(time.Minute),
ThresholdAbove: ptr(100.0),
ThresholdBelow: ptr(0.0),
},
{
SensorID: "hum-001",
SensorType: Humidity,
SamplingInterval: ptr(time.Minute * 2),
ThresholdAbove: ptr(80.0),
ThresholdBelow: ptr(20.0),
},
},
expecErr: false,
},
{
name: "success - empty list when no sensors",
setupMock: func(q *MockRepository) {
q.EXPECT().ReadAllSensors().Return([]Sensor{}, nil)
},
expected: []Sensor{},
expecErr: false,
},
{
name: "error - database error",
setupMock: func(q *MockRepository) {
q.EXPECT().ReadAllSensors().Return([]Sensor{}, errors.New("database error"))
},
expected: []Sensor{},
expecErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, q := setup(t)
tt.setupMock(q)
result, err := s.ListSensors()
if tt.expecErr && err == nil {
t.Error("expected error, got nil")
return
}
if !tt.expecErr && err != nil {
t.Errorf("expected no error, got %v", err)
return
}
if !tt.expecErr {
if len(result) != len(tt.expected) {
t.Errorf("expected %d sensors, got %d", len(tt.expected), len(result))
}
} }
}) })
} }