DEV Community

Cover image for Goravel events and listeners for notifications
Krishan
Krishan

Posted on Edited on Originally published at devchalk.com

Goravel events and listeners for notifications

Part 15 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-15.

Right now, when Ana gives Ben a task, nothing tells him about it. This part sends him a notification, but the task handler doesn't write it itself: it announces the assignment as an event, TaskAssigned, and a separate listener writes the notification. The listener starts out inside the request and later moves to the background, so Ana's request no longer waits for it.

Start from the File Storage code:

git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-14
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test"
go install github.com/air-verse/air@latest
air
Enter fullscreen mode Exit fullscreen mode

Why an event

The simplest way would be to write the notification in the handler, right after the task is saved, and for one notification that's fine. But an assignment usually leads to more than that, like an email or a message in a chat app. If each of those goes into the task handler, it keeps growing, and every request has to wait for all of them to finish.

An event splits that work up. The handler only announces what happened, that Ana assigned task 8 to Ben, and the listeners decide what to do about it. Sending an email as well would then mean adding one more listener, without touching the handler at all.

Installing events

A new Goravel app doesn't have events yet. Install them like any other facade:

$ go run . artisan package:install Event
  INFO    Event depends on Log, Cache, Orm, Schema, DB, Queue, they will be installed simultaneously
 SUCCESS  Facade Event installed successfully
Enter fullscreen mode Exit fullscreen mode

The queue is on that list because a listener can run on it. TaskFlow has had the queue since Installation & Project Architecture, so only the event pieces were added: app/facades/event.go, and the event service provider in bootstrap/providers.go.

The notifications table

This is the table Installation & Project Architecture planned. A notification is one row with who it is for, which task it is about and the text to show, plus read_at, which stays empty until the user has read it:

app/models/notification.go

package models

import (
    "github.com/goravel/framework/database/orm"
    "github.com/goravel/framework/support/carbon"
)

type Notification struct {
    orm.Model
    UserID  uint             `gorm:"not null" json:"user_id"`
    TaskID  uint             `gorm:"not null" json:"task_id"`
    Message string           `gorm:"size:255;not null" json:"message"`
    ReadAt  *carbon.DateTime `json:"read_at"`
}
Enter fullscreen mode Exit fullscreen mode

Register the model next to the others, as in File Storage:

bootstrap/app.go

 // ... 34 lines not shown
        WithCallback(func() {
            facades.Schema().Extend(schema.Extension{
-               Models: []any{models.User{}, models.Task{}, models.Attachment{}},
+               Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}},
            })

            // ... 34 lines not shown
Enter fullscreen mode Exit fullscreen mode

Then generate the migration from the model:

$ go run . artisan make:migration create_notifications_table -m Notification
Enter fullscreen mode Exit fullscreen mode

The columns come from the model, but the two foreign keys don't, so add them. The one on task_id uses CascadeOnDelete, so when a task is deleted, Postgres deletes its notifications along with it. Without that, Postgres would refuse to delete any task that has a notification:

database/migrations/20261026214627_create_notifications_table.go

