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