rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1// Package ideartest builds a real idear roster over a real SQLite
2// file, for idear's own tests and its example app's.
3//
4// Real, not a stand-in: the invariants this module exists to hold are
5// transaction invariants, and a fake store cannot be raced. The
6// database is rastrillo/db's split pool — one writer connection,
7// several readers — because that pool's routing is the environment the
8// store is written against, and the traps in it (a statement issued
9// outside a transaction that thinks it is inside one hangs rather than
10// erroring) only reproduce on the real thing.
11package ideartest
12
13import (
14 "context"
15 "fmt"
16 "path/filepath"
17 "testing"
18 "time"
19
20 "github.com/carlosframework/rastrillo/db"
21 "github.com/carlosframework/rastrillo/migrate"
22 "github.com/carlosframework/rastrillo/sessions"
23
24 "amadan.net/rastrillo/idear"
25)
26
27// Harness is one instance's world: a database, its migrations, and the
28// Roster over them. Close is registered with t.Cleanup, so a test just
29// calls New and forgets about it.
30//
31// Every helper on it reports failure with t.Fatalf, so every helper
32// must be called from the TEST goroutine. The race tests below drive
33// h.Roster directly from their worker goroutines and collect errors to
34// assert on afterwards; that is not a stylistic choice, it is what
35// testing.T's contract requires.
36type Harness struct {
37 T *testing.T
38 DB *db.DB
39 Roster *idear.Roster
40
41 seq int // makes seeded subjects and addresses unique per harness
42}
43
44// New returns a Harness over a fresh temp database with idear's
45// default Config.
46func New(t *testing.T) *Harness {
47 t.Helper()
48 return NewWith(t, idear.Config{})
49}
50
51// NewWith is New with the caller's Config — OpenSignUp, a short
52// InviteTTL, a custom NotFound. cfg.DB is filled in by the harness and
53// anything the caller put there is ignored: the point of the harness
54// is that the database is the harness's.
55func NewWith(t *testing.T, cfg idear.Config) *Harness {
56 t.Helper()
57
58 d, err := db.Open(filepath.Join(t.TempDir(), "idear.db"), nil)
59 if err != nil {
60 t.Fatalf("db.Open: %v", err)
61 }
62 t.Cleanup(func() { d.Close() })
63
64 // The documented BootSchema order: sessions first, then idear.
65 // Merged, never folded into an app's own Schema — see idear.Schema.
66 if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, idear.Schema)); err != nil {
67 t.Fatalf("migrate.Apply: %v", err)
68 }
69
70 cfg.DB = d.G
71 rs, err := idear.New(cfg)
72 if err != nil {
73 t.Fatalf("idear.New: %v", err)
74 }
75 return &Harness{T: t, DB: d, Roster: rs}
76}
77
78// Ctx is the context every harness helper and most tests use.
79func (h *Harness) Ctx() context.Context { return context.Background() }
80
81// Member seeds one member at role, straight into the table.
82//
83// It goes around the store on purpose. The store will not mint an
84// Owner except by Claim, nor an Admin except by Invite-then-Accept,
85// and a test of Deactivate should not have to perform an invitation
86// flow to reach its starting position. Seeding is the arrangement;
87// the store is what is under test.
88func (h *Harness) Member(role idear.Role) *idear.Member {
89 h.T.Helper()
90 h.seq++
91 n := h.seq
92 return h.MemberAs(
93 fmt.Sprintf("subject-%d", n),
94 fmt.Sprintf("member-%d@example.test", n),
95 fmt.Sprintf("Member %d", n),
96 role,
97 )
98}
99
100// MemberAs seeds a member with a caller-chosen subject, address and
101// name — for tests that care what those are.
102func (h *Harness) MemberAs(subject, email, name string, role idear.Role) *idear.Member {
103 h.T.Helper()
104 m := &idear.Member{Subject: subject, Email: email, Name: name, Role: role}
105 if err := h.DB.G.Create(m).Error; err != nil {
106 h.T.Fatalf("seeding %s %q: %v", role, subject, err)
107 }
108 return m
109}
110
111// Owner seeds the instance's Owner. Most tests want one of these and
112// then some victims.
113func (h *Harness) Owner() *idear.Member { return h.Member(idear.RoleOwner) }
114
115// Deactivated seeds a member at role who is already deactivated.
116func (h *Harness) Deactivated(role idear.Role) *idear.Member {
117 h.T.Helper()
118 m := h.Member(role)
119 now := time.Now().UTC()
120 if err := h.DB.G.Model(&idear.Member{}).Where("id = ?", m.ID).
121 Update("deactivated_at", now).Error; err != nil {
122 h.T.Fatalf("deactivating seeded member %d: %v", m.ID, err)
123 }
124 m.DeactivatedAt = &now
125 return m
126}
127
128// Reload re-reads a member by id — the only honest way to assert what
129// a mutation did, since the *Member a test is holding was read before
130// the call.
131func (h *Harness) Reload(id int64) *idear.Member {
132 h.T.Helper()
133 var m idear.Member
134 if err := h.DB.G.Where("id = ?", id).Take(&m).Error; err != nil {
135 h.T.Fatalf("reloading member %d: %v", id, err)
136 }
137 return &m
138}
139
140// Invitation re-reads an invitation by id, spent ones included —
141// PendingInvitations deliberately hides those, and a test of Revoke or
142// Accept needs to see them.
143func (h *Harness) Invitation(id int64) *idear.Invitation {
144 h.T.Helper()
145 var inv idear.Invitation
146 if err := h.DB.G.Where("id = ?", id).Take(&inv).Error; err != nil {
147 h.T.Fatalf("reloading invitation %d: %v", id, err)
148 }
149 return &inv
150}
151
152// Expire backdates an invitation's ExpiresAt so it is already dead.
153//
154// It writes a time.Time through the same driver the store writes
155// through, deliberately: the expiry comparison in Accept's CAS is a
156// text comparison over the driver's own timestamp format, and a test
157// that seeded the column with a hand-written string would be testing a
158// format the store never produces.
159func (h *Harness) Expire(invitationID int64) {
160 h.T.Helper()
161 past := time.Now().UTC().Add(-time.Hour)
162 if err := h.DB.G.Model(&idear.Invitation{}).Where("id = ?", invitationID).
163 Update("expires_at", past).Error; err != nil {
164 h.T.Fatalf("expiring invitation %d: %v", invitationID, err)
165 }
166}
167
168// CountMembers is every row in the roster, deactivated included —
169// which is the count Claim's "zero rows, not zero active rows" rule is
170// about.
171func (h *Harness) CountMembers() int64 {
172 h.T.Helper()
173 var n int64
174 if err := h.DB.G.Model(&idear.Member{}).Count(&n).Error; err != nil {
175 h.T.Fatalf("counting members: %v", err)
176 }
177 return n
178}
179
180// Owners is every row at RoleOwner. The single-owner invariant is
181// asserted against this: exactly one, and active.
182func (h *Harness) Owners() []idear.Member {
183 h.T.Helper()
184 var out []idear.Member
185 if err := h.DB.G.Where("role = ?", idear.RoleOwner).Order("id").Find(&out).Error; err != nil {
186 h.T.Fatalf("listing owners: %v", err)
187 }
188 return out
189}
190
191// TheOwner asserts that exactly one Owner row exists and returns it.
192// Every race test ends with this call: "exactly one owner" is the
193// invariant, and a test that only checked the count of successes would
194// not notice a transaction that left two.
195func (h *Harness) TheOwner() *idear.Member {
196 h.T.Helper()
197 owners := h.Owners()
198 if len(owners) != 1 {
199 h.T.Fatalf("roster has %d owners, want exactly 1: %+v", len(owners), owners)
200 }
201 return &owners[0]
202}
203