08749df050
Swap modernc.org/sqlite for jackc/pgx/v5 with no observable change: same endpoints, same wire format, same updated_at ordering rule. The schema now comes from numbered SQL embedded in the binary and applied on startup, one transaction each, recorded in schema_migrations. That replaces two pieces of SQLite-era machinery, both deleted rather than ported: the column probing (Postgres has ADD COLUMN IF NOT EXISTS, and there is no legacy database left to probe) and the Asura key rewrite, which has run clean on every start for months now that the userscripts strip build hashes before writing. Its regexp survives as latest.asuraBuildHash, where the poller still needs it to scope chapter links to a series whose slug carries a rotating hash. Types get real: favorite is a boolean, chapter numbers double precision, timestamps stay unix-ms bigint. SQLite's null-safe IS NOT becomes IS DISTINCT FROM, which is what implements the rule that only reading progress reorders a list. Inside COALESCE/NULLIF the status and kind parameters need an explicit ::text -- there is no target column to infer from and Postgres refuses to guess. Tests lose their free t.TempDir() database, so Docker is now a hard prerequisite for `go test ./...`: internal/pgtest starts one postgres:17-alpine per test binary and hands each test a database of its own. Also lands CONTEXT.md and the four ADRs written while scoping #18. BREAKING CHANGE: DB_PATH is retired for DATABASE_URL, which is required and has no default. Compose gains a postgres service on an internal network with its own volume; POSTGRES_PASSWORD joins .env. The old bookmarks-data volume is deliberately left undeclared so `docker compose down -v` cannot take the pre-migration database with it. main is not deployable until #25 and #26 land. Closes #20 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
121 lines
3.4 KiB
Go
121 lines
3.4 KiB
Go
// Package pgtest runs the Postgres the test suite needs: one throwaway
|
|
// container per test binary, one fresh database per test. Docker is therefore
|
|
// a hard prerequisite for `go test ./...`.
|
|
//
|
|
// Rolled by hand rather than pulled in as a dependency — it is one `docker
|
|
// run`, one `docker port` and a ping loop, against a module list that is
|
|
// otherwise stdlib plus what the poller genuinely needs.
|
|
package pgtest
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
const (
|
|
image = "postgres:17-alpine"
|
|
readyLimit = 60 * time.Second
|
|
)
|
|
|
|
var (
|
|
adminURL string
|
|
dbSeq atomic.Int64
|
|
)
|
|
|
|
// Main starts the container, runs the package's tests and tears the container
|
|
// down. Every test package that touches the store calls it from TestMain:
|
|
//
|
|
// func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
|
|
func Main(m *testing.M) int {
|
|
id, url, err := start()
|
|
if err != nil {
|
|
fmt.Println("pgtest:", err)
|
|
return 1
|
|
}
|
|
defer exec.Command("docker", "rm", "-f", id).Run()
|
|
|
|
adminURL = url
|
|
return m.Run()
|
|
}
|
|
|
|
// URL creates a database of its own for t and returns a connection URL for it.
|
|
// Nothing drops it again: the container goes away wholesale when Main returns.
|
|
func URL(t testing.TB) string {
|
|
t.Helper()
|
|
if adminURL == "" {
|
|
t.Fatal("pgtest: no container; this package needs TestMain to call pgtest.Main")
|
|
}
|
|
// Generated, never derived from the test name, so it needs no quoting and
|
|
// cannot collide when tests run in parallel.
|
|
name := "test_" + strconv.FormatInt(dbSeq.Add(1), 10)
|
|
|
|
admin, err := sql.Open("pgx", adminURL)
|
|
if err != nil {
|
|
t.Fatalf("pgtest: open admin connection: %v", err)
|
|
}
|
|
defer admin.Close()
|
|
if _, err := admin.Exec(`CREATE DATABASE ` + name); err != nil {
|
|
t.Fatalf("pgtest: create database %s: %v", name, err)
|
|
}
|
|
return strings.Replace(adminURL, "/postgres?", "/"+name+"?", 1)
|
|
}
|
|
|
|
// start launches the container and waits for it to accept queries, returning
|
|
// its id and a connection URL for the default database.
|
|
func start() (id, url string, err error) {
|
|
out, err := exec.Command("docker", "run", "-d", "--rm",
|
|
"-e", "POSTGRES_PASSWORD=pgtest",
|
|
"-P", image,
|
|
// Durability buys nothing for a database that dies with the test
|
|
// binary, and turning it off is most of the container's start-up cost.
|
|
"-c", "fsync=off", "-c", "full_page_writes=off",
|
|
).Output()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("docker run %s: %w", image, err)
|
|
}
|
|
id = strings.TrimSpace(string(out))
|
|
|
|
port, err := exec.Command("docker", "port", id, "5432/tcp").Output()
|
|
if err != nil {
|
|
exec.Command("docker", "rm", "-f", id).Run()
|
|
return "", "", fmt.Errorf("docker port: %w", err)
|
|
}
|
|
// "0.0.0.0:32768" (and possibly a second, IPv6 line); the port is all we want.
|
|
first, _, _ := strings.Cut(strings.TrimSpace(string(port)), "\n")
|
|
url = fmt.Sprintf("postgres://postgres:pgtest@127.0.0.1:%s/postgres?sslmode=disable",
|
|
first[strings.LastIndex(first, ":")+1:])
|
|
|
|
if err := waitReady(url); err != nil {
|
|
exec.Command("docker", "rm", "-f", id).Run()
|
|
return "", "", err
|
|
}
|
|
return id, url, nil
|
|
}
|
|
|
|
func waitReady(url string) error {
|
|
db, err := sql.Open("pgx", url)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
deadline := time.Now().Add(readyLimit)
|
|
for {
|
|
if err = db.Ping(); err == nil {
|
|
return nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("postgres not ready after %s: %w", readyLimit, err)
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
}
|