~evanj/cms

9d9f1d50551bc11469b5527d1a410b3f4110ecf8 — Evan M Jones 4 months ago 2f529fb
Feat(c/space): Adding more testing for space. Removing old/stale tests
(db and content).
M .gitignore => .gitignore +1 -0
@@ 2,3 2,4 @@
dev.db
cms
cms.sql
*.out

M TODO => TODO +1 -1
@@ 9,7 9,7 @@ Provide a Go API under git.sr.ht/~evanj/cms (move from
Add "after" pagination option.
In ContentSearch use transaction on ContentGet.
Make content create have name field be StringSmall, StringBig works for example (if cURLing).

At least 80% code coverage on entire repository.

[revisit] 
Fullscreen takeover for html/markdown editors.

M internal/c/content/content_test.go => internal/c/content/content_test.go +0 -82
@@ 1,83 1,1 @@
package content_test

import (
	"bytes"
	"io/ioutil"
	"log"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"os"
	"testing"

	"git.sr.ht/~evanj/cms/internal/c/content"
	"github.com/bmizerany/assert"
	gomock "github.com/golang/mock/gomock"
)

func TestContentNewNoAuth(t *testing.T) {
	t.Parallel()

	routes := []string{
		"/content/new",
		"/content/delete",
		"/content/search",
		"/content/update",
	}

	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	c := content.New(
		log.New(bytes.NewBufferString(os.DevNull), "", 0),
		content.NewMockDBer(ctrl),
		content.NewMockE3er(ctrl),
		content.NewMockHooker(ctrl),
	)

	for _, route := range routes {
		// Create body of post request.
		requestBody := bytes.Buffer{}
		formwriter := multipart.NewWriter(&requestBody)

		// Add a field.
		fieldwriter, _ := formwriter.CreateFormField("access")
		fieldwriter.Write([]byte("public"))
		defer formwriter.Close()

		// Send req.
		req, _ := http.NewRequest(http.MethodPost, route, &requestBody)

		// Read req.
		w := httptest.NewRecorder()
		c.ServeHTTP(w, req)
		resp := w.Result()
		bod, _ := ioutil.ReadAll(resp.Body)

		assert.Equal(t, content.ErrNoLogin.Error(), string(bod))
		assert.NotEqual(t, http.StatusOK, resp.StatusCode)
	}
}

func TestContentNotFound(t *testing.T) {
	t.Parallel()

	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	c := content.New(
		log.New(bytes.NewBufferString(os.DevNull), "", 0),
		content.NewMockDBer(ctrl),
		content.NewMockE3er(ctrl),
		content.NewMockHooker(ctrl),
	)

	// Send req.
	req, _ := http.NewRequest(http.MethodGet, "/content/whatever", nil)

	// Read req.
	w := httptest.NewRecorder()
	c.ServeHTTP(w, req)
	resp := w.Result()
	assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}

M internal/c/space/space.go => internal/c/space/space.go +9 -8
@@ 18,8 18,10 @@ import (
)

var (
	spaceHTML  = tmpl.MustParse("html/space.html")
	spaceHTML = tmpl.MustParse("html/space.html")

	ErrNoLogin = errors.New("must be logged in")
	ErrNoSpace = errors.New("failed to find space")
)

type Space struct {


@@ 57,13 59,13 @@ func (s *Space) serve(w http.ResponseWriter, r *http.Request) {

	user, err := s.GetCookieUser(w, r)
	if err != nil {
		s.Error2(w, r, http.StatusBadRequest, errors.Wrap(ErrNoLogin, "can't get space"))
		s.Error2(w, r, http.StatusBadRequest, ErrNoLogin)
		return
	}

	space, err := s.db.SpaceGet(user, spaceID)
	if err != nil {
		s.Error(w, r, http.StatusNotFound, "failed to find space")
		s.Error2(w, r, http.StatusNotFound, ErrNoSpace)
		return
	}



@@ 102,8 104,7 @@ func (s *Space) create(w http.ResponseWriter, r *http.Request) {

	space, err := s.db.SpaceNew(user, name, desc)
	if err != nil {
		s.log.Println(err)
		s.Error(w, r, http.StatusBadRequest, err.Error())
		s.Error2(w, r, http.StatusBadRequest, err)
		return
	}



@@ 125,7 126,7 @@ func (s *Space) copy(w http.ResponseWriter, r *http.Request) {

	spacePrev, err := s.db.SpaceGet(user, spaceID)
	if err != nil {
		s.Error(w, r, http.StatusNotFound, "failed to find space")
		s.Error2(w, r, http.StatusNotFound, ErrNoSpace)
		return
	}



@@ 154,7 155,7 @@ func (s *Space) update(w http.ResponseWriter, r *http.Request) {

	prev, err := s.db.SpaceGet(user, spaceID)
	if err != nil {
		s.Error(w, r, http.StatusNotFound, "failed to find space")
		s.Error2(w, r, http.StatusNotFound, ErrNoSpace)
		return
	}



@@ 181,7 182,7 @@ func (s *Space) delete(w http.ResponseWriter, r *http.Request) {

	space, err := s.db.SpaceGet(user, spaceID)
	if err != nil {
		s.Error(w, r, http.StatusNotFound, "failed to find space")
		s.Error2(w, r, http.StatusNotFound, ErrNoSpace)
		return
	}


M internal/c/space/space_test.go => internal/c/space/space_test.go +191 -89
@@ 91,7 91,7 @@ func TestNoUser(t *testing.T) {
	}
}

func TestHappyPathAll(t *testing.T) {
func TestAll(t *testing.T) {
	t.Parallel()

	var (


@@ 113,101 113,203 @@ func TestHappyPathAll(t *testing.T) {

		contentTypeList = fakecontenttypelist()
		hookList        = fakehooklist()

		ctrl = gomock.NewController(t)
		db   = mock_space.NewMockdber(ctrl)
		l    = log.New(bytes.NewBufferString(os.DevNull), "", 0)
		s    = space.New(l, db)
		ts   = httptest.NewServer(s)
	)

	// Create
	{
		db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
		db.EXPECT().SpaceNew(uItem, sname, sdesc).Return(sItem, nil).AnyTimes()

		req, _ := newrequest("POST", ts.URL, url.Values{"name": []string{sname}, "desc": []string{sdesc}})
		req.SetBasicAuth(uname, upass)
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		req.Header.Set("Accept", "text/html")

		res, _ := http.DefaultClient.Do(req)
		assert.Equal(t, res.StatusCode, http.StatusTemporaryRedirect)
	}

	// Get
	{
		db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
		db.EXPECT().SpaceGet(uItem, sItem.ID()).Return(sItem, nil).AnyTimes()
		db.EXPECT().ContentTypesPerSpace(sItem, 0).Return(contentTypeList, nil).AnyTimes()
		db.EXPECT().HooksPerSpace(sItem, 0).Return(hookList, nil).AnyTimes()

		req, _ := newrequest("GET", ts.URL, url.Values{"space": []string{sItem.ID()}})
		req.SetBasicAuth(uname, upass)
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		req.Header.Set("Accept", "text/html")

		res, _ := http.DefaultClient.Do(req)
		assert.Equal(t, res.StatusCode, http.StatusOK)
	}

	// Update
	{
		db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
		db.EXPECT().SpaceGet(uItem, sItem.ID()).Return(sItem, nil).AnyTimes()
		db.EXPECT().SpaceUpdate(uItem, sItem, snameUpdate, sdescUpdate).Return(sItemUpdate, nil).AnyTimes()

		req, _ := newrequest("PATCH", ts.URL,
			url.Values{
				"space": []string{sItem.ID()},
				"name":  []string{snameUpdate},
				"desc":  []string{sdescUpdate}})
	type SpaceTest struct {
		name string

		req.SetBasicAuth(uname, upass)
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		req.Header.Set("Accept", "text/html")
		create func(*mock_space.Mockdber)
		get    func(*mock_space.Mockdber)
		update func(*mock_space.Mockdber)
		copy   func(*mock_space.Mockdber)
		delete func(*mock_space.Mockdber)

		res, _ := http.DefaultClient.Do(req)
		assert.Equal(t, res.StatusCode, http.StatusTemporaryRedirect)
		createSC int
		getSC    int
		updateSC int
		copySC   int
		deleteSC int
	}

	// Copy
	{

		db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
		db.EXPECT().SpaceGet(uItem, sItemUpdate.ID()).Return(sItemUpdate, nil).AnyTimes()
		db.EXPECT().SpaceCopy(uItem, sItemUpdate, snameCopy, sdescCopy).Return(sItemCopy, nil).AnyTimes()

		req, _ := newrequest("PUT", ts.URL,
			url.Values{
				"space": []string{sItemUpdate.ID()},
				"name":  []string{snameCopy},
				"desc":  []string{sdescCopy}})
		req.SetBasicAuth(uname, upass)
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		req.Header.Set("Accept", "text/html")

		res, _ := http.DefaultClient.Do(req)
		assert.Equal(t, res.StatusCode, http.StatusTemporaryRedirect)
	tests := []SpaceTest{

		SpaceTest{
			name:     "TestAllHappyPath",
			createSC: http.StatusTemporaryRedirect,
			create: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
				db.EXPECT().SpaceNew(uItem, sname, sdesc).Return(sItem, nil).AnyTimes()
			},
			getSC: http.StatusOK,
			get: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
				db.EXPECT().SpaceGet(uItem, sItem.ID()).Return(sItem, nil).AnyTimes()
				db.EXPECT().ContentTypesPerSpace(sItem, 0).Return(contentTypeList, nil).AnyTimes()
				db.EXPECT().HooksPerSpace(sItem, 0).Return(hookList, nil).AnyTimes()
			},
			updateSC: http.StatusTemporaryRedirect,
			update: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
				db.EXPECT().SpaceGet(uItem, sItem.ID()).Return(sItem, nil).AnyTimes()
				db.EXPECT().SpaceUpdate(uItem, sItem, snameUpdate, sdescUpdate).Return(sItemUpdate, nil).AnyTimes()
			},
			copySC: http.StatusTemporaryRedirect,
			copy: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
				db.EXPECT().SpaceGet(uItem, sItemUpdate.ID()).Return(sItemUpdate, nil).AnyTimes()
				db.EXPECT().SpaceCopy(uItem, sItemUpdate, snameCopy, sdescCopy).Return(sItemCopy, nil).AnyTimes()
			},
			deleteSC: http.StatusTemporaryRedirect,
			delete: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
				db.EXPECT().SpaceGet(uItem, sItem.ID()).Return(sItem, nil).AnyTimes()
				db.EXPECT().SpaceDelete(uItem, sItem).Return(nil).AnyTimes()
			},
		},

		SpaceTest{
			name:     "TestAllBadUser",
			createSC: http.StatusBadRequest,
			create: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(nil, space.ErrNoLogin).AnyTimes()
			},
			getSC: http.StatusBadRequest,
			get: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
			},
			updateSC: http.StatusBadRequest,
			update: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
			},
			copySC: http.StatusBadRequest,
			copy: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
			},
			deleteSC: http.StatusBadRequest,
			delete: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
			},
		},

		SpaceTest{
			name:     "TestAllBadSpace",
			createSC: http.StatusBadRequest,
			create: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
				db.EXPECT().SpaceNew(uItem, sname, sdesc).Return(nil, space.ErrNoSpace).AnyTimes()
			},
			getSC: http.StatusNotFound,
			get: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
				db.EXPECT().SpaceGet(uItem, sItem.ID()).Return(nil, space.ErrNoSpace).AnyTimes()
			},
			updateSC: http.StatusNotFound,
			update: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
				db.EXPECT().SpaceGet(uItem, sItem.ID()).Return(nil, space.ErrNoSpace).AnyTimes()
			},
			copySC: http.StatusNotFound,
			copy: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
				db.EXPECT().SpaceGet(uItem, sItemUpdate.ID()).Return(nil, space.ErrNoSpace).AnyTimes()
			},
			deleteSC: http.StatusNotFound,
			delete: func(db *mock_space.Mockdber) {
				db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
				db.EXPECT().SpaceGet(uItem, sItem.ID()).Return(nil, space.ErrNoSpace).AnyTimes()
			},
		},
	}

	// Delete
	{
		db.EXPECT().UserGet(uname, upass).Return(uItem, nil).AnyTimes()
		db.EXPECT().SpaceGet(uItem, sItem.ID()).Return(sItem, nil).AnyTimes()
		db.EXPECT().SpaceDelete(uItem, sItem).Return(nil).AnyTimes()

		req, _ := newrequest("DELETE", ts.URL,
			url.Values{
				"space":  []string{sItem.ID()},
				"method": []string{"DELETE"},
			})

		req.SetBasicAuth(uname, upass)
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		req.Header.Set("Accept", "text/html")

		res, _ := http.DefaultClient.Do(req)
		assert.Equal(t, res.StatusCode, http.StatusTemporaryRedirect)
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {

			var (
				ctrl = gomock.NewController(t)
				db   = mock_space.NewMockdber(ctrl)
				l    = log.New(bytes.NewBufferString(os.DevNull), "", 0)
				s    = space.New(l, db)
				ts   = httptest.NewServer(s)
			)

			// Create
			{
				test.create(db)

				req, _ := newrequest("POST", ts.URL, url.Values{"name": []string{sname}, "desc": []string{sdesc}})
				req.SetBasicAuth(uname, upass)
				req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
				req.Header.Set("Accept", "text/html")

				res, _ := http.DefaultClient.Do(req)
				assert.Equal(t, res.StatusCode, test.createSC)
			}

			// Get
			{
				test.get(db)

				req, _ := newrequest("GET", ts.URL, url.Values{"space": []string{sItem.ID()}})
				req.SetBasicAuth(uname, upass)
				req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
				req.Header.Set("Accept", "text/html")

				res, _ := http.DefaultClient.Do(req)
				assert.Equal(t, res.StatusCode, test.getSC)
			}

			// Update
			{
				test.update(db)

				req, _ := newrequest("PATCH", ts.URL,
					url.Values{
						"space": []string{sItem.ID()},
						"name":  []string{snameUpdate},
						"desc":  []string{sdescUpdate}})

				req.SetBasicAuth(uname, upass)
				req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
				req.Header.Set("Accept", "text/html")

				res, _ := http.DefaultClient.Do(req)
				assert.Equal(t, res.StatusCode, test.updateSC)
			}

			// Copy
			{
				test.copy(db)

				req, _ := newrequest("PUT", ts.URL,
					url.Values{
						"space": []string{sItemUpdate.ID()},
						"name":  []string{snameCopy},
						"desc":  []string{sdescCopy}})
				req.SetBasicAuth(uname, upass)
				req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
				req.Header.Set("Accept", "text/html")

				res, _ := http.DefaultClient.Do(req)
				assert.Equal(t, res.StatusCode, test.copySC)
			}

			// Delete
			{
				test.delete(db)

				req, _ := newrequest("DELETE", ts.URL,
					url.Values{
						"space":  []string{sItem.ID()},
						"method": []string{"DELETE"},
					})

				req.SetBasicAuth(uname, upass)
				req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
				req.Header.Set("Accept", "text/html")

				res, _ := http.DefaultClient.Do(req)
				assert.Equal(t, res.StatusCode, test.deleteSC)
			}
		})
	}
}

M internal/s/db/db_test.go => internal/s/db/db_test.go +0 -151
@@ 1,152 1,1 @@
package db_test

import (
	"fmt"
	"log"
	"os"
	"strings"
	"testing"

	"git.sr.ht/~evanj/cms/internal/m/content"
	"git.sr.ht/~evanj/cms/internal/m/contenttype"
	"git.sr.ht/~evanj/cms/internal/m/space"
	"git.sr.ht/~evanj/cms/internal/m/valuetype"
	"git.sr.ht/~evanj/cms/internal/s/db"
	"git.sr.ht/~evanj/security"
	"github.com/bmizerany/assert"
)

var conn, dberr = setup()

func setup() (*db.DB, error) {
	dbname := os.Getenv("TEST_DB_NAME")
	conn, dberr := db.New(
		log.New(os.Stdout, "", 0),
		os.Getenv("TEST_DBTYPE"),
		os.Getenv("TEST_DB"),
		security.Default(os.Getenv("TEST_SECRET")),
	)

	// Get over it. It's a test databse.
	conn.Exec(fmt.Sprintf(`DROP DATABASE %s`, dbname))
	conn.Exec(fmt.Sprintf(`CREATE DATABASE %s`, dbname))
	conn.Exec(fmt.Sprintf(`USE %s`, dbname))
	return conn, dberr
}

func TestBasic(t *testing.T) {
	t.Parallel()

	// Create tables
	assert.Equal(t, nil, dberr)
	assert.Equal(t, nil, conn.EnsureSetup())

	// Create user
	user, err := conn.UserNew("tester", "passer", "passer")
	assert.Equal(t, nil, err)
	assert.Equal(t, "tester", user.Name())

	// Create space
	space, err := conn.SpaceNew(user, "spacer", "desc")
	assert.Equal(t, nil, err)
	assert.Equal(t, "spacer", space.Name())
	assert.Equal(t, "desc", space.Desc())

	// Create contenttype
	ct1, err := conn.ContentTypeNew(space, "blogger", []db.ContentTypeNewParam{
		db.ContentTypeNewParam{"name", valuetype.StringSmall},
		db.ContentTypeNewParam{"slug", valuetype.StringSmall},
		db.ContentTypeNewParam{"desc", valuetype.StringBig},
	})
	assert.Equal(t, nil, err)
	assert.Equal(t, "blogger", ct1.Name())

	// Create content of "blogger"
	c1, err := conn.ContentNew(space, ct1, []db.ContentNewParam{
		db.ContentNewParam{valuetype.StringSmall, "name", "content1"},
		db.ContentNewParam{valuetype.StringSmall, "slug", "content-1"},
		db.ContentNewParam{valuetype.StringBig, "desc", "long-desc"},
	})
	assert.Equal(t, nil, err)
	assert.Equal(t, ct1.ID(), c1.Type())
	assert.Equal(t, 3, len(c1.Values()))
	assert.Equal(t, "content1", c1.MustValueByName("name").Value())
	assert.Equal(t, "content-1", c1.MustValueByName("slug").Value())
	assert.Equal(t, "long-desc", c1.MustValueByName("desc").Value())

	// Create content of "blogger"
	c2, err := conn.ContentNew(space, ct1, []db.ContentNewParam{
		db.ContentNewParam{valuetype.StringSmall, "name", "content2"},
		db.ContentNewParam{valuetype.StringSmall, "slug", "content-2"},
		db.ContentNewParam{valuetype.StringBig, "desc", "long-desc-2"},
	})
	assert.Equal(t, nil, err)
	assert.Equal(t, ct1.ID(), c2.Type())
	assert.Equal(t, 3, len(c2.Values()))
	assert.Equal(t, "content2", c2.MustValueByName("name").Value())
	assert.Equal(t, "content-2", c2.MustValueByName("slug").Value())
	assert.Equal(t, "long-desc-2", c2.MustValueByName("desc").Value())

	// Create content type "category" with ref to "blogger"
	ct2, err := conn.ContentTypeNew(space, "category", []db.ContentTypeNewParam{
		db.ContentTypeNewParam{"name", valuetype.StringSmall},
		db.ContentTypeNewParam{"blog list", valuetype.ReferenceList},
	})
	assert.Equal(t, nil, err)
	assert.Equal(t, "category", ct2.Name())

	// Create content of "category"
	c3, err := conn.ContentNew(space, ct2, []db.ContentNewParam{
		db.ContentNewParam{valuetype.StringSmall, "name", "category1"},
		// A string of content IDs seperated by "-" (dash).
		db.ContentNewParam{valuetype.ReferenceList, "blog list", strings.Join([]string{c1.ID(), c2.ID()}, "-")},
	})
	assert.Equal(t, nil, err)
	assert.Equal(t, ct2.ID(), c3.Type())
	assert.Equal(t, 2, len(c3.Values()))
	assert.Equal(t, "category1", c3.MustValueByName("name").Value())
	assert.Equal(t, 2, len(c3.MustValueByName("blog list").RefListIDs()))

	// Delete one of the referenced types.
	err = conn.ContentDelete(space, ct1, c1)
	assert.Equal(t, nil, err)
	isdeleted(t, space, ct1, c1)

	// Fetch the content with references.
	c4, err := conn.ContentGet(space, ct2, c3.ID())
	assert.Equal(t, nil, err)
	assert.Equal(t, ct2.ID(), c4.Type())
	assert.Equal(t, 2, len(c4.Values()))
	assert.Equal(t, "category1", c4.MustValueByName("name").Value())
	assert.Equal(t, 1, len(c4.MustValueByName("blog list").RefListIDs()))

	// Delete the content with references
	err = conn.ContentDelete(space, ct2, c4)
	assert.Equal(t, nil, err)
	isdeleted(t, space, ct2, c4)

	// Fetch a content that still exists and was referenced.
	c5, err := conn.ContentGet(space, ct1, c2.ID())
	assert.Equal(t, nil, err)
	assert.Equal(t, ct1.ID(), c5.Type())
	assert.Equal(t, 3, len(c5.Values()))
	assert.Equal(t, "content2", c5.MustValueByName("name").Value())
	assert.Equal(t, "content-2", c5.MustValueByName("slug").Value())
	assert.Equal(t, "long-desc-2", c5.MustValueByName("desc").Value())

	err = conn.ContentTypeDelete(space, ct1)
	assert.Equal(t, nil, err)

	err = conn.SpaceDelete(space)
	assert.Equal(t, nil, err)

	// Now, make sure we space's CTs are deleted
	_, err = conn.ContentTypeGet(space, ct2.ID())
	assert.NotEqual(t, nil, err)
}

func isdeleted(t *testing.T, s space.Space, ct contenttype.ContentType, c content.Content) {
	c, err := conn.ContentGet(s, ct, c.ID())
	assert.NotEqual(t, nil, err)
	assert.Equal(t, nil, c)
}

M internal/s/hook/hook.go => internal/s/hook/hook.go +6 -5
@@ 79,15 79,16 @@ func (h *Hook) do(ctx context.Context, content content.Content, hook hook.Hook, 

func (h *Hook) Do(space space.Space, content content.Content, ht HookType) {
	var (
		hooks  hook.HookList
		before int
		err    error
		eg     errgroup.Group
		ctx, _ = context.WithTimeout(
		hooks       hook.HookList
		before      int
		err         error
		eg          errgroup.Group
		ctx, cancel = context.WithTimeout(
			context.Background(),
			10*time.Second, // TODO: May want to lower?
		)
	)
	defer cancel()

	for i := 0; i == 0 || hooks.More(); i++ {
		hooks, err = h.db.HooksPerSpace(space, before)

M internal/s/tmpl/html/_header.html => internal/s/tmpl/html/_header.html +1 -1
@@ 41,7 41,7 @@
            </form>
          </li>
        {{ end}}
        <li class='nav-item'><a class='nav-link' href='/doc'>API Documentation</a></li>
        <li class='nav-item'><a class='nav-link' href='/doc'>Docs</a></li>
        <li class='nav-item'><a class='nav-link' href='//git.sr.ht/~evanj/cms'>Source</a></li>
      </ul>
    </div>

M internal/s/tmpl/tmpls_embed.go => internal/s/tmpl/tmpls_embed.go +1 -1
@@ 24,7 24,7 @@ func init() {

	tmpls["html/_head.html"] = tostring("PG1ldGEgY2hhcnNldD0ndXRmLTgnPgo8bWV0YSBuYW1lPSd2aWV3cG9ydCcgY29udGVudD0nd2lkdGg9ZGV2aWNlLXdpZHRoLCBpbml0aWFsLXNjYWxlPTEnPgo8bGluayByZWw9J2ljb24nIHR5cGU9J2ltYWdlL3gtaWNvbicgaHJlZj0naHR0cHM6Ly9mYXZpY29uLmV2YW5qb24uZXMvMC8xMDUvMjE3LzMyL2Zhdmljb24uaWNvJyAvPgo8bGluayByZWw9J3N0eWxlc2hlZXQnIGhyZWY9Jy9zdGF0aWMvY3NzL2Jvb3RzdHJhcC5taW4uY3NzJyAvPgo=")

	tmpls["html/_header.html"] = tostring("PGhlYWRlciBjbGFzcz0nYmctcHJpbWFyeSc+CiAgPG5hdiBjbGFzcz0nY29udGFpbmVyIG5hdmJhciBuYXZiYXItZXhwYW5kLWxnIG5hdmJhci1kYXJrJz4KICAgIDxhIGNsYXNzPSduYXZiYXItYnJhbmQnIGhyZWY9Jy8nPkNNUzwvYT4KICAgIDxidXR0b24gY2xhc3M9J25hdmJhci10b2dnbGVyJyB0eXBlPSdidXR0b24nIGRhdGEtdG9nZ2xlPSdjb2xsYXBzZScgZGF0YS10YXJnZXQ9JyNuYXZiYXJTdXBwb3J0ZWRDb250ZW50JyBhcmlhLWNvbnRyb2xzPSduYXZiYXJTdXBwb3J0ZWRDb250ZW50JyBhcmlhLWV4cGFuZGVkPSdmYWxzZScgYXJpYS1sYWJlbD0nVG9nZ2xlIG5hdmlnYXRpb24nPgogICAgICA8c3BhbiBjbGFzcz0nbmF2YmFyLXRvZ2dsZXItaWNvbic+PC9zcGFuPgogICAgPC9idXR0b24+CiAgICA8ZGl2IGNsYXNzPSdjb2xsYXBzZSBuYXZiYXItY29sbGFwc2UnIGlkPSduYXZiYXJTdXBwb3J0ZWRDb250ZW50Jz4KICAgICAgPHVsIGNsYXNzPSduYXZiYXItbmF2IG1sLWF1dG8nPgogICAgICAgIHt7IGlmIC5TcGFjZSB9fQogICAgICAgIDxsaSBjbGFzcz0nbmF2LWl0ZW0nPjxhIGNsYXNzPSduYXYtbGluaycgaHJlZj0nLyc+SG9tZTwvYT48L2xpPgogICAgICAgIHt7IGVuZCB9fQogICAgICAgIHt7IGlmIC5Db250ZW50VHlwZSB9fQogICAgICAgIDxsaSBjbGFzcz0nbmF2LWl0ZW0nPjxhIGNsYXNzPSduYXYtbGluaycgaHJlZj0nL3NwYWNlL3t7IC5TcGFjZS5JRCB9fSc+e3sgLlNwYWNlLk5hbWUgfX08L2E+PC9saT4KICAgICAgICB7eyBlbmQgfX0KICAgICAgICB7eyBpZiAuSG9vayB9fQogICAgICAgIDxsaSBjbGFzcz0nbmF2LWl0ZW0nPjxhIGNsYXNzPSduYXYtbGluaycgaHJlZj0nL3NwYWNlL3t7IC5TcGFjZS5JRCB9fSc+e3sgLlNwYWNlLk5hbWUgfX08L2E+PC9saT4KICAgICAgICB7eyBlbmQgfX0KICAgICAgICB7eyBpZiAuQ29udGVudCB9fQogICAgICAgIDxsaSBjbGFzcz0nbmF2LWl0ZW0nPjxhIGNsYXNzPSduYXYtbGluaycgaHJlZj0nL2NvbnRlbnR0eXBlL3t7IC5TcGFjZS5JRH19L3t7IC5Db250ZW50VHlwZS5JRCB9fSc+e3sgLkNvbnRlbnRUeXBlLk5hbWUgfX08L2E+PC9saT4KICAgICAgICB7eyBlbmQgfX0KICAgICAgICB7eyBpZiBhbmQgLlNwYWNlIChub3QgLkNvbnRlbnRUeXBlKSAobm90IC5Ib29rKSB9fQogICAgICAgICAgPGxpIGNsYXNzPSduYXYtaXRlbSc+PGEgZGF0YS10b2dnbGU9Im1vZGFsIiBkYXRhLXRhcmdldD0iI2NvcHlNb2RhbCIgY2xhc3M9J25hdi1saW5rJyBocmVmPScjJz5Db3B5PC9hPjwvbGk+CiAgICAgICAgICA8bGkgY2xhc3M9J25hdi1pdGVtJz48YSBkYXRhLXRvZ2dsZT0ibW9kYWwiIGRhdGEtdGFyZ2V0PSIjdXBkYXRlTW9kYWwiIGNsYXNzPSduYXYtbGluaycgaHJlZj0nIyc+VXBkYXRlPC9hPjwvbGk+CiAgICAgICAgICA8bGkgY2xhc3M9J25hdi1pdGVtJz48YSBkYXRhLXRvZ2dsZT0ibW9kYWwiIGRhdGEtdGFyZ2V0PSIjZGVsZXRlTW9kYWwiIGNsYXNzPSduYXYtbGluaycgaHJlZj0nIyc+RGVsZXRlPC9hPjwvbGk+CiAgICAgICAge3sgZW5kIH19CiAgICAgICAge3sgaWYgYW5kIC5Db250ZW50VHlwZSAobm90IC5Db250ZW50KSB9fQogICAgICAgICAgPGxpIGNsYXNzPSduYXYtaXRlbSc+PGEgZGF0YS10b2dnbGU9Im1vZGFsIiBkYXRhLXRhcmdldD0iI3VwZGF0ZU1vZGFsIiBjbGFzcz0nbmF2LWxpbmsnIGhyZWY9JyMnPlVwZGF0ZTwvYT48L2xpPgogICAgICAgICAgPGxpIGNsYXNzPSduYXYtaXRlbSc+PGEgZGF0YS10b2dnbGU9Im1vZGFsIiBkYXRhLXRhcmdldD0iI2RlbGV0ZU1vZGFsIiBjbGFzcz0nbmF2LWxpbmsnIGhyZWY9JyMnPkRlbGV0ZTwvYT48L2xpPgogICAgICAgIHt7IGVuZCB9fQogICAgICAgIHt7IGlmIC5Db250ZW50IH19CiAgICAgICAgICA8bGkgY2xhc3M9J25hdi1pdGVtJz48aW5wdXQgdHlwZT1zdWJtaXQgY2xhc3M9ImJ0biBidG4tbGluayBuYXYtbGluayBib3JkZXItMCIgdmFsdWU9U2F2ZSAvPjwvbGk+CiAgICAgICAgICA8bGkgY2xhc3M9J25hdi1pdGVtJz48YSBkYXRhLXRvZ2dsZT0ibW9kYWwiIGRhdGEtdGFyZ2V0PSIjZGVsZXRlTW9kYWwiIGNsYXNzPSduYXYtbGluaycgaHJlZj0nIyc+RGVsZXRlPC9hPjwvbGk+CiAgICAgICAge3sgZW5kIH19CiAgICAgICAge3sgaWYgLkhvb2sgfX0KICAgICAgICAgIDxsaSBjbGFzcz0nbmF2LWl0ZW0nPjxhIGRhdGEtdG9nZ2xlPSJtb2RhbCIgZGF0YS10YXJnZXQ9IiNkZWxldGVNb2RhbCIgY2xhc3M9J25hdi1saW5rJyBocmVmPScjJz5EZWxldGU8L2E+PC9saT4KICAgICAgICB7eyBlbmQgfX0KICAgICAgICB7eyBpZiAuVXNlciB9fQogICAgICAgICAgPGxpIGNsYXNzPSduYXYtaXRlbSc+CiAgICAgICAgICAgIDxmb3JtIG1ldGhvZD1QT1NUIGFjdGlvbj0nL3VzZXIvbG9nb3V0JyBlbmN0eXBlPSdtdWx0aXBhcnQvZm9ybS1kYXRhJz4KICAgICAgICAgICAgICA8aW5wdXQgdHlwZT1zdWJtaXQgY2xhc3M9ImJ0biBidG4tbGluayBuYXYtbGluayBib3JkZXItMCIgdmFsdWU9TG9nb3V0IC8+CiAgICAgICAgICAgIDwvZm9ybT4KICAgICAgICAgIDwvbGk+CiAgICAgICAge3sgZW5kfX0KICAgICAgICA8bGkgY2xhc3M9J25hdi1pdGVtJz48YSBjbGFzcz0nbmF2LWxpbmsnIGhyZWY9Jy9kb2MnPkFQSSBEb2N1bWVudGF0aW9uPC9hPjwvbGk+CiAgICAgICAgPGxpIGNsYXNzPSduYXYtaXRlbSc+PGEgY2xhc3M9J25hdi1saW5rJyBocmVmPScvL2dpdC5zci5odC9+ZXZhbmovY21zJz5Tb3VyY2U8L2E+PC9saT4KICAgICAgPC91bD4KICAgIDwvZGl2PgogIDwvbmF2Pgo8L2hlYWRlcj4K")
	tmpls["html/_header.html"] = tostring("PGhlYWRlciBjbGFzcz0nYmctcHJpbWFyeSc+CiAgPG5hdiBjbGFzcz0nY29udGFpbmVyIG5hdmJhciBuYXZiYXItZXhwYW5kLWxnIG5hdmJhci1kYXJrJz4KICAgIDxhIGNsYXNzPSduYXZiYXItYnJhbmQnIGhyZWY9Jy8nPkNNUzwvYT4KICAgIDxidXR0b24gY2xhc3M9J25hdmJhci10b2dnbGVyJyB0eXBlPSdidXR0b24nIGRhdGEtdG9nZ2xlPSdjb2xsYXBzZScgZGF0YS10YXJnZXQ9JyNuYXZiYXJTdXBwb3J0ZWRDb250ZW50JyBhcmlhLWNvbnRyb2xzPSduYXZiYXJTdXBwb3J0ZWRDb250ZW50JyBhcmlhLWV4cGFuZGVkPSdmYWxzZScgYXJpYS1sYWJlbD0nVG9nZ2xlIG5hdmlnYXRpb24nPgogICAgICA8c3BhbiBjbGFzcz0nbmF2YmFyLXRvZ2dsZXItaWNvbic+PC9zcGFuPgogICAgPC9idXR0b24+CiAgICA8ZGl2IGNsYXNzPSdjb2xsYXBzZSBuYXZiYXItY29sbGFwc2UnIGlkPSduYXZiYXJTdXBwb3J0ZWRDb250ZW50Jz4KICAgICAgPHVsIGNsYXNzPSduYXZiYXItbmF2IG1sLWF1dG8nPgogICAgICAgIHt7IGlmIC5TcGFjZSB9fQogICAgICAgIDxsaSBjbGFzcz0nbmF2LWl0ZW0nPjxhIGNsYXNzPSduYXYtbGluaycgaHJlZj0nLyc+SG9tZTwvYT48L2xpPgogICAgICAgIHt7IGVuZCB9fQogICAgICAgIHt7IGlmIC5Db250ZW50VHlwZSB9fQogICAgICAgIDxsaSBjbGFzcz0nbmF2LWl0ZW0nPjxhIGNsYXNzPSduYXYtbGluaycgaHJlZj0nL3NwYWNlL3t7IC5TcGFjZS5JRCB9fSc+e3sgLlNwYWNlLk5hbWUgfX08L2E+PC9saT4KICAgICAgICB7eyBlbmQgfX0KICAgICAgICB7eyBpZiAuSG9vayB9fQogICAgICAgIDxsaSBjbGFzcz0nbmF2LWl0ZW0nPjxhIGNsYXNzPSduYXYtbGluaycgaHJlZj0nL3NwYWNlL3t7IC5TcGFjZS5JRCB9fSc+e3sgLlNwYWNlLk5hbWUgfX08L2E+PC9saT4KICAgICAgICB7eyBlbmQgfX0KICAgICAgICB7eyBpZiAuQ29udGVudCB9fQogICAgICAgIDxsaSBjbGFzcz0nbmF2LWl0ZW0nPjxhIGNsYXNzPSduYXYtbGluaycgaHJlZj0nL2NvbnRlbnR0eXBlL3t7IC5TcGFjZS5JRH19L3t7IC5Db250ZW50VHlwZS5JRCB9fSc+e3sgLkNvbnRlbnRUeXBlLk5hbWUgfX08L2E+PC9saT4KICAgICAgICB7eyBlbmQgfX0KICAgICAgICB7eyBpZiBhbmQgLlNwYWNlIChub3QgLkNvbnRlbnRUeXBlKSAobm90IC5Ib29rKSB9fQogICAgICAgICAgPGxpIGNsYXNzPSduYXYtaXRlbSc+PGEgZGF0YS10b2dnbGU9Im1vZGFsIiBkYXRhLXRhcmdldD0iI2NvcHlNb2RhbCIgY2xhc3M9J25hdi1saW5rJyBocmVmPScjJz5Db3B5PC9hPjwvbGk+CiAgICAgICAgICA8bGkgY2xhc3M9J25hdi1pdGVtJz48YSBkYXRhLXRvZ2dsZT0ibW9kYWwiIGRhdGEtdGFyZ2V0PSIjdXBkYXRlTW9kYWwiIGNsYXNzPSduYXYtbGluaycgaHJlZj0nIyc+VXBkYXRlPC9hPjwvbGk+CiAgICAgICAgICA8bGkgY2xhc3M9J25hdi1pdGVtJz48YSBkYXRhLXRvZ2dsZT0ibW9kYWwiIGRhdGEtdGFyZ2V0PSIjZGVsZXRlTW9kYWwiIGNsYXNzPSduYXYtbGluaycgaHJlZj0nIyc+RGVsZXRlPC9hPjwvbGk+CiAgICAgICAge3sgZW5kIH19CiAgICAgICAge3sgaWYgYW5kIC5Db250ZW50VHlwZSAobm90IC5Db250ZW50KSB9fQogICAgICAgICAgPGxpIGNsYXNzPSduYXYtaXRlbSc+PGEgZGF0YS10b2dnbGU9Im1vZGFsIiBkYXRhLXRhcmdldD0iI3VwZGF0ZU1vZGFsIiBjbGFzcz0nbmF2LWxpbmsnIGhyZWY9JyMnPlVwZGF0ZTwvYT48L2xpPgogICAgICAgICAgPGxpIGNsYXNzPSduYXYtaXRlbSc+PGEgZGF0YS10b2dnbGU9Im1vZGFsIiBkYXRhLXRhcmdldD0iI2RlbGV0ZU1vZGFsIiBjbGFzcz0nbmF2LWxpbmsnIGhyZWY9JyMnPkRlbGV0ZTwvYT48L2xpPgogICAgICAgIHt7IGVuZCB9fQogICAgICAgIHt7IGlmIC5Db250ZW50IH19CiAgICAgICAgICA8bGkgY2xhc3M9J25hdi1pdGVtJz48aW5wdXQgdHlwZT1zdWJtaXQgY2xhc3M9ImJ0biBidG4tbGluayBuYXYtbGluayBib3JkZXItMCIgdmFsdWU9U2F2ZSAvPjwvbGk+CiAgICAgICAgICA8bGkgY2xhc3M9J25hdi1pdGVtJz48YSBkYXRhLXRvZ2dsZT0ibW9kYWwiIGRhdGEtdGFyZ2V0PSIjZGVsZXRlTW9kYWwiIGNsYXNzPSduYXYtbGluaycgaHJlZj0nIyc+RGVsZXRlPC9hPjwvbGk+CiAgICAgICAge3sgZW5kIH19CiAgICAgICAge3sgaWYgLkhvb2sgfX0KICAgICAgICAgIDxsaSBjbGFzcz0nbmF2LWl0ZW0nPjxhIGRhdGEtdG9nZ2xlPSJtb2RhbCIgZGF0YS10YXJnZXQ9IiNkZWxldGVNb2RhbCIgY2xhc3M9J25hdi1saW5rJyBocmVmPScjJz5EZWxldGU8L2E+PC9saT4KICAgICAgICB7eyBlbmQgfX0KICAgICAgICB7eyBpZiAuVXNlciB9fQogICAgICAgICAgPGxpIGNsYXNzPSduYXYtaXRlbSc+CiAgICAgICAgICAgIDxmb3JtIG1ldGhvZD1QT1NUIGFjdGlvbj0nL3VzZXIvbG9nb3V0JyBlbmN0eXBlPSdtdWx0aXBhcnQvZm9ybS1kYXRhJz4KICAgICAgICAgICAgICA8aW5wdXQgdHlwZT1zdWJtaXQgY2xhc3M9ImJ0biBidG4tbGluayBuYXYtbGluayBib3JkZXItMCIgdmFsdWU9TG9nb3V0IC8+CiAgICAgICAgICAgIDwvZm9ybT4KICAgICAgICAgIDwvbGk+CiAgICAgICAge3sgZW5kfX0KICAgICAgICA8bGkgY2xhc3M9J25hdi1pdGVtJz48YSBjbGFzcz0nbmF2LWxpbmsnIGhyZWY9Jy9kb2MnPkRvY3M8L2E+PC9saT4KICAgICAgICA8bGkgY2xhc3M9J25hdi1pdGVtJz48YSBjbGFzcz0nbmF2LWxpbmsnIGhyZWY9Jy8vZ2l0LnNyLmh0L35ldmFuai9jbXMnPlNvdXJjZTwvYT48L2xpPgogICAgICA8L3VsPgogICAgPC9kaXY+CiAgPC9uYXY+CjwvaGVhZGVyPgo=")

	tmpls["html/_scripts.html"] = tostring("PHNjcmlwdCBzcmM9Jy9zdGF0aWMvanMvcG9wcGVyLm1pbi5qcyc+PC9zY3JpcHQ+CjxzY3JpcHQgc3JjPScvc3RhdGljL2pzL2Jvb3RzdHJhcC5taW4uanMnPjwvc2NyaXB0Pgo=")


M makefile => makefile +13 -1
@@ 17,7 17,19 @@ gen:
	@go generate ./...

test: 
	@go test ./...
	@env $(ENV) go test ./...

coverage: 
	@env $(ENV) go test ./... -cover -coverprofile=coverage.out ; go tool cover -html=coverage.out

lint: 
	@env $(ENV) go vet ./...
	# TODO: This encounters a runtime error. Submit issue to golangci-lint?
	# Investigate?
	# @env $(ENV) golangci-lint run --no-config --issues-exit-code=0 \
	# --disable-all --enable=deadcode  --enable=gocyclo --enable=golint --enable=varcheck \
	# --enable=structcheck --enable=maligned --enable=errcheck --enable=dupl --enable=ineffassign \
	# --enable=interfacer --enable=unconvert --enable=goconst --enable=gosec --enable=megacheck

run: gen build
	@clear