test: Add unit and integration tests for API and storage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan Novak
2026-01-19 19:50:21 +01:00
parent e43373bfcf
commit 49fe79d2dc
8 changed files with 2517 additions and 0 deletions

View File

@@ -0,0 +1,374 @@
package api
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"training-tracker/internal/models"
)
func TestExerciseHandler_List(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mock *mockExerciseRepo
wantStatus int
wantLen int
}{
{
name: "empty list",
mock: &mockExerciseRepo{exercises: nil},
wantStatus: http.StatusOK,
wantLen: 0,
},
{
name: "multiple exercises",
mock: &mockExerciseRepo{
exercises: []models.Exercise{
{ID: 1, Name: "Bench Press", Type: models.ExerciseTypeStrength},
{ID: 2, Name: "Running", Type: models.ExerciseTypeCardio},
},
},
wantStatus: http.StatusOK,
wantLen: 2,
},
{
name: "repository error",
mock: &mockExerciseRepo{err: errors.New("db error")},
wantStatus: http.StatusInternalServerError,
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := NewExerciseHandler(tt.mock)
req := httptest.NewRequest(http.MethodGet, "/api/exercises", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
if tt.wantStatus == http.StatusOK {
var exercises []models.Exercise
if err := json.NewDecoder(rec.Body).Decode(&exercises); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(exercises) != tt.wantLen {
t.Errorf("len = %d, want %d", len(exercises), tt.wantLen)
}
}
})
}
}
func TestExerciseHandler_GetByID(t *testing.T) {
t.Parallel()
now := time.Now()
tests := []struct {
name string
url string
mock *mockExerciseRepo
wantStatus int
wantName string
}{
{
name: "found",
url: "/api/exercises/1",
mock: &mockExerciseRepo{
exercise: &models.Exercise{
ID: 1,
Name: "Squat",
Type: models.ExerciseTypeStrength,
CreatedAt: now,
},
},
wantStatus: http.StatusOK,
wantName: "Squat",
},
{
name: "not found",
url: "/api/exercises/999",
mock: &mockExerciseRepo{exercise: nil},
wantStatus: http.StatusNotFound,
},
{
name: "repository error",
url: "/api/exercises/1",
mock: &mockExerciseRepo{err: errors.New("db error")},
wantStatus: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := NewExerciseHandler(tt.mock)
req := httptest.NewRequest(http.MethodGet, tt.url, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
if tt.wantStatus == http.StatusOK {
var exercise models.Exercise
if err := json.NewDecoder(rec.Body).Decode(&exercise); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if exercise.Name != tt.wantName {
t.Errorf("name = %q, want %q", exercise.Name, tt.wantName)
}
}
})
}
}
func TestExerciseHandler_Create(t *testing.T) {
t.Parallel()
now := time.Now()
tests := []struct {
name string
body string
mock *mockExerciseRepo
wantStatus int
}{
{
name: "valid strength exercise",
body: `{"name":"Deadlift","type":"strength","muscle_group":"Back"}`,
mock: &mockExerciseRepo{
exercise: &models.Exercise{
ID: 1,
Name: "Deadlift",
Type: models.ExerciseTypeStrength,
MuscleGroup: "Back",
CreatedAt: now,
},
},
wantStatus: http.StatusCreated,
},
{
name: "valid cardio exercise",
body: `{"name":"Cycling","type":"cardio"}`,
mock: &mockExerciseRepo{
exercise: &models.Exercise{
ID: 2,
Name: "Cycling",
Type: models.ExerciseTypeCardio,
CreatedAt: now,
},
},
wantStatus: http.StatusCreated,
},
{
name: "missing name",
body: `{"type":"strength"}`,
mock: &mockExerciseRepo{},
wantStatus: http.StatusBadRequest,
},
{
name: "missing type",
body: `{"name":"Test"}`,
mock: &mockExerciseRepo{},
wantStatus: http.StatusBadRequest,
},
{
name: "invalid type",
body: `{"name":"Test","type":"invalid"}`,
mock: &mockExerciseRepo{},
wantStatus: http.StatusBadRequest,
},
{
name: "invalid JSON",
body: `{invalid}`,
mock: &mockExerciseRepo{},
wantStatus: http.StatusBadRequest,
},
{
name: "repository error",
body: `{"name":"Test","type":"strength"}`,
mock: &mockExerciseRepo{err: errors.New("db error")},
wantStatus: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := NewExerciseHandler(tt.mock)
req := httptest.NewRequest(http.MethodPost, "/api/exercises", bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
})
}
}
func TestExerciseHandler_Update(t *testing.T) {
t.Parallel()
now := time.Now()
tests := []struct {
name string
url string
body string
mock *mockExerciseRepo
wantStatus int
}{
{
name: "valid update",
url: "/api/exercises/1",
body: `{"name":"Updated Squat","type":"strength"}`,
mock: &mockExerciseRepo{
exercise: &models.Exercise{
ID: 1,
Name: "Updated Squat",
Type: models.ExerciseTypeStrength,
CreatedAt: now,
},
},
wantStatus: http.StatusOK,
},
{
name: "not found",
url: "/api/exercises/999",
body: `{"name":"Test","type":"strength"}`,
mock: &mockExerciseRepo{exercise: nil},
wantStatus: http.StatusNotFound,
},
{
name: "missing name",
url: "/api/exercises/1",
body: `{"type":"strength"}`,
mock: &mockExerciseRepo{},
wantStatus: http.StatusBadRequest,
},
{
name: "invalid type",
url: "/api/exercises/1",
body: `{"name":"Test","type":"invalid"}`,
mock: &mockExerciseRepo{},
wantStatus: http.StatusBadRequest,
},
{
name: "no ID in URL",
url: "/api/exercises/",
body: `{"name":"Test","type":"strength"}`,
mock: &mockExerciseRepo{},
wantStatus: http.StatusBadRequest,
},
{
name: "repository error",
url: "/api/exercises/1",
body: `{"name":"Test","type":"strength"}`,
mock: &mockExerciseRepo{err: errors.New("db error")},
wantStatus: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := NewExerciseHandler(tt.mock)
req := httptest.NewRequest(http.MethodPut, tt.url, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
})
}
}
func TestExerciseHandler_Delete(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
mock *mockExerciseRepo
wantStatus int
}{
{
name: "successful delete",
url: "/api/exercises/1",
mock: &mockExerciseRepo{deleteErr: nil},
wantStatus: http.StatusNoContent,
},
{
name: "not found",
url: "/api/exercises/999",
mock: &mockExerciseRepo{deleteErr: sql.ErrNoRows},
wantStatus: http.StatusNotFound,
},
{
name: "no ID in URL",
url: "/api/exercises/",
mock: &mockExerciseRepo{},
wantStatus: http.StatusBadRequest,
},
{
name: "repository error",
url: "/api/exercises/1",
mock: &mockExerciseRepo{deleteErr: errors.New("db error")},
wantStatus: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := NewExerciseHandler(tt.mock)
req := httptest.NewRequest(http.MethodDelete, tt.url, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
})
}
}
func TestExerciseHandler_MethodNotAllowed(t *testing.T) {
t.Parallel()
handler := NewExerciseHandler(&mockExerciseRepo{})
req := httptest.NewRequest(http.MethodPatch, "/api/exercises/1", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
}
}