Files
job-search/internal/store/store_test.go
T
bwilliams 6e101d2b4d Added an events bus, and pushed OpportunitiesUpdated to WebSocket clients when the fetcher stores new opportunities
internal/events decouples the parts of JobSearch that cause events from
the parts that react to them. bus.go has Bus: Subscribe, and a synchronous
Publish that calls each handler in turn on the publisher's goroutine, so
handlers mustn't block (the hub doesn't: it queues per client). events.go
has the event types and their payloads, starting with OpportunitiesUpdated.

With fetcher.WithEvents, a save that stores new opportunities, or new
releases of stored ones, publishes OpportunitiesUpdated with their UUIDs and
counts. An opportunity whose row is only rewritten, as when it is fetched
again from the newest stored release, isn't counted as updated, and nothing
is published when nothing changed. store.SaveResult now reports
NewOpportunity for each opportunity saved.

job-search serve subscribes its WebSocket hub to the bus, so every client
gets {"type":"event","eventType":"OpportunitiesUpdated","payload":{"new":
[...],"updated":[...],"summary":{"new":n,"updated":n}}}. The Envelope gains
an eventType field, and Hub.PushEvent and Server.PushEvent send events.
2026-10-16 11:59:26 +01:00

905 lines
24 KiB
Go

package store_test
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"git.avsdev.uk/Go/job-search/internal/store"
"git.avsdev.uk/Go/job-search/internal/types"
)
// Run with: go test -tags sqlite_fts5 ./...
func openStore(t *testing.T) *store.Store {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
s, err := store.Open(context.Background(), path, store.WithAutoMigrate())
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() {
s.Close()
})
return s
}
func day(d int) time.Time {
return time.Date(2026, time.March, d, 9, 30, 0, 0, time.UTC)
}
func opportunity(
uuid, title string,
releaseDay int,
releaseIDs ...string,
) *types.Opportunity {
start, end := day(20), day(28)
t := &types.Opportunity{
UUID: uuid,
UUIDType: "ocid",
Title: title,
Status: "active",
Description: "A description",
BuyerName: "Example Council",
BuyerID: "GB-EXAMPLE",
MainCategory: "services",
ProcurementMethod: "open",
MultiLot: true,
SMESuitable: true,
StartDate: &start,
EndDate: &end,
GrossValue: 125000.50,
HasDocuments: true,
Source: "FindATender",
Classifications: []types.ClassificationCode{
{
IsCPV: true,
Code: "72000000",
Classification: "IT services",
},
{
IsCPV: true,
Code: "03000000",
Classification: "Agricultural products",
},
},
// No UUID: SaveOpportunity must fill it in.
Marks: types.OpportunityMarks{
Interested: true,
BidSubmitted: true,
},
}
for i, id := range releaseIDs {
t.Releases = append(t.Releases, types.OpportunityRelease{
ReleaseID: id,
ReleaseDate: day(releaseDay).Add(time.Duration(i) * time.Minute),
JSON: `{"id":"` + id + `"}`,
Tags: []types.OpportunityReleaseTag{
{
Tag: "opportunity",
},
{
Tag: "planning",
},
},
})
}
return t
}
func mustSave(
t *testing.T, s *store.Store, td *types.Opportunity,
) store.SaveResult {
t.Helper()
res, err := s.SaveOpportunity(context.Background(), td)
if err != nil {
t.Fatalf("SaveOpportunity(%s): %v", td.UUID, err)
}
return res
}
func mustGet(t *testing.T, s *store.Store, uuid string) *types.Opportunity {
t.Helper()
got, err := s.GetOpportunity(context.Background(), uuid)
if err != nil {
t.Fatalf("GetOpportunity(%s): %v", uuid, err)
}
return got
}
func uuids(ts []types.Opportunity) []string {
out := []string{}
for _, t := range ts {
out = append(out, t.UUID)
}
return out
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestSaveAndGetRoundTrip(t *testing.T) {
s := openStore(t)
in := opportunity("ocds-1", "Cloud migration services", 1, "r1", "r2")
res := mustSave(t, s, in)
if !res.OpportunityUpdated || res.NewReleases != 2 {
t.Fatalf("first save: got %+v", res)
}
got := mustGet(t, s, "ocds-1")
if got.Title != in.Title || got.BuyerName != in.BuyerName ||
got.BuyerID != in.BuyerID || got.MainCategory != in.MainCategory ||
got.ProcurementMethod != in.ProcurementMethod || got.UUIDType != "ocid" ||
got.Status != "active" || got.Description != in.Description ||
!got.MultiLot || !got.SMESuitable || got.VCSESuitable ||
!got.HasDocuments || got.GrossValue != in.GrossValue ||
got.Source != in.Source {
t.Errorf("opportunity fields differ:\n got %+v\nwant %+v", got, in)
}
if got.StartDate == nil || !got.StartDate.Equal(day(20)) ||
got.EndDate == nil || !got.EndDate.Equal(day(28)) {
t.Errorf("dates: got %v / %v", got.StartDate, got.EndDate)
}
if want := day(1).Add(time.Minute); !got.LatestReleaseDate.Equal(want) {
t.Errorf("LatestReleaseDate = %v, want %v", got.LatestReleaseDate, want)
}
if len(got.Classifications) != 2 ||
got.Classifications[0].Code != "03000000" ||
got.Classifications[1].Code != "72000000" ||
got.Classifications[0].ID == 0 {
t.Errorf(
"classifications: got %+v (leading zero must survive)",
got.Classifications,
)
}
wantMarks := types.OpportunityMarks{
UUID: "ocds-1",
Interested: true,
BidSubmitted: true,
}
if got.Marks != wantMarks {
t.Errorf("marks: got %+v, want %+v", got.Marks, wantMarks)
}
if len(got.Releases) != 2 {
t.Fatalf("releases: got %d", len(got.Releases))
}
r := got.Releases[0]
if r.ReleaseID != "r1" || r.UUID != "ocds-1" || r.JSON != `{"id":"r1"}` ||
!r.ReleaseDate.Equal(day(1)) {
t.Errorf("release: got %+v", r)
}
if len(r.Tags) != 2 || r.Tags[0].Tag != "opportunity" ||
r.Tags[1].Tag != "planning" || r.Tags[0].ReleaseID != "r1" ||
r.Tags[0].UUID != "ocds-1" {
t.Errorf("tags: got %+v", r.Tags)
}
}
func TestStartAndEndDateMayBeNil(t *testing.T) {
s := openStore(t)
in := opportunity("ocds-1", "No dates", 1, "r1")
in.StartDate, in.EndDate = nil, nil
mustSave(t, s, in)
got := mustGet(t, s, "ocds-1")
if got.StartDate != nil || got.EndDate != nil {
t.Errorf("got %v / %v, want nil", got.StartDate, got.EndDate)
}
}
func TestReleasesAreWriteOnce(t *testing.T) {
s := openStore(t)
mustSave(t, s, opportunity("ocds-1", "Title", 1, "r1"))
again := opportunity("ocds-1", "Title", 1, "r1")
again.Releases[0].JSON = `{"changed":true}`
again.Releases[0].Tags = []types.OpportunityReleaseTag{{
Tag: "award",
}}
if res := mustSave(t, s, again); res.NewReleases != 0 {
t.Errorf("re-save: NewReleases = %d, want 0", res.NewReleases)
}
got := mustGet(t, s, "ocds-1").Releases
if len(got) != 1 || got[0].JSON != `{"id":"r1"}` || len(got[0].Tags) != 2 {
t.Errorf("stored release changed: %+v", got)
}
}
func TestOlderReleaseDoesNotOverwriteOpportunity(t *testing.T) {
s := openStore(t)
mustSave(t, s, opportunity("ocds-1", "Newer title", 10, "r2"))
old := opportunity("ocds-1", "Older title", 5, "r1")
old.Classifications = old.Classifications[:1]
res := mustSave(t, s, old)
if res.OpportunityUpdated || res.NewReleases != 1 {
t.Fatalf("older save: got %+v", res)
}
got := mustGet(t, s, "ocds-1")
if got.Title != "Newer title" || len(got.Classifications) != 2 ||
!got.LatestReleaseDate.Equal(day(10)) {
t.Errorf("opportunity was overwritten by an older release: %+v", got)
}
if len(got.Releases) != 2 || got.Releases[0].ReleaseID != "r1" {
t.Errorf(
"older release should still be stored, oldest first: %+v", got.Releases,
)
}
}
func TestNewerReleaseUpdatesOpportunityAndSearchIndex(t *testing.T) {
s := openStore(t)
ctx := context.Background()
first := opportunity("ocds-1", "Cloud hosting", 1, "r1")
mustSave(t, s, first)
oldIT := first.Classifications[0].ID
newer := opportunity("ocds-1", "Website redesign", 2, "r2")
newer.Status = "complete"
newer.Classifications = []types.ClassificationCode{
{
IsCPV: true,
Code: "72000000",
Classification: "IT services (amended)",
},
}
if res := mustSave(t, s, newer); !res.OpportunityUpdated {
t.Fatalf("newer save not applied: %+v", res)
}
got := mustGet(t, s, "ocds-1")
if got.Title != "Website redesign" || got.Status != "complete" {
t.Errorf("opportunity not updated: %+v", got)
}
if len(got.Classifications) != 1 ||
got.Classifications[0].Classification != "IT services (amended)" {
t.Fatalf("classifications not replaced: %+v", got.Classifications)
}
if got.Classifications[0].ID == oldIT {
t.Errorf("changed description should create a new classification row")
}
for title, want := range map[string]int{"cloud": 0, "redesign": 1} {
ts, err := s.ListOpportunities(ctx, store.OpportunityFilter{
Title: title,
})
if err != nil {
t.Fatal(err)
}
if len(ts) != want {
t.Errorf(
"search %q after title change: %d results, want %d",
title,
len(ts),
want,
)
}
}
}
func TestClassificationRowsAreShared(t *testing.T) {
s := openStore(t)
a := opportunity("ocds-a", "A", 1, "r1")
b := opportunity("ocds-b", "B", 1, "r1")
mustSave(t, s, a)
mustSave(t, s, b)
if a.Classifications[0].ID == 0 ||
a.Classifications[0].ID != b.Classifications[0].ID {
t.Errorf(
"identical classifications should share an ID: %d vs %d",
a.Classifications[0].ID,
b.Classifications[0].ID,
)
}
c := opportunity("ocds-c", "C", 1, "r1")
c.Classifications = []types.ClassificationCode{
{
IsCPV: false,
Code: "72000000",
Classification: "IT services",
},
}
mustSave(t, s, c)
if c.Classifications[0].ID == a.Classifications[0].ID {
t.Errorf("different is_cpv should not share an ID")
}
}
func TestSameReleaseIDInDifferentOpportunities(t *testing.T) {
s := openStore(t)
a := opportunity("ocds-a", "A", 1, "001")
b := opportunity("ocds-b", "B", 1, "001")
b.Releases[0].Tags = []types.OpportunityReleaseTag{{
Tag: "award",
}}
if res := mustSave(t, s, a); res.NewReleases != 1 {
t.Fatal(res)
}
if res := mustSave(t, s, b); res.NewReleases != 1 {
t.Fatalf("release id reused under another OCID must be stored: %+v", res)
}
got := mustGet(t, s, "ocds-b").Releases
if len(got) != 1 || len(got[0].Tags) != 1 || got[0].Tags[0].Tag != "award" {
t.Errorf("tags leaked between opportunities: %+v", got)
}
}
func TestListOpportunitiesFilters(t *testing.T) {
s := openStore(t)
ctx := context.Background()
cloud := opportunity("ocds-1", "Cloud migration services", 1, "r1")
roads := opportunity("ocds-2", "Road resurfacing works", 2, "r1")
roads.Status = "complete"
roads.Classifications = []types.ClassificationCode{
{
IsCPV: true,
Code: "45233142",
Classification: "Road-repair works",
},
}
hosting := opportunity("ocds-3", "Managed cloud hosting", 3, "r1")
for _, td := range []*types.Opportunity{cloud, roads, hosting} {
mustSave(t, s, td)
}
cases := []struct {
name string
f store.OpportunityFilter
want []string
}{
{
"all, newest first",
store.OpportunityFilter{},
[]string{"ocds-3", "ocds-2", "ocds-1"},
},
{"status", store.OpportunityFilter{
Status: "complete",
}, []string{"ocds-2"}},
{
"classification",
store.OpportunityFilter{
ClassificationCode: "72000000",
},
[]string{"ocds-3", "ocds-1"},
},
{"title word", store.OpportunityFilter{
Title: "cloud",
}, []string{"ocds-3", "ocds-1"}},
{"title prefixes", store.OpportunityFilter{
Title: "clo migr",
}, []string{"ocds-1"}},
{"title stemming", store.OpportunityFilter{
Title: "migrating",
}, []string{"ocds-1"}},
{"title case", store.OpportunityFilter{
Title: "ROAD",
}, []string{"ocds-2"}},
{
"title syntax is not FTS syntax",
store.OpportunityFilter{
Title: `cloud" OR title:road*`,
},
[]string{},
},
{
"title punctuation only",
store.OpportunityFilter{
Title: `"*:()`,
},
[]string{"ocds-3", "ocds-2", "ocds-1"},
},
{"released from", store.OpportunityFilter{
ReleasedFrom: day(2),
}, []string{"ocds-3", "ocds-2"}},
{"released before", store.OpportunityFilter{
ReleasedBefore: day(2),
}, []string{"ocds-1"}},
{"combined", store.OpportunityFilter{
Title: "cloud",
ReleasedFrom: day(2),
}, []string{"ocds-3"}},
{"limit", store.OpportunityFilter{
Limit: 2,
}, []string{"ocds-3", "ocds-2"}},
{"offset", store.OpportunityFilter{
Limit: 2,
Offset: 2,
}, []string{"ocds-1"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := s.ListOpportunities(ctx, tc.f)
if err != nil {
t.Fatalf("ListOpportunities: %v", err)
}
if !equal(uuids(got), tc.want) {
t.Errorf("got %v, want %v", uuids(got), tc.want)
}
})
}
got, err := s.ListOpportunities(ctx, store.OpportunityFilter{
Status: "complete",
})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || len(got[0].Classifications) != 1 ||
got[0].Releases != nil || got[0].Marks.UUID != "ocds-2" ||
!got[0].Marks.Interested {
t.Errorf(
"list should include classifications and marks and omit releases: %+v",
got,
)
}
}
func TestReleaseExistsAndListReleases(t *testing.T) {
s := openStore(t)
ctx := context.Background()
mustSave(t, s, opportunity("ocds-1", "T", 1, "r1", "r2"))
for _, tc := range []struct {
uuid, id string
want bool
}{{"ocds-1", "r1", true}, {"ocds-1", "r3", false}, {"ocds-2", "r1", false}} {
got, err := s.ReleaseExists(ctx, tc.uuid, tc.id)
if err != nil || got != tc.want {
t.Errorf(
"ReleaseExists(%s, %s) = %v, %v; want %v",
tc.uuid,
tc.id,
got,
err,
tc.want,
)
}
}
rs, err := s.ListReleases(ctx, "ocds-1")
if err != nil || len(rs) != 2 || rs[0].ReleaseID != "r1" ||
rs[1].ReleaseID != "r2" || len(rs[1].Tags) != 2 {
t.Errorf("ListReleases: %+v, %v", rs, err)
}
}
func TestGetOpportunityNotFound(t *testing.T) {
s := openStore(t)
_, err := s.GetOpportunity(context.Background(), "missing")
if !errors.Is(err, store.ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestSaveOpportunityValidation(t *testing.T) {
s := openStore(t)
ctx := context.Background()
for name, td := range map[string]*types.Opportunity{
"nil": nil,
"no uuid": opportunity("", "T", 1, "r1"),
"no release date": {UUID: "ocds-1", Title: "T", Status: "active"},
"no release id": opportunity("ocds-1", "T", 1, ""),
} {
if _, err := s.SaveOpportunity(ctx, td); err == nil {
t.Errorf("%s: expected an error", name)
}
}
}
func TestReopenKeepsDataAndSkipsAppliedMigrations(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
ctx := context.Background()
s, err := store.Open(ctx, path, store.WithAutoMigrate())
if err != nil {
t.Fatal(err)
}
mustSave(t, s, opportunity("ocds-1", "Cloud migration", 1, "r1"))
s.Close()
// No WithAutoMigrate: an up-to-date database must pass the version check.
s, err = store.Open(ctx, path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer s.Close()
got, err := s.ListOpportunities(ctx, store.OpportunityFilter{
Title: "cloud",
})
if err != nil || len(got) != 1 {
t.Errorf("after reopen: %v, %v", uuids(got), err)
}
}
func TestResaveLoadedOpportunity(t *testing.T) {
s := openStore(t)
mustSave(t, s, opportunity("ocds-1", "Original", 1, "r1"))
loaded := mustGet(t, s, "ocds-1") // classifications carry database IDs
ids := []int64{loaded.Classifications[0].ID, loaded.Classifications[1].ID}
loaded.Title = "Amended"
loaded.Releases = append(loaded.Releases, types.OpportunityRelease{
ReleaseID: "r2",
ReleaseDate: day(2),
JSON: `{}`,
Tags: []types.OpportunityReleaseTag{{
Tag: "opportunityAmendment",
}},
})
res := mustSave(t, s, loaded)
if !res.OpportunityUpdated || res.NewReleases != 1 {
t.Fatalf("resave: %+v", res)
}
got := mustGet(t, s, "ocds-1")
if got.Title != "Amended" || len(got.Releases) != 2 ||
!got.LatestReleaseDate.Equal(day(2)) {
t.Errorf("resave not applied: %+v", got)
}
if got.Classifications[0].ID != ids[0] ||
got.Classifications[1].ID != ids[1] {
t.Errorf(
"classification IDs changed on resave: %v -> %d, %d",
ids,
got.Classifications[0].ID,
got.Classifications[1].ID,
)
}
}
func mustSetMarks(t *testing.T, s *store.Store, m types.OpportunityMarks) {
t.Helper()
if err := s.SetMarks(context.Background(), m); err != nil {
t.Fatalf("SetMarks(%s): %v", m.UUID, err)
}
}
func TestSetMarks(t *testing.T) {
s := openStore(t)
mustSave(t, s, opportunity("ocds-1", "T", 1, "r1"))
for _, want := range []types.OpportunityMarks{
{UUID: "ocds-1", Hidden: true, Skipped: true},
{UUID: "ocds-1", Interested: true, BidSubmitted: true, Won: true},
{UUID: "ocds-1"},
} {
mustSetMarks(t, s, want)
if got := mustGet(t, s, "ocds-1").Marks; got != want {
t.Errorf("after SetMarks: got %+v, want %+v", got, want)
}
}
}
func TestSetMarksErrors(t *testing.T) {
s := openStore(t)
ctx := context.Background()
if err := s.SetMarks(
ctx,
types.OpportunityMarks{
UUID: "missing",
Hidden: true,
},
); !errors.Is(err, store.ErrNotFound) {
t.Errorf("unknown opportunity: err = %v, want ErrNotFound", err)
}
if err := s.SetMarks(ctx, types.OpportunityMarks{
Hidden: true,
}); err == nil {
t.Errorf("no UUID: expected an error")
}
}
// Fetched opportunities carry zero marks; saving them must not reset the
// marks the user has set.
func TestSaveKeepsExistingMarks(t *testing.T) {
s := openStore(t)
ctx := context.Background()
mustSave(t, s, opportunity("ocds-1", "T", 1, "r1"))
mustSave(t, s, opportunity("ocds-2", "T", 1, "r1"))
want := types.OpportunityMarks{
UUID: "ocds-1",
Hidden: true,
Lost: true,
}
mustSetMarks(t, s, want)
newer := opportunity("ocds-1", "Amended", 2, "r2")
newer.Marks = types.OpportunityMarks{}
if res := mustSave(t, s, newer); !res.OpportunityUpdated {
t.Fatalf("newer save not applied: %+v", res)
}
if got := mustGet(t, s, "ocds-1").Marks; got != want {
t.Errorf("SaveOpportunity changed marks: got %+v, want %+v", got, want)
}
newest := opportunity("ocds-1", "Amended again", 3, "r3")
newest.Marks = types.OpportunityMarks{}
if _, err := s.SaveOpportunities(
ctx, []*types.Opportunity{newest},
); err != nil {
t.Fatal(err)
}
if got := mustGet(t, s, "ocds-1").Marks; got != want {
t.Errorf("SaveOpportunities changed marks: got %+v, want %+v", got, want)
}
}
func TestSaveOpportunitiesStoresMarks(t *testing.T) {
s := openStore(t)
a := opportunity("ocds-a", "A", 1, "r1")
b := opportunity("ocds-b", "B", 1, "r1")
b.Marks = types.OpportunityMarks{
Skipped: true,
}
if _, err := s.SaveOpportunities(
context.Background(), []*types.Opportunity{a, b},
); err != nil {
t.Fatal(err)
}
for uuid, want := range map[string]types.OpportunityMarks{
"ocds-a": {UUID: "ocds-a", Interested: true, BidSubmitted: true},
"ocds-b": {UUID: "ocds-b", Skipped: true},
} {
if got := mustGet(t, s, uuid).Marks; got != want {
t.Errorf("%s marks: got %+v, want %+v", uuid, got, want)
}
}
}
func TestListOpportunitiesMarkFilters(t *testing.T) {
s := openStore(t)
ctx := context.Background()
for i, uuid := range []string{"ocds-1", "ocds-2", "ocds-3", "ocds-4"} {
mustSave(t, s, opportunity(uuid, "Cloud hosting", i+1, "r1"))
}
// ocds-1 keeps the marks it was saved with: Interested, BidSubmitted.
mustSetMarks(t, s, types.OpportunityMarks{
UUID: "ocds-2",
Hidden: true,
})
mustSetMarks(t, s, types.OpportunityMarks{
UUID: "ocds-3",
Skipped: true,
Lost: true,
})
mustSetMarks(t, s, types.OpportunityMarks{
UUID: "ocds-4",
Interested: true,
BidSubmitted: true,
Won: true,
})
set, unset := store.MarkSet, store.MarkUnset
cases := []struct {
name string
f store.OpportunityFilter
want []string
}{
{
"any",
store.OpportunityFilter{},
[]string{"ocds-4", "ocds-3", "ocds-2", "ocds-1"},
},
{"hidden", store.OpportunityFilter{
Hidden: set,
}, []string{"ocds-2"}},
{"not hidden", store.OpportunityFilter{
Hidden: unset,
}, []string{"ocds-4", "ocds-3", "ocds-1"}},
{"skipped", store.OpportunityFilter{
Skipped: set,
}, []string{"ocds-3"}},
{
"not skipped",
store.OpportunityFilter{
Skipped: unset,
},
[]string{"ocds-4", "ocds-2", "ocds-1"},
},
{"interested", store.OpportunityFilter{
Interested: set,
}, []string{"ocds-4", "ocds-1"}},
{"not interested", store.OpportunityFilter{
Interested: unset,
}, []string{"ocds-3", "ocds-2"}},
{"bid submitted", store.OpportunityFilter{
BidSubmitted: set,
}, []string{"ocds-4", "ocds-1"}},
{
"bid not submitted",
store.OpportunityFilter{
BidSubmitted: unset,
},
[]string{"ocds-3", "ocds-2"},
},
{"won", store.OpportunityFilter{
Won: set,
}, []string{"ocds-4"}},
{"not won", store.OpportunityFilter{
Won: unset,
}, []string{"ocds-3", "ocds-2", "ocds-1"}},
{"lost", store.OpportunityFilter{
Lost: set,
}, []string{"ocds-3"}},
{"not lost", store.OpportunityFilter{
Lost: unset,
}, []string{"ocds-4", "ocds-2", "ocds-1"}},
{"two marks", store.OpportunityFilter{
BidSubmitted: set,
Won: unset,
}, []string{"ocds-1"}},
{"no match", store.OpportunityFilter{
Won: set,
Lost: set,
}, []string{}},
{
"with other filters",
store.OpportunityFilter{
Hidden: unset,
Title: "cloud",
ReleasedFrom: day(3),
},
[]string{"ocds-4", "ocds-3"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := s.ListOpportunities(ctx, tc.f)
if err != nil {
t.Fatalf("ListOpportunities: %v", err)
}
if !equal(uuids(got), tc.want) {
t.Errorf("got %v, want %v", uuids(got), tc.want)
}
})
}
got, err := s.ListOpportunities(ctx, store.OpportunityFilter{})
if err != nil {
t.Fatal(err)
}
if len(got) != 4 || !got[0].Marks.Won || !got[1].Marks.Lost ||
!got[2].Marks.Hidden || got[3].Marks.Hidden || !got[3].Marks.Interested {
t.Errorf("list marks: %+v", got)
}
}
func TestWonAndLostAreExclusive(t *testing.T) {
s := openStore(t)
ctx := context.Background()
mustSave(t, s, opportunity("ocds-1", "T", 1, "r1"))
before := mustGet(t, s, "ocds-1").Marks
both := types.OpportunityMarks{
UUID: "ocds-1",
Won: true,
Lost: true,
}
if err := s.SetMarks(ctx, both); !errors.Is(err, types.ErrWonAndLost) {
t.Errorf("SetMarks(won and lost): err = %v, want ErrWonAndLost", err)
}
if got := mustGet(t, s, "ocds-1").Marks; got != before {
t.Errorf("rejected SetMarks changed marks: got %+v, want %+v", got, before)
}
in := opportunity("ocds-2", "T", 1, "r1")
in.Marks = types.OpportunityMarks{
Won: true,
Lost: true,
}
_, err := s.SaveOpportunity(ctx, in)
if !errors.Is(err, types.ErrWonAndLost) {
t.Errorf("SaveOpportunity(won and lost): err = %v, want ErrWonAndLost", err)
}
_, err = s.SaveOpportunities(ctx, []*types.Opportunity{in})
if !errors.Is(err, types.ErrWonAndLost) {
t.Errorf(
"SaveOpportunities(won and lost): err = %v, want ErrWonAndLost", err,
)
}
_, err = s.GetOpportunity(ctx, "ocds-2")
if !errors.Is(err, store.ErrNotFound) {
t.Errorf("rejected opportunity was stored: err = %v", err)
}
// Either one alone is fine, and switching between them works.
for _, m := range []types.OpportunityMarks{
{UUID: "ocds-1", Won: true},
{UUID: "ocds-1", Lost: true},
} {
mustSetMarks(t, s, m)
if got := mustGet(t, s, "ocds-1").Marks; got != m {
t.Errorf("got %+v, want %+v", got, m)
}
}
}
// LatestReleaseDate is the newest release of one source's opportunities only,
// and the zero time for a source with none stored.
func TestLatestReleaseDate(t *testing.T) {
s := openStore(t)
ctx := context.Background()
latest, err := s.LatestReleaseDate(ctx, "FindATender")
if err != nil || !latest.IsZero() {
t.Fatalf("empty store: %v, %v; want the zero time", latest, err)
}
mustSave(t, s, opportunity("ocds-1", "Older", 1, "r1"))
mustSave(t, s, opportunity("ocds-2", "Newer", 5, "r1"))
other := opportunity("ocds-3", "Another source's", 9, "r1")
other.Source = "FTSMock"
mustSave(t, s, other)
for source, uuid := range map[string]string{
"FindATender": "ocds-2",
"FTSMock": "ocds-3",
} {
want := mustGet(t, s, uuid).LatestReleaseDate
latest, err := s.LatestReleaseDate(ctx, source)
if err != nil || !latest.Equal(want) {
t.Errorf("%s: %v, %v; want %v", source, latest, err, want)
}
}
latest, err = s.LatestReleaseDate(ctx, "Nowhere")
if err != nil || !latest.IsZero() {
t.Errorf("unknown source: %v, %v; want the zero time", latest, err)
}
}
// NewOpportunity is set only for an opportunity that wasn't already stored,
// by both SaveOpportunity and SaveOpportunities.
func TestSaveResultNewOpportunity(t *testing.T) {
s := openStore(t)
ctx := context.Background()
res := mustSave(t, s, opportunity("ocds-1", "First", 1, "r1"))
if !res.NewOpportunity {
t.Errorf("first save: NewOpportunity = false")
}
res = mustSave(t, s, opportunity("ocds-1", "First", 2, "r2"))
if res.NewOpportunity {
t.Errorf("second save: NewOpportunity = true")
}
total, err := s.SaveOpportunities(ctx, []*types.Opportunity{
opportunity("ocds-1", "First", 3, "r3"),
opportunity("ocds-2", "Second", 3, "r1"),
})
if err != nil {
t.Fatal(err)
}
results := total.OpportunityResults
if results[0].NewOpportunity || !results[1].NewOpportunity {
t.Errorf(
"SaveOpportunities results = %+v; want only the second new",
results,
)
}
}