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
+12
-2
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/events"
|
||||
"git.avsdev.uk/Go/job-search/internal/store"
|
||||
)
|
||||
|
||||
@@ -11,7 +12,8 @@ import (
|
||||
// server applies the API's middleware (CSRF, NoStore, RequireJSON, rate
|
||||
// limits); the API only registers handlers.
|
||||
type API struct {
|
||||
store *store.Store // nil: no opportunity routes
|
||||
store *store.Store // nil: no opportunity routes
|
||||
events *events.Bus // nil: nothing is published
|
||||
}
|
||||
|
||||
// Option configures the API.
|
||||
@@ -25,6 +27,14 @@ func WithStore(opportunityStore *store.Store) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithEvents makes the API publish its changes to bus, such as an
|
||||
// events.OpportunityMarksUpdated event when an opportunity's marks change.
|
||||
func WithEvents(bus *events.Bus) Option {
|
||||
return func(a *API) {
|
||||
a.events = bus
|
||||
}
|
||||
}
|
||||
|
||||
// New returns the API.
|
||||
func New(opts ...Option) *API {
|
||||
api := &API{}
|
||||
@@ -40,6 +50,6 @@ func New(opts ...Option) *API {
|
||||
func (a *API) Routes(router chi.Router) {
|
||||
docsRoutes(router)
|
||||
if a.store != nil {
|
||||
newOpportunityRoutes(a.store).Routes(router)
|
||||
newOpportunityRoutes(a.store, a.events).Routes(router)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/events"
|
||||
"git.avsdev.uk/Go/job-search/internal/httpx"
|
||||
"git.avsdev.uk/Go/job-search/internal/store"
|
||||
"git.avsdev.uk/Go/job-search/internal/types"
|
||||
@@ -33,7 +34,8 @@ type OpportunityPage struct {
|
||||
// opportunityRoutes wires HTTP handlers to the opportunity store.
|
||||
// Opportunities are read-only; only their marks can be changed.
|
||||
type opportunityRoutes struct {
|
||||
store *store.Store
|
||||
store *store.Store
|
||||
events *events.Bus // nil: nothing is published
|
||||
|
||||
// marksMu makes changing one mark (read the marks, change one, write
|
||||
// them all back) atomic, so concurrent changes to different marks of an
|
||||
@@ -42,10 +44,13 @@ type opportunityRoutes struct {
|
||||
}
|
||||
|
||||
// newOpportunityRoutes returns the opportunity handlers for
|
||||
// opportunityStore.
|
||||
func newOpportunityRoutes(opportunityStore *store.Store) *opportunityRoutes {
|
||||
// opportunityStore, publishing mark changes to bus if it isn't nil.
|
||||
func newOpportunityRoutes(
|
||||
opportunityStore *store.Store, bus *events.Bus,
|
||||
) *opportunityRoutes {
|
||||
return &opportunityRoutes{
|
||||
store: opportunityStore,
|
||||
store: opportunityStore,
|
||||
events: bus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +184,9 @@ func (h *opportunityRoutes) clearMark(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// changeMark sets the mark named in the URL to value, keeping the
|
||||
// opportunity's other marks, and responds with all of its marks. Setting won
|
||||
// while lost is set, or lost while won is, is refused with 409 Conflict.
|
||||
// while lost is set, or lost while won is, is refused with 409 Conflict. A
|
||||
// change is published as an events.OpportunityMarksUpdated event; one that
|
||||
// changes nothing is neither stored nor published.
|
||||
func (h *opportunityRoutes) changeMark(
|
||||
w http.ResponseWriter, r *http.Request, value bool,
|
||||
) {
|
||||
@@ -196,6 +203,7 @@ func (h *opportunityRoutes) changeMark(
|
||||
|
||||
marks := opportunity.Marks
|
||||
marks.UUID = uuid
|
||||
before := marks
|
||||
if !setMark(&marks, name, value) {
|
||||
httpx.WriteProblem(
|
||||
w,
|
||||
@@ -205,6 +213,10 @@ func (h *opportunityRoutes) changeMark(
|
||||
)
|
||||
return
|
||||
}
|
||||
if marks == before {
|
||||
httpx.WriteJSON(w, http.StatusOK, marks)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.SetMarks(r.Context(), marks)
|
||||
if err != nil {
|
||||
@@ -213,6 +225,15 @@ func (h *opportunityRoutes) changeMark(
|
||||
}
|
||||
|
||||
httpx.WriteJSON(w, http.StatusOK, marks)
|
||||
|
||||
// Still under marksMu, so events are published in the order the
|
||||
// changes were made.
|
||||
if h.events != nil {
|
||||
h.events.Publish(events.Event{
|
||||
Type: events.OpportunityMarksUpdated,
|
||||
Payload: marks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// findMarks returns the marks of types.OpportunityMarks: every bool field,
|
||||
|
||||
@@ -14,13 +14,15 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/events"
|
||||
"git.avsdev.uk/Go/job-search/internal/store"
|
||||
"git.avsdev.uk/Go/job-search/internal/types"
|
||||
)
|
||||
|
||||
// opportunitiesServer serves the opportunity routes over a store holding
|
||||
// count opportunities, ocds-1 the oldest, and returns its URL.
|
||||
func opportunitiesServer(t *testing.T, count int) string {
|
||||
// opportunitiesServer serves the API, given opts as well as its store, over
|
||||
// a store holding count opportunities, ocds-1 the oldest, and returns its
|
||||
// URL.
|
||||
func opportunitiesServer(t *testing.T, count int, opts ...Option) string {
|
||||
t.Helper()
|
||||
opportunityStore, err := store.Open(
|
||||
context.Background(),
|
||||
@@ -57,7 +59,7 @@ func opportunitiesServer(t *testing.T, count int) string {
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
New(WithStore(opportunityStore)).Routes(router)
|
||||
New(append([]Option{WithStore(opportunityStore)}, opts...)...).Routes(router)
|
||||
server := httptest.NewServer(router)
|
||||
t.Cleanup(server.Close)
|
||||
return server.URL
|
||||
@@ -302,3 +304,55 @@ func TestFindMarks(t *testing.T) {
|
||||
t.Error("setMark accepted a name that isn't a mark")
|
||||
}
|
||||
}
|
||||
|
||||
// A change to a mark is published with all the marks; a change that changes
|
||||
// nothing, or is refused, isn't.
|
||||
func TestChangeMarkPublishes(t *testing.T) {
|
||||
var bus events.Bus
|
||||
var mu sync.Mutex
|
||||
var published []types.OpportunityMarks
|
||||
bus.Subscribe(func(event events.Event) {
|
||||
if event.Type != events.OpportunityMarksUpdated {
|
||||
t.Errorf("event type %q", event.Type)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
published = append(published, event.Payload.(types.OpportunityMarks))
|
||||
})
|
||||
url := opportunitiesServer(t, 1, WithEvents(&bus))
|
||||
marks := url + "/opportunities/ocds-1/marks/"
|
||||
|
||||
request(t, "PUT", marks+"interested", nil) // published
|
||||
request(t, "PUT", marks+"interested", nil) // already set: not published
|
||||
request(t, "DELETE", marks+"skipped", nil) // not set: not published
|
||||
request(t, "PUT", marks+"shiny", nil) // no such mark: refused
|
||||
request(t, "PUT", marks+"won", nil) // published
|
||||
request(t, "PUT", marks+"lost", nil) // won and lost: refused
|
||||
request(t, "DELETE", marks+"interested", nil) // published
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
want := []types.OpportunityMarks{
|
||||
{
|
||||
UUID: "ocds-1",
|
||||
Interested: true,
|
||||
},
|
||||
{
|
||||
UUID: "ocds-1",
|
||||
Interested: true,
|
||||
Won: true,
|
||||
},
|
||||
{
|
||||
UUID: "ocds-1",
|
||||
Won: true,
|
||||
},
|
||||
}
|
||||
if len(published) != len(want) {
|
||||
t.Fatalf("published %+v; want %+v", published, want)
|
||||
}
|
||||
for i := range want {
|
||||
if published[i] != want[i] {
|
||||
t.Errorf("event %d: %+v; want %+v", i, published[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user