// ... 16 lines not shown
func (r *M20261026214627CreateNotificationsTable) Up() error {
    if !facades.Schema().HasTable("notifications") {
        return facades.Schema().Create("notifications", func(table schema.Blueprint) {
            table.TimestampTz("created_at").Nullable()
            table.TimestampTz("updated_at").Nullable()
            table.BigIncrements("id")
            table.UnsignedBigInteger("user_id")
            table.UnsignedBigInteger("task_id")
            table.String("message", 255)
            table.TimestampTz("read_at").Nullable()

            table.Foreign("user_id").References("id").On("users")
            table.Foreign("task_id").References("id").On("tasks").CascadeOnDelete()
        })
    // ... 9 lines not shown
Enter fullscreen mode Exit fullscreen mode
$ go run . artisan migrate
Enter fullscreen mode Exit fullscreen mode

The event

Generate the event:

$ go run . artisan make:event TaskAssigned
Enter fullscreen mode Exit fullscreen mode

A Goravel event has two parts: a struct whose name says what happened, and a list of args with the details. TaskAssigned keeps its struct empty, for a reason the Gotcha below explains, so the details all go in the args: the task, who got it and who gave it:

app/events/task_assigned.go

package events

import (
    "errors"

    "github.com/goravel/framework/contracts/event"
)

type TaskAssigned struct {
}

func NewTaskAssigned(taskID, assignedTo, assignedBy uint) (*TaskAssigned, []event.Arg) {
    return &TaskAssigned{}, []event.Arg{
        {Type: "uint", Value: taskID},
        {Type: "uint", Value: assignedTo},
        {Type: "uint", Value: assignedBy},
    }
}

func (receiver *TaskAssigned) Handle(args []event.Arg) ([]event.Arg, error) {
    if len(args) != 3 {
        return nil, errors.New("task assigned: needs the task id, the assignee id and the id of who assigned it")
    }
    for _, arg := range args {
        if _, ok := arg.Value.(uint); !ok {
            return nil, errors.New("task assigned: every id must be a uint")
        }
    }

    return args, nil
}
Enter fullscreen mode Exit fullscreen mode
  • NewTaskAssigned builds both parts at once, so every call passes all three ids, in the same order and as uints. When Ana gives task 8 to Ben, the call is NewTaskAssigned(8, 5, 1): task 8, Ben (user 5) and Ana (user 1).
  • Type: "uint" isn't used yet. It matters once the listener runs on the queue, where the job is stored as JSON and the worker reads Type to turn 8 back into a uint.
  • Handle runs before any listener, so bad args fail right away at Dispatch, in the handler, and not later inside a listener. NewTaskAssigned always gets the args right, so the check is there for a call that builds them by hand.

The listener

Generate the listener:

$ go run . artisan make:listener NotifyAssignee
Enter fullscreen mode Exit fullscreen mode

A listener is the code that reacts to an event. Here is NotifyAssignee:

app/listeners/notify_assignee.go

package listeners

import (
    "errors"
    "fmt"

    "github.com/goravel/framework/contracts/event"
    frameworkerrors "github.com/goravel/framework/errors"

    "taskflow/app/facades"
    "taskflow/app/models"
)

type NotifyAssignee struct {
}

func (receiver *NotifyAssignee) Signature() string {
    return "notify_assignee"
}

func (receiver *NotifyAssignee) Queue(args ...any) event.Queue {
    return event.Queue{
        Enable:     false,
        Connection: "",
        Queue:      "",
    }
}

func (receiver *NotifyAssignee) Handle(args ...any) error {
    taskID, ok1 := args[0].(uint)
    assignedTo, ok2 := args[1].(uint)
    assignedBy, ok3 := args[2].(uint)
    if !ok1 || !ok2 || !ok3 {
        return errors.New("notify assignee: the ids must be uints")
    }
    if assignedTo == assignedBy {
        return nil
    }

    var task models.Task
    err := facades.Orm().Query().FindOrFail(&task, taskID)
    if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
        return nil
    }
    if err != nil {
        return err
    }

    var manager models.User
    if err := facades.Orm().Query().FindOrFail(&manager, assignedBy); err != nil {
        return err
    }

    return facades.Orm().Query().Create(&models.Notification{
        UserID:  assignedTo,
        TaskID:  task.ID,
        Message: fmt.Sprintf("%s assigned you %q", manager.Name, task.Title),
    })
}
Enter fullscreen mode Exit fullscreen mode
  • Signature names the listener notify_assignee. Once the listener runs on the queue, the worker finds it by this name.
  • Queue returns Enable: false for now, so the listener runs right away, inside the request.
  • Handle writes the notification from the args, which arrive in the order the event put them in. It skips a manager who takes a task themselves, and it checks the type of every arg and returns an error instead of crashing; the Gotcha in the queue section shows why.

Firing the event

The event and the listener exist now, but nothing connects them yet. WithEvents in bootstrap/app.go does that, by listing each event with the listeners that react to it. One event can have several listeners, and in the request they run in the order listed here:

bootstrap/app.go

 // ... 7 lines not shown
    contractsaccess "github.com/goravel/framework/contracts/auth/access"
    "github.com/goravel/framework/contracts/database/schema"
+   contractsevent "github.com/goravel/framework/contracts/event"
    contractsfoundation "github.com/goravel/framework/contracts/foundation"
    "github.com/goravel/framework/contracts/http"
    "github.com/goravel/framework/foundation"
    "github.com/goravel/framework/http/limit"

    "taskflow/app/enums"
+   "taskflow/app/events"
    "taskflow/app/facades"
    "taskflow/app/http/authuser"
+   "taskflow/app/listeners"
    "taskflow/app/models"
    "taskflow/app/policies"
    // ... 11 lines not shown
            routes.Web()
        }).
