// Package ideartest builds a real idear roster over a real SQLite // file, for idear's own tests and its example app's. // // Real, not a stand-in: the invariants this module exists to hold are // transaction invariants, and a fake store cannot be raced. The // database is rastrillo/db's split pool — one writer connection, // several readers — because that pool's routing is the environment the // store is written against, and the traps in it (a statement issued // outside a transaction that thinks it is inside one hangs rather than // erroring) only reproduce on the real thing. package ideartest import ( "context" "fmt" "path/filepath" "testing" "time" "github.com/carlosframework/rastrillo/db" "github.com/carlosframework/rastrillo/migrate" "github.com/carlosframework/rastrillo/sessions" "amadan.net/rastrillo/idear" ) // Harness is one instance's world: a database, its migrations, and the // Roster over them. Close is registered with t.Cleanup, so a test just // calls New and forgets about it. // // Every helper on it reports failure with t.Fatalf, so every helper // must be called from the TEST goroutine. The race tests below drive // h.Roster directly from their worker goroutines and collect errors to // assert on afterwards; that is not a stylistic choice, it is what // testing.T's contract requires. type Harness struct { T *testing.T DB *db.DB Roster *idear.Roster seq int // makes seeded subjects and addresses unique per harness } // New returns a Harness over a fresh temp database with idear's // default Config. func New(t *testing.T) *Harness { t.Helper() return NewWith(t, idear.Config{}) } // NewWith is New with the caller's Config — OpenSignUp, a short // InviteTTL, a custom NotFound. cfg.DB is filled in by the harness and // anything the caller put there is ignored: the point of the harness // is that the database is the harness's. func NewWith(t *testing.T, cfg idear.Config) *Harness { t.Helper() d, err := db.Open(filepath.Join(t.TempDir(), "idear.db"), nil) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) // The documented BootSchema order: sessions first, then idear. // Merged, never folded into an app's own Schema — see idear.Schema. if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, idear.Schema)); err != nil { t.Fatalf("migrate.Apply: %v", err) } cfg.DB = d.G rs, err := idear.New(cfg) if err != nil { t.Fatalf("idear.New: %v", err) } return &Harness{T: t, DB: d, Roster: rs} } // Ctx is the context every harness helper and most tests use. func (h *Harness) Ctx() context.Context { return context.Background() } // Member seeds one member at role, straight into the table. // // It goes around the store on purpose. The store will not mint an // Owner except by Claim, nor an Admin except by Invite-then-Accept, // and a test of Deactivate should not have to perform an invitation // flow to reach its starting position. Seeding is the arrangement; // the store is what is under test. func (h *Harness) Member(role idear.Role) *idear.Member { h.T.Helper() h.seq++ n := h.seq return h.MemberAs( fmt.Sprintf("subject-%d", n), fmt.Sprintf("member-%d@example.test", n), fmt.Sprintf("Member %d", n), role, ) } // MemberAs seeds a member with a caller-chosen subject, address and // name — for tests that care what those are. func (h *Harness) MemberAs(subject, email, name string, role idear.Role) *idear.Member { h.T.Helper() m := &idear.Member{Subject: subject, Email: email, Name: name, Role: role} if err := h.DB.G.Create(m).Error; err != nil { h.T.Fatalf("seeding %s %q: %v", role, subject, err) } return m } // Owner seeds the instance's Owner. Most tests want one of these and // then some victims. func (h *Harness) Owner() *idear.Member { return h.Member(idear.RoleOwner) } // Deactivated seeds a member at role who is already deactivated. func (h *Harness) Deactivated(role idear.Role) *idear.Member { h.T.Helper() m := h.Member(role) now := time.Now().UTC() if err := h.DB.G.Model(&idear.Member{}).Where("id = ?", m.ID). Update("deactivated_at", now).Error; err != nil { h.T.Fatalf("deactivating seeded member %d: %v", m.ID, err) } m.DeactivatedAt = &now return m } // Reload re-reads a member by id — the only honest way to assert what // a mutation did, since the *Member a test is holding was read before // the call. func (h *Harness) Reload(id int64) *idear.Member { h.T.Helper() var m idear.Member if err := h.DB.G.Where("id = ?", id).Take(&m).Error; err != nil { h.T.Fatalf("reloading member %d: %v", id, err) } return &m } // Invitation re-reads an invitation by id, spent ones included — // PendingInvitations deliberately hides those, and a test of Revoke or // Accept needs to see them. func (h *Harness) Invitation(id int64) *idear.Invitation { h.T.Helper() var inv idear.Invitation if err := h.DB.G.Where("id = ?", id).Take(&inv).Error; err != nil { h.T.Fatalf("reloading invitation %d: %v", id, err) } return &inv } // Expire backdates an invitation's ExpiresAt so it is already dead. // // It writes a time.Time through the same driver the store writes // through, deliberately: the expiry comparison in Accept's CAS is a // text comparison over the driver's own timestamp format, and a test // that seeded the column with a hand-written string would be testing a // format the store never produces. func (h *Harness) Expire(invitationID int64) { h.T.Helper() past := time.Now().UTC().Add(-time.Hour) if err := h.DB.G.Model(&idear.Invitation{}).Where("id = ?", invitationID). Update("expires_at", past).Error; err != nil { h.T.Fatalf("expiring invitation %d: %v", invitationID, err) } } // CountMembers is every row in the roster, deactivated included — // which is the count Claim's "zero rows, not zero active rows" rule is // about. func (h *Harness) CountMembers() int64 { h.T.Helper() var n int64 if err := h.DB.G.Model(&idear.Member{}).Count(&n).Error; err != nil { h.T.Fatalf("counting members: %v", err) } return n } // Owners is every row at RoleOwner. The single-owner invariant is // asserted against this: exactly one, and active. func (h *Harness) Owners() []idear.Member { h.T.Helper() var out []idear.Member if err := h.DB.G.Where("role = ?", idear.RoleOwner).Order("id").Find(&out).Error; err != nil { h.T.Fatalf("listing owners: %v", err) } return out } // TheOwner asserts that exactly one Owner row exists and returns it. // Every race test ends with this call: "exactly one owner" is the // invariant, and a test that only checked the count of successes would // not notice a transaction that left two. func (h *Harness) TheOwner() *idear.Member { h.T.Helper() owners := h.Owners() if len(owners) != 1 { h.T.Fatalf("roster has %d owners, want exactly 1: %+v", len(owners), owners) } return &owners[0] }