@@ -2,7 +2,6 @@ package listeners
22
33import (
44 "context"
5- "encoding/json"
65 "sync/atomic"
76 "testing"
87 "time"
@@ -12,169 +11,54 @@ import (
1211 "github.com/NdoleStudio/httpsms/pkg/repositories"
1312 "github.com/NdoleStudio/httpsms/pkg/services"
1413 "github.com/NdoleStudio/httpsms/pkg/telemetry"
15- "github.com/NdoleStudio/stacktrace"
1614 cloudevents "github.com/cloudevents/sdk-go/v2"
1715 "github.com/google/uuid"
1816 "github.com/stretchr/testify/require"
1917 "go.opentelemetry.io/otel/metric/noop"
2018)
2119
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-
3720type offlineMonitorRepository struct {
3821 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
22+ monitor * entities.HeartbeatMonitor
5723}
5824
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
25+ func (repository * offlineMonitorRepository ) UpdatePhoneOnline (_ context.Context , userID entities.UserID , monitorID uuid.UUID , online bool ) error {
26+ if repository .monitor . UserID == userID && repository . monitor . ID == monitorID {
27+ repository . monitor . PhoneOnline = online
6228 }
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
8729 return nil
8830}
8931
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
32+ func TestHeartbeatOfflineDelivery_AllowsRepeatedAndOutOfOrderEvents (t * testing.T ) {
33+ logger := & noopListenerLogger {}
34+ tracer := telemetry .NewOtelLogger ("test" , logger )
35+ meter , err := noop .NewMeterProvider ().Meter ("test" ).Float64Histogram ("dispatch" )
36+ require .NoError (t , err )
37+ dispatcher := services .NewEventDispatcher (logger , tracer , meter , nil , services.PushQueueConfig {})
38+ monitor := & entities.HeartbeatMonitor {ID : uuid .New (), UserID : "user-1" , PhoneOnline : true }
39+ repository := & offlineMonitorRepository {monitor : monitor }
40+ service := services .NewHeartbeatService (logger , tracer , nil , repository , dispatcher )
41+ _ , routes := NewHeartbeatListener (logger , tracer , service )
42+ for eventType , listener := range routes {
43+ dispatcher .Subscribe (eventType , listener )
10444 }
105- queue .events = append (queue .events , event )
106- return event .ID (), nil
107- }
45+ var notifications atomic.Int32
46+ dispatcher .Subscribe (events .EventTypePhoneHeartbeatOffline , func (context.Context , cloudevents.Event ) error {
47+ notifications .Add (1 )
48+ return nil
49+ })
50+ event := cloudevents .NewEvent ()
51+ event .SetID (uuid .NewString ())
52+ event .SetSource ("https://example.com" )
53+ event .SetType (events .EventTypePhoneHeartbeatOffline )
54+ require .NoError (t , event .SetData (cloudevents .ApplicationJSON , events.PhoneHeartbeatOfflinePayload {
55+ MonitorID : monitor .ID , UserID : monitor .UserID , LastHeartbeatTimestamp : time .Now ().UTC ().Add (- 24 * time .Hour ),
56+ }))
10857
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- })
58+ for delivery := 1 ; delivery <= 2 ; delivery ++ {
59+ monitor .PhoneOnline = true
60+ require .NoError (t , dispatcher .DispatchSync (context .Background (), event ))
61+ require .False (t , monitor .PhoneOnline )
62+ require .EqualValues (t , delivery , notifications .Load ())
17963 }
18064}
0 commit comments