package service
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"net/url"
"os"
"sort"
"strings"
"testing"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/storer"
_ "github.com/lib/pq"
"github.com/vaughan0/go-ini"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)
// testEnv names the DSN env var that gates the Postgres-backed tests, spelled
// exactly as db/ spells it so one variable turns on the integration tests of
// the whole module. When it is unset those tests skip; everything expressible
// without a database — config validation, the token-store error contract, the
// push rejection message, the reconciler's decision table — runs regardless.
const testEnv = "SPECSRHT_TEST_PG"
var fxSpace = core.SpaceRef{Owner: "bigbes", Name: "rfcs"}
func fxTime(n int) time.Time {
return time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC).Add(time.Duration(n) * time.Minute)
}
// testInstance is the identity half of the config, matching what
// authn.InstanceFromConfig would derive from the origin below.
func testInstance() authn.Instance {
return authn.Instance{
OwnerName: "bigbes",
OwnerEmail: "bigbes@gmail.com",
AgentEmail: "agent@spec.srht.bigb.es",
}
}
func testConfig(t *testing.T, repos string) Config {
t.Helper()
return Config{
Repos: repos,
Cache: t.TempDir(),
Origin: "https://spec.srht.bigb.es",
ConnectionString: "postgresql://specsrht@localhost/spec.sr.ht?sslmode=disable",
Instance: testInstance(),
}
}
// testIni renders a config.ini with every key service/ requires, minus the ones
// named in omit, so a test can knock out exactly one key and see what is said
// about it.
func testIni(t *testing.T, repos string, omit ...string) ini.File {
t.Helper()
keys := []struct{ section, key, value string }{
{"sr.ht", "owner-name", "bigbes"},
{"sr.ht", "owner-email", "bigbes@gmail.com"},
{ConfigSection, "origin", "https://spec.srht.bigb.es"},
{ConfigSection, "repos", repos},
{ConfigSection, "cache", "/var/cache/spec"},
{ConfigSection, "connection-string", "postgresql://specsrht@localhost/spec.sr.ht?sslmode=disable"},
}
dropped := make(map[string]bool, len(omit))
for _, o := range omit {
dropped[o] = true
}
var b strings.Builder
section := ""
for _, k := range keys {
if dropped[k.key] {
continue
}
if k.section != section {
fmt.Fprintf(&b, "[%s]\n", k.section)
section = k.section
}
fmt.Fprintf(&b, "%s=%s\n", k.key, k.value)
}
conf, err := ini.Load(strings.NewReader(b.String()))
if err != nil {
t.Fatalf("load synthesized ini: %v", err)
}
return conf
}
// deadDB is a database handle that resolves and then fails to connect. It gives
// the tests a *sql.DB whose every query errors without a server, which is what
// New requires (a nil handle is refused) and what the compensating-delete test
// in CreateSpace needs.
func deadDB(t *testing.T) *sql.DB {
t.Helper()
pool, err := sql.Open("postgres",
"postgres://nobody@127.0.0.1:1/nothing?sslmode=disable&connect_timeout=1")
if err != nil {
t.Fatalf("open dead pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
return pool
}
// newService builds a Service over a repos root and a database that cannot be
// reached, for the tests that exercise git and pure logic only. Any accidental
// query fails loudly instead of passing silently.
func newService(t *testing.T) (*Service, string) {
t.Helper()
root := t.TempDir()
svc, err := New(testConfig(t, root), deadDB(t))
if err != nil {
t.Fatalf("New: %v", err)
}
return svc, root
}
// newSpace creates a bare repository for fxSpace under root and returns it as a
// Space with the row id a test supplies. It bypasses Service.CreateSpace on
// purpose: most tests here have no database, and the repository is the space.
func newSpace(t *testing.T, root string, id int) *Space {
t.Helper()
repo, err := gitx.Create(context.Background(), root, fxSpace, gitx.CreateOptions{
Owner: gitx.Signature{Name: "bigbes", Email: "bigbes@gmail.com", When: fxTime(0)},
})
if err != nil {
t.Fatalf("gitx.Create: %v", err)
}
return &Space{Ref: fxSpace, ID: id, Created: fxTime(0), Repo: repo}
}
// cutBranch creates a proposal branch at base, the way an agent's first write
// does. Committing onto a branch that was never cut would produce an orphan
// history, which no push in this system can create.
func cutBranch(t *testing.T, sp *Space, branch, base string) {
t.Helper()
if _, err := sp.Repo.CreateProposalBranch(context.Background(), branch, base); err != nil {
t.Fatalf("CreateProposalBranch(%q, %q): %v", branch, base, err)
}
}
// mdDoc renders a minimal valid document: the three required keys, then a body.
func mdDoc(id, title, body string) []byte {
return []byte(fmt.Sprintf("---\nid: %s\ntitle: %s\nstatus: draft\n---\n\n%s\n", id, title, body))
}
// commitFiles writes files onto a branch, preserving everything already there,
// and returns the new commit. It stands in for a human push through
// receive-pack, which is the only way content reaches the approved branch.
//
// A nil value deletes the path.
func commitFiles(t *testing.T, sp *Space, branch string, n int, files map[string][]byte) plumbing.Hash {
t.Helper()
repo, err := git.PlainOpen(sp.Repo.Dir())
if err != nil {
t.Fatalf("PlainOpen %s: %v", sp.Repo.Dir(), err)
}
st := repo.Storer
name := plumbing.NewBranchReferenceName(branch)
var parents []plumbing.Hash
entries := map[string]plumbing.Hash{}
if ref, err := repo.Reference(name, false); err == nil {
parents = []plumbing.Hash{ref.Hash()}
commit, err := repo.CommitObject(ref.Hash())
if err != nil {
t.Fatalf("commit %s: %v", ref.Hash(), err)
}
iter, err := commit.Files()
if err != nil {
t.Fatalf("files of %s: %v", ref.Hash(), err)
}
if err := iter.ForEach(func(f *object.File) error {
entries[f.Name] = f.Blob.Hash
return nil
}); err != nil {
t.Fatalf("walk %s: %v", ref.Hash(), err)
}
}
for path, data := range files {
if data == nil {
delete(entries, path)
continue
}
entries[path] = writeBlob(t, st, data)
}
tree := writeTree(t, st, entries)
sig := object.Signature{Name: "bigbes", Email: "bigbes@gmail.com", When: fxTime(n)}
commit := &object.Commit{
Author: sig, Committer: sig,
Message: fmt.Sprintf("commit %d\n", n),
TreeHash: tree,
ParentHashes: parents,
}
obj := st.NewEncodedObject()
if err := commit.Encode(obj); err != nil {
t.Fatalf("encode commit: %v", err)
}
hash, err := st.SetEncodedObject(obj)
if err != nil {
t.Fatalf("store commit: %v", err)
}
if err := st.SetReference(plumbing.NewHashReference(name, hash)); err != nil {
t.Fatalf("set %s: %v", name, err)
}
return hash
}
func writeBlob(t *testing.T, st storer.EncodedObjectStorer, data []byte) plumbing.Hash {
t.Helper()
obj := st.NewEncodedObject()
obj.SetType(plumbing.BlobObject)
obj.SetSize(int64(len(data)))
w, err := obj.Writer()
if err != nil {
t.Fatalf("blob writer: %v", err)
}
if _, err := w.Write(data); err != nil {
t.Fatalf("write blob: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close blob: %v", err)
}
hash, err := st.SetEncodedObject(obj)
if err != nil {
t.Fatalf("store blob: %v", err)
}
return hash
}
// writeTree builds a tree from a flat path->blob map, recursing on directories.
// Entries are sorted the way git sorts them (directories compare as if they
// ended in '/'), so the objects this produces are byte-identical to git's.
func writeTree(t *testing.T, st storer.EncodedObjectStorer, files map[string]plumbing.Hash) plumbing.Hash {
t.Helper()
blobs := map[string]plumbing.Hash{}
dirs := map[string]map[string]plumbing.Hash{}
for path, hash := range files {
name, rest, nested := strings.Cut(path, "/")
if !nested {
blobs[name] = hash
continue
}
if dirs[name] == nil {
dirs[name] = map[string]plumbing.Hash{}
}
dirs[name][rest] = hash
}
var entries []object.TreeEntry
for name, hash := range blobs {
entries = append(entries, object.TreeEntry{Name: name, Mode: filemode.Regular, Hash: hash})
}
for name, sub := range dirs {
entries = append(entries, object.TreeEntry{
Name: name, Mode: filemode.Dir, Hash: writeTree(t, st, sub),
})
}
sortKey := func(e object.TreeEntry) string {
if e.Mode == filemode.Dir {
return e.Name + "/"
}
return e.Name
}
sort.Slice(entries, func(i, j int) bool { return sortKey(entries[i]) < sortKey(entries[j]) })
tree := &object.Tree{Entries: entries}
obj := st.NewEncodedObject()
if err := tree.Encode(obj); err != nil {
t.Fatalf("encode tree: %v", err)
}
hash, err := st.SetEncodedObject(obj)
if err != nil {
t.Fatalf("store tree: %v", err)
}
return hash
}
// newTestService connects to the Postgres pointed at by SPECSRHT_TEST_PG,
// applies schema.sql into an isolated scratch schema, and returns a Service
// bound to it plus its repos root. Skips when the variable is unset. The shape
// is db/'s newTestStore, adapted: a per-test schema needs no CREATE DATABASE
// privilege and cleans up with one DROP SCHEMA ... CASCADE.
func newTestService(t *testing.T) (*Service, string) {
t.Helper()
base := os.Getenv(testEnv)
if base == "" {
t.Skipf("%s not set; skipping Postgres-backed test (set it to a DSN to run)", testEnv)
}
admin, err := sql.Open("postgres", base)
if err != nil {
t.Fatalf("open admin pool: %v", err)
}
if err := admin.Ping(); err != nil {
admin.Close()
t.Fatalf("ping %s: %v", testEnv, err)
}
schema := "specsrht_test_" + randToken()
if _, err := admin.Exec(`CREATE SCHEMA "` + schema + `"`); err != nil {
admin.Close()
t.Fatalf("create schema %s: %v", schema, err)
}
drop := func() {
if _, err := admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`); err != nil {
t.Errorf("drop schema %s: %v", schema, err)
}
admin.Close()
}
scopedDSN, err := withSearchPath(base, schema)
if err != nil {
drop()
t.Fatalf("build scoped dsn: %v", err)
}
pool, err := sql.Open("postgres", scopedDSN)
if err != nil {
drop()
t.Fatalf("open scoped pool: %v", err)
}
ddl, err := os.ReadFile("../schema.sql")
if err != nil {
pool.Close()
drop()
t.Fatalf("read schema.sql: %v", err)
}
if _, err := pool.Exec(string(ddl)); err != nil {
pool.Close()
drop()
t.Fatalf("apply schema.sql: %v", err)
}
t.Cleanup(func() {
pool.Close()
drop()
})
root := t.TempDir()
svc, err := New(testConfig(t, root), pool)
if err != nil {
t.Fatalf("New: %v", err)
}
return svc, root
}
func withSearchPath(base, schema string) (string, error) {
opt := "-c search_path=" + schema
if strings.Contains(base, "://") {
u, err := url.Parse(base)
if err != nil {
return "", err
}
q := u.Query()
q.Set("options", opt)
u.RawQuery = q.Encode()
return u.String(), nil
}
return base + " options='" + opt + "'", nil
}
func randToken() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}