Short and single-letter names in application code become full words (tests keep theirs). Wrapped calls, signatures and composite literals put one item per line, and one-line function bodies are expanded. Exported names and groups of constants get doc comments, and comments refer to the renamed identifiers.
757 lines
22 KiB
Go
757 lines
22 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,
|
|
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)
|
|
}
|
|
}
|
|
}
|