add end-to-end test for guestbook view and user registration
This commit is contained in:
parent
1983662216
commit
d574dab3a7
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
121
cmd/web/handlers_user_test.go
Normal file
121
cmd/web/handlers_user_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
@ -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
22
internal/assert/assert.go
Normal 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)
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
66
internal/models/mocks/guestbook.go
Normal file
66
internal/models/mocks/guestbook.go
Normal 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
|
||||
}
|
64
internal/models/mocks/guestbookcomment.go
Normal file
64
internal/models/mocks/guestbookcomment.go
Normal 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
|
||||
}
|
89
internal/models/mocks/users.go
Normal file
89
internal/models/mocks/users.go
Normal 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
|
||||
}
|
50
internal/models/mocks/website.go
Normal file
50
internal/models/mocks/website.go
Normal 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
|
||||
}
|
@ -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)
|
||||
|
@ -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 (?, ?, ?, ?, ?, ?)`
|
||||
|
Loading…
x
Reference in New Issue
Block a user