Added a ClassificationsUpdated event

SaveOpportunities now reports the classification codes a save brings into
use (AddedClassifications, in full) and takes out of use
(RemovedClassificationIDs), by comparing the codes in use before and after
the save inside its transaction. The fetcher publishes them as a
ClassificationsUpdated event, {"added":[codes],"removed":[ids]}, never null,
before the save's OpportunitiesUpdated, so clients can keep their list of
classifications without asking the API again.
This commit is contained in:
bwilliams committed 2026-10-16 15:24:37 +01:00
1 parent aa06b94870
commit 51d6470649
6 files changed
+287 -3

No files matched your search

+38 -2
View File
@@ -39,8 +39,10 @@ func WithStore(opportunityStore *store.Store) Option {
}
// WithEvents makes the workers publish an events.OpportunitiesUpdated event
// to bus whenever a save stores new opportunities or new releases. It needs
// WithStore: without a store nothing is saved, so nothing is published.
// to bus whenever a save stores new opportunities or new releases, and an
// events.ClassificationsUpdated event when it changes the classification
// codes in use. It needs WithStore: without a store nothing is saved, so
// nothing is published.
func WithEvents(bus *events.Bus) Option {
return func(f *Fetcher) {
f.events = bus
@@ -235,11 +237,45 @@ func (f *Fetcher) save(
"new_releases", result.NewReleases,
)
f.publishClassificationChanges(result)
f.publishUpdates(opportunities, result.OpportunityResults)
return nil
}
// publishClassificationChanges publishes an events.ClassificationsUpdated
// event for the classification codes a save, with result, brought into or
// out of use, if it changed any and a bus was given WithEvents. It's
// published before the save's events.OpportunitiesUpdated, so a client
// knows a new code before an opportunity that has it.
func (f *Fetcher) publishClassificationChanges(
result store.SaveMultipleResult,
) {
if f.events == nil {
return
}
if len(result.AddedClassifications) == 0 &&
len(result.RemovedClassificationIDs) == 0 {
return
}
payload := events.ClassificationsUpdatedPayload{
Added: result.AddedClassifications,
Removed: result.RemovedClassificationIDs,
}
if payload.Added == nil {
payload.Added = []types.ClassificationCode{}
}
if payload.Removed == nil {
payload.Removed = []int64{}
}
f.events.Publish(events.Event{
Type: events.ClassificationsUpdated,
Payload: payload,
})
}
// publishUpdates publishes an events.OpportunitiesUpdated event for the
// opportunities that results, from saving them, show were new or gained new
// releases, if there were any and a bus was given WithEvents. An opportunity
+71
View File
@@ -737,3 +737,74 @@ func TestSaveWithoutEvents(t *testing.T) {
t.Fatal(err)
}
}
// withClassification returns onePackage with its release changed to id and
// date, and its tender classified by code.
func withClassification(id, date, code string) string {
return strings.NewReplacer(
`"id": "r1"`, `"id": "`+id+`"`,
"2026-10-01T12:00:00Z", date,
`"tender": {`,
`"tender": {"classification": {"scheme": "CPV", "id": "`+code+
`", "description": "Code `+code+`"}, `,
).Replace(onePackage)
}
// A save publishes the classification codes it brings into use, in full, and
// the ids of those it takes out of use, before OpportunitiesUpdated; nothing
// when the codes in use don't change.
func TestSavePublishesClassificationChanges(t *testing.T) {
var bus events.Bus
var published []events.Event
bus.Subscribe(func(event events.Event) {
published = append(published, event)
})
f := New(
&config.JSCSources{},
WithStore(openTestStore(t)),
WithEvents(&bus),
)
save := func(body string) []events.ClassificationsUpdatedPayload {
t.Helper()
published = nil
err := f.save(context.Background(), slog.Default(), fetched(t, body))
if err != nil {
t.Fatal(err)
}
var changes []events.ClassificationsUpdatedPayload
for i, event := range published {
if event.Type != events.ClassificationsUpdated {
continue
}
if i != 0 {
t.Errorf("ClassificationsUpdated was event %d; want it first", i)
}
changes = append(
changes, event.Payload.(events.ClassificationsUpdatedPayload),
)
}
return changes
}
changes := save(withClassification("r1", "2026-10-01T12:00:00Z", "72000000"))
if len(changes) != 1 || len(changes[0].Added) != 1 ||
changes[0].Added[0].Code != "72000000" || len(changes[0].Removed) != 0 {
t.Fatalf("first save published %+v; want 72000000 added", changes)
}
oldID := changes[0].Added[0].ID
changes = save(withClassification("r1", "2026-10-01T12:00:00Z", "72000000"))
if len(changes) != 0 {
t.Errorf("saving again published %+v; want nothing", changes)
}
changes = save(withClassification("r2", "2026-10-02T12:00:00Z", "03000000"))
if len(changes) != 1 || len(changes[0].Added) != 1 ||
changes[0].Added[0].Code != "03000000" ||
len(changes[0].Removed) != 1 || changes[0].Removed[0] != oldID {
t.Errorf(
"replacing the code published %+v; want 03000000 added, %d removed",
changes, oldID,
)
}
}