Laravel & Vue: Notificaciones en Tiempo Real usando Reverb, Redis y TDD - 03

Capítulo 3: Configuración de Redis para Almacenar y Administrar Notificaciones

Objetivo

En este capítulo, configuraremos Redis para gestionar notificaciones en tiempo real, sincronizándolas con la base de datos para obtener persistencia a largo plazo. Utilizaremos el ciclo TDD (rojo → verde → refactor) para desarrollar un sistema eficiente de notificaciones en tiempo real con Redis y una persistencia robusta en la base de datos.

1. Generar notificaciones automáticas al cambiar el estado de una orden (Order).
2. Almacenar notificaciones en Redis con tiempos de expiración para mejorar el rendimiento.
3. Notificar tanto al usuario como a los administradores sobre cambios en el estado de una orden.
4. Implementar un sistema para marcar notificaciones como "leídas" o "no leídas" utilizando Redis.

Introducción

En aplicaciones modernas, Redis es una base de datos en memoria que ofrece una velocidad excepcional para operaciones de lectura y escritura. Esto lo convierte en una solución ideal para manejar notificaciones en tiempo real, donde la latencia debe ser mínima. Al combinar Redis con una base de datos relacional como MySQL o PostgreSQL, podemos obtener lo mejor de ambos mundos: rapidez en la entrega de notificaciones y persistencia para mantener un historial permanente.

Vamos a trabajar con una nueva entidad llamada Order, que representa un pedido con un estado de entrega (delivery_status). Cada vez que el estado de una orden cambie (por ejemplo, de "en preparación" a "en tránsito"), un Observer se encargará de generar automáticamente notificaciones tanto para el usuario como para los administradores del sistema.

Ciclo TDD: Generación de Notificaciones

1. Escribir la Prueba Inicial

Empezaremos escribiendo una prueba para validar que se generan notificaciones cuando el estado de una orden cambia y que estas notificaciones se envían tanto al usuario como a los administradores.

Ejecuta el siguiente comando para crear un archivo de prueba:

sail artisan make:test OrderStatusObserverTest

Modifica tests/Feature/OrderStatusObserverTest.php con el siguiente contenido:

namespace Tests\Feature;

use App\Models\Notification;
use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase;

class OrderStatusObserverTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_creates_notifications_for_user_and_admins_on_order_status_change()
    {
        // Given
        $user = User::factory()->create();
        $admin = User::factory()->state(['is_admin' => true])->create();

        // When
        $order = Order::create([
            'user_id' => $user->id,
            'delivery_status' => 'in_preparation',
        ]);

        $order->update(['delivery_status' => 'in_transit']);

        // Then
        $this->assertDatabaseHas('notifications', [
            'user_id' => $user->id,
            'message' => 'El estado del pedido ha cambiado a: En tránsito.',
        ]);

        $this->assertDatabaseHas('notifications', [
            'user_id' => $admin->id,
            'message' => 'El pedido #1 ha cambiado de estado a: En tránsito.',
        ]);

        $userNotification = json_decode(Redis::lpop("notifications:{$user->id}"), true);
        $adminNotification = json_decode(Redis::lpop("notifications:{$admin->id}"), true);

        $this->assertEquals('El estado del pedido ha cambiado a: En tránsito.', $userNotification['message']);
        $this->assertEquals('El pedido #1 ha cambiado de estado a: En tránsito.', $adminNotification['message']);
    }
}

2. Ejecutar la Prueba Inicial (Rojo)

Ejecuta el test para confirmar que falle debido a que no existen ni el modelo Order ni su observer:

sail artisan test --filter=OrderStatusObserverTest

Errores esperados:

  1. Clase Order no encontrada.
  2. Migración para orders no definida.

Ahora que hemos identificado los errores esperados, vamos a crear el modelo Order y su respectiva migración para solucionar el primer fallo.

Creación de Entidades y Migraciones

3. Crear el Modelo y la Migración para Order

Crea el modelo Order y su migración con el siguiente comando:

sail artisan make:model Order -m

Modifica la migración generada en database/migrations/xxxx_xx_xx_create_orders_table.php para incluir la estructura de la tabla:

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('delivery_status');
    $table->timestamps();
});

En el modelo app/Models/Order.php, añade el atributo fillable:

protected $fillable = ['user_id', 'delivery_status'];

Ejecuta la migración:

sail artisan migrate

4. Crear una Nueva Migración para Agregar el Campo message a notifications

Para agregar el campo message a la tabla notifications, ejecuta:

sail artisan make:migration add_message_to_notifications_table --table=notifications

Modifica la migración generada en database/migrations/xxxx_xx_xx_add_message_to_notifications_table.php:

Schema::table('notifications', function (Blueprint $table) {
    $table->text('message');
});

Ejecuta la migración:

sail artisan migrate

Modifica el modelo Notification para incluir message en $fillable:

protected $fillable = ['user_id', 'status', 'message'];

Gestión del Estado de Lectura de Notificaciones

Para mejorar la experiencia del usuario, implementaremos una funcionalidad que permita marcar notificaciones como "leídas" o "no leídas" utilizando Redis.

Explicación del Proceso:

  • Cuando se crea una notificación, se añade automáticamente al conjunto de notificaciones no leídas en Redis.

  • Cuando el usuario revisa una notificación, se elimina del conjunto de no leídas.

Código para Manejar el Estado de Lectura

  1. Añadir una Notificación como No Leída:

    Redis::sadd("notifications:unread:{$user->id}", $notification->id);
    
  2. Marcar una Notificación como Leída:

    Redis::srem("notifications:unread:{$user->id}", $notificationId);
    

