GET /api/opportunities takes released_from, an RFC 3339 time, and lists only the opportunities whose latest release is at or after it. It works with paging and with changed_after, which keeps the same batch either way; an invalid value is 400. The frontend's filter defaults to 7 days, from midnight UK time, and sends it with every load and catch-up, so a year of opportunities isn't downloaded to show a week's. Changing the days loads the list again; Clear filters goes back to 7.
291 lines
8.1 KiB
Go
291 lines
8.1 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"path/filepath"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
// Run with: go test -tags sqlite_fts5 ./...
|
|
|
|
// queryCheck is one statement to prepare. Preparing it makes SQLite parse it
|
|
// against the migrated schema, catching syntax errors and unknown tables or
|
|
// columns without running it. (Whether result columns map onto struct
|
|
// fields is covered by the behavioural tests in store_test.go.)
|
|
type queryCheck struct {
|
|
sql string
|
|
named bool // ":column" parameters, prepared via sqlx's named support
|
|
in bool // contains "IN (?)" for sqlx.In
|
|
}
|
|
|
|
// checks lists every constant in queries.go by name. Fragments are checked
|
|
// inside a complete statement. TestQueriesAllListed fails if a constant is
|
|
// missing here.
|
|
var checks = map[string]queryCheck{
|
|
// opportunities
|
|
"selectOpportunityLatestReleaseDate": {
|
|
sql: selectOpportunityLatestReleaseDate,
|
|
},
|
|
"selectSourceLatestReleaseDate": {
|
|
sql: selectSourceLatestReleaseDate,
|
|
},
|
|
"upsertOpportunity": {sql: upsertOpportunity, named: true},
|
|
"selectOpportunityByUUID": {sql: selectOpportunityByUUID},
|
|
"listOpportunitiesSelect": {
|
|
sql: listOpportunitiesSelect + listOpportunitiesOrderAndPage,
|
|
},
|
|
"listOpportunitiesOrderAndPage": {
|
|
sql: listOpportunitiesSelect + listOpportunitiesOrderAndPage,
|
|
},
|
|
"filterOpportunityStatus": {
|
|
sql: withFilter(filterOpportunityStatus),
|
|
},
|
|
"filterOpportunityClassificationCode": {
|
|
sql: withFilter(filterOpportunityClassificationCode),
|
|
},
|
|
"filterOpportunityTitleMatch": {
|
|
sql: withFilter(filterOpportunityTitleMatch),
|
|
},
|
|
"filterOpportunityReleasedFrom": {
|
|
sql: withFilter(filterOpportunityReleasedFrom),
|
|
},
|
|
"filterOpportunityReleasedBefore": {
|
|
sql: withFilter(filterOpportunityReleasedBefore),
|
|
},
|
|
"filterOpportunityMarkHidden": {
|
|
sql: withFilter(filterOpportunityMarkHidden),
|
|
},
|
|
"filterOpportunityMarkSkipped": {
|
|
sql: withFilter(filterOpportunityMarkSkipped),
|
|
},
|
|
"filterOpportunityMarkInterested": {
|
|
sql: withFilter(filterOpportunityMarkInterested),
|
|
},
|
|
"filterOpportunityMarkBidSubmitted": {
|
|
sql: withFilter(filterOpportunityMarkBidSubmitted),
|
|
},
|
|
"filterOpportunityMarkWon": {
|
|
sql: withFilter(filterOpportunityMarkWon),
|
|
},
|
|
"filterOpportunityMarkLost": {
|
|
sql: withFilter(filterOpportunityMarkLost),
|
|
},
|
|
|
|
// opportunity_ocds_releases
|
|
"insertRelease": {sql: insertRelease, named: true},
|
|
"selectReleasesByOpportunity": {sql: selectReleasesByOpportunity},
|
|
"selectReleaseExists": {sql: selectReleaseExists},
|
|
|
|
// release_tags
|
|
"insertReleaseTag": {sql: insertReleaseTag, named: true},
|
|
"selectReleaseTagsByOpportunity": {sql: selectReleaseTagsByOpportunity},
|
|
|
|
// classification_codes
|
|
"upsertClassificationCodeReturningID": {
|
|
sql: upsertClassificationCodeReturningID,
|
|
named: true,
|
|
},
|
|
|
|
// opportunity_classifications
|
|
"deleteOpportunityClassifications": {
|
|
sql: deleteOpportunityClassifications,
|
|
},
|
|
"insertOpportunityClassification": {
|
|
sql: insertOpportunityClassification,
|
|
},
|
|
"selectClassificationsInUse": {
|
|
sql: selectClassificationsInUse,
|
|
},
|
|
"selectClassificationsForOpportunities": {
|
|
sql: selectClassificationsForOpportunities,
|
|
in: true,
|
|
},
|
|
|
|
// opportunity_marks
|
|
"insertOpportunityMarks": {sql: insertOpportunityMarks, named: true},
|
|
"upsertOpportunityMarks": {sql: upsertOpportunityMarks, named: true},
|
|
"selectMarksForOpportunities": {sql: selectMarksForOpportunities, in: true},
|
|
|
|
// opportunity_change_batches
|
|
"insertChangeBatch": {sql: insertChangeBatch},
|
|
"selectLatestChangeBatch": {sql: selectLatestChangeBatch},
|
|
"selectChangeBatchByHash": {sql: selectChangeBatchByHash},
|
|
"deleteChangeBatchesBefore": {
|
|
sql: deleteChangeBatchesBefore,
|
|
},
|
|
|
|
// opportunity_change_batch_opportunities
|
|
"insertChangeBatchOpportunity": {sql: insertChangeBatchOpportunity},
|
|
"selectOpportunitiesChangedBetween": {
|
|
sql: selectOpportunitiesChangedBetween + orderOpportunitiesChanged,
|
|
},
|
|
"orderOpportunitiesChanged": {
|
|
sql: selectOpportunitiesChangedBetween + "\nAND " +
|
|
filterOpportunityReleasedFrom + orderOpportunitiesChanged,
|
|
},
|
|
}
|
|
|
|
func withFilter(fragment string) string {
|
|
return listOpportunitiesSelect + "\nWHERE " + fragment +
|
|
listOpportunitiesOrderAndPage
|
|
}
|
|
|
|
func openTestDB(t *testing.T) *sqlx.DB {
|
|
t.Helper()
|
|
s, err := Open(
|
|
context.Background(),
|
|
filepath.Join(t.TempDir(), "queries.db"),
|
|
WithAutoMigrate(),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
s.Close()
|
|
})
|
|
return s.db
|
|
}
|
|
|
|
func prepare(db *sqlx.DB, c queryCheck) error {
|
|
var err error
|
|
switch {
|
|
case c.named:
|
|
var stmt *sqlx.NamedStmt
|
|
if stmt, err = db.PrepareNamed(c.sql); err == nil {
|
|
stmt.Close()
|
|
}
|
|
default:
|
|
query := c.sql
|
|
if c.in {
|
|
var expanded string
|
|
if expanded, _, err = sqlx.In(query, []string{"a", "b"}); err != nil {
|
|
return err
|
|
}
|
|
query = db.Rebind(expanded)
|
|
}
|
|
var stmt *sqlx.Stmt
|
|
if stmt, err = db.Preparex(query); err == nil {
|
|
stmt.Close()
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func TestQueriesPrepare(t *testing.T) {
|
|
db := openTestDB(t)
|
|
for name, c := range checks {
|
|
if err := prepare(db, c); err != nil {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestListOpportunitiesQueryAllFilters prepares the assembled query with every
|
|
// filter set, checking the fragments combine correctly and that there is
|
|
// exactly one argument per placeholder.
|
|
func TestListOpportunitiesQueryAllFilters(t *testing.T) {
|
|
db := openTestDB(t)
|
|
query, args := listOpportunitiesQuery(OpportunityFilter{
|
|
Status: "active",
|
|
ClassificationCode: "72000000",
|
|
Title: "cloud migration",
|
|
ReleasedFrom: time.Now().Add(-time.Hour),
|
|
ReleasedBefore: time.Now(),
|
|
Hidden: MarkUnset,
|
|
Skipped: MarkUnset,
|
|
Interested: MarkSet,
|
|
BidSubmitted: MarkSet,
|
|
Won: MarkUnset,
|
|
Lost: MarkUnset,
|
|
Limit: 10,
|
|
Offset: 5,
|
|
})
|
|
|
|
stmt, err := db.Preparex(query)
|
|
if err != nil {
|
|
t.Fatalf("prepare:\n%s\n%v", query, err)
|
|
}
|
|
defer stmt.Close()
|
|
|
|
var rows []opportunityRow
|
|
if err := stmt.Select(&rows, args...); err != nil {
|
|
t.Errorf("run with %d args:\n%s\n%v", len(args), query, err)
|
|
}
|
|
}
|
|
|
|
// TestQueriesAllListed fails if queries.go declares a constant that checks
|
|
// doesn't list, so every new statement is prepared by TestQueriesPrepare.
|
|
func TestQueriesAllListed(t *testing.T) {
|
|
file, err := parser.ParseFile(token.NewFileSet(), "queries.go", nil, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var declared []string
|
|
for _, decl := range file.Decls {
|
|
gen, ok := decl.(*ast.GenDecl)
|
|
if !ok || gen.Tok != token.CONST {
|
|
continue
|
|
}
|
|
for _, spec := range gen.Specs {
|
|
for _, name := range spec.(*ast.ValueSpec).Names {
|
|
declared = append(declared, name.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Strings(declared)
|
|
if len(declared) == 0 {
|
|
t.Fatal("found no constants in queries.go")
|
|
}
|
|
|
|
for _, name := range declared {
|
|
if _, ok := checks[name]; !ok {
|
|
t.Errorf("queries.go constant %s is not listed in checks", name)
|
|
}
|
|
}
|
|
|
|
for name := range checks {
|
|
if i := sort.SearchStrings(declared, name); i == len(declared) ||
|
|
declared[i] != name {
|
|
t.Errorf("checks lists %s, which queries.go does not declare", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMarksCheckConstraint checks the database itself rejects won and lost
|
|
// together, for writes that bypass OpportunityMarks.Validate.
|
|
func TestMarksCheckConstraint(t *testing.T) {
|
|
db := openTestDB(t)
|
|
insert := `INSERT INTO opportunities
|
|
(uuid, uuid_type, title, status, latest_release_date)
|
|
VALUES ('ocds-1', 'ocid', 'T', 'active', '2026-01-01T00:00:00Z')`
|
|
if _, err := db.Exec(insert); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
insertWonAndLost := `INSERT INTO opportunity_marks (uuid, won, lost)
|
|
VALUES ('ocds-1', 1, 1)`
|
|
if _, err := db.Exec(insertWonAndLost); err == nil {
|
|
t.Error("inserting won and lost together succeeded")
|
|
}
|
|
|
|
insertWon := `INSERT INTO opportunity_marks (uuid, won)
|
|
VALUES ('ocds-1', 1)`
|
|
if _, err := db.Exec(insertWon); err != nil {
|
|
t.Fatalf("inserting won: %v", err)
|
|
}
|
|
|
|
updateLost := `UPDATE opportunity_marks SET lost = 1 WHERE uuid = 'ocds-1'`
|
|
if _, err := db.Exec(updateLost); err == nil {
|
|
t.Error("updating to won and lost together succeeded")
|
|
}
|
|
}
|