~bigbes/sr-ht-spec

ref: 0879325b6c7e7d74454ecff0196b8d5ecc12f1ea sr-ht-spec/db/db_test.go -rw-r--r-- 4.4 KiB
0879325b — bigbes docs: the empty-space-list polarity is retracted, not just documented 27 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
package db

import (
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/hex"
	"net/url"
	"os"
	"strings"
	"testing"

	_ "github.com/lib/pq"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

// testEnv names the DSN env var that gates the Postgres-backed tests. When
// unset, every integration test in this package skips with a clear message; the
// pure-logic tests (transition guards, token hashing, batch duplicate
// detection, schema/migration agreement) run regardless, so `go test ./db/...`
// is meaningful on a machine with no database at all.
const testEnv = "SPECSRHT_TEST_PG"

// newTestStore connects to the Postgres pointed at by SPECSRHT_TEST_PG, creates
// an isolated scratch schema (specsrht_test_<random>), applies schema.sql into
// it, and returns a Store bound to a pool whose search_path is that schema. The
// returned cleanup drops the schema and closes the pools. If the env var is
// unset the test is skipped.
//
// Isolation is achieved with a per-test schema rather than a whole database so
// no admin/CREATE DATABASE privilege is required and cleanup is a single
// DROP SCHEMA ... CASCADE. The scratch pool routes every connection to that
// schema via lib/pq's `options=-c search_path=...` startup parameter.
func newTestStore(t *testing.T) (*Store, *sql.DB, func()) {
	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)
	}

	scopedDSN, err := withSearchPath(base, schema)
	if err != nil {
		admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`)
		admin.Close()
		t.Fatalf("build scoped dsn: %v", err)
	}
	pool, err := sql.Open("postgres", scopedDSN)
	if err != nil {
		admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`)
		admin.Close()
		t.Fatalf("open scoped pool: %v", err)
	}

	ddl, err := os.ReadFile("../schema.sql")
	if err != nil {
		pool.Close()
		admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`)
		admin.Close()
		t.Fatalf("read schema.sql: %v", err)
	}
	if _, err := pool.Exec(string(ddl)); err != nil {
		pool.Close()
		admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`)
		admin.Close()
		t.Fatalf("apply schema.sql: %v", err)
	}

	cleanup := func() {
		pool.Close()
		if _, err := admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`); err != nil {
			t.Errorf("drop schema %s: %v", schema, err)
		}
		admin.Close()
	}
	return NewStore(pool), pool, cleanup
}

// withSearchPath returns base with a connection option that pins search_path to
// schema for every pooled connection, handling both URL and keyword DSN forms.
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)
}

// mkSpace is a convenience for CreateSpace in tests.
func mkSpace(t *testing.T, s *Store, ctx context.Context, owner, name string) *Space {
	t.Helper()
	sp, err := s.CreateSpace(ctx, core.SpaceRef{Owner: owner, Name: name})
	if err != nil {
		t.Fatalf("create space ~%s/%s: %v", owner, name, err)
	}
	return sp
}

// docID parses a document ID or fails the test.
func docID(t *testing.T, s string) core.DocID {
	t.Helper()
	id, err := core.ParseDocID(s)
	if err != nil {
		t.Fatalf("parse doc id %q: %v", s, err)
	}
	return id
}

// mkProposal opens a proposal with plausible provenance.
func mkProposal(t *testing.T, s *Store, ctx context.Context, spaceID int, title string) *Proposal {
	t.Helper()
	p, err := s.OpenProposal(ctx, &Proposal{
		SpaceID:      spaceID,
		Title:        title,
		Rationale:    "because",
		BaseRev:      "0000000000000000000000000000000000000000",
		Agent:        "claude-code/spec-writer",
		AgentSession: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
	})
	if err != nil {
		t.Fatalf("open proposal %q: %v", title, err)
	}
	return p
}