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,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)
}
})
}

View 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)
}
})
}

View 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)
}
})
}

View 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
}