fix(pkg/api): emit installed change events
Some checks failed
Security Scan / security (push) Failing after 19s
Test / test (push) Failing after 2m0s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 14:00:53 +00:00
parent fe8c7e5982
commit 25667064ca
4 changed files with 99 additions and 3 deletions

2
go.mod
View file

@ -15,6 +15,7 @@ require (
forge.lthn.ai/core/config v0.1.8 forge.lthn.ai/core/config v0.1.8
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/goccy/go-json v0.10.6 github.com/goccy/go-json v0.10.6
github.com/gorilla/websocket v1.5.3
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/net v0.52.0 golang.org/x/net v0.52.0
golang.org/x/sys v0.42.0 golang.org/x/sys v0.42.0
@ -93,7 +94,6 @@ require (
github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // 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/go-version v1.8.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect

View file

@ -29,12 +29,19 @@ import (
// and Renderable. // and Renderable.
type ScmProvider struct { type ScmProvider struct {
index *marketplace.Index index *marketplace.Index
installer *marketplace.Installer installer marketplaceInstaller
registry *repos.Registry registry *repos.Registry
hub *ws.Hub hub *ws.Hub
medium io.Medium 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 // compile-time interface checks
var ( var (
_ provider.Provider = (*ScmProvider)(nil) _ provider.Provider = (*ScmProvider)(nil)
@ -47,7 +54,7 @@ var (
// installer, and registry. The WS hub is used to emit real-time events. // installer, and registry. The WS hub is used to emit real-time events.
// Pass nil for any dependency that is not available. // Pass nil for any dependency that is not available.
// Usage: NewProvider(...) // 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{ return &ScmProvider{
index: idx, index: idx,
installer: inst, installer: inst,
@ -81,6 +88,7 @@ func (p *ScmProvider) Channels() []string {
"scm.marketplace.refreshed", "scm.marketplace.refreshed",
"scm.marketplace.installed", "scm.marketplace.installed",
"scm.marketplace.removed", "scm.marketplace.removed",
"scm.installed.changed",
"scm.manifest.verified", "scm.manifest.verified",
"scm.registry.changed", "scm.registry.changed",
} }
@ -293,6 +301,11 @@ func (p *ScmProvider) installItem(c *gin.Context) {
"code": mod.Code, "code": mod.Code,
"name": mod.Name, "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})) 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.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})) 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 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})) c.JSON(http.StatusOK, api.OK(map[string]any{"updated": true, "code": code}))
} }

View file

@ -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"])
}

View file

@ -42,6 +42,7 @@ func TestScmProvider_Channels_Good(t *testing.T) {
assert.Contains(t, channels, "scm.marketplace.refreshed") assert.Contains(t, channels, "scm.marketplace.refreshed")
assert.Contains(t, channels, "scm.marketplace.installed") assert.Contains(t, channels, "scm.marketplace.installed")
assert.Contains(t, channels, "scm.marketplace.removed") 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.manifest.verified")
assert.Contains(t, channels, "scm.registry.changed") assert.Contains(t, channels, "scm.registry.changed")
} }