todo handler and tests

This commit is contained in:
Michael Thomson 2025-02-09 00:21:58 -05:00
parent 6175abee25
commit c10cc07324
No known key found for this signature in database
GPG Key ID: 8EFECCD347C72F7D
14 changed files with 714 additions and 34 deletions

View File

@ -2,10 +2,11 @@ package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"gitea.michaelthomson.dev/mthomson/habits/internal/migrate"
todohandler "gitea.michaelthomson.dev/mthomson/habits/internal/todo/handler"
todosqliterepository "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository/sqlite"
todoservice "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
_ "github.com/mattn/go-sqlite3"
@ -21,19 +22,28 @@ func main() {
defer db.Close()
// run migrations
migrate.Migrate(db);
migrate.Migrate(db)
// create repos
todoRepository := todosqliterepository.NewSqliteTodoRepository(db)
// create services
todoService := todoservice.NewTodoService(&todoRepository)
todoService := todoservice.NewTodoService(todoRepository)
// create todo
todo := todoservice.NewTodo("clean dishes", false)
newTodo, err := todoService.CreateTodo(todo)
if err != nil {
log.Fatal(err)
// create mux
mux := http.NewServeMux()
// register handlers
mux.Handle("GET /todo/{id}", todohandler.HandleTodoGet(todoService))
mux.Handle("POST /todo", todohandler.HandleTodoCreate(todoService))
mux.Handle("DELETE /todo/{id}", todohandler.HandleTodoDelete(todoService))
mux.Handle("PUT /todo/{id}", todohandler.HandleTodoUpdate(todoService))
// create server
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
fmt.Printf("ID of created todo: %d\n", newTodo.Id)
server.ListenAndServe()
}

View File

@ -0,0 +1,68 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
)
type CreateTodoRequest struct {
Name string `json:"name"`
Done bool `json:"done"`
}
func TodoFromCreateTodoRequest(todo CreateTodoRequest) service.Todo {
return service.Todo{Id: 0, Name: todo.Name, Done: todo.Done}
}
type CreateTodoResponse struct {
Id int64 `json:"id"`
Name string `json:"name"`
Done bool `json:"done"`
}
func CreateTodoResponseFromTodo(todo service.Todo) CreateTodoResponse {
return CreateTodoResponse{Id: todo.Id, Name: todo.Name, Done: todo.Done}
}
func HandleTodoCreate(todoService TodoCreater) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
createTodoRequest := CreateTodoRequest{}
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&createTodoRequest)
if err != nil {
http.Error(w, "", http.StatusBadRequest)
return
}
todo, err := todoService.CreateTodo(TodoFromCreateTodoRequest(createTodoRequest))
if err != nil {
if err == service.ErrNotFound {
http.Error(w, "", http.StatusNotFound)
return
}
http.Error(w, "", http.StatusInternalServerError)
return
}
response := CreateTodoResponseFromTodo(todo)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", fmt.Sprintf("/todo/%d", response.Id))
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(response)
if err != nil {
http.Error(w, "", http.StatusInternalServerError)
return
}
}
}

View File