+       WithEvents(func() map[contractsevent.Event][]contractsevent.Listener {
+           return map[contractsevent.Event][]contractsevent.Listener{
+               &events.TaskAssigned{}: {&listeners.NotifyAssignee{}},
+           }
+       }).
        WithProviders(Providers).
        WithConfig(config.Boot).
        // ... 39 lines not shown
Enter fullscreen mode Exit fullscreen mode

Gotcha Goravel finds an event's listeners by its pointer, so the &TaskAssigned{} the handler fires has to match the one in WithEvents. They're two separate values, and they only match because Go usually puts every empty struct at the same address. Add a field, like TaskID uint, and each one gets its own address, so Goravel finds no listeners:

event &{0} doesn't bind listeners

That's why the struct stays empty and the ids go in the args.

Now the task handlers fire the event. A task gets an assignee in two places: when Ana creates it, in Store, and when she changes it, in Update. Both fire TaskAssigned only when someone new is assigned, so renaming a task sends nothing:

app/http/controllers/task_controller.go

 package controllers

 import (
    "errors"
    "fmt"

    "taskflow/app/enums"
+   "taskflow/app/events"
    "taskflow/app/facades"
    "taskflow/app/http/authuser"
    // ... 60 lines not shown
            "error": "Could not load the task.",
        })
    }

+   if saved.AssignedTo != nil {
+       err := facades.Event().Job(events.NewTaskAssigned(saved.ID, *saved.AssignedTo, authuser.From(ctx).ID)).Dispatch()
+       if err != nil {
+           facades.Log().Error(err)
+       }
+   }
+
    return ctx.Response().Json(201, saved)
 }
 // ... 108 lines not shown

    changes := validator.Validated()
+   var assignedBefore uint
+   if task.AssignedTo != nil {
+       assignedBefore = *task.AssignedTo
+   }
    response := facades.Gate().WithContext(ctx).Inspect(policies.UpdateTask, map[string]any{
        "task":    &task,
        // ... 22 lines not shown
            "error": "Could not load the task.",
        })
    }

+   if saved.AssignedTo != nil && *saved.AssignedTo != assignedBefore {
+       err := facades.Event().Job(events.NewTaskAssigned(saved.ID, *saved.AssignedTo, authuser.From(ctx).ID)).Dispatch()
+       if err != nil {
+           facades.Log().Error(err)
+       }
+   }
+
    return ctx.Response().Json(200, saved)
 }
 // ... 35 lines not shown
Enter fullscreen mode Exit fullscreen mode
  • Job(...) gets the event ready and Dispatch() sends it: first the event's Handle runs, then each listener. NewTaskAssigned returns the event and its args, which is exactly what Job takes.
  • Update(changes) writes the new values into task itself, so the old assignee is copied into assignedBefore before the save. Without that copy, the handler couldn't tell that the assignee changed.
  • If Dispatch returns an error, the task is already saved, so the handler only logs the error and still answers 201 or 200.

Reading notifications

Each user reads their own notifications at GET /notifications, newest first. They pile up, one for every assignment, so the list comes in pages, like the task list in Task CRUD. POST /notifications/{id}/read marks one as read:

app/http/controllers/notification_controller.go

// ... 24 lines not shown
func (r *NotificationController) Index(ctx http.Context) http.Response {
    validator, err := ctx.Request().Validate(map[string]any{
        "page":     "bail|filled|integer|min:1",
        "per_page": "bail|filled|integer|min:1|max:100",
    // ... 16 lines not shown
    var notifications []models.Notification
    var total int64
    err = facades.Orm().WithContext(ctx).Query().
        Where("user_id", authuser.From(ctx).ID).
        OrderByDesc("id").
        Paginate(page, perPage, &notifications, &total)
    // ... 16 lines not shown

func (r *NotificationController) Read(ctx http.Context) http.Response {
    var notification models.Notification
    err := facades.Orm().WithContext(ctx).Query().
        Where("user_id", authuser.From(ctx).ID).
        FindOrFail(&notification, ctx.Request().RouteInt("id"))
    if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
        return ctx.Response().Json(404, map[string]any{
            "error": "The notification does not exist.",
        })
    }
    if err != nil {
        facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not load the notification.",
        })
    }

    if notification.ReadAt == nil {
        notification.ReadAt = carbon.NewDateTime(carbon.Now().StartOfSecond())
        if err := facades.Orm().WithContext(ctx).Query().Save(&notification); err != nil {
            facades.Log().Error(err)
            return ctx.Response().Json(500, map[string]any{
                "error": "Could not save the notification.",
            })
        }
    }

    return ctx.Response().Json(200, notification)
}
Enter fullscreen mode Exit fullscreen mode
  • Both handlers look only among the user's own notifications, so when Read gets another user's id, it answers the same 404 as for an id that doesn't exist.
  • Read sets read_at only once, so it keeps when the user first read it. StartOfSecond drops the fraction of a second: the column keeps whole seconds and Postgres rounds, so without it the answer could show a different second from the one saved.

