11package handlers
22
33import (
4+ "bufio"
45 "context"
6+ "encoding/json"
7+ "io"
58 "net/http"
69 "net/http/httptest"
710 "testing"
@@ -14,6 +17,7 @@ import (
1417 "github.com/getarcaneapp/arcane/backend/v2/internal/database"
1518 "github.com/getarcaneapp/arcane/backend/v2/internal/models"
1619 "github.com/getarcaneapp/arcane/backend/v2/internal/services"
20+ "github.com/getarcaneapp/arcane/types/v2/activity"
1721)
1822
1923func setupActivityHandlerTestDBInternal (t * testing.T ) * database.DB {
@@ -97,3 +101,145 @@ func TestActivityHandlerClearHistoryProxiesRemoteEnvironmentInternal(t *testing.
97101 require .NoError (t , err )
98102 require .EqualValues (t , 7 , out .Body .Data .Deleted )
99103}
104+
105+ // limitStreamTestDBToSingleConnInternal serializes DB access: the aggregated
106+ // stream queries from concurrent goroutines, and every extra pooled
107+ // connection to a :memory: SQLite database is a fresh empty database.
108+ func limitStreamTestDBToSingleConnInternal (t * testing.T , db * database.DB ) {
109+ t .Helper ()
110+ sqlDB , err := db .DB .DB ()
111+ require .NoError (t , err )
112+ sqlDB .SetMaxOpenConns (1 )
113+ }
114+
115+ func createStreamTestRemoteEnvironmentInternal (t * testing.T , db * database.DB , apiURL , token string ) {
116+ t .Helper ()
117+ now := time .Now ()
118+ require .NoError (t , db .Create (& models.Environment {
119+ BaseModel : models.BaseModel {
120+ ID : "remote-1" ,
121+ CreatedAt : now ,
122+ UpdatedAt : & now ,
123+ },
124+ Name : "Remote" ,
125+ ApiUrl : apiURL ,
126+ Status : string (models .EnvironmentStatusOnline ),
127+ Enabled : true ,
128+ AccessToken : & token ,
129+ }).Error )
130+ }
131+
132+ // runStreamAllInternal drives streamAllActivitiesInternal through a pipe and
133+ // returns each decoded event to onEvent until it reports done or the stream
134+ // ends; remaining output is drained so a blocked encoder can always finish.
135+ func runStreamAllInternal (t * testing.T , ctx context.Context , cancel context.CancelFunc , handler * ActivityHandler , onEvent func (activity.StreamEvent ) bool ) {
136+ t .Helper ()
137+
138+ pr , pw := io .Pipe ()
139+ done := make (chan struct {})
140+ go func () {
141+ defer close (done )
142+ defer pw .Close ()
143+ handler .streamAllActivitiesInternal (ctx , 50 , json .NewEncoder (pw ), func () {})
144+ }()
145+
146+ scanner := bufio .NewScanner (pr )
147+ for scanner .Scan () {
148+ var event activity.StreamEvent
149+ require .NoError (t , json .Unmarshal (scanner .Bytes (), & event ))
150+ if onEvent (event ) {
151+ cancel ()
152+ break
153+ }
154+ }
155+
156+ go func () {
157+ _ , _ = io .Copy (io .Discard , pr )
158+ }()
159+ select {
160+ case <- done :
161+ case <- time .After (5 * time .Second ):
162+ t .Fatal ("stream did not terminate after cancel" )
163+ }
164+ }
165+
166+ func TestActivityHandlerStreamAllEmitsEnvironmentScopedEventsInternal (t * testing.T ) {
167+ ctx , cancel := context .WithTimeout (context .Background (), 10 * time .Second )
168+ defer cancel ()
169+
170+ db := setupActivityHandlerTestDBInternal (t )
171+ limitStreamTestDBToSingleConnInternal (t , db )
172+ settingsService , err := services .NewSettingsService (ctx , db )
173+ require .NoError (t , err )
174+ activityService := services .NewActivityService (db )
175+
176+ local , err := activityService .StartActivity (ctx , services.StartActivityRequest {EnvironmentID : "0" , Type : models .ActivityTypeResourceAction })
177+ require .NoError (t , err )
178+
179+ token := "remote-token"
180+ server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
181+ w .Header ().Set ("Content-Type" , "application/json" )
182+ _ , _ = w .Write ([]byte (`{"success":true,"data":[{"id":"remote-activity-1"}],"pagination":{"totalPages":1,"totalItems":1,"currentPage":1,"itemsPerPage":50}}` ))
183+ }))
184+ defer server .Close ()
185+ createStreamTestRemoteEnvironmentInternal (t , db , server .URL , token )
186+
187+ handler := & ActivityHandler {
188+ activityService : activityService ,
189+ environmentService : services .NewEnvironmentService (db , server .Client (), nil , nil , settingsService , nil ),
190+ }
191+
192+ var localSnapshot , remoteSnapshot bool
193+ runStreamAllInternal (t , ctx , cancel , handler , func (event activity.StreamEvent ) bool {
194+ if event .Type == "snapshot" && event .EnvironmentID == "0" && len (event .Activities ) == 1 {
195+ require .Equal (t , local .ID , event .Activities [0 ].ID )
196+ require .Equal (t , "0" , event .Activities [0 ].SourceEnvironmentID )
197+ localSnapshot = true
198+ }
199+ if event .Type == "snapshot" && event .EnvironmentID == "remote-1" && len (event .Activities ) == 1 {
200+ require .Equal (t , "remote-activity-1" , event .Activities [0 ].ID )
201+ remoteSnapshot = true
202+ }
203+ return localSnapshot && remoteSnapshot
204+ })
205+
206+ require .True (t , localSnapshot )
207+ require .True (t , remoteSnapshot )
208+ }
209+
210+ func TestActivityHandlerStreamAllRemoteFailureEmitsErrorAndKeepsStreamingInternal (t * testing.T ) {
211+ ctx , cancel := context .WithTimeout (context .Background (), 10 * time .Second )
212+ defer cancel ()
213+
214+ db := setupActivityHandlerTestDBInternal (t )
215+ limitStreamTestDBToSingleConnInternal (t , db )
216+ settingsService , err := services .NewSettingsService (ctx , db )
217+ require .NoError (t , err )
218+ activityService := services .NewActivityService (db )
219+
220+ token := "remote-token"
221+ server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
222+ http .Error (w , "boom" , http .StatusInternalServerError )
223+ }))
224+ defer server .Close ()
225+ createStreamTestRemoteEnvironmentInternal (t , db , server .URL , token )
226+
227+ handler := & ActivityHandler {
228+ activityService : activityService ,
229+ environmentService : services .NewEnvironmentService (db , server .Client (), nil , nil , settingsService , nil ),
230+ }
231+
232+ var localSnapshot , remoteError bool
233+ runStreamAllInternal (t , ctx , cancel , handler , func (event activity.StreamEvent ) bool {
234+ if event .Type == "snapshot" && event .EnvironmentID == "0" {
235+ localSnapshot = true
236+ }
237+ if event .Type == "error" && event .EnvironmentID == "remote-1" && event .Error != "" {
238+ remoteError = true
239+ }
240+ return localSnapshot && remoteError
241+ })
242+
243+ require .True (t , localSnapshot )
244+ require .True (t , remoteError )
245+ }
0 commit comments