Files
job-search/internal/store/queries_test.go
T

273 lines
7.5 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},
}
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")
}
}