routes/api.go

 // ... 14 lines not shown
    userController := controllers.NewUserController()
    attachmentController := controllers.NewAttachmentController()
+   notificationController := controllers.NewNotificationController()

    facades.Route().Prefix("api/v1").Group(func(router route.Router) {
        router.Post("register", authController.Register)
        // ... 15 lines not shown
            router.Delete("tasks/{id}/attachments/{attachment}", attachmentController.Destroy)

+           router.Get("notifications", notificationController.Index)
+           router.Post("notifications/{id}/read", notificationController.Read)
+
            router.Get("users", userController.Index)
            router.Get("users/{id}", userController.Show)
        })
    })
 }
Enter fullscreen mode Exit fullscreen mode

See it work

Set up as in Gates & Policies, with one more member:

  1. Reset the database with migrate:fresh --seed. The seeder adds Ana, the manager, and three members.
  2. Register Ben and Cat. They get ids 5 and 6.
  3. Log in as Ana, Ben and Cat, and save each token in a variable: ANA, BEN and CAT.

Ana creates a task for Ben, and his notification is there as soon as the request returns. Cat can't mark it as read, but Ben can:

$ curl -X POST http://localhost:3000/api/v1/tasks \
  -H "Authorization: Bearer $ANA" \
  -H "Content-Type: application/json" \
  -d '{"title":"Fix the login page","description":"The error message overlaps the button.","assigned_to":5}'
{…,"id":8,…,"assigned_to":5,…,"title":"Fix the login page",…}

$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":null}],"meta":{"page":1,"per_page":15,"total":1}}

$ curl -X POST http://localhost:3000/api/v1/notifications/1/read -H "Authorization: Bearer $CAT"
{"error":"The notification does not exist."}

