diff --git a/go.mod b/go.mod index 0b97fe0..912bd72 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( forge.lthn.ai/core/config v0.1.8 github.com/gin-gonic/gin v1.12.0 github.com/goccy/go-json v0.10.6 + github.com/gorilla/websocket v1.5.3 github.com/stretchr/testify v1.11.1 golang.org/x/net v0.52.0 golang.org/x/sys v0.42.0 @@ -93,7 +94,6 @@ require ( github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/go-version v1.8.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 0a478f0..f72246d 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -29,12 +29,19 @@ import ( // and Renderable. type ScmProvider struct { index *marketplace.Index - installer *marketplace.Installer + installer marketplaceInstaller registry *repos.Registry hub *ws.Hub medium io.Medium } +type marketplaceInstaller interface { + Install(context.Context, marketplace.Module) error + Remove(string) error + Update(context.Context, string) error + Installed() ([]marketplace.InstalledModule, error) +} + // compile-time interface checks var ( _ provider.Provider = (*ScmProvider)(nil) @@ -47,7 +54,7 @@ var ( // installer, and registry. The WS hub is used to emit real-time events. // Pass nil for any dependency that is not available. // Usage: NewProvider(...) -func NewProvider(idx *marketplace.Index, inst *marketplace.Installer, reg *repos.Registry, hub *ws.Hub) *ScmProvider { +func NewProvider(idx *marketplace.Index, inst marketplaceInstaller, reg *repos.Registry, hub *ws.Hub) *ScmProvider { return &ScmProvider{ index: idx, installer: inst, @@ -81,6 +88,7 @@ func (p *ScmProvider) Channels() []string { "scm.marketplace.refreshed", "scm.marketplace.installed", "scm.marketplace.removed", + "scm.installed.changed", "scm.manifest.verified", "scm.registry.changed", } @@ -293,6 +301,11 @@ func (p *ScmProvider) installItem(c *gin.Context) { "code": mod.Code, "name": mod.Name, }) + p.emitEvent("scm.installed.changed", map[string]any{ + "action": "installed", + "code": mod.Code, + "name": mod.Name, + }) c.JSON(http.StatusOK, api.OK(map[string]any{"installed": true, "code": mod.Code})) } @@ -313,6 +326,10 @@ func (p *ScmProvider) removeItem(c *gin.Context) { } p.emitEvent("scm.marketplace.removed", map[string]any{"code": code}) + p.emitEvent("scm.installed.changed", map[string]any{ + "action": "removed", + "code": code, + }) c.JSON(http.StatusOK, api.OK(map[string]any{"removed": true, "code": code})) } @@ -474,6 +491,11 @@ func (p *ScmProvider) updateInstalled(c *gin.Context) { return } + p.emitEvent("scm.installed.changed", map[string]any{ + "action": "updated", + "code": code, + }) + c.JSON(http.StatusOK, api.OK(map[string]any{"updated": true, "code": code})) } diff --git a/pkg/api/provider_events_test.go b/pkg/api/provider_events_test.go new file mode 100644 index 0000000..a74c3a3 --- /dev/null +++ b/pkg/api/provider_events_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "dappco.re/go/core/scm/marketplace" + scmapi "dappco.re/go/core/scm/pkg/api" + "dappco.re/go/core/ws" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeInstaller struct { + updateCalls []string +} + +func (f *fakeInstaller) Install(context.Context, marketplace.Module) error { return nil } + +func (f *fakeInstaller) Remove(string) error { return nil } + +func (f *fakeInstaller) Update(_ context.Context, code string) error { + f.updateCalls = append(f.updateCalls, code) + return nil +} + +func (f *fakeInstaller) Installed() ([]marketplace.InstalledModule, error) { return nil, nil } + +func TestScmProvider_UpdateInstalled_EmitsInstalledChangedEvent_Good(t *testing.T) { + hub := ws.NewHub() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go hub.Run(ctx) + + server := httptest.NewServer(hub.Handler()) + t.Cleanup(server.Close) + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + require.NoError(t, conn.WriteJSON(ws.Message{Type: ws.TypeSubscribe, Data: "scm.installed.changed"})) + time.Sleep(50 * time.Millisecond) + + installer := &fakeInstaller{} + p := scmapi.NewProvider(nil, installer, nil, hub) + r := setupRouter(p) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/scm/installed/demo/update", nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, []string{"demo"}, installer.updateCalls) + + var msg ws.Message + require.NoError(t, conn.ReadJSON(&msg)) + assert.Equal(t, ws.TypeEvent, msg.Type) + assert.Equal(t, "scm.installed.changed", msg.Channel) + + data, ok := msg.Data.(map[string]any) + require.True(t, ok) + assert.Equal(t, "updated", data["action"]) + assert.Equal(t, "demo", data["code"]) +} diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index 3b2eac9..c0a14fa 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -42,6 +42,7 @@ func TestScmProvider_Channels_Good(t *testing.T) { assert.Contains(t, channels, "scm.marketplace.refreshed") assert.Contains(t, channels, "scm.marketplace.installed") assert.Contains(t, channels, "scm.marketplace.removed") + assert.Contains(t, channels, "scm.installed.changed") assert.Contains(t, channels, "scm.manifest.verified") assert.Contains(t, channels, "scm.registry.changed") }