add end-to-end test for guestbook view and user registration

This commit is contained in:
yequari 2025-05-26 21:36:05 -07:00
parent 1983662216
commit d574dab3a7
11 changed files with 531 additions and 1 deletions

View File

@ -1,6 +1,7 @@
package main
import (
"fmt"
"net/http"
"testing"
@ -17,3 +18,43 @@ func TestPing(t *testing.T) {
assert.Equal(t, code, http.StatusOK)
assert.Equal(t, body, "OK")
}
func TestGetGuestbookView(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
tests := []struct {
name string
urlPath string
wantCode int
wantBody string
}{
{
name: "Valid id",
urlPath: fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(1)),
wantCode: http.StatusOK,
wantBody: "Guestbook for Example",
},
{
name: "Non-existent ID",
urlPath: fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(2)),
wantCode: http.StatusNotFound,
},
{
name: "String ID",
urlPath: "/websites/abcd/guestbook",
wantCode: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, _, body := ts.get(t, tt.urlPath)
assert.Equal(t, code, tt.wantCode)
if tt.wantBody != "" {
assert.StringContains(t, body, tt.wantBody)
}
})
}
}

View File

@ -0,0 +1,121 @@
package main
import (
"net/http"
"net/url"
"testing"
"git.32bit.cafe/32bitcafe/guestbook/internal/assert"
)
func TestUserSignup(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
_, _, body := ts.get(t, "/users/register")
validCSRFToken := extractCSRFToken(t, body)
const (
validName = "John"
validPassword = "validPassword"
validEmail = "john@example.com"
formTag = `<form action="/users/register" method="post">`
)
tests := []struct {
name string
userName string
userEmail string
userPassword string
csrfToken string
wantCode int
wantFormTag string
}{
{
name: "Valid submission",
userName: validName,
userEmail: validEmail,
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusSeeOther,
},
{
name: "Missing token",
userName: validName,
userEmail: validEmail,
userPassword: validPassword,
wantCode: http.StatusBadRequest,
},
{
name: "Empty name",
userName: "",
userEmail: validEmail,
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Empty email",
userName: validName,
userEmail: "",
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Empty password",
userName: validName,
userEmail: validEmail,
userPassword: "",
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Invalid email",
userName: validName,
userEmail: "asdfasdf",
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Invalid password",
userName: validName,
userEmail: validEmail,
userPassword: "asdfasd",
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Duplicate email",
userName: validName,
userEmail: "dupe@example.com",
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
}
for _, tt := range tests {
t.Run(tt.name, func(*testing.T) {
form := url.Values{}
form.Add("username", tt.userName)
form.Add("email", tt.userEmail)
form.Add("password", tt.userPassword)
form.Add("csrf_token", tt.csrfToken)
code, _, body := ts.postForm(t, "/users/register", form)
assert.Equal(t, code, tt.wantCode)
if tt.wantFormTag != "" {
assert.StringContains(t, body, tt.wantFormTag)
}
})
}
}

View File

@ -2,17 +2,39 @@ package main
import (
"bytes"
"html"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"regexp"
"testing"
"time"
"git.32bit.cafe/32bitcafe/guestbook/internal/models/mocks"
"github.com/alexedwards/scs/v2"
"github.com/gorilla/schema"
)
func newTestApplication(t *testing.T) *application {
formDecoder := schema.NewDecoder()
formDecoder.IgnoreUnknownKeys(true)
sessionManager := scs.New()
sessionManager.Lifetime = 12 * time.Hour
sessionManager.Cookie.Secure = true
return &application{
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
sessionManager: sessionManager,
websites: &mocks.WebsiteModel{},
guestbooks: &mocks.GuestbookModel{},
users: &mocks.UserModel{},
guestbookComments: &mocks.GuestbookCommentModel{},
formDecoder: formDecoder,
timezones: getAvailableTimezones(),
}
}
@ -48,3 +70,28 @@ func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, strin
return rs.StatusCode, rs.Header, string(body)
}
func (ts *testServer) postForm(t *testing.T, urlPath string, form url.Values) (int, http.Header, string) {
rs, err := ts.Client().PostForm(ts.URL+urlPath, form)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
body = bytes.TrimSpace(body)
return rs.StatusCode, rs.Header, string(body)
}
var csrfTokenRX = regexp.MustCompile(`<input type="hidden" name="csrf_token" value="(.+?)">`)
func extractCSRFToken(t *testing.T, body string) string {
matches := csrfTokenRX.FindStringSubmatch(body)
if len(matches) < 2 {
t.Fatal("no csrf token found in body")
}
return html.UnescapeString(matches[1])
}

