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