5. Implementar el Observer para Generar Notificaciones (Verde)

Crea un Observer para el modelo Order:

sail artisan make:observer OrderObserver --model=Order

Modifica app/Observers/OrderObserver.php con la lógica para generar las notificaciones:

namespace App\Observers;

use App\Models\Notification;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\Redis;

class OrderObserver
{
    public function updating(Order $order)
    {
        if ($order->isDirty('delivery_status')) {
            $status = $order->delivery_status;

            // Mensaje para el usuario
            $userMessage = "El estado del pedido ha cambiado a: {$this->getStatusLabel($status)}.";
            Notification::create([
                'user_id' => $order->user_id,
                'status' => $status,
                'message' => $userMessage,
            ]);

            Redis::lpush("notifications:{$order->user_id}", json_encode([
                'message' => $userMessage,
                'timestamp' => now()->timestamp,
            ]));

            // Mensaje para los administradores
            $adminMessage = "El pedido #{$order->id} ha cambiado de estado a: {$this->getStatusLabel($status)}.";
            $admins = User::where('is_admin', true)->get();
            foreach ($admins as $admin) {
                Notification::create([
                    'user_id' => $admin->id,
                    'status' => $status,
                    'message' => $adminMessage,
                ]);

                Redis::lpush("notifications:{$admin->id}", json_encode([
                    'message' => $adminMessage,
                    'timestamp' => now()->timestamp,
                ]));
            }
        }
    }

    private function getStatusLabel(string $status): string
    {
        return [
            'in_preparation' => 'En preparación',
            'in_transit' => 'En tránsito',
            'delivered' => 'Entregado',
        ][$status] ?? $status;
    }
}

Registra el observer en AppServiceProvider:

use App\Models\Order;
use App\Observers\OrderObserver;

public function boot()
{
    Order::observe(OrderObserver::class);
}

Ahora, ejecuta nuevamente el test:

sail artisan test --filter=OrderStatusObserverTest
PASS  Tests\Feature\OrderStatusObserverTest
  ✓ it creates notifications for user and admins on order status change

Actualización del Test NotificationTest

¿Por qué Actualizamos el Test? Después de agregar el campo message a la tabla notifications, nuestro test original comenzó a fallar porque no contemplaba este nuevo campo obligatorio. Vamos a actualizar el test para reflejar esta nueva estructura de datos y cumplir con el ciclo TDD.

1. Ejecución del Test (Rojo)

Si ejecutamos el siguiente comando para correr todos los tests:

sail artisan test

Obtenemos el siguiente error:

FAIL  Tests\Feature\NotificationTest
  ⨯ it creates a notification

  Failed asserting that a row in the table [notifications] matches the attributes {
    "user_id": 1,
    "status": "Enviado"
  }.

  Found similar results: [
    {
      "user_id": 1,
      "status": "Enviado",
      "message": null
    }
  ].

Análisis del Error: El test falla porque la creación de una notificación ahora requiere un message no nulo. La migración que añadimos establece el campo message como un text, por lo que debemos incluirlo al crear una notificación en el test.

2. Modificar el Test (Verde)

Para solucionar el error, actualizaremos el test NotificationTest para incluir el campo message al crear una notificación.

Modifica el archivo tests/Feature/NotificationTest.php de la siguiente manera:

<?php

namespace Tests\Feature;

use App\Models\Notification;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class NotificationTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_creates_a_notification()
    {
        // Given
        $user = User::factory()->create();
        $data = [
            'user_id' => $user->id,
            'status' => 'Enviado',
            'message' => 'El pedido ha sido enviado',
        ];

        // When
        Notification::create($data);

        // Then
        $this->assertDatabaseHas('notifications', $data);
    }
}

3. Ejecutar el Test Corregido

Ahora, ejecuta nuevamente el test:

sail artisan test --filter=NotificationTest

El test debería pasar correctamente:

PASS  Tests\Feature\NotificationTest
  ✓ it creates a notification

4. Ahora ejecutamos todos los tests

sail artisan test

Explicación de los Cambios

  1. Adición del Campo message: Se agregó el campo message en el array $data para cumplir con el nuevo requisito del modelo Notification.

  2. Consistencia con la Migración: El campo message es obligatorio debido a la migración que añadimos. Esta actualización del test asegura que estamos creando notificaciones válidas que cumplen con los requisitos actuales del modelo y la base de datos.

  3. Aplicación del Ciclo TDD:

    • Rojo: El test falló después de añadir el campo message.
    • Verde: Actualizamos el test para incluir el campo message, y el test pasó.
    • Refactor: No se requiere refactorización adicional en este caso, ya que el código es claro y conciso.

Conclusión

En este capítulo, implementamos un sistema completo de notificaciones usando Redis y Laravel, siguiendo el ciclo TDD. Hemos logrado:

  1. Generar notificaciones automáticas al cambiar el estado de una orden.
  2. Almacenar notificaciones en Redis para mejorar el rendimiento.
  3. Sincronizar con la base de datos para persistencia a largo plazo.
  4. Gestionar el estado "leído" o "no leído" con Redis.

En el próximo capítulo, conectaremos Laravel con el frontend para recibir notificaciones en tiempo real usando Laravel Echo. 🚀

José Rafael Gutierrez

Soy un desarrollador web con más de 14 años de experiencia, especializado en la creación de sistemas a medida. Apasionado por la tecnología, la ciencia, y la lectura, disfruto resolviendo problemas de...

Suscríbete para Actualizaciones

Proporcione su correo electrónico para recibir notificaciones sobre nuevas publicaciones o actualizaciones.