Compare commits

...

3 Commits

13 changed files with 569 additions and 3 deletions

View File

@ -17,3 +17,7 @@ func (app *application) home(w http.ResponseWriter, r *http.Request) {
func (app *application) notImplemented(w http.ResponseWriter, r *http.Request) {
views.ComingSoon("Coming Soon", app.newCommonData(r)).Render(r.Context(), w)
}
func ping(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
}

View File

@ -0,0 +1,60 @@
package main
import (
"fmt"
"net/http"
"testing"
"git.32bit.cafe/32bitcafe/guestbook/internal/assert"
)
func TestPing(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
code, _, body := ts.get(t, "/ping")
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

@ -21,9 +21,9 @@ import (
type application struct {
sequence uint16
logger *slog.Logger
websites *models.WebsiteModel
users *models.UserModel
guestbookComments *models.GuestbookCommentModel
websites models.WebsiteModelInterface
users models.UserModelInterface
guestbookComments models.GuestbookCommentModelInterface
sessionManager *scs.SessionManager
formDecoder *schema.Decoder
debug bool

View File

@ -11,6 +11,8 @@ func (app *application) routes() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(ui.Files))
mux.HandleFunc("GET /ping", ping)
dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)
standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders)

96
cmd/web/testutils_test.go Normal file
View File

@ -0,0 +1,96 @@
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)),
sessionManager: sessionManager,
websites: &mocks.WebsiteModel{},
users: &mocks.UserModel{},
guestbookComments: &mocks.GuestbookCommentModel{},
formDecoder: formDecoder,
timezones: getAvailableTimezones(),
}
}
type testServer struct {
*httptest.Server
}
func newTestServer(t *testing.T, h http.Handler) *testServer {
ts := httptest.NewTLSServer(h)
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
ts.Client().Jar = jar
ts.Client().CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
return &testServer{ts}
}
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, string) {
rs, err := ts.Client().Get(ts.URL + urlPath)
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)
}
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,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,77 @@
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,
},
}
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
}
func (m *WebsiteModel) InitializeSettingsMap() error {
return nil
}
func (m *WebsiteModel) UpdateGuestbookSettings(guestbookId int64, settings models.GuestbookSettings) error {
return nil
}
func (m *WebsiteModel) UpdateSetting(guestbookId int64, setting models.Setting, value string) error {
return nil
}

View File

@ -35,6 +35,18 @@ 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)
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,16 @@ 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)
GetAllUser(userId int64) ([]Website, error)
GetAll() ([]Website, error)
InitializeSettingsMap() error
UpdateGuestbookSettings(guestbookId int64, settings GuestbookSettings) error
UpdateSetting(guestbookId int64, setting Setting, value string) 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 (?, ?, ?, ?, ?, ?)`