A test can now put a line of its own among the statements a Counted handle recorded, and have the Stripe mock hand it every call it receives, so one list shows each Stripe call between the statements it ran between, begin and commit included. Statements.Note places a line after the statements recorded so far. Names returns statements and notes in order; Total and ByName leave notes out; Reset forgets them. MockBackend.OnCall is called for every request as soon as it is recorded, before Err, PathErrs or Handle answers it, so a refused request is seen too. The recording moved from Call into a small record method, which keeps (*MockBackend).Call at its cognitive complexity of 65. stripetest.Line prints a call as "stripe <METHOD> <path>" and its form's pairs sorted by key, unescaped. docs/testing.md names OnCall, Line and Note. No production code changes, so no output changes: only test helper packages and docs/testing.md are edited, and the build, vet, gofmt, lint and deadcode checks pass with the lint snapshot unchanged. The names and results of ./internal/testkit/..., ./internal/stripetest, ./internal/fulfillment, ./internal/integrations/stripe/... and ./internal/server equal the parent's (1767 at the parent, 1771 here; every result pass), the four additions being the new tests TestLineNamesTheCallAndItsSortedForm, TestOnCallSeesEveryCallInOrder, TestOnCallSeesARequestErrRefuses and TestNotesTakeTheirPlaceAmongTheStatements, after a database reset before each side. Eleven breaks, each caught by the new test. Not probed: OnCall called from concurrent goroutines. The mock appends under its mutex and calls OnCall outside it, and no test of this series calls Stripe from two goroutines at once.
167 lines
4.5 KiB
Go
167 lines
4.5 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package dbtest
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"maps"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/stdlib"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit"
|
|
)
|
|
|
|
// Counted opens TEST_DATABASE_URL as Open does, on a connection whose every
|
|
// statement is recorded, so a test can count what one request runs. Hand the
|
|
// handle to the code under test only, and build fixtures on a handle from
|
|
// Open, so the record holds the code's statements and nothing else. It
|
|
// starts empty.
|
|
func Counted(tb testing.TB) (*sql.DB, *Statements) {
|
|
tb.Helper()
|
|
return CountedAt(tb, DSN(tb))
|
|
}
|
|
|
|
// CountedAt is Counted over the database at dsn, a scratch database for
|
|
// instance. Its core ledger must be at the highest version, as Open requires
|
|
// of the test database.
|
|
func CountedAt(tb testing.TB, dsn string) (*sql.DB, *Statements) {
|
|
tb.Helper()
|
|
cfg, err := pgx.ParseConfig(dsn)
|
|
if err != nil {
|
|
tb.Fatalf("parse the database address: %v", err)
|
|
}
|
|
s := &Statements{}
|
|
cfg.Tracer = s
|
|
d := stdlib.OpenDB(*cfg)
|
|
tb.Cleanup(func() { testkit.Close(tb, d) })
|
|
if err := ledgerBehind(d); err != nil {
|
|
tb.Fatalf("%v", err)
|
|
}
|
|
s.Reset()
|
|
return d, s
|
|
}
|
|
|
|
// Statements records the statements run on a Counted handle, each by its
|
|
// name: the sqlc query name for a generated query, whose text starts
|
|
// "-- name: <Name> :<kind>", and otherwise the first line of the text with
|
|
// its spaces collapsed (a raw query, BEGIN, COMMIT). Pings are not
|
|
// statements and are not recorded.
|
|
type Statements struct {
|
|
mu sync.Mutex
|
|
names []string
|
|
notes []note
|
|
}
|
|
|
|
// note is a line a test added to the record, placed after the first at
|
|
// statements.
|
|
type note struct {
|
|
at int
|
|
text string
|
|
}
|
|
|
|
// TraceQueryStart records one statement. It implements pgx.QueryTracer.
|
|
func (s *Statements) TraceQueryStart(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryStartData) context.Context {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.names = append(s.names, statementName(data.SQL))
|
|
return ctx
|
|
}
|
|
|
|
// TraceQueryEnd implements pgx.QueryTracer; the end of a statement adds
|
|
// nothing.
|
|
func (s *Statements) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {}
|
|
|
|
// Reset forgets every statement recorded so far.
|
|
func (s *Statements) Reset() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.names = nil
|
|
s.notes = nil
|
|
}
|
|
|
|
// Note adds text to the record after the statements recorded so far, so
|
|
// Names places an event the database does not see, a call to Stripe for
|
|
// instance, among the statements around it. A note is not a statement:
|
|
// Total and ByName leave it out.
|
|
func (s *Statements) Note(text string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.notes = append(s.notes, note{at: len(s.names), text: text})
|
|
}
|
|
|
|
// Total is the number of statements recorded since the last Reset.
|
|
func (s *Statements) Total() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.names)
|
|
}
|
|
|
|
// Names returns the names of the statements recorded since the last Reset,
|
|
// in the order they ran, with each note at its place among them. A
|
|
// transaction's begin, commit and rollback are among them.
|
|
func (s *Statements) Names() []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]string, 0, len(s.names)+len(s.notes))
|
|
next := 0
|
|
for i, n := range s.names {
|
|
for next < len(s.notes) && s.notes[next].at == i {
|
|
out = append(out, s.notes[next].text)
|
|
next++
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
for _, n := range s.notes[next:] {
|
|
out = append(out, n.text)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ByName counts the statements recorded since the last Reset by name.
|
|
func (s *Statements) ByName() map[string]int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := map[string]int{}
|
|
for _, n := range s.names {
|
|
out[n]++
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Diff lists, one "name: before -> after" line per name in sorted order, the
|
|
// names whose counts differ between two ByName results.
|
|
func Diff(before, after map[string]int) string {
|
|
names := maps.Clone(before)
|
|
maps.Copy(names, after)
|
|
var b strings.Builder
|
|
for _, n := range slices.Sorted(maps.Keys(names)) {
|
|
if before[n] != after[n] {
|
|
fmt.Fprintf(&b, "%s: %d -> %d\n", n, before[n], after[n])
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func statementName(text string) string {
|
|
for line := range strings.Lines(text) {
|
|
line = strings.Join(strings.Fields(line), " ")
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if rest, ok := strings.CutPrefix(line, "-- name: "); ok {
|
|
name, _, _ := strings.Cut(rest, " ")
|
|
return name
|
|
}
|
|
return line
|
|
}
|
|
return ""
|
|
}
|