@ -0,0 +1,121 @@
package handler
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
)
type MockTodoCreater struct {
CreateTodoFunc func(todo service.Todo) (service.Todo, error)
}
func (tg *MockTodoCreater) CreateTodo(todo service.Todo) (service.Todo, error) {
return tg.CreateTodoFunc(todo)
}
func TestCreateTodo(t *testing.T) {
t.Run("create todo", func(t *testing.T) {
createTodoRequest := CreateTodoRequest{Name: "clean dishes", Done: false}
createdTodo := service.Todo{Id: 1, Name: "clean dishes", Done: false}
want := CreateTodoResponse{Id: 1, Name: "clean dishes", Done: false}
service := MockTodoCreater{
CreateTodoFunc: func(todo service.Todo) (service.Todo, error) {
return createdTodo, nil
},
}
handler := HandleTodoCreate(&service)
requestBody, err := json.Marshal(createTodoRequest)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", createTodoRequest, err)
}
req := httptest.NewRequest(http.MethodPost, "/todo", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
if res.Code != http.StatusCreated {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusCreated)
}
if res.Header().Get("Location") != "/todo/1" {
t.Errorf("did not get correct Location, got %q, want %q", res.Header().Get("Location"), "/todo/1")
}
var got CreateTodoResponse
err = json.NewDecoder(res.Body).Decode(&got)
if err != nil {
t.Fatalf("Unable to decode response from server %q into GetTodoResponse: %v", res.Body, err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
})
t.Run("returns 400 with bad json", func(t *testing.T) {
handler := HandleTodoCreate(nil)
badStruct := struct {
Foo string
}{
Foo: "bar",
}
requestBody, err := json.Marshal(badStruct)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", badStruct, err)
}
req := httptest.NewRequest(http.MethodPost, "/todo", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
if res.Code != http.StatusBadRequest {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
t.Run("returns 500 arbitrary errors", func(t *testing.T) {
createTodoRequest := CreateTodoRequest{Name: "clean dishes", Done: false}
service := MockTodoCreater{
CreateTodoFunc: func(todo service.Todo) (service.Todo, error) {
return service.Todo{}, errors.New("foo bar")
},
}
handler := HandleTodoCreate(&service)
requestBody, err := json.Marshal(createTodoRequest)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", createTodoRequest, err)
}
req := httptest.NewRequest(http.MethodPost, "/todo", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
if res.Code != http.StatusInternalServerError {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
}

View File

@ -0,0 +1,35 @@
package handler
import (
"net/http"
"strconv"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
)
func HandleTodoDelete(todoService TodoDeleter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idString := r.PathValue("id")
id, err := strconv.ParseInt(idString, 10, 64)
if err != nil {
http.Error(w, "", http.StatusBadRequest)
return
}
err = todoService.DeleteTodo(id)
if err != nil {
if err == service.ErrNotFound {
http.Error(w, "", http.StatusNotFound)
return
}
http.Error(w, "", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}

View File

@ -0,0 +1,95 @@
package handler
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
)
type MockTodoDeleter struct {
DeleteTodoFunc func(id int64) error
}
func (tg *MockTodoDeleter) DeleteTodo(id int64) error {
return tg.DeleteTodoFunc(id)
}
func TestDeleteTodo(t *testing.T) {
t.Run("deletes existing todo", func(t *testing.T) {
service := MockTodoDeleter{
DeleteTodoFunc: func(id int64) error {
return nil
},
}
handler := HandleTodoDelete(&service)
req := httptest.NewRequest(http.MethodDelete, "/todo/1", nil)
res := httptest.NewRecorder()
req.SetPathValue("id", "1")
handler(res, req)
if res.Code != http.StatusNoContent {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
t.Run("returns 400 with bad id", func(t *testing.T) {
handler := HandleTodoDelete(nil)
req := httptest.NewRequest(http.MethodDelete, "/todo/hello", nil)
res := httptest.NewRecorder()
req.SetPathValue("id", "hello")
handler(res, req)
if res.Code != http.StatusBadRequest {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
t.Run("returns 404 for not found todo", func(t *testing.T) {
service := MockTodoDeleter{
DeleteTodoFunc: func(id int64) error {
return service.ErrNotFound
},
}
handler := HandleTodoDelete(&service)
req := httptest.NewRequest(http.MethodDelete, "/todo/1", nil)
res := httptest.NewRecorder()
req.SetPathValue("id", "1")
handler(res, req)
if res.Code != http.StatusNotFound {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
t.Run("returns 500 for arbitrary errors", func(t *testing.T) {
service := MockTodoDeleter{
DeleteTodoFunc: func(id int64) error {
return errors.New("foo bar")
},
}
handler := HandleTodoDelete(&service)
req := httptest.NewRequest(http.MethodDelete, "/todo/1", nil)
res := httptest.NewRecorder()
req.SetPathValue("id", "1")
handler(res, req)
if res.Code != http.StatusInternalServerError {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
}

View File

@ -0,0 +1,56 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
)
type GetTodoResponse struct {
Id int64 `json:"id"`
Name string `json:"name"`
Done bool `json:"done"`
}
func GetTodoResponseFromTodo(todo service.Todo) GetTodoResponse {
return GetTodoResponse{Id: todo.Id, Name: todo.Name, Done: todo.Done}
}
func HandleTodoGet(todoService TodoGetter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idString := r.PathValue("id")
id, err := strconv.ParseInt(idString, 10, 64)
if err != nil {
http.Error(w, "", http.StatusBadRequest)
return
}
todo, err := todoService.GetTodo(id)
if err != nil {
if err == service.ErrNotFound {
http.Error(w, "", http.StatusNotFound)
return
}
http.Error(w, "", http.StatusInternalServerError)
return
}
response := GetTodoResponseFromTodo(todo)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(response)
if err != nil {
http.Error(w, "", http.StatusInternalServerError)
return
}
}
}

View File

@ -0,0 +1,112 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
)
type MockTodoGetter struct {
GetTodoFunc func(id int64) (service.Todo, error)
}
func (tg *MockTodoGetter) GetTodo(id int64) (service.Todo, error) {
return tg.GetTodoFunc(id)
}
func TestGetTodo(t *testing.T) {
t.Run("gets existing todo", func(t *testing.T) {
todo := service.Todo{Id: 1, Name: "clean dishes", Done: false}
wantedTodo := GetTodoResponse{Id: 1, Name: "clean dishes", Done: false}
service := MockTodoGetter{
GetTodoFunc: func(id int64) (service.Todo, error) {
return todo, nil
},
}
handler := HandleTodoGet(&service)
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/todo/%d", todo.Id), nil)
res := httptest.NewRecorder()
req.SetPathValue("id", "1")
handler(res, req)
if res.Code != http.StatusOK {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
var got GetTodoResponse
err := json.NewDecoder(res.Body).Decode(&got)
if err != nil {
t.Fatalf("Unable to parse response from server %q into GetTodoResponse: %v", res.Body, err)
}
if !reflect.DeepEqual(got, wantedTodo) {
t.Errorf("got %+v, want %+v", got, wantedTodo)
}
})
t.Run("returns 400 with bad id", func(t *testing.T) {
handler := HandleTodoGet(nil)
req := httptest.NewRequest(http.MethodGet, "/todo/hello", nil)
res := httptest.NewRecorder()
req.SetPathValue("id", "hello")
handler(res, req)
if res.Code != http.StatusBadRequest {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
t.Run("returns 404 for not found todo", func(t *testing.T) {
service := MockTodoGetter{
GetTodoFunc: func(id int64) (service.Todo, error) {
return service.Todo{}, service.ErrNotFound
},
}
handler := HandleTodoGet(&service)
req := httptest.NewRequest(http.MethodGet, "/todo/1", nil)
res := httptest.NewRecorder()
req.SetPathValue("id", "1")
handler(res, req)
if res.Code != http.StatusNotFound {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
t.Run("returns 500 for arbitrary errors", func(t *testing.T) {
service := MockTodoGetter{
GetTodoFunc: func(id int64) (service.Todo, error) {
return service.Todo{}, errors.New("foo bar")
},
}
handler := HandleTodoGet(&service)
req := httptest.NewRequest(http.MethodGet, "/todo/1", nil)
res := httptest.NewRecorder()
req.SetPathValue("id", "1")
handler(res, req)
if res.Code != http.StatusInternalServerError {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
}

View File

@ -0,0 +1,21 @@
package handler
import (
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
)
type TodoGetter interface {
GetTodo(id int64) (service.Todo, error)
}
type TodoCreater interface {
CreateTodo(todo service.Todo) (service.Todo, error)
}
type TodoDeleter interface {
DeleteTodo(id int64) error
}
type TodoUpdater interface {
UpdateTodo(todo service.Todo) error
}

View File

@ -0,0 +1,56 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
)
type UpdateTodoRequest struct {
Name string `json:"name"`
Done bool `json:"done"`
}
func TodoFromUpdateTodoRequest(todo UpdateTodoRequest, id int64) service.Todo {
return service.Todo{Id: id, Name: todo.Name, Done: todo.Done}
}
func HandleTodoUpdate(todoService TodoUpdater) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateTodoRequest := UpdateTodoRequest{}
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&updateTodoRequest)
if err != nil {
http.Error(w, "", http.StatusBadRequest)
return
}
idString := r.PathValue("id")
id, err := strconv.ParseInt(idString, 10, 64)
if err != nil {
http.Error(w, "", http.StatusBadRequest)
return
}
err = todoService.UpdateTodo(TodoFromUpdateTodoRequest(updateTodoRequest, id))
if err != nil {
if err == service.ErrNotFound {
http.Error(w, "", http.StatusNotFound)
return
}
http.Error(w, "", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
}
}

View File

@ -0,0 +1,119 @@
package handler
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
)
type MockTodoUpdater struct {
UpdateTodoFunc func(todo service.Todo) error
}
func (tg *MockTodoUpdater) UpdateTodo(todo service.Todo) error {
return tg.UpdateTodoFunc(todo)
}
func TestUpdateTodo(t *testing.T) {
t.Run("update todo", func(t *testing.T) {
updateTodoRequest := UpdateTodoRequest{Name: "clean dishes", Done: false}
service := MockTodoUpdater{
UpdateTodoFunc: func(todo service.Todo) error {
return nil
},
}
handler := HandleTodoUpdate(&service)
requestBody, err := json.Marshal(updateTodoRequest)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", updateTodoRequest, err)
}
req := httptest.NewRequest(http.MethodPut, "/todo/1", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
req.SetPathValue("id", "1")
handler(res, req)
if res.Code != http.StatusOK {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
t.Run("returns 400 with bad json", func(t *testing.T) {
handler := HandleTodoUpdate(nil)
badStruct := struct {
Foo string
}{
Foo: "bar",
}
requestBody, err := json.Marshal(badStruct)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", badStruct, err)
}
req := httptest.NewRequest(http.MethodPut, "/todo/1", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
if res.Code != http.StatusBadRequest {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
t.Run("returns 400 with bad id", func(t *testing.T) {
handler := HandleTodoUpdate(nil)
req := httptest.NewRequest(http.MethodPut, "/todo/hello", nil)
res := httptest.NewRecorder()
req.SetPathValue("id", "hello")
handler(res, req)
if res.Code != http.StatusBadRequest {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
t.Run("returns 500 arbitrary errors", func(t *testing.T) {
updateTodoRequest := UpdateTodoRequest{Name: "clean dishes", Done: false}
service := MockTodoUpdater{
UpdateTodoFunc: func(todo service.Todo) error {
return errors.New("foo bar")
},
}
handler := HandleTodoUpdate(&service)
requestBody, err := json.Marshal(updateTodoRequest)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", updateTodoRequest, err)
}
req := httptest.NewRequest(http.MethodPost, "/todo/1", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
req.SetPathValue("id", "1")
handler(res, req)
if res.Code != http.StatusInternalServerError {
t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK)
}
})
}

View File

@ -17,10 +17,3 @@ type TodoRow struct {
func NewTodoRow(name string, done bool) TodoRow {
return TodoRow{Name: name, Done: done}
}
type TodoRepository interface {
Create(todo TodoRow) (TodoRow, error)
GetById(id int64) (TodoRow, error)
Update(todo TodoRow) error
Delete(id int64) error
}

View File

@ -10,8 +10,8 @@ type SqliteTodoRepository struct {
db *sql.DB
}
func NewSqliteTodoRepository(db *sql.DB) SqliteTodoRepository {
return SqliteTodoRepository{
func NewSqliteTodoRepository(db *sql.DB) *SqliteTodoRepository {
return &SqliteTodoRepository{
db: db,
}
}

View File

@ -32,27 +32,18 @@ func (t Todo) Equal(todo Todo) bool {
return t.Id == todo.Id && t.Name == todo.Name && t.Done == todo.Done
}
type TodoGetter interface {
GetTodo(id int64) (Todo, error)
}
type TodoCreater interface {
CreateTodo(todo Todo) (Todo, error)
}
type TodoDeleter interface {
DeleteTodo(id int64) error
}
type TodoUpdater interface {
UpdateTodo(todo Todo) error
type TodoRepository interface {
Create(todo repository.TodoRow) (repository.TodoRow, error)
GetById(id int64) (repository.TodoRow, error)
Update(todo repository.TodoRow) error
Delete(id int64) error
}
type TodoService struct {
repo repository.TodoRepository
repo TodoRepository
}
func NewTodoService(todoRepo repository.TodoRepository) *TodoService {
func NewTodoService(todoRepo TodoRepository) *TodoService {
return &TodoService{todoRepo}
}

View File

@ -4,5 +4,8 @@ run:
build:
go build -o tmp/main cmd/main.go
test:
go test ./...
clean:
rm tmp/main habits.db