$ curl -X POST http://localhost:3000/api/v1/notifications/1/read -H "Authorization: Bearer $BEN"
{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":"2026-10-26 21:50:12"}
Enter fullscreen mode Exit fullscreen mode

Then Ana renames the task, gives it to Cat, and finally takes it herself. Of those three changes, only giving it to Cat sends a notification:

$ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA" -H "Content-Type: application/json" -d '{"title":"Fix the login form"}'
200

$ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA" -H "Content-Type: application/json" -d '{"assigned_to":6}'
200

$ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA" -H "Content-Type: application/json" -d '{"assigned_to":1}'
200

$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $CAT"
{"data":[{…,"id":2,"user_id":6,"task_id":8,"message":"Ana assigned you \"Fix the login form\"","read_at":null}],"meta":{"page":1,"per_page":15,"total":1}}

$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $ANA"
{"data":[],"meta":{"page":1,"per_page":15,"total":0}}
Enter fullscreen mode Exit fullscreen mode

Ben still has only his first notification, now read.

When a listener fails:

Listeners in the request run one after another, and as soon as one returns an error, the ones after it are skipped. A listener that always fails makes this easy to watch. Make one with make:listener AlwaysFails, and have its Handle return an error:

func (receiver *AlwaysFails) Handle(args ...any) error {
    return errors.New("this listener always fails")
}
Enter fullscreen mode Exit fullscreen mode

Then put it in front of NotifyAssignee in WithEvents:

&events.TaskAssigned{}: {&listeners.AlwaysFails{}, &listeners.NotifyAssignee{}},
Enter fullscreen mode Exit fullscreen mode

Ana gives Ben one more task. It is saved and she gets her 201, because the handler only logs the error, but NotifyAssignee never runs, so Ben's list still has only his first notification:

$ curl -X POST http://localhost:3000/api/v1/tasks \
  -H "Authorization: Bearer $ANA" \
  -H "Content-Type: application/json" \
  -d '{"title":"Update the README","description":"Add the new endpoints.","assigned_to":5}'
{…,"id":9,…,"assigned_to":5,…,"title":"Update the README",…}

$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":"2026-10-26 21:50:12"}],"meta":{"page":1,"per_page":15,"total":1}}

$ grep local.error storage/logs/goravel-*.log
[2026-10-26 21:50:19.486] local.error: this listener always fails
Enter fullscreen mode Exit fullscreen mode

Goravel's docs call this "stopping the propagation". On the queue, each listener is a job of its own, so if AlwaysFails were queued too, it would fail in the worker and NotifyAssignee would still run. Take AlwaysFails out of WithEvents again before going on.

Running the listener in the background

As long as the listener runs inside the request, Ana's request waits for it. Writing one row is quick enough that she won't notice, but an email can take seconds, and if the mail server is down, she'd be waiting on that too. A queue fixes this: the handler leaves a job on a list and answers right away, and a worker picks the job up a moment later and runs the listener.

TaskFlow already has both halves. The config/queue.go that came with the project sets default to redis. Of its three connections, sync runs a job straight away, inside the call, and database and redis keep jobs in Postgres or Redis:

config/queue.go

// ... 8 lines not shown
func init() {
    config := facades.Config()
    config.Add("queue", map[string]any{
        "default": "redis",

        // Queue Connections
        //
        // Here you may configure the connection information for each server that is used by your application.
        // Drivers: "sync", "database", "custom"
        "connections": map[string]any{
            "sync": map[string]any{
                "driver": "sync",
            },
            "database": map[string]any{
                "driver":     "database",
                "connection": config.Env("DB_CONNECTION"),
                "queue":      "default",
                "concurrent": 1,
            },
            "redis": map[string]any{
                "driver":     "custom",
                "connection": "default",
                "queue":      "default",
                "via": func() (queue.Driver, error) {
                    return redisfacades.Queue("redis") // The `redis` value is the key of `connections`
                },
            },
        },
// ... 11 lines not shown
Enter fullscreen mode Exit fullscreen mode

Whenever the default isn't sync, app.Start() also runs a worker next to the HTTP server, which is the line air prints when it starts:

  INFO    Processing jobs from [redis] connection and [default] queue
Enter fullscreen mode Exit fullscreen mode

A listener's Queue method decides whether it uses that queue:

  • Enable puts the listener on the queue when it is true.
  • Connection picks one of the connections above. Empty means the default one.
  • Queue picks a queue on that connection, for example to keep slow jobs apart from quick ones. Empty means the default connection's queue, which is default.

So to put NotifyAssignee on the queue, only Enable changes:

app/listeners/notify_assignee.go

 // ... 20 lines not shown
 func (receiver *NotifyAssignee) Queue(args ...any) event.Queue {
    return event.Queue{
-       Enable:     false,
+       Enable:     true,
        Connection: "",
        Queue:      "",
    }
 // ... 33 lines not shown
Enter fullscreen mode Exit fullscreen mode

Ana gives Ben a third task, and Ben checks his list straight away and again a second later. Paste the four commands together, because the worker looks for new jobs about once a second, and typed by hand, even the first check would come too late:

$ curl -X POST http://localhost:3000/api/v1/tasks \
  -H "Authorization: Bearer $ANA" \
  -H "Content-Type: application/json" \
  -d '{"title":"Write the API docs","description":"Start with the auth routes.","assigned_to":5}'
{…,"assigned_to":5,…,"title":"Write the API docs",…}

$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":1,…,"message":"Ana assigned you \"Fix the login page\"",…}],"meta":{"page":1,"per_page":15,"total":1}}

$ sleep 1
$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":3,…,"message":"Ana assigned you \"Write the API docs\"","read_at":null},{…,"id":1,…}],"meta":{"page":1,"per_page":15,"total":2}}
Enter fullscreen mode Exit fullscreen mode

Ana's request came back before the listener ran, and the worker prints the job it ran a moment later, under the listener's signature:

  2026-10-26 21:50:26.42 notify_assignee ............................. RUNNING
  2026-10-26 21:50:26.428 notify_assignee ....................... 8.159ms DONE
