package store import ( "context" "fmt" "go/ast" "go/parser" "go/token" "path/filepath" "sort" "testing" "time" "github.com/jmoiron/sqlx" "git.avsdev.uk/Go/job-search/internal/search" ) // 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, }, "selectOtherSourcesTitles": { sql: selectOtherSourcesTitles, }, "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), }, "searchOpportunitiesSelect": {sql: searchOpportunitiesSelect}, "searchOpportunityText": { sql: withSearch(searchOpportunityText), }, "searchOpportunityBuyerID": { sql: withSearch(searchOpportunityBuyerID), in: true, }, "searchOpportunityCPV": { sql: withSearch(fmt.Sprintf(searchOpportunityCPV, "1")), }, "searchCPVCodePrefix": { sql: withSearch(fmt.Sprintf(searchOpportunityCPV, searchCPVCodePrefix)), }, "searchCPVDescription": { sql: withSearch(fmt.Sprintf(searchOpportunityCPV, searchCPVDescription)), }, // 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 withSearch(fragment string) string { return searchOpportunitiesSelect + "\nWHERE " + fragment } 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) } } // TestSearchOpportunitiesQueryAllTerms runs the assembled search query with // a term of every kind, both plain and negated, checking the fragments // combine and the arguments line up with the placeholders. func TestSearchOpportunitiesQueryAllTerms(t *testing.T) { db := openTestDB(t) query := search.Parse(`cloud "road repairs" title:a title:b buyer:c ` + `buyer_id:GB-1 buyer_id:GB-2 cpv:72 cpv:food !cloud !title:d ` + `!buyer:e !buyer_id:GB-3 !cpv:7221 !cpv:fuel`) statement, args := searchOpportunitiesQuery( query, time.Now().Add(-time.Hour), ) statement, args, err := sqlx.In(statement, args...) if err != nil { t.Fatal(err) } var uuids []string if err := db.Select(&uuids, db.Rebind(statement), args...); err != nil { t.Errorf("run with %d args:\n%s\n%v", len(args), statement, 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") } }