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:
374
backend/internal/api/exercises_test.go
Normal file
374
backend/internal/api/exercises_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
97
backend/internal/api/mocks_test.go
Normal file
97
backend/internal/api/mocks_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"training-tracker/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockExerciseRepo is a mock implementation of ExerciseRepository for testing
|
||||||
|
type mockExerciseRepo struct {
|
||||||
|
exercises []models.Exercise
|
||||||
|
exercise *models.Exercise
|
||||||
|
err error
|
||||||
|
deleteErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockExerciseRepo) List(ctx context.Context) ([]models.Exercise, error) {
|
||||||
|
return m.exercises, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockExerciseRepo) GetByID(ctx context.Context, id int64) (*models.Exercise, error) {
|
||||||
|
return m.exercise, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockExerciseRepo) Create(ctx context.Context, req *models.CreateExerciseRequest) (*models.Exercise, error) {
|
||||||
|
return m.exercise, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockExerciseRepo) Update(ctx context.Context, id int64, req *models.CreateExerciseRequest) (*models.Exercise, error) {
|
||||||
|
return m.exercise, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockExerciseRepo) Delete(ctx context.Context, id int64) error {
|
||||||
|
return m.deleteErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockPlanRepo is a mock implementation of PlanRepository for testing
|
||||||
|
type mockPlanRepo struct {
|
||||||
|
plans []models.TrainingPlan
|
||||||
|
plan *models.TrainingPlan
|
||||||
|
err error
|
||||||
|
deleteErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPlanRepo) List(ctx context.Context) ([]models.TrainingPlan, error) {
|
||||||
|
return m.plans, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPlanRepo) GetByID(ctx context.Context, id int64) (*models.TrainingPlan, error) {
|
||||||
|
return m.plan, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPlanRepo) Create(ctx context.Context, req *models.CreatePlanRequest) (*models.TrainingPlan, error) {
|
||||||
|
return m.plan, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPlanRepo) Update(ctx context.Context, id int64, req *models.CreatePlanRequest) (*models.TrainingPlan, error) {
|
||||||
|
return m.plan, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPlanRepo) Delete(ctx context.Context, id int64) error {
|
||||||
|
return m.deleteErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockSessionRepo is a mock implementation of SessionRepository for testing
|
||||||
|
type mockSessionRepo struct {
|
||||||
|
sessions []models.Session
|
||||||
|
session *models.Session
|
||||||
|
entry *models.SessionEntry
|
||||||
|
err error
|
||||||
|
deleteErr error
|
||||||
|
entryErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSessionRepo) List(ctx context.Context) ([]models.Session, error) {
|
||||||
|
return m.sessions, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSessionRepo) GetByID(ctx context.Context, id int64) (*models.Session, error) {
|
||||||
|
return m.session, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSessionRepo) Create(ctx context.Context, req *models.CreateSessionRequest) (*models.Session, error) {
|
||||||
|
return m.session, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSessionRepo) Update(ctx context.Context, id int64, req *models.CreateSessionRequest) (*models.Session, error) {
|
||||||
|
return m.session, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSessionRepo) Delete(ctx context.Context, id int64) error {
|
||||||
|
return m.deleteErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSessionRepo) AddEntry(ctx context.Context, sessionID int64, req *models.CreateSessionEntryRequest) (*models.SessionEntry, error) {
|
||||||
|
return m.entry, m.entryErr
|
||||||
|
}
|
||||||
396
backend/internal/api/plans_test.go
Normal file
396
backend/internal/api/plans_test.go
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"training-tracker/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanHandler_List(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mock *mockPlanRepo
|
||||||
|
wantStatus int
|
||||||
|
wantLen int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty list",
|
||||||
|
mock: &mockPlanRepo{plans: nil},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantLen: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple plans",
|
||||||
|
mock: &mockPlanRepo{
|
||||||
|
plans: []models.TrainingPlan{
|
||||||
|
{ID: 1, Name: "Push Day"},
|
||||||
|
{ID: 2, Name: "Pull Day"},
|
||||||
|
{ID: 3, Name: "Leg Day"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantLen: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository error",
|
||||||
|
mock: &mockPlanRepo{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 := NewPlanHandler(tt.mock)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/plans", 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 plans []models.TrainingPlan
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&plans); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(plans) != tt.wantLen {
|
||||||
|
t.Errorf("len = %d, want %d", len(plans), tt.wantLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanHandler_GetByID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
mock *mockPlanRepo
|
||||||
|
wantStatus int
|
||||||
|
wantName string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "found",
|
||||||
|
url: "/api/plans/1",
|
||||||
|
mock: &mockPlanRepo{
|
||||||
|
plan: &models.TrainingPlan{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Full Body",
|
||||||
|
Description: "Complete workout",
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantName: "Full Body",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "found with exercises",
|
||||||
|
url: "/api/plans/2",
|
||||||
|
mock: &mockPlanRepo{
|
||||||
|
plan: &models.TrainingPlan{
|
||||||
|
ID: 2,
|
||||||
|
Name: "Upper Body",
|
||||||
|
Exercises: []models.PlanExercise{
|
||||||
|
{ID: 1, PlanID: 2, ExerciseID: 1, Sets: 3, Reps: 10},
|
||||||
|
{ID: 2, PlanID: 2, ExerciseID: 2, Sets: 4, Reps: 8},
|
||||||
|
},
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantName: "Upper Body",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not found",
|
||||||
|
url: "/api/plans/999",
|
||||||
|
mock: &mockPlanRepo{plan: nil},
|
||||||
|
wantStatus: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository error",
|
||||||
|
url: "/api/plans/1",
|
||||||
|
mock: &mockPlanRepo{err: errors.New("db error")},
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := NewPlanHandler(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 plan models.TrainingPlan
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&plan); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if plan.Name != tt.wantName {
|
||||||
|
t.Errorf("name = %q, want %q", plan.Name, tt.wantName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanHandler_Create(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
mock *mockPlanRepo
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid plan without exercises",
|
||||||
|
body: `{"name":"Beginner Plan","description":"A simple plan"}`,
|
||||||
|
mock: &mockPlanRepo{
|
||||||
|
plan: &models.TrainingPlan{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Beginner Plan",
|
||||||
|
Description: "A simple plan",
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusCreated,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid plan with exercises",
|
||||||
|
body: `{"name":"Advanced Plan","exercises":[{"exercise_id":1,"sets":3,"reps":10,"order":1}]}`,
|
||||||
|
mock: &mockPlanRepo{
|
||||||
|
plan: &models.TrainingPlan{
|
||||||
|
ID: 2,
|
||||||
|
Name: "Advanced Plan",
|
||||||
|
Exercises: []models.PlanExercise{
|
||||||
|
{ID: 1, PlanID: 2, ExerciseID: 1, Sets: 3, Reps: 10, Order: 1},
|
||||||
|
},
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusCreated,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing name",
|
||||||
|
body: `{"description":"No name"}`,
|
||||||
|
mock: &mockPlanRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty name",
|
||||||
|
body: `{"name":""}`,
|
||||||
|
mock: &mockPlanRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid JSON",
|
||||||
|
body: `{invalid}`,
|
||||||
|
mock: &mockPlanRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository error",
|
||||||
|
body: `{"name":"Test Plan"}`,
|
||||||
|
mock: &mockPlanRepo{err: errors.New("db error")},
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := NewPlanHandler(tt.mock)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/plans", 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 TestPlanHandler_Update(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
body string
|
||||||
|
mock *mockPlanRepo
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid update",
|
||||||
|
url: "/api/plans/1",
|
||||||
|
body: `{"name":"Updated Plan","description":"New description"}`,
|
||||||
|
mock: &mockPlanRepo{
|
||||||
|
plan: &models.TrainingPlan{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Updated Plan",
|
||||||
|
Description: "New description",
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update with exercises",
|
||||||
|
url: "/api/plans/1",
|
||||||
|
body: `{"name":"Plan with Exercises","exercises":[{"exercise_id":1,"sets":4,"reps":12,"order":1}]}`,
|
||||||
|
mock: &mockPlanRepo{
|
||||||
|
plan: &models.TrainingPlan{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Plan with Exercises",
|
||||||
|
Exercises: []models.PlanExercise{
|
||||||
|
{ID: 1, PlanID: 1, ExerciseID: 1, Sets: 4, Reps: 12, Order: 1},
|
||||||
|
},
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not found",
|
||||||
|
url: "/api/plans/999",
|
||||||
|
body: `{"name":"Test"}`,
|
||||||
|
mock: &mockPlanRepo{plan: nil},
|
||||||
|
wantStatus: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing name",
|
||||||
|
url: "/api/plans/1",
|
||||||
|
body: `{"description":"No name"}`,
|
||||||
|
mock: &mockPlanRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no ID in URL",
|
||||||
|
url: "/api/plans/",
|
||||||
|
body: `{"name":"Test"}`,
|
||||||
|
mock: &mockPlanRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository error",
|
||||||
|
url: "/api/plans/1",
|
||||||
|
body: `{"name":"Test"}`,
|
||||||
|
mock: &mockPlanRepo{err: errors.New("db error")},
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := NewPlanHandler(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 TestPlanHandler_Delete(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
mock *mockPlanRepo
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful delete",
|
||||||
|
url: "/api/plans/1",
|
||||||
|
mock: &mockPlanRepo{deleteErr: nil},
|
||||||
|
wantStatus: http.StatusNoContent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not found",
|
||||||
|
url: "/api/plans/999",
|
||||||
|
mock: &mockPlanRepo{deleteErr: sql.ErrNoRows},
|
||||||
|
wantStatus: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no ID in URL",
|
||||||
|
url: "/api/plans/",
|
||||||
|
mock: &mockPlanRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository error",
|
||||||
|
url: "/api/plans/1",
|
||||||
|
mock: &mockPlanRepo{deleteErr: errors.New("db error")},
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := NewPlanHandler(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 TestPlanHandler_MethodNotAllowed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := NewPlanHandler(&mockPlanRepo{})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/plans/1", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
517
backend/internal/api/sessions_test.go
Normal file
517
backend/internal/api/sessions_test.go
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"training-tracker/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionHandler_List(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mock *mockSessionRepo
|
||||||
|
wantStatus int
|
||||||
|
wantLen int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty list",
|
||||||
|
mock: &mockSessionRepo{sessions: nil},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantLen: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple sessions",
|
||||||
|
mock: &mockSessionRepo{
|
||||||
|
sessions: []models.Session{
|
||||||
|
{ID: 1, Date: now, Notes: "Morning workout"},
|
||||||
|
{ID: 2, Date: now.AddDate(0, 0, -1), Notes: "Evening cardio"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantLen: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "session with plan",
|
||||||
|
mock: &mockSessionRepo{
|
||||||
|
sessions: []models.Session{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Date: now,
|
||||||
|
PlanID: ptr(int64(1)),
|
||||||
|
Plan: &models.TrainingPlan{ID: 1, Name: "Push Day"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantLen: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository error",
|
||||||
|
mock: &mockSessionRepo{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 := NewSessionHandler(tt.mock)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions", 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 sessions []models.Session
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&sessions); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(sessions) != tt.wantLen {
|
||||||
|
t.Errorf("len = %d, want %d", len(sessions), tt.wantLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionHandler_GetByID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
mock *mockSessionRepo
|
||||||
|
wantStatus int
|
||||||
|
wantNotes string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "found",
|
||||||
|
url: "/api/sessions/1",
|
||||||
|
mock: &mockSessionRepo{
|
||||||
|
session: &models.Session{
|
||||||
|
ID: 1,
|
||||||
|
Date: now,
|
||||||
|
Notes: "Great workout",
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantNotes: "Great workout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "found with entries",
|
||||||
|
url: "/api/sessions/1",
|
||||||
|
mock: &mockSessionRepo{
|
||||||
|
session: &models.Session{
|
||||||
|
ID: 1,
|
||||||
|
Date: now,
|
||||||
|
Notes: "Complete session",
|
||||||
|
Entries: []models.SessionEntry{
|
||||||
|
{ID: 1, SessionID: 1, ExerciseID: 1, Weight: 100, Reps: 10},
|
||||||
|
{ID: 2, SessionID: 1, ExerciseID: 2, Duration: 1800},
|
||||||
|
},
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantNotes: "Complete session",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not found",
|
||||||
|
url: "/api/sessions/999",
|
||||||
|
mock: &mockSessionRepo{session: nil},
|
||||||
|
wantStatus: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository error",
|
||||||
|
url: "/api/sessions/1",
|
||||||
|
mock: &mockSessionRepo{err: errors.New("db error")},
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := NewSessionHandler(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 session models.Session
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&session); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if session.Notes != tt.wantNotes {
|
||||||
|
t.Errorf("notes = %q, want %q", session.Notes, tt.wantNotes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionHandler_Create(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
dateStr := now.Format(time.RFC3339)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
mock *mockSessionRepo
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid session without plan",
|
||||||
|
body: `{"date":"` + dateStr + `","notes":"Morning run"}`,
|
||||||
|
mock: &mockSessionRepo{
|
||||||
|
session: &models.Session{
|
||||||
|
ID: 1,
|
||||||
|
Date: now,
|
||||||
|
Notes: "Morning run",
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusCreated,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid session with plan",
|
||||||
|
body: `{"date":"` + dateStr + `","plan_id":1,"notes":"Following plan"}`,
|
||||||
|
mock: &mockSessionRepo{
|
||||||
|
session: &models.Session{
|
||||||
|
ID: 2,
|
||||||
|
Date: now,
|
||||||
|
PlanID: ptr(int64(1)),
|
||||||
|
Notes: "Following plan",
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusCreated,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing date",
|
||||||
|
body: `{"notes":"No date"}`,
|
||||||
|
mock: &mockSessionRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid JSON",
|
||||||
|
body: `{invalid}`,
|
||||||
|
mock: &mockSessionRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository error",
|
||||||
|
body: `{"date":"` + dateStr + `"}`,
|
||||||
|
mock: &mockSessionRepo{err: errors.New("db error")},
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := NewSessionHandler(tt.mock)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/sessions", 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 TestSessionHandler_Update(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
dateStr := now.Format(time.RFC3339)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
body string
|
||||||
|
mock *mockSessionRepo
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid update",
|
||||||
|
url: "/api/sessions/1",
|
||||||
|
body: `{"date":"` + dateStr + `","notes":"Updated notes"}`,
|
||||||
|
mock: &mockSessionRepo{
|
||||||
|
session: &models.Session{
|
||||||
|
ID: 1,
|
||||||
|
Date: now,
|
||||||
|
Notes: "Updated notes",
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update with plan",
|
||||||
|
url: "/api/sessions/1",
|
||||||
|
body: `{"date":"` + dateStr + `","plan_id":2}`,
|
||||||
|
mock: &mockSessionRepo{
|
||||||
|
session: &models.Session{
|
||||||
|
ID: 1,
|
||||||
|
Date: now,
|
||||||
|
PlanID: ptr(int64(2)),
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not found",
|
||||||
|
url: "/api/sessions/999",
|
||||||
|
body: `{"date":"` + dateStr + `"}`,
|
||||||
|
mock: &mockSessionRepo{session: nil},
|
||||||
|
wantStatus: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing date",
|
||||||
|
url: "/api/sessions/1",
|
||||||
|
body: `{"notes":"No date"}`,
|
||||||
|
mock: &mockSessionRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no ID in URL",
|
||||||
|
url: "/api/sessions/",
|
||||||
|
body: `{"date":"` + dateStr + `"}`,
|
||||||
|
mock: &mockSessionRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository error",
|
||||||
|
url: "/api/sessions/1",
|
||||||
|
body: `{"date":"` + dateStr + `"}`,
|
||||||
|
mock: &mockSessionRepo{err: errors.New("db error")},
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := NewSessionHandler(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 TestSessionHandler_Delete(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
mock *mockSessionRepo
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful delete",
|
||||||
|
url: "/api/sessions/1",
|
||||||
|
mock: &mockSessionRepo{deleteErr: nil},
|
||||||
|
wantStatus: http.StatusNoContent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not found",
|
||||||
|
url: "/api/sessions/999",
|
||||||
|
mock: &mockSessionRepo{deleteErr: sql.ErrNoRows},
|
||||||
|
wantStatus: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no ID in URL",
|
||||||
|
url: "/api/sessions/",
|
||||||
|
mock: &mockSessionRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository error",
|
||||||
|
url: "/api/sessions/1",
|
||||||
|
mock: &mockSessionRepo{deleteErr: errors.New("db error")},
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := NewSessionHandler(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 TestSessionHandler_AddEntry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
body string
|
||||||
|
mock *mockSessionRepo
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid strength entry",
|
||||||
|
url: "/api/sessions/1/entries",
|
||||||
|
body: `{"exercise_id":1,"weight":100,"reps":10,"sets_completed":3}`,
|
||||||
|
mock: &mockSessionRepo{
|
||||||
|
entry: &models.SessionEntry{
|
||||||
|
ID: 1,
|
||||||
|
SessionID: 1,
|
||||||
|
ExerciseID: 1,
|
||||||
|
Weight: 100,
|
||||||
|
Reps: 10,
|
||||||
|
SetsCompleted: 3,
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusCreated,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid cardio entry",
|
||||||
|
url: "/api/sessions/1/entries",
|
||||||
|
body: `{"exercise_id":2,"duration":1800,"distance":5.5,"heart_rate":145}`,
|
||||||
|
mock: &mockSessionRepo{
|
||||||
|
entry: &models.SessionEntry{
|
||||||
|
ID: 2,
|
||||||
|
SessionID: 1,
|
||||||
|
ExerciseID: 2,
|
||||||
|
Duration: 1800,
|
||||||
|
Distance: 5.5,
|
||||||
|
HeartRate: 145,
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusCreated,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing exercise_id",
|
||||||
|
url: "/api/sessions/1/entries",
|
||||||
|
body: `{"weight":100,"reps":10}`,
|
||||||
|
mock: &mockSessionRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid JSON",
|
||||||
|
url: "/api/sessions/1/entries",
|
||||||
|
body: `{invalid}`,
|
||||||
|
mock: &mockSessionRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no session ID",
|
||||||
|
url: "/api/sessions//entries",
|
||||||
|
body: `{"exercise_id":1}`,
|
||||||
|
mock: &mockSessionRepo{},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository error",
|
||||||
|
url: "/api/sessions/1/entries",
|
||||||
|
body: `{"exercise_id":1}`,
|
||||||
|
mock: &mockSessionRepo{entryErr: errors.New("db error")},
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := NewSessionHandler(tt.mock)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, 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 TestSessionHandler_MethodNotAllowed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := NewSessionHandler(&mockSessionRepo{})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/sessions/1", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionHandler_EntriesMethodNotAllowed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := NewSessionHandler(&mockSessionRepo{})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions/1/entries", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptr is a helper to create a pointer to a value
|
||||||
|
func ptr[T any](v T) *T {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
245
backend/internal/storage/exercises_test.go
Normal file
245
backend/internal/storage/exercises_test.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"training-tracker/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExerciseRepository_CRUD(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
tdb := setupTestDB(t)
|
||||||
|
defer tdb.cleanup(t)
|
||||||
|
|
||||||
|
repo := NewExerciseRepository(tdb.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("List_empty", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
exercises, err := repo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List failed: %v", err)
|
||||||
|
}
|
||||||
|
if exercises != nil && len(exercises) != 0 {
|
||||||
|
t.Errorf("expected empty list, got %d items", len(exercises))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create_strength", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
req := &models.CreateExerciseRequest{
|
||||||
|
Name: "Bench Press",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
MuscleGroup: "Chest",
|
||||||
|
Description: "Barbell bench press",
|
||||||
|
}
|
||||||
|
|
||||||
|
exercise, err := repo.Create(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exercise.ID == 0 {
|
||||||
|
t.Error("expected non-zero ID")
|
||||||
|
}
|
||||||
|
if exercise.Name != req.Name {
|
||||||
|
t.Errorf("name = %q, want %q", exercise.Name, req.Name)
|
||||||
|
}
|
||||||
|
if exercise.Type != req.Type {
|
||||||
|
t.Errorf("type = %q, want %q", exercise.Type, req.Type)
|
||||||
|
}
|
||||||
|
if exercise.MuscleGroup != req.MuscleGroup {
|
||||||
|
t.Errorf("muscle_group = %q, want %q", exercise.MuscleGroup, req.MuscleGroup)
|
||||||
|
}
|
||||||
|
if exercise.Description != req.Description {
|
||||||
|
t.Errorf("description = %q, want %q", exercise.Description, req.Description)
|
||||||
|
}
|
||||||
|
if exercise.CreatedAt.IsZero() {
|
||||||
|
t.Error("expected non-zero created_at")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create_cardio", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
req := &models.CreateExerciseRequest{
|
||||||
|
Name: "Running",
|
||||||
|
Type: models.ExerciseTypeCardio,
|
||||||
|
}
|
||||||
|
|
||||||
|
exercise, err := repo.Create(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exercise.Type != models.ExerciseTypeCardio {
|
||||||
|
t.Errorf("type = %q, want %q", exercise.Type, models.ExerciseTypeCardio)
|
||||||
|
}
|
||||||
|
if exercise.MuscleGroup != "" {
|
||||||
|
t.Errorf("expected empty muscle_group, got %q", exercise.MuscleGroup)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetByID_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
created, _ := repo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "Squat",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
|
||||||
|
exercise, err := repo.GetByID(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByID failed: %v", err)
|
||||||
|
}
|
||||||
|
if exercise == nil {
|
||||||
|
t.Fatal("expected exercise, got nil")
|
||||||
|
}
|
||||||
|
if exercise.ID != created.ID {
|
||||||
|
t.Errorf("ID = %d, want %d", exercise.ID, created.ID)
|
||||||
|
}
|
||||||
|
if exercise.Name != "Squat" {
|
||||||
|
t.Errorf("name = %q, want %q", exercise.Name, "Squat")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetByID_not_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
exercise, err := repo.GetByID(ctx, 99999)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByID failed: %v", err)
|
||||||
|
}
|
||||||
|
if exercise != nil {
|
||||||
|
t.Errorf("expected nil, got %+v", exercise)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Update_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
created, _ := repo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "Old Name",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
|
||||||
|
updated, err := repo.Update(ctx, created.ID, &models.CreateExerciseRequest{
|
||||||
|
Name: "New Name",
|
||||||
|
Type: models.ExerciseTypeCardio,
|
||||||
|
MuscleGroup: "Legs",
|
||||||
|
Description: "Updated description",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
t.Fatal("expected updated exercise, got nil")
|
||||||
|
}
|
||||||
|
if updated.Name != "New Name" {
|
||||||
|
t.Errorf("name = %q, want %q", updated.Name, "New Name")
|
||||||
|
}
|
||||||
|
if updated.Type != models.ExerciseTypeCardio {
|
||||||
|
t.Errorf("type = %q, want %q", updated.Type, models.ExerciseTypeCardio)
|
||||||
|
}
|
||||||
|
if updated.MuscleGroup != "Legs" {
|
||||||
|
t.Errorf("muscle_group = %q, want %q", updated.MuscleGroup, "Legs")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Update_not_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
updated, err := repo.Update(ctx, 99999, &models.CreateExerciseRequest{
|
||||||
|
Name: "Test",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
if updated != nil {
|
||||||
|
t.Errorf("expected nil, got %+v", updated)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
created, _ := repo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "To Delete",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
|
||||||
|
err := repo.Delete(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Delete failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exercise, _ := repo.GetByID(ctx, created.ID)
|
||||||
|
if exercise != nil {
|
||||||
|
t.Error("expected exercise to be deleted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete_not_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
err := repo.Delete(ctx, 99999)
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
t.Errorf("expected sql.ErrNoRows, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("List_multiple", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
repo.Create(ctx, &models.CreateExerciseRequest{Name: "Alpha", Type: models.ExerciseTypeStrength})
|
||||||
|
repo.Create(ctx, &models.CreateExerciseRequest{Name: "Beta", Type: models.ExerciseTypeCardio})
|
||||||
|
repo.Create(ctx, &models.CreateExerciseRequest{Name: "Gamma", Type: models.ExerciseTypeStrength})
|
||||||
|
|
||||||
|
exercises, err := repo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(exercises) != 3 {
|
||||||
|
t.Errorf("len = %d, want 3", len(exercises))
|
||||||
|
}
|
||||||
|
// Results are ordered by name
|
||||||
|
if exercises[0].Name != "Alpha" {
|
||||||
|
t.Errorf("first exercise = %q, want Alpha", exercises[0].Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Update_clear_optional_fields", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
created, _ := repo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "With Optional",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
MuscleGroup: "Arms",
|
||||||
|
Description: "Has description",
|
||||||
|
})
|
||||||
|
|
||||||
|
updated, err := repo.Update(ctx, created.ID, &models.CreateExerciseRequest{
|
||||||
|
Name: "Without Optional",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
if updated.MuscleGroup != "" {
|
||||||
|
t.Errorf("expected empty muscle_group, got %q", updated.MuscleGroup)
|
||||||
|
}
|
||||||
|
if updated.Description != "" {
|
||||||
|
t.Errorf("expected empty description, got %q", updated.Description)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
346
backend/internal/storage/plans_test.go
Normal file
346
backend/internal/storage/plans_test.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"training-tracker/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanRepository_CRUD(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
tdb := setupTestDB(t)
|
||||||
|
defer tdb.cleanup(t)
|
||||||
|
|
||||||
|
planRepo := NewPlanRepository(tdb.DB)
|
||||||
|
exerciseRepo := NewExerciseRepository(tdb.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("List_empty", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
plans, err := planRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List failed: %v", err)
|
||||||
|
}
|
||||||
|
if plans != nil && len(plans) != 0 {
|
||||||
|
t.Errorf("expected empty list, got %d items", len(plans))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create_without_exercises", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
req := &models.CreatePlanRequest{
|
||||||
|
Name: "Push Day",
|
||||||
|
Description: "Chest, shoulders, triceps",
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := planRepo.Create(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if plan.ID == 0 {
|
||||||
|
t.Error("expected non-zero ID")
|
||||||
|
}
|
||||||
|
if plan.Name != req.Name {
|
||||||
|
t.Errorf("name = %q, want %q", plan.Name, req.Name)
|
||||||
|
}
|
||||||
|
if plan.Description != req.Description {
|
||||||
|
t.Errorf("description = %q, want %q", plan.Description, req.Description)
|
||||||
|
}
|
||||||
|
if plan.CreatedAt.IsZero() {
|
||||||
|
t.Error("expected non-zero created_at")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create_with_exercises", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
exercise1, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "Bench Press",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
exercise2, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "Shoulder Press",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
|
||||||
|
req := &models.CreatePlanRequest{
|
||||||
|
Name: "Push Day",
|
||||||
|
Exercises: []models.PlanExerciseInput{
|
||||||
|
{ExerciseID: exercise1.ID, Sets: 4, Reps: 8, Order: 1},
|
||||||
|
{ExerciseID: exercise2.ID, Sets: 3, Reps: 12, Order: 2},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := planRepo.Create(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(plan.Exercises) != 2 {
|
||||||
|
t.Fatalf("expected 2 exercises, got %d", len(plan.Exercises))
|
||||||
|
}
|
||||||
|
if plan.Exercises[0].Sets != 4 {
|
||||||
|
t.Errorf("sets = %d, want 4", plan.Exercises[0].Sets)
|
||||||
|
}
|
||||||
|
if plan.Exercises[0].Reps != 8 {
|
||||||
|
t.Errorf("reps = %d, want 8", plan.Exercises[0].Reps)
|
||||||
|
}
|
||||||
|
if plan.Exercises[0].Exercise.Name != "Bench Press" {
|
||||||
|
t.Errorf("exercise name = %q, want %q", plan.Exercises[0].Exercise.Name, "Bench Press")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create_with_duration_exercise", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "Running",
|
||||||
|
Type: models.ExerciseTypeCardio,
|
||||||
|
})
|
||||||
|
|
||||||
|
req := &models.CreatePlanRequest{
|
||||||
|
Name: "Cardio Day",
|
||||||
|
Exercises: []models.PlanExerciseInput{
|
||||||
|
{ExerciseID: exercise.ID, Duration: 1800, Order: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := planRepo.Create(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if plan.Exercises[0].Duration != 1800 {
|
||||||
|
t.Errorf("duration = %d, want 1800", plan.Exercises[0].Duration)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetByID_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
created, _ := planRepo.Create(ctx, &models.CreatePlanRequest{
|
||||||
|
Name: "Test Plan",
|
||||||
|
Description: "Test description",
|
||||||
|
})
|
||||||
|
|
||||||
|
plan, err := planRepo.GetByID(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByID failed: %v", err)
|
||||||
|
}
|
||||||
|
if plan == nil {
|
||||||
|
t.Fatal("expected plan, got nil")
|
||||||
|
}
|
||||||
|
if plan.ID != created.ID {
|
||||||
|
t.Errorf("ID = %d, want %d", plan.ID, created.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetByID_with_exercises", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "Squat",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
|
||||||
|
created, _ := planRepo.Create(ctx, &models.CreatePlanRequest{
|
||||||
|
Name: "Leg Day",
|
||||||
|
Exercises: []models.PlanExerciseInput{
|
||||||
|
{ExerciseID: exercise.ID, Sets: 5, Reps: 5, Order: 1},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
plan, err := planRepo.GetByID(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByID failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Exercises) != 1 {
|
||||||
|
t.Fatalf("expected 1 exercise, got %d", len(plan.Exercises))
|
||||||
|
}
|
||||||
|
if plan.Exercises[0].Exercise.Name != "Squat" {
|
||||||
|
t.Errorf("exercise name = %q, want Squat", plan.Exercises[0].Exercise.Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetByID_not_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
plan, err := planRepo.GetByID(ctx, 99999)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByID failed: %v", err)
|
||||||
|
}
|
||||||
|
if plan != nil {
|
||||||
|
t.Errorf("expected nil, got %+v", plan)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Update_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
created, _ := planRepo.Create(ctx, &models.CreatePlanRequest{
|
||||||
|
Name: "Old Name",
|
||||||
|
Description: "Old description",
|
||||||
|
})
|
||||||
|
|
||||||
|
updated, err := planRepo.Update(ctx, created.ID, &models.CreatePlanRequest{
|
||||||
|
Name: "New Name",
|
||||||
|
Description: "New description",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
t.Fatal("expected updated plan, got nil")
|
||||||
|
}
|
||||||
|
if updated.Name != "New Name" {
|
||||||
|
t.Errorf("name = %q, want New Name", updated.Name)
|
||||||
|
}
|
||||||
|
if updated.Description != "New description" {
|
||||||
|
t.Errorf("description = %q, want New description", updated.Description)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Update_replaces_exercises", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
exercise1, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "Old Exercise",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
exercise2, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "New Exercise",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
|
||||||
|
created, _ := planRepo.Create(ctx, &models.CreatePlanRequest{
|
||||||
|
Name: "Test Plan",
|
||||||
|
Exercises: []models.PlanExerciseInput{
|
||||||
|
{ExerciseID: exercise1.ID, Sets: 3, Reps: 10, Order: 1},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
updated, err := planRepo.Update(ctx, created.ID, &models.CreatePlanRequest{
|
||||||
|
Name: "Test Plan",
|
||||||
|
Exercises: []models.PlanExerciseInput{
|
||||||
|
{ExerciseID: exercise2.ID, Sets: 4, Reps: 12, Order: 1},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(updated.Exercises) != 1 {
|
||||||
|
t.Fatalf("expected 1 exercise, got %d", len(updated.Exercises))
|
||||||
|
}
|
||||||
|
if updated.Exercises[0].Exercise.Name != "New Exercise" {
|
||||||
|
t.Errorf("exercise name = %q, want New Exercise", updated.Exercises[0].Exercise.Name)
|
||||||
|
}
|
||||||
|
if updated.Exercises[0].Sets != 4 {
|
||||||
|
t.Errorf("sets = %d, want 4", updated.Exercises[0].Sets)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Update_not_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
updated, err := planRepo.Update(ctx, 99999, &models.CreatePlanRequest{
|
||||||
|
Name: "Test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
if updated != nil {
|
||||||
|
t.Errorf("expected nil, got %+v", updated)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
created, _ := planRepo.Create(ctx, &models.CreatePlanRequest{
|
||||||
|
Name: "To Delete",
|
||||||
|
})
|
||||||
|
|
||||||
|
err := planRepo.Delete(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Delete failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, _ := planRepo.GetByID(ctx, created.ID)
|
||||||
|
if plan != nil {
|
||||||
|
t.Error("expected plan to be deleted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete_cascades_exercises", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "Test Exercise",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
|
||||||
|
created, _ := planRepo.Create(ctx, &models.CreatePlanRequest{
|
||||||
|
Name: "Plan with Exercises",
|
||||||
|
Exercises: []models.PlanExerciseInput{
|
||||||
|
{ExerciseID: exercise.ID, Sets: 3, Reps: 10, Order: 1},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := planRepo.Delete(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Delete failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify plan exercises are deleted
|
||||||
|
var count int
|
||||||
|
tdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM plan_exercises WHERE plan_id = $1", created.ID).Scan(&count)
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("expected 0 plan_exercises, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// But the exercise itself should still exist
|
||||||
|
existingExercise, _ := exerciseRepo.GetByID(ctx, exercise.ID)
|
||||||
|
if existingExercise == nil {
|
||||||
|
t.Error("expected exercise to still exist")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete_not_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
err := planRepo.Delete(ctx, 99999)
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
t.Errorf("expected sql.ErrNoRows, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("List_multiple", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
planRepo.Create(ctx, &models.CreatePlanRequest{Name: "Alpha Plan"})
|
||||||
|
planRepo.Create(ctx, &models.CreatePlanRequest{Name: "Beta Plan"})
|
||||||
|
planRepo.Create(ctx, &models.CreatePlanRequest{Name: "Gamma Plan"})
|
||||||
|
|
||||||
|
plans, err := planRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(plans) != 3 {
|
||||||
|
t.Errorf("len = %d, want 3", len(plans))
|
||||||
|
}
|
||||||
|
// Results are ordered by name
|
||||||
|
if plans[0].Name != "Alpha Plan" {
|
||||||
|
t.Errorf("first plan = %q, want Alpha Plan", plans[0].Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
435
backend/internal/storage/sessions_test.go
Normal file
435
backend/internal/storage/sessions_test.go
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"training-tracker/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionRepository_CRUD(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
tdb := setupTestDB(t)
|
||||||
|
defer tdb.cleanup(t)
|
||||||
|
|
||||||
|
sessionRepo := NewSessionRepository(tdb.DB)
|
||||||
|
planRepo := NewPlanRepository(tdb.DB)
|
||||||
|
exerciseRepo := NewExerciseRepository(tdb.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("List_empty", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
sessions, err := sessionRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List failed: %v", err)
|
||||||
|
}
|
||||||
|
if sessions != nil && len(sessions) != 0 {
|
||||||
|
t.Errorf("expected empty list, got %d items", len(sessions))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create_without_plan", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
now := time.Now().Truncate(time.Second)
|
||||||
|
req := &models.CreateSessionRequest{
|
||||||
|
Date: now,
|
||||||
|
Notes: "Morning workout",
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := sessionRepo.Create(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.ID == 0 {
|
||||||
|
t.Error("expected non-zero ID")
|
||||||
|
}
|
||||||
|
if session.PlanID != nil {
|
||||||
|
t.Errorf("expected nil plan_id, got %d", *session.PlanID)
|
||||||
|
}
|
||||||
|
if session.Notes != req.Notes {
|
||||||
|
t.Errorf("notes = %q, want %q", session.Notes, req.Notes)
|
||||||
|
}
|
||||||
|
if session.CreatedAt.IsZero() {
|
||||||
|
t.Error("expected non-zero created_at")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create_with_plan", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
plan, _ := planRepo.Create(ctx, &models.CreatePlanRequest{Name: "Test Plan"})
|
||||||
|
|
||||||
|
now := time.Now().Truncate(time.Second)
|
||||||
|
req := &models.CreateSessionRequest{
|
||||||
|
PlanID: &plan.ID,
|
||||||
|
Date: now,
|
||||||
|
Notes: "Following plan",
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := sessionRepo.Create(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.PlanID == nil {
|
||||||
|
t.Fatal("expected plan_id, got nil")
|
||||||
|
}
|
||||||
|
if *session.PlanID != plan.ID {
|
||||||
|
t.Errorf("plan_id = %d, want %d", *session.PlanID, plan.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetByID_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
created, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{
|
||||||
|
Date: time.Now(),
|
||||||
|
Notes: "Test session",
|
||||||
|
})
|
||||||
|
|
||||||
|
session, err := sessionRepo.GetByID(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByID failed: %v", err)
|
||||||
|
}
|
||||||
|
if session == nil {
|
||||||
|
t.Fatal("expected session, got nil")
|
||||||
|
}
|
||||||
|
if session.ID != created.ID {
|
||||||
|
t.Errorf("ID = %d, want %d", session.ID, created.ID)
|
||||||
|
}
|
||||||
|
if session.Notes != "Test session" {
|
||||||
|
t.Errorf("notes = %q, want Test session", session.Notes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetByID_with_plan", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
plan, _ := planRepo.Create(ctx, &models.CreatePlanRequest{Name: "My Plan"})
|
||||||
|
|
||||||
|
created, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{
|
||||||
|
PlanID: &plan.ID,
|
||||||
|
Date: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
session, err := sessionRepo.GetByID(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByID failed: %v", err)
|
||||||
|
}
|
||||||
|
if session.Plan == nil {
|
||||||
|
t.Fatal("expected plan, got nil")
|
||||||
|
}
|
||||||
|
if session.Plan.Name != "My Plan" {
|
||||||
|
t.Errorf("plan name = %q, want My Plan", session.Plan.Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetByID_with_entries", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "Bench Press",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
|
||||||
|
session, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{
|
||||||
|
Date: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
sessionRepo.AddEntry(ctx, session.ID, &models.CreateSessionEntryRequest{
|
||||||
|
ExerciseID: exercise.ID,
|
||||||
|
Weight: 100,
|
||||||
|
Reps: 10,
|
||||||
|
SetsCompleted: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
fetched, err := sessionRepo.GetByID(ctx, session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByID failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(fetched.Entries) != 1 {
|
||||||
|
t.Fatalf("expected 1 entry, got %d", len(fetched.Entries))
|
||||||
|
}
|
||||||
|
if fetched.Entries[0].Weight != 100 {
|
||||||
|
t.Errorf("weight = %f, want 100", fetched.Entries[0].Weight)
|
||||||
|
}
|
||||||
|
if fetched.Entries[0].Exercise == nil {
|
||||||
|
t.Fatal("expected exercise, got nil")
|
||||||
|
}
|
||||||
|
if fetched.Entries[0].Exercise.Name != "Bench Press" {
|
||||||
|
t.Errorf("exercise name = %q, want Bench Press", fetched.Entries[0].Exercise.Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetByID_not_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
session, err := sessionRepo.GetByID(ctx, 99999)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByID failed: %v", err)
|
||||||
|
}
|
||||||
|
if session != nil {
|
||||||
|
t.Errorf("expected nil, got %+v", session)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Update_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
created, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{
|
||||||
|
Date: time.Now(),
|
||||||
|
Notes: "Old notes",
|
||||||
|
})
|
||||||
|
|
||||||
|
newDate := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||||
|
updated, err := sessionRepo.Update(ctx, created.ID, &models.CreateSessionRequest{
|
||||||
|
Date: newDate,
|
||||||
|
Notes: "New notes",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
t.Fatal("expected updated session, got nil")
|
||||||
|
}
|
||||||
|
if updated.Notes != "New notes" {
|
||||||
|
t.Errorf("notes = %q, want New notes", updated.Notes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Update_add_plan", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
plan, _ := planRepo.Create(ctx, &models.CreatePlanRequest{Name: "New Plan"})
|
||||||
|
|
||||||
|
created, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{
|
||||||
|
Date: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
updated, err := sessionRepo.Update(ctx, created.ID, &models.CreateSessionRequest{
|
||||||
|
PlanID: &plan.ID,
|
||||||
|
Date: created.Date,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
if updated.PlanID == nil {
|
||||||
|
t.Fatal("expected plan_id, got nil")
|
||||||
|
}
|
||||||
|
if *updated.PlanID != plan.ID {
|
||||||
|
t.Errorf("plan_id = %d, want %d", *updated.PlanID, plan.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Update_not_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
updated, err := sessionRepo.Update(ctx, 99999, &models.CreateSessionRequest{
|
||||||
|
Date: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
if updated != nil {
|
||||||
|
t.Errorf("expected nil, got %+v", updated)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
created, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{
|
||||||
|
Date: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
err := sessionRepo.Delete(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Delete failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, _ := sessionRepo.GetByID(ctx, created.ID)
|
||||||
|
if session != nil {
|
||||||
|
t.Error("expected session to be deleted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete_cascades_entries", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "Test Exercise",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
|
||||||
|
session, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{
|
||||||
|
Date: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
sessionRepo.AddEntry(ctx, session.ID, &models.CreateSessionEntryRequest{
|
||||||
|
ExerciseID: exercise.ID,
|
||||||
|
Weight: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
err := sessionRepo.Delete(ctx, session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Delete failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify entries are deleted
|
||||||
|
var count int
|
||||||
|
tdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM session_entries WHERE session_id = $1", session.ID).Scan(&count)
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("expected 0 session_entries, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// But the exercise itself should still exist
|
||||||
|
existingExercise, _ := exerciseRepo.GetByID(ctx, exercise.ID)
|
||||||
|
if existingExercise == nil {
|
||||||
|
t.Error("expected exercise to still exist")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete_not_found", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
err := sessionRepo.Delete(ctx, 99999)
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
t.Errorf("expected sql.ErrNoRows, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("List_multiple_ordered_by_date", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
now := time.Now().Truncate(time.Second)
|
||||||
|
sessionRepo.Create(ctx, &models.CreateSessionRequest{Date: now.Add(-48 * time.Hour), Notes: "Two days ago"})
|
||||||
|
sessionRepo.Create(ctx, &models.CreateSessionRequest{Date: now, Notes: "Today"})
|
||||||
|
sessionRepo.Create(ctx, &models.CreateSessionRequest{Date: now.Add(-24 * time.Hour), Notes: "Yesterday"})
|
||||||
|
|
||||||
|
sessions, err := sessionRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(sessions) != 3 {
|
||||||
|
t.Errorf("len = %d, want 3", len(sessions))
|
||||||
|
}
|
||||||
|
// Results are ordered by date DESC
|
||||||
|
if sessions[0].Notes != "Today" {
|
||||||
|
t.Errorf("first session = %q, want Today", sessions[0].Notes)
|
||||||
|
}
|
||||||
|
if sessions[1].Notes != "Yesterday" {
|
||||||
|
t.Errorf("second session = %q, want Yesterday", sessions[1].Notes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddEntry_strength", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "Squat",
|
||||||
|
Type: models.ExerciseTypeStrength,
|
||||||
|
})
|
||||||
|
|
||||||
|
session, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{
|
||||||
|
Date: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
entry, err := sessionRepo.AddEntry(ctx, session.ID, &models.CreateSessionEntryRequest{
|
||||||
|
ExerciseID: exercise.ID,
|
||||||
|
Weight: 140,
|
||||||
|
Reps: 5,
|
||||||
|
SetsCompleted: 5,
|
||||||
|
Notes: "Felt heavy",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddEntry failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.ID == 0 {
|
||||||
|
t.Error("expected non-zero ID")
|
||||||
|
}
|
||||||
|
if entry.SessionID != session.ID {
|
||||||
|
t.Errorf("session_id = %d, want %d", entry.SessionID, session.ID)
|
||||||
|
}
|
||||||
|
if entry.Weight != 140 {
|
||||||
|
t.Errorf("weight = %f, want 140", entry.Weight)
|
||||||
|
}
|
||||||
|
if entry.Reps != 5 {
|
||||||
|
t.Errorf("reps = %d, want 5", entry.Reps)
|
||||||
|
}
|
||||||
|
if entry.SetsCompleted != 5 {
|
||||||
|
t.Errorf("sets_completed = %d, want 5", entry.SetsCompleted)
|
||||||
|
}
|
||||||
|
if entry.Notes != "Felt heavy" {
|
||||||
|
t.Errorf("notes = %q, want Felt heavy", entry.Notes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddEntry_cardio", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{
|
||||||
|
Name: "Running",
|
||||||
|
Type: models.ExerciseTypeCardio,
|
||||||
|
})
|
||||||
|
|
||||||
|
session, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{
|
||||||
|
Date: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
entry, err := sessionRepo.AddEntry(ctx, session.ID, &models.CreateSessionEntryRequest{
|
||||||
|
ExerciseID: exercise.ID,
|
||||||
|
Duration: 3600,
|
||||||
|
Distance: 10.5,
|
||||||
|
HeartRate: 155,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddEntry failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Duration != 3600 {
|
||||||
|
t.Errorf("duration = %d, want 3600", entry.Duration)
|
||||||
|
}
|
||||||
|
if entry.Distance != 10.5 {
|
||||||
|
t.Errorf("distance = %f, want 10.5", entry.Distance)
|
||||||
|
}
|
||||||
|
if entry.HeartRate != 155 {
|
||||||
|
t.Errorf("heart_rate = %d, want 155", entry.HeartRate)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Plan_delete_sets_null_on_sessions", func(t *testing.T) {
|
||||||
|
tdb.truncateTables(t)
|
||||||
|
|
||||||
|
plan, _ := planRepo.Create(ctx, &models.CreatePlanRequest{Name: "Will be deleted"})
|
||||||
|
|
||||||
|
session, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{
|
||||||
|
PlanID: &plan.ID,
|
||||||
|
Date: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
err := planRepo.Delete(ctx, plan.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Delete plan failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session should still exist but with null plan_id
|
||||||
|
fetched, _ := sessionRepo.GetByID(ctx, session.ID)
|
||||||
|
if fetched == nil {
|
||||||
|
t.Fatal("expected session to still exist")
|
||||||
|
}
|
||||||
|
if fetched.PlanID != nil {
|
||||||
|
t.Errorf("expected nil plan_id after plan deletion, got %d", *fetched.PlanID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
107
backend/internal/storage/testhelpers_test.go
Normal file
107
backend/internal/storage/testhelpers_test.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/testcontainers/testcontainers-go"
|
||||||
|
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testDB holds the shared database connection for integration tests
|
||||||
|
type testDB struct {
|
||||||
|
*DB
|
||||||
|
container *postgres.PostgresContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTestDB creates a PostgreSQL container and returns a connected DB instance
|
||||||
|
func setupTestDB(t *testing.T) *testDB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
container, err := postgres.Run(ctx,
|
||||||
|
"postgres:16-alpine",
|
||||||
|
postgres.WithDatabase("training_tracker_test"),
|
||||||
|
postgres.WithUsername("test"),
|
||||||
|
postgres.WithPassword("test"),
|
||||||
|
testcontainers.WithWaitStrategy(
|
||||||
|
wait.ForLog("database system is ready to accept connections").
|
||||||
|
WithOccurrence(2).
|
||||||
|
WithStartupTimeout(30*time.Second),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to start postgres container: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
connStr, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get connection string: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := NewDBWithConnString(ctx, connStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Migrate(ctx); err != nil {
|
||||||
|
t.Fatalf("failed to run migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &testDB{
|
||||||
|
DB: db,
|
||||||
|
container: container,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup terminates the container and closes the database connection
|
||||||
|
func (tdb *testDB) cleanup(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if tdb.DB != nil {
|
||||||
|
tdb.DB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if tdb.container != nil {
|
||||||
|
if err := tdb.container.Terminate(ctx); err != nil {
|
||||||
|
t.Logf("failed to terminate container: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateTables clears all data from tables for test isolation
|
||||||
|
func (tdb *testDB) truncateTables(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
tables := []string{"session_entries", "sessions", "plan_exercises", "training_plans", "exercises"}
|
||||||
|
|
||||||
|
for _, table := range tables {
|
||||||
|
_, err := tdb.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to truncate %s: %v", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDBWithConnString creates a DB instance with a custom connection string
|
||||||
|
func NewDBWithConnString(ctx context.Context, connStr string) (*DB, error) {
|
||||||
|
db, err := openDB(connStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DB{db}, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user