First pass implementation of an SQLite backing store for fetched opportunities
This commit is contained in:
1 parent
9b0e6dccfe
commit
045492a148
29 files changed
+3132
-9
No files matched your search
@@ -0,0 +1,647 @@
|
||||
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,
|
||||
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 {
|
||||
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)
|
||||
if _, err := s.GetOpportunity(
|
||||
context.Background(),
|
||||
"missing",
|
||||
); !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}
|
||||
if _, err := s.SaveOpportunity(ctx, in); !errors.Is(err, types.ErrWonAndLost) {
|
||||
t.Errorf("SaveOpportunity(won and lost): err = %v, want ErrWonAndLost", err)
|
||||
}
|
||||
if _, err := s.SaveOpportunities(
|
||||
ctx,
|
||||
[]*types.Opportunity{in},
|
||||
); !errors.Is(
|
||||
err,
|
||||
types.ErrWonAndLost,
|
||||
) {
|
||||
t.Errorf("SaveOpportunities(won and lost): err = %v, want ErrWonAndLost", err)
|
||||
}
|
||||
if _, err := s.GetOpportunity(ctx, "ocds-2"); !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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user