Files
job-search/internal/store/queries_test.go
T
bwilliams 465358609a Naming, layout and comments: descriptive full-word names, one item per line in wrapped calls and literals, and doc comments on exported names
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.
2026-10-15 16:12:56 +01:00

214 lines
7.2 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},
"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},
"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)
if _, err := db.Exec(`INSERT INTO opportunities
(uuid, uuid_type, title, status, latest_release_date)
VALUES ('ocds-1', 'ocid', 'T', 'active', '2026-01-01T00:00:00Z')`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO opportunity_marks (uuid, won, lost)
VALUES ('ocds-1', 1, 1)`); err == nil {
t.Error("inserting won and lost together succeeded")
}
if _, err := db.Exec(`INSERT INTO opportunity_marks (uuid, won)
VALUES ('ocds-1', 1)`); err != nil {
t.Fatalf("inserting won: %v", err)
}
if _, err := db.Exec(`UPDATE opportunity_marks SET lost = 1 WHERE uuid = 'ocds-1'`); err == nil {
t.Error("updating to won and lost together succeeded")
}
}