Enter fullscreen mode Exit fullscreen mode

Three things change once a listener is on the queue:

  • Errors no longer reach the handler. Dispatch only fails if the job can't be put on the queue, so if the listener fails later, only the worker knows.
  • The args must be plain values. The job is saved in Redis as JSON, and the worker rebuilds each value from its Type. That works for plain values like numbers and strings, which is why the event passes ids and not a Task or a User.
  • The world may change before the job runs. By then the task may have been renamed, given to someone else or deleted. That's why the listener takes the assignee from the args, which say who was assigned at that moment, and why it stops quietly, without an error, when the task is gone.

Gotcha A panic in a queued listener stops the whole app. The worker runs inside the same process as the HTTP server and doesn't recover from a panic, so an unchecked args[0].(int) on a uint brings both down:

panic: interface conversion: interface {} is uint, not int

The process exits, so the API is down until it's started again. That's why the listener checks every type and returns an error instead.

Why not a model observer:

Goravel can also run code whenever a model is saved, with an observer. A TaskObserver's Created and Updated methods could write the notification when assigned_to is set or changes. But an observer fires on every save, wherever it comes from, so the seeders would send notifications too, and it runs inside the save, not on the queue. Only the task handler knows "Ana just gave this task to Ben", so TaskFlow fires the event there.

Here is the whole trip, first with the listener in the request, then on the queue:

Interactive figure on DevChalk: Two assignments.

In the request: the client sends the new task, the API saves it in Postgres, fires TaskAssigned, NotifyAssignee inserts the notification, and only then does the API answer 201. On the queue: the API saves the task, puts a notify_assignee job on Redis and answers 201 at once. The worker takes the job from Redis and NotifyAssignee inserts the notification a moment later.

Note Goravel 1.19 is set to add its own notifications. Until then, an event and a listener do the job.

Tests

The tests for this part are in tests/feature/notification_test.go in TaskFlow. They check who gets a notification after each kind of change, that users only see and mark their own, that deleting a task removes its notifications, and that the event turns down bad args.

The tests don't start the app, so no worker runs, and a queued listener would never run at all. The sync connection runs it straight away instead, so the queue's default now comes from an env variable, and the tests set it to sync:

config/queue.go

 // ... 8 lines not shown
 func init() {
    config := facades.Config()
    config.Add("queue", map[string]any{
-       "default": "redis",
+       "default": config.Env("QUEUE_CONNECTION", "redis"),

        // ... 34 lines not shown
Enter fullscreen mode Exit fullscreen mode

.env.example gets the line with redis, and an .env without it falls back to redis too:

.env.example

 # ... 23 lines not shown

 FILESYSTEM_DISK=local
+QUEUE_CONNECTION=redis

 REDIS_HOST=127.0.0.1
 REDIS_PASSWORD=
 REDIS_PORT=6380
Enter fullscreen mode Exit fullscreen mode

and the tests get sync:

.env.testing

 # ... 29 lines not shown
 HASH_ROUNDS=4
 FILESYSTEM_DISK=testing
+QUEUE_CONNECTION=sync
Enter fullscreen mode Exit fullscreen mode

If the tests ran on redis, their jobs would wait in the shared Redis, and a running server could take them and write into the wrong database, so the test case refuses any queue but sync, the same way it refuses the wrong disk:

tests/test_case.go

 // ... 19 lines not shown
        panic("the tests delete uploaded files, so they only run on the testing disk: go test ./tests/... --env=.env.testing")
    }
+
+   if facades.Config().GetString("queue.default") != "sync" {
+       panic("the tests check what queued listeners wrote, so they only run on the sync queue: go test ./tests/... --env=.env.testing")
+   }
 }

 type TestCase struct {
    testing.TestCase
 }
Enter fullscreen mode Exit fullscreen mode
$ go test ./tests/... --env=.env.testing
?       taskflow/tests  [no test files]
ok      taskflow/tests/feature  1.299s
ok      taskflow/tests/unit 0.401s
Enter fullscreen mode Exit fullscreen mode

Assigning a task now sends a notification, written by a listener that runs on the queue, while the task handler only says what happened.


Previous: 14. File Storage
Next: 16. Queues & Jobs
Series: Goravel

Top comments (0)