package integration_test import ( "context" "database/sql" "fmt" "math/rand/v2" "net/http" "os" "strconv" "strings" "testing" "time" "github.com/go-rod/rod" _ "modernc.org/sqlite" "git.awl.red/~neallred/recipes/ops" "git.awl.red/~neallred/recipes/serve" ) const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" const ( letterIdxBits = 6 // 6 bits to represent a letter index letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits ) var port *int func init() { initialPort := 12345 port = &initialPort } type usercred struct { user string pass string } type harness struct { conn string addr string b *rod.Browser srv *serve.Server r *routes admin usercred user usercred cleanedUp bool } type routes struct { addr string Logout string Login string Signup string User string UserEdit string LaterNotes string Idx string Recipes string RecipeNew string RecipesForLater string } func NewRoutes(addr string) *routes { return &routes{ addr: addr, // session/user Logout: addr + "/logout", Login: addr + "/login", Signup: addr + "/signup", User: addr + "/user", UserEdit: addr + "/user/edit", LaterNotes: addr + "/later_notes", // recipes Idx: addr, Recipes: addr + "/recipes", RecipeNew: addr + "/recipe_new", RecipesForLater: addr + "/recipes_for_later", } } func (r routes) RecipeDetails(id int64) string { return fmt.Sprintf("%s/%d", r.Recipes, id) } func allocatePort() int { allocated := *port *port += 1 return allocated } func NewHarness() (*harness, error) { conn, err := prepareDb() if err != nil { return nil, err } port := allocatePort() srv := serve.New(serve.Config{ Port: strconv.Itoa(port), DbConn: conn, }) go func() { srv.Start() }() addr := "http://localhost:" + strconv.Itoa(port) awaitServerStart(addr, "exopterygotic") admin := usercred{"admin", "adminpass"} user := usercred{"user", "userpass"} h := &harness{ conn: conn, addr: addr, b: rod.New().MustConnect(), srv: srv, r: NewRoutes(addr), admin: admin, user: user, } h.signupUser(admin) h.signupUser(user) h.b.MustPage(h.r.Logout) return h, nil } func (h *harness) signupUser(u usercred) { p := h.b.MustPage(h.r.Logout) p.MustNavigate(h.r.Signup) p.MustElement(`input[name="username"]`).MustInput(u.user) p.MustElement(`input[name="password"]`).MustInput(u.pass) p.MustElement(`input[type="submit"]`).MustClick() p.MustElementR("summary", "Search options") p.MustClose() } func (h *harness) loginUser(u usercred) { p := h.b.MustPage(h.r.Logout) p.MustNavigate(h.r.Login) p.MustElement(`input[name="username"]`).MustInput(u.user) p.MustElement(`input[name="password"]`).MustInput(u.pass) p.MustElement(`input[type="submit"]`).MustClick() p.MustElementR("summary", "Search options") p.MustClose() } func (h *harness) logout(u usercred) { h.b.MustPage(h.r.Logout).MustClose() } func (h *harness) Cleanup(t *testing.T) { h.b.MustClose() noErr(t, h.srv.Close()) if !h.cleanedUp { // did not clean up because was not success // tell user where they can find and inspect the DB t.Logf("db available at %s", h.conn) } } func (h *harness) CleanupSuccess(t *testing.T) { noErr(t, disposeDb(h.conn)) h.cleanedUp = true } func disposeDb(conn string) error { queryParamsIndex := strings.Index(conn, "?") if queryParamsIndex == -1 { return fmt.Errorf("could not find database name from conn string") } return os.Remove(conn[0:queryParamsIndex]) } func prepareDb() (string, error) { dbFile, err := os.CreateTemp("", "test_recipes_*.db") if err != nil { return "", err } dbName := dbFile.Name() err = dbFile.Close() if err != nil { return "", err } conn := fmt.Sprintf("%s?_foreign_keys=on", dbName) db, err := sql.Open("sqlite", conn) if err != nil { return "", err } _, err = db.Exec(string(ops.ModelBytes)) if err != nil { return "", err } err = db.Close() if err != nil { return "", err } return conn, nil } func noErr(t *testing.T, err error, msgAndArgs ...any) { if err == nil { return } t.Log(err.Error()) if len(msgAndArgs) > 0 { t.Log(msgAndArgs...) } t.FailNow() } func hasErr(t *testing.T, err error, msgAndArgs ...any) { if err != nil { return } t.Log("Expected error but had none") if len(msgAndArgs) > 0 { t.Log(msgAndArgs...) } t.FailNow() } const defaultTestTimeout = time.Second * 10 func awaitServerStart(addr string, errId string) error { now := time.Now() for { if time.Since(now) > defaultTestTimeout { return fmt.Errorf("expected server to have started after %s: %s", defaultTestTimeout, errId) } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() req, err := http.NewRequestWithContext( ctx, http.MethodGet, addr, nil, ) if err != nil { return fmt.Errorf("could not create wait for server request: %w", err) } res, err := http.DefaultClient.Do(req) if err != nil || res.StatusCode != http.StatusOK { time.Sleep(30 * time.Millisecond) } else { break } } return nil } // https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-go func newWord(n int) string { sb := strings.Builder{} sb.Grow(n) // A src.Int63() generates 63 random bits, enough for letterIdxMax characters! for i, cache, remain := n-1, rand.Int64(), letterIdxMax; i >= 0; { if remain == 0 { cache, remain = rand.Int64(), letterIdxMax } if idx := int(cache & letterIdxMask); idx < len(letterBytes) { sb.WriteByte(letterBytes[idx]) i-- } cache >>= letterIdxBits remain-- } return sb.String() }