|
| 1 | +package listeners |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
| 6 | + "sync/atomic" |
| 7 | + "testing" |
| 8 | + "time" |
| 9 | + |
| 10 | + "github.com/NdoleStudio/httpsms/pkg/entities" |
| 11 | + "github.com/NdoleStudio/httpsms/pkg/events" |
| 12 | + "github.com/NdoleStudio/httpsms/pkg/repositories" |
| 13 | + "github.com/NdoleStudio/httpsms/pkg/services" |
| 14 | + "github.com/NdoleStudio/httpsms/pkg/telemetry" |
| 15 | + "github.com/NdoleStudio/stacktrace" |
| 16 | + cloudevents "github.com/cloudevents/sdk-go/v2" |
| 17 | + "github.com/google/uuid" |
| 18 | + "github.com/stretchr/testify/require" |
| 19 | + "go.opentelemetry.io/otel/metric/noop" |
| 20 | +) |
| 21 | + |
| 22 | +type offlineHeartbeatRepository struct { |
| 23 | + repositories.HeartbeatRepository |
| 24 | + heartbeat *entities.Heartbeat |
| 25 | + err error |
| 26 | +} |
| 27 | + |
| 28 | +func (repository *offlineHeartbeatRepository) Last(context.Context, entities.UserID, string) (*entities.Heartbeat, error) { |
| 29 | + return repository.heartbeat, repository.err |
| 30 | +} |
| 31 | + |
| 32 | +func (repository *offlineHeartbeatRepository) Store(_ context.Context, heartbeat *entities.Heartbeat) error { |
| 33 | + repository.heartbeat = heartbeat |
| 34 | + return nil |
| 35 | +} |
| 36 | + |
| 37 | +type offlineMonitorRepository struct { |
| 38 | + repositories.HeartbeatMonitorRepository |
| 39 | + monitor *entities.HeartbeatMonitor |
| 40 | + lastHeartbeatID uuid.UUID |
| 41 | + beforeOffline func() |
| 42 | +} |
| 43 | + |
| 44 | +func (repository *offlineMonitorRepository) Load(context.Context, entities.UserID, string) (*entities.HeartbeatMonitor, error) { |
| 45 | + monitor := *repository.monitor |
| 46 | + monitor.LastHeartbeatID = repository.lastHeartbeatID |
| 47 | + return &monitor, nil |
| 48 | +} |
| 49 | + |
| 50 | +func (repository *offlineMonitorRepository) CompareAndSwapQueueID(_ context.Context, _ uuid.UUID, expected, next string) (bool, error) { |
| 51 | + if repository.monitor.QueueID != expected { |
| 52 | + return false, nil |
| 53 | + } |
| 54 | + repository.monitor.QueueID = next |
| 55 | + repository.monitor.UpdatedAt = time.Now().UTC() |
| 56 | + return true, nil |
| 57 | +} |
| 58 | + |
| 59 | +func (repository *offlineMonitorRepository) RecordHeartbeat(_ context.Context, _ entities.UserID, _ uuid.UUID, expected, heartbeatID uuid.UUID) (*entities.HeartbeatMonitor, error) { |
| 60 | + if repository.lastHeartbeatID != expected { |
| 61 | + return nil, nil |
| 62 | + } |
| 63 | + previous := *repository.monitor |
| 64 | + previous.LastHeartbeatID = repository.lastHeartbeatID |
| 65 | + repository.lastHeartbeatID = heartbeatID |
| 66 | + repository.monitor.PhoneOnline = true |
| 67 | + return &previous, nil |
| 68 | +} |
| 69 | + |
| 70 | +func (repository *offlineMonitorRepository) UpdatePhoneOfflineForHeartbeat(_ context.Context, _ entities.UserID, _ uuid.UUID, expected, heartbeatID uuid.UUID) (bool, error) { |
| 71 | + repository.runOfflineHook() |
| 72 | + if repository.lastHeartbeatID != expected { |
| 73 | + return false, nil |
| 74 | + } |
| 75 | + repository.monitor.PhoneOnline = false |
| 76 | + if heartbeatID != uuid.Nil { |
| 77 | + repository.lastHeartbeatID = heartbeatID |
| 78 | + } |
| 79 | + return true, nil |
| 80 | +} |
| 81 | + |
| 82 | +func (repository *offlineMonitorRepository) UpdatePhoneOnline(_ context.Context, _ entities.UserID, _ uuid.UUID, online bool) error { |
| 83 | + if !online { |
| 84 | + repository.runOfflineHook() |
| 85 | + } |
| 86 | + repository.monitor.PhoneOnline = online |
| 87 | + return nil |
| 88 | +} |
| 89 | + |
| 90 | +func (repository *offlineMonitorRepository) runOfflineHook() { |
| 91 | + if repository.beforeOffline != nil { |
| 92 | + hook := repository.beforeOffline |
| 93 | + repository.beforeOffline = nil |
| 94 | + hook() |
| 95 | + } |
| 96 | +} |
| 97 | + |
| 98 | +type offlineEventQueue struct{ events []cloudevents.Event } |
| 99 | + |
| 100 | +func (queue *offlineEventQueue) Enqueue(_ context.Context, task *services.PushQueueTask, _ time.Duration) (string, error) { |
| 101 | + var event cloudevents.Event |
| 102 | + if err := json.Unmarshal(task.Body, &event); err != nil { |
| 103 | + return "", err |
| 104 | + } |
| 105 | + queue.events = append(queue.events, event) |
| 106 | + return event.ID(), nil |
| 107 | +} |
| 108 | + |
| 109 | +func TestHeartbeatOfflineDelivery_RejectsNewerHeartbeatBeforeSubscribers(t *testing.T) { |
| 110 | + for _, arrival := range []string{"before delivery", "between history and status update", "history read fails", "history expired", "no newer heartbeat", "legacy event"} { |
| 111 | + t.Run(arrival, func(t *testing.T) { |
| 112 | + logger := &noopListenerLogger{} |
| 113 | + tracer := telemetry.NewOtelLogger("test", logger) |
| 114 | + meter, err := noop.NewMeterProvider().Meter("test").Float64Histogram("dispatch") |
| 115 | + require.NoError(t, err) |
| 116 | + queue := &offlineEventQueue{} |
| 117 | + dispatcher := services.NewEventDispatcher(logger, tracer, meter, queue, services.PushQueueConfig{}) |
| 118 | + history := &offlineHeartbeatRepository{heartbeat: &entities.Heartbeat{ |
| 119 | + ID: uuid.New(), Timestamp: time.Now().UTC().Add(-65 * time.Minute), |
| 120 | + }} |
| 121 | + monitor := &offlineMonitorRepository{monitor: &entities.HeartbeatMonitor{ |
| 122 | + ID: uuid.New(), PhoneID: uuid.New(), UserID: "user-1", Owner: "+18005550199", |
| 123 | + PhoneOnline: true, QueueID: "check:current-check", UpdatedAt: time.Now().UTC(), |
| 124 | + }, lastHeartbeatID: history.heartbeat.ID} |
| 125 | + service := services.NewHeartbeatService(logger, tracer, history, monitor, dispatcher) |
| 126 | + heartbeatListener, routes := NewHeartbeatListener(logger, tracer, service) |
| 127 | + dispatcher.SubscribeFilter(events.EventTypePhoneHeartbeatOffline, heartbeatListener.FilterOfflineEvent) |
| 128 | + for eventType, listener := range routes { |
| 129 | + dispatcher.Subscribe(eventType, listener) |
| 130 | + } |
| 131 | + var notifications atomic.Int32 |
| 132 | + dispatcher.Subscribe(events.EventTypePhoneHeartbeatOffline, func(context.Context, cloudevents.Event) error { |
| 133 | + notifications.Add(1) |
| 134 | + return nil |
| 135 | + }) |
| 136 | + require.NoError(t, service.Monitor(context.Background(), &services.HeartbeatMonitorParams{ |
| 137 | + UserID: monitor.monitor.UserID, Owner: monitor.monitor.Owner, MonitorID: monitor.monitor.ID, |
| 138 | + Source: "https://example.com", CheckID: "current-check", |
| 139 | + })) |
| 140 | + require.Len(t, queue.events, 3) |
| 141 | + event := queue.events[1] |
| 142 | + storeHeartbeat := func() { |
| 143 | + _, err := service.Store(context.Background(), services.HeartbeatStoreParams{ |
| 144 | + UserID: monitor.monitor.UserID, Owner: monitor.monitor.Owner, |
| 145 | + Source: "https://example.com", Timestamp: time.Now().UTC(), |
| 146 | + }) |
| 147 | + require.NoError(t, err) |
| 148 | + } |
| 149 | + switch arrival { |
| 150 | + case "before delivery": |
| 151 | + storeHeartbeat() |
| 152 | + case "between history and status update": |
| 153 | + monitor.beforeOffline = storeHeartbeat |
| 154 | + case "history read fails": |
| 155 | + history.err = stacktrace.NewError("history unavailable") |
| 156 | + case "history expired": |
| 157 | + history.err = stacktrace.NewErrorWithCode(repositories.ErrCodeNotFound, "history expired") |
| 158 | + case "legacy event": |
| 159 | + var data map[string]any |
| 160 | + require.NoError(t, event.DataAs(&data)) |
| 161 | + delete(data, "heartbeat_id") |
| 162 | + require.NoError(t, event.SetData(cloudevents.ApplicationJSON, data)) |
| 163 | + monitor.lastHeartbeatID = uuid.Nil |
| 164 | + } |
| 165 | + err = dispatcher.DispatchSync(context.Background(), event) |
| 166 | + if arrival == "history read fails" { |
| 167 | + require.Error(t, err) |
| 168 | + } else { |
| 169 | + require.NoError(t, err) |
| 170 | + } |
| 171 | + if arrival == "no newer heartbeat" || arrival == "legacy event" { |
| 172 | + require.EqualValues(t, 1, notifications.Load()) |
| 173 | + require.False(t, monitor.monitor.PhoneOnline) |
| 174 | + } else { |
| 175 | + require.Zero(t, notifications.Load(), "stale or unverifiable events must not reach email/webhook subscribers") |
| 176 | + require.True(t, monitor.monitor.PhoneOnline) |
| 177 | + } |
| 178 | + }) |
| 179 | + } |
| 180 | +} |
0 commit comments