212 lines
7.2 KiB
Go
212 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")
|
|
}
|
|
}
|