Viewing:
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()
}