| 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. |
| 11 | package ideartest |
| 12 | |
| 13 | import ( |
| 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. |
| 36 | type 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. |
| 46 | func 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. |
| 55 | func 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. |
| 79 | func (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. |
| 88 | func (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. |
| 102 | func (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. |
| 113 | func (h *Harness) Owner() *idear.Member { return h.Member(idear.RoleOwner) } |
| 114 | |
| 115 | // Deactivated seeds a member at role who is already deactivated. |
| 116 | func (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. |
| 131 | func (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. |
| 143 | func (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. |
| 159 | func (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. |
| 171 | func (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. |
| 182 | func (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. |
| 195 | func (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 | |