Published an OpportunityMarksUpdated event when an opportunity's marks change over the API
This commit is contained in:
1 parent
70d62f280e
commit
848edc73e2
7 files changed
+203
-34
No files matched your search
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/api"
|
||||
"git.avsdev.uk/Go/job-search/internal/config"
|
||||
"git.avsdev.uk/Go/job-search/internal/events"
|
||||
"git.avsdev.uk/Go/job-search/internal/logging"
|
||||
"git.avsdev.uk/Go/job-search/internal/middleware"
|
||||
"git.avsdev.uk/Go/job-search/internal/store"
|
||||
@@ -72,6 +73,16 @@ func WithStore(store *store.Store) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithEvents connects the server to bus: every event published to it is
|
||||
// pushed to the server's WebSocket clients (as websocket.TypeEvent
|
||||
// messages), and the server's API publishes its changes to it (see
|
||||
// api.WithEvents).
|
||||
func WithEvents(bus *events.Bus) Option {
|
||||
return func(s *Server) {
|
||||
s.events = bus
|
||||
}
|
||||
}
|
||||
|
||||
// WithMiddleware appends middleware to a group. This is the generic hook
|
||||
// (auth, metrics, tracing, custom header rules, ...).
|
||||
func WithMiddleware(group Group, middlewares ...Middleware) Option {
|
||||
@@ -120,6 +131,9 @@ type Server struct {
|
||||
hub *websocket.Hub
|
||||
// store is the opportunities' database, or nil for none.
|
||||
store *store.Store
|
||||
// events is where events are pushed from and the API publishes to, or
|
||||
// nil for none.
|
||||
events *events.Bus
|
||||
// api is the REST API mounted under /api, built from the options.
|
||||
api *api.API
|
||||
started time.Time
|
||||
@@ -142,7 +156,10 @@ func New(conf *config.JSCServer, opts ...Option) (*Server, error) {
|
||||
for _, option := range opts {
|
||||
option(server)
|
||||
}
|
||||
server.api = api.New(api.WithStore(server.store))
|
||||
server.api = api.New(
|
||||
api.WithStore(server.store),
|
||||
api.WithEvents(server.events),
|
||||
)
|
||||
|
||||
var err error
|
||||
server.trusted, err = util.ParseNetPrefixes(conf.Proxy.TrustedProxies)
|
||||
@@ -190,6 +207,13 @@ func New(conf *config.JSCServer, opts ...Option) (*Server, error) {
|
||||
server.handler = logging.AccessLog(server.accessLog, true, server.handler)
|
||||
}
|
||||
|
||||
// Subscribed last, so a server that failed to build is never pushed to.
|
||||
if server.events != nil {
|
||||
server.events.Subscribe(func(event events.Event) {
|
||||
server.hub.PushEvent(event.Type, event.Payload)
|
||||
})
|
||||
}
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
@@ -210,12 +234,6 @@ func (s *Server) logger() *slog.Logger {
|
||||
// Handler exposes the root handler (useful for httptest).
|
||||
func (s *Server) Handler() http.Handler { return s.handler }
|
||||
|
||||
// PushEvent sends an event to every client of the server's WebSocket hub
|
||||
// (see websocket.Hub.PushEvent).
|
||||
func (s *Server) PushEvent(eventType string, payload any) {
|
||||
s.hub.PushEvent(eventType, payload)
|
||||
}
|
||||
|
||||
// Run listens and serves until ctx is cancelled, then shuts down gracefully.
|
||||
// With TLS, it also serves an HTTP to HTTPS redirect if tls.redirect_http_port
|
||||
// is set.
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/config"
|
||||
"git.avsdev.uk/Go/job-search/internal/events"
|
||||
"git.avsdev.uk/Go/job-search/internal/middleware"
|
||||
"git.avsdev.uk/Go/job-search/internal/store"
|
||||
"git.avsdev.uk/Go/job-search/internal/types"
|
||||
@@ -336,10 +337,11 @@ func (b *syncBuffer) String() string {
|
||||
return b.buffer.String()
|
||||
}
|
||||
|
||||
// With WithOpportunityStore the opportunities are served, and changing a mark
|
||||
// needs the CSRF token; without it there are no opportunity routes.
|
||||
func TestOpportunityRoutes(t *testing.T) {
|
||||
opportunityStore, err := store.Open(
|
||||
// opportunityStore returns a store, in a temporary file, holding one
|
||||
// opportunity, ocds-1.
|
||||
func opportunityStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.Open(
|
||||
context.Background(),
|
||||
filepath.Join(t.TempDir(), "server.db"),
|
||||
store.WithAutoMigrate(),
|
||||
@@ -348,9 +350,9 @@ func TestOpportunityRoutes(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
opportunityStore.Close()
|
||||
st.Close()
|
||||
})
|
||||
_, err = opportunityStore.SaveOpportunity(
|
||||
_, err = st.SaveOpportunity(
|
||||
context.Background(),
|
||||
&types.Opportunity{
|
||||
UUID: "ocds-1",
|
||||
@@ -368,7 +370,13 @@ func TestOpportunityRoutes(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ts := newTestServer(t, nil, WithStore(opportunityStore))
|
||||
return st
|
||||
}
|
||||
|
||||
// With WithStore the opportunities are served, and changing a mark needs
|
||||
// the CSRF token; without it there are no opportunity routes.
|
||||
func TestOpportunityRoutes(t *testing.T) {
|
||||
ts := newTestServer(t, nil, WithStore(opportunityStore(t)))
|
||||
c := clientWithJar(t)
|
||||
do := apiClient(t, ts, c, getToken(t, c, ts.URL))
|
||||
|
||||
@@ -393,3 +401,54 @@ func TestOpportunityRoutes(t *testing.T) {
|
||||
t.Errorf("no store: %d; want 404", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// With WithEvents, events published to the bus, and the API's own, such as
|
||||
// a changed mark, are pushed to WebSocket clients.
|
||||
func TestEventsPushed(t *testing.T) {
|
||||
var bus events.Bus
|
||||
ts := newTestServer(
|
||||
t, nil, WithStore(opportunityStore(t)), WithEvents(&bus),
|
||||
)
|
||||
c := clientWithJar(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
conn, _, err := websocket.Dial(
|
||||
ctx,
|
||||
"ws"+strings.TrimPrefix(ts.URL, "http")+"/ws",
|
||||
&websocket.DialOptions{
|
||||
HTTPClient: c,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
read := func() ws.Envelope {
|
||||
var e ws.Envelope
|
||||
if err := wsjson.Read(ctx, conn, &e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
read() // welcome
|
||||
|
||||
bus.Publish(events.Event{
|
||||
Type: events.OpportunitiesUpdated,
|
||||
Payload: map[string]int{"n": 1},
|
||||
})
|
||||
e := read()
|
||||
if e.Type != ws.TypeEvent || e.EventType != events.OpportunitiesUpdated {
|
||||
t.Fatalf("want the published event, got %+v", e)
|
||||
}
|
||||
|
||||
do := apiClient(t, ts, c, getToken(t, c, ts.URL))
|
||||
mark := "/api/opportunities/ocds-1/marks/interested"
|
||||
if res := do("PUT", mark, "", true); res.StatusCode != 200 {
|
||||
t.Fatalf("mark: %d", res.StatusCode)
|
||||
}
|
||||
e = read()
|
||||
if e.EventType != events.OpportunityMarksUpdated ||
|
||||
!strings.Contains(string(e.Payload), `"interested":true`) {
|
||||
t.Fatalf("want the marks event, got %+v", e)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user