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