Every save that stores new opportunities or new releases records them in a change batch (migration 00008: opportunity_change_batches and opportunity_change_batch_opportunities; the opportunities table is unchanged), as does every change of marks. A batch is named by a random SHA-256 hash, so it can't be guessed, and ordered by its created_at, which is kept strictly increasing even if the clock goes back. GET /api/opportunities pages now carry the newest batch, read before the page, and GET /api/opportunities?changed_after=<batch> returns every opportunity changed since that batch, unpaged, with the newest batch it includes. Offset and limit can't be combined with it, and an unknown batch is 410 Gone, telling the client to reload the whole list. OpportunitiesUpdated now carries its save's batch, and a "large" flag: over 50 opportunities, new and updated are left empty and only the summary counts them, so a big catch-up no longer sends tens of thousands of UUIDs.
284 lines
7.8 KiB
Go
284 lines
7.8 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},
|
|
|
|
// opportunity_change_batch_opportunities
|
|
"insertChangeBatchOpportunity": {sql: insertChangeBatchOpportunity},
|
|
"selectOpportunitiesChangedBetween": {
|
|
sql: selectOpportunitiesChangedBetween,
|
|
},
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|