22
internal/assert/assert.go Normal file
View File

@ -0,0 +1,22 @@
package assert
import (
"strings"
"testing"
)
func Equal[T comparable](t *testing.T, actual, expected T) {
t.Helper()
if actual != expected {
t.Errorf("got: %v; want %v", actual, expected)
}
}
func StringContains(t *testing.T, actual, expectedSubstring string) {
t.Helper()
if !strings.Contains(actual, expectedSubstring) {
t.Errorf("got: %q; expected to contain: %q", actual, expectedSubstring)
}
}

View File

@ -24,6 +24,15 @@ type GuestbookCommentModel struct {
DB *sql.DB
}
type GuestbookCommentModelInterface interface {
Insert(shortId uint64, guestbookId, parentId int64, authorName, authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (int64, error)
Get(shortId uint64) (GuestbookComment, error)
GetAll(guestbookId int64) ([]GuestbookComment, error)
GetDeleted(guestbookId int64) ([]GuestbookComment, error)
GetUnpublished(guestbookId int64) ([]GuestbookComment, error)
UpdateComment(comment *GuestbookComment) error
}
func (m *GuestbookCommentModel) Insert(shortId uint64, guestbookId, parentId int64, authorName,
authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (int64, error) {
stmt := `INSERT INTO guestbook_comments (ShortId, GuestbookId, ParentId, AuthorName,

View File

@ -0,0 +1,66 @@
package mocks
import (
"time"
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
)
var mockGuestbook = models.Guestbook{
ID: 1,
ShortId: 1,
UserId: 1,
WebsiteId: 1,
Created: time.Now(),
IsActive: true,
Settings: models.GuestbookSettings{
IsCommentingEnabled: true,
IsVisible: true,
FilteredWords: make([]string, 0),
AllowRemoteHostAccess: true,
},
}
type GuestbookModel struct{}
func (m *GuestbookModel) InitializeSettingsMap() error {
return nil
}
func (m *GuestbookModel) Insert(shortId uint64, userId int64, websiteId int64, settings models.GuestbookSettings) (int64, error) {
return 2, nil
}
func (m *GuestbookModel) Get(shortId uint64) (models.Guestbook, error) {
switch shortId {
case 1:
return mockGuestbook, nil
default:
return models.Guestbook{}, models.ErrNoRecord
}
}
func (m *GuestbookModel) GetAll(userId int64) ([]models.Guestbook, error) {
switch userId {
case 1:
return []models.Guestbook{mockGuestbook}, nil
default:
return []models.Guestbook{}, models.ErrNoRecord
}
}
func (m *GuestbookModel) UpdateGuestbookSettings(guestbookId int64, settings models.GuestbookSettings) error {
return nil
}
func (m *GuestbookModel) UpdateSetting(guestbookId int64, setting models.Setting, value string) error {
return nil
}
func (m *GuestbookModel) AddFilteredWord(guestbookId int64, word string) error {
return nil
}
func (m *GuestbookModel) RemoveFilteredWord(guestbookId int64, word string) error {
return nil
}

View File

@ -0,0 +1,64 @@
package mocks
import (
"time"
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
)
var mockGuestbookComment = models.GuestbookComment{
ID: 1,
ShortId: 1,
GuestbookId: 1,
AuthorName: "John Test",
AuthorEmail: "test@example.com",
AuthorSite: "example.com",
CommentText: "Hello, world",
Created: time.Now(),
IsPublished: true,
}
type GuestbookCommentModel struct{}
func (m *GuestbookCommentModel) Insert(shortId uint64, guestbookId, parentId int64, authorName,
authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (int64, error) {
return 2, nil
}
func (m *GuestbookCommentModel) Get(shortId uint64) (models.GuestbookComment, error) {
switch shortId {
case 1:
return mockGuestbookComment, nil
default:
return models.GuestbookComment{}, models.ErrNoRecord
}
}
func (m *GuestbookCommentModel) GetAll(guestbookId int64) ([]models.GuestbookComment, error) {
switch guestbookId {
case 1:
return []models.GuestbookComment{mockGuestbookComment}, nil
case 2:
return []models.GuestbookComment{}, nil
default:
return []models.GuestbookComment{}, models.ErrNoRecord
}
}
func (m *GuestbookCommentModel) GetDeleted(guestbookId int64) ([]models.GuestbookComment, error) {
switch guestbookId {
default:
return []models.GuestbookComment{}, models.ErrNoRecord
}
}
func (m *GuestbookCommentModel) GetUnpublished(guestbookId int64) ([]models.GuestbookComment, error) {
switch guestbookId {
default:
return []models.GuestbookComment{}, models.ErrNoRecord
}
}
func (m *GuestbookCommentModel) UpdateComment(comment *models.GuestbookComment) error {
return nil
}

View File

@ -0,0 +1,89 @@
package mocks
import (
"time"
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
)
var mockUser = models.User{
ID: 1,
ShortId: 1,
Username: "tester",
Email: "test@example.com",
Deleted: false,
IsBanned: false,
Created: time.Now(),
Settings: mockUserSettings,
}
var mockUserSettings = models.UserSettings{
LocalTimezone: time.UTC,
}
type UserModel struct {
Settings map[string]models.Setting
}
func (m *UserModel) InitializeSettingsMap() error {
return nil
}
func (m *UserModel) Insert(shortId uint64, username string, email string, password string, settings models.UserSettings) error {
switch email {
case "dupe@example.com":
return models.ErrDuplicateEmail
default:
return nil
}
}
func (m *UserModel) Get(shortId uint64) (models.User, error) {
switch shortId {
case 1:
return mockUser, nil
default:
return models.User{}, models.ErrNoRecord
}
}
func (m *UserModel) GetById(id int64) (models.User, error) {
switch id {
case 1:
return mockUser, nil
default:
return models.User{}, models.ErrNoRecord
}
}
func (m *UserModel) GetAll() ([]models.User, error) {
return []models.User{mockUser}, nil
}
func (m *UserModel) Authenticate(email, password string) (int64, error) {
if email == "test@example.com" && password == "password" {
return 1, nil
}
return 0, models.ErrInvalidCredentials
}
func (m *UserModel) Exists(id int64) (bool, error) {
switch id {
case 1:
return true, nil
default:
return false, nil
}
}
func (m *UserModel) GetSettings(userId int64) (models.UserSettings, error) {
return mockUserSettings, nil
}
func (m *UserModel) UpdateUserSettings(userId int64, settings models.UserSettings) error {
return nil
}
func (m *UserModel) UpdateSetting(userId int64, setting models.Setting, value string) error {
return nil
}

View File

@ -0,0 +1,50 @@
package mocks
import (
"time"
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
)
var mockWebsite = models.Website{
ID: 1,
ShortId: 1,
Name: "Example",
SiteUrl: "example.com",
AuthorName: "John Test",
UserId: 1,
Created: time.Now(),
Guestbook: mockGuestbook,
}
type WebsiteModel struct{}
func (m *WebsiteModel) Insert(shortId uint64, userId int64, siteName, siteUrl, authorName string) (int64, error) {
return 2, nil
}
func (m *WebsiteModel) Get(shortId uint64) (models.Website, error) {
switch shortId {
case 1:
return mockWebsite, nil
default:
return models.Website{}, models.ErrNoRecord
}
}
func (m *WebsiteModel) GetAllUser(userId int64) ([]models.Website, error) {
return []models.Website{mockWebsite}, nil
}
func (m *WebsiteModel) GetById(id int64) (models.Website, error) {
switch id {
case 1:
return mockWebsite, nil
default:
return models.Website{}, models.ErrNoRecord
}
}
func (m *WebsiteModel) GetAll() ([]models.Website, error) {
return []models.Website{mockWebsite}, nil
}

View File

@ -35,6 +35,19 @@ type UserModel struct {
Settings map[string]Setting
}
type UserModelInterface interface {
InitializeSettingsMap() error
Insert(shortId uint64, username string, email string, password string, settings UserSettings) error
Get(shortId uint64) (User, error)
GetById(id int64) (User, error)
GetAll() ([]User, error)
Authenticate(email, password string) (int64, error)
Exists(id int64) (bool, error)
GetSettings(userId int64) (UserSettings, error)
UpdateUserSettings(userId int64, settings UserSettings) error
UpdateSetting(userId int64, setting Setting, value string) error
}
func (m *UserModel) InitializeSettingsMap() error {
if m.Settings == nil {
m.Settings = make(map[string]Setting)

View File

@ -90,6 +90,14 @@ func (m *WebsiteModel) InitializeSettingsMap() error {
return nil
}
type WebsiteModelInterface interface {
Insert(shortId uint64, userId int64, siteName, siteUrl, authorName string) (int64, error)
Get(shortId uint64) (Website, error)
GetById(id int64) (Website, error)
GetAllUser(userId int64) ([]Website, error)
GetAll() ([]Website, error)
}
func (m *WebsiteModel) Insert(shortId uint64, userId int64, siteName, siteUrl, authorName string) (int64, error) {
stmt := `INSERT INTO websites (ShortId, Name, SiteUrl, AuthorName, UserId, Created)
VALUES (?, ?, ?, ?, ?, ?)`