Files
job-search/internal/store/queries_test.go
T
bwilliams 155b84caea Added change batches, so clients can catch up on changed opportunities
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.
2026-10-16 15:25:48 +01:00

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")
}
}