Add PDF reports, file uploads, admin auth, and comprehensive tests

Features:
- PDF service for generating task reports with ReportLab-style formatting
- Storage service for file uploads (local and S3-compatible)
- Admin authentication middleware with JWT support
- Admin user model and repository

Infrastructure:
- Updated Docker configuration for admin panel builds
- Email service enhancements for task notifications
- Updated router with admin and file upload routes
- Environment configuration updates

Tests:
- Unit tests for handlers (auth, residence, task)
- Unit tests for models (user, residence, task)
- Unit tests for repositories (user, residence, task)
- Unit tests for services (residence, task)
- Integration test setup
- Test utilities for mocking database and services

Database:
- Admin user seed data
- Updated test data seeds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-27 23:36:20 -06:00
parent 2817deee3c
commit 469f21a833
50 changed files with 6795 additions and 582 deletions

View File

@@ -0,0 +1,107 @@
package repositories
import (
"errors"
"time"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/models"
)
var (
ErrAdminNotFound = errors.New("admin user not found")
ErrAdminExists = errors.New("admin user already exists")
)
// AdminRepository handles admin user database operations
type AdminRepository struct {
db *gorm.DB
}
// NewAdminRepository creates a new admin repository
func NewAdminRepository(db *gorm.DB) *AdminRepository {
return &AdminRepository{db: db}
}
// FindByID finds an admin user by ID
func (r *AdminRepository) FindByID(id uint) (*models.AdminUser, error) {
var admin models.AdminUser
if err := r.db.First(&admin, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAdminNotFound
}
return nil, err
}
return &admin, nil
}
// FindByEmail finds an admin user by email (case-insensitive)
func (r *AdminRepository) FindByEmail(email string) (*models.AdminUser, error) {
var admin models.AdminUser
if err := r.db.Where("LOWER(email) = LOWER(?)", email).First(&admin).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAdminNotFound
}
return nil, err
}
return &admin, nil
}
// Create creates a new admin user
func (r *AdminRepository) Create(admin *models.AdminUser) error {
// Check if email already exists
var count int64
if err := r.db.Model(&models.AdminUser{}).Where("LOWER(email) = LOWER(?)", admin.Email).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return ErrAdminExists
}
return r.db.Create(admin).Error
}
// Update updates an admin user
func (r *AdminRepository) Update(admin *models.AdminUser) error {
return r.db.Save(admin).Error
}
// Delete deletes an admin user
func (r *AdminRepository) Delete(id uint) error {
return r.db.Delete(&models.AdminUser{}, id).Error
}
// UpdateLastLogin updates the last login timestamp
func (r *AdminRepository) UpdateLastLogin(id uint) error {
now := time.Now()
return r.db.Model(&models.AdminUser{}).Where("id = ?", id).Update("last_login", now).Error
}
// List returns all admin users with pagination
func (r *AdminRepository) List(page, pageSize int) ([]models.AdminUser, int64, error) {
var admins []models.AdminUser
var total int64
// Get total count
if err := r.db.Model(&models.AdminUser{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * pageSize
if err := r.db.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&admins).Error; err != nil {
return nil, 0, err
}
return admins, total, nil
}
// ExistsByEmail checks if an admin user with the given email exists
func (r *AdminRepository) ExistsByEmail(email string) (bool, error) {
var count int64
if err := r.db.Model(&models.AdminUser{}).Where("LOWER(email) = LOWER(?)", email).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}

View File

@@ -0,0 +1,330 @@
package repositories
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/mycrib-api/internal/models"
"github.com/treytartt/mycrib-api/internal/testutil"
)
func TestResidenceRepository_Create(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := &models.Residence{
OwnerID: user.ID,
Name: "Test House",
StreetAddress: "123 Main St",
City: "Austin",
StateProvince: "TX",
PostalCode: "78701",
Country: "USA",
IsActive: true,
IsPrimary: true,
}
err := repo.Create(residence)
require.NoError(t, err)
assert.NotZero(t, residence.ID)
}
func TestResidenceRepository_FindByID(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
found, err := repo.FindByID(residence.ID)
require.NoError(t, err)
assert.Equal(t, residence.ID, found.ID)
assert.Equal(t, "Test House", found.Name)
assert.Equal(t, user.ID, found.OwnerID)
}
func TestResidenceRepository_FindByID_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
_, err := repo.FindByID(9999)
assert.Error(t, err)
}
func TestResidenceRepository_FindByID_InactiveNotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Deactivate
db.Model(residence).Update("is_active", false)
_, err := repo.FindByID(residence.ID)
assert.Error(t, err) // Should not find inactive residences
}
func TestResidenceRepository_FindByUser(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
// Create residences
r1 := testutil.CreateTestResidence(t, db, owner.ID, "House 1")
r2 := testutil.CreateTestResidence(t, db, owner.ID, "House 2")
// Share r1 with sharedUser
repo.AddUser(r1.ID, sharedUser.ID)
// Owner should see both
ownerResidences, err := repo.FindByUser(owner.ID)
require.NoError(t, err)
assert.Len(t, ownerResidences, 2)
// Shared user should see only r1
sharedResidences, err := repo.FindByUser(sharedUser.ID)
require.NoError(t, err)
assert.Len(t, sharedResidences, 1)
assert.Equal(t, r1.ID, sharedResidences[0].ID)
// Another user should see nothing
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
otherResidences, err := repo.FindByUser(otherUser.ID)
require.NoError(t, err)
assert.Len(t, otherResidences, 0)
_ = r2 // suppress unused
}
func TestResidenceRepository_FindOwnedByUser(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
otherOwner := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
testutil.CreateTestResidence(t, db, owner.ID, "House 1")
testutil.CreateTestResidence(t, db, owner.ID, "House 2")
testutil.CreateTestResidence(t, db, otherOwner.ID, "Other House")
residences, err := repo.FindOwnedByUser(owner.ID)
require.NoError(t, err)
assert.Len(t, residences, 2)
}
func TestResidenceRepository_Update(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
residence.Name = "Updated House"
residence.City = "Dallas"
err := repo.Update(residence)
require.NoError(t, err)
found, err := repo.FindByID(residence.ID)
require.NoError(t, err)
assert.Equal(t, "Updated House", found.Name)
assert.Equal(t, "Dallas", found.City)
}
func TestResidenceRepository_Delete(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
err := repo.Delete(residence.ID)
require.NoError(t, err)
// Should not be found (soft delete sets is_active = false)
_, err = repo.FindByID(residence.ID)
assert.Error(t, err)
}
func TestResidenceRepository_HasAccess(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
repo.AddUser(residence.ID, sharedUser.ID)
tests := []struct {
name string
userID uint
expected bool
}{
{"owner has access", owner.ID, true},
{"shared user has access", sharedUser.ID, true},
{"other user no access", otherUser.ID, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasAccess, err := repo.HasAccess(residence.ID, tt.userID)
require.NoError(t, err)
assert.Equal(t, tt.expected, hasAccess)
})
}
}
func TestResidenceRepository_IsOwner(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
repo.AddUser(residence.ID, sharedUser.ID)
// Owner should be owner
isOwner, err := repo.IsOwner(residence.ID, owner.ID)
require.NoError(t, err)
assert.True(t, isOwner)
// Shared user should not be owner
isOwner, err = repo.IsOwner(residence.ID, sharedUser.ID)
require.NoError(t, err)
assert.False(t, isOwner)
}
func TestResidenceRepository_AddAndRemoveUser(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
// Initially no access
hasAccess, _ := repo.HasAccess(residence.ID, sharedUser.ID)
assert.False(t, hasAccess)
// Add user
err := repo.AddUser(residence.ID, sharedUser.ID)
require.NoError(t, err)
hasAccess, _ = repo.HasAccess(residence.ID, sharedUser.ID)
assert.True(t, hasAccess)
// Remove user
err = repo.RemoveUser(residence.ID, sharedUser.ID)
require.NoError(t, err)
hasAccess, _ = repo.HasAccess(residence.ID, sharedUser.ID)
assert.False(t, hasAccess)
}
func TestResidenceRepository_GetResidenceUsers(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
user1 := testutil.CreateTestUser(t, db, "user1", "user1@test.com", "password")
user2 := testutil.CreateTestUser(t, db, "user2", "user2@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
repo.AddUser(residence.ID, user1.ID)
repo.AddUser(residence.ID, user2.ID)
users, err := repo.GetResidenceUsers(residence.ID)
require.NoError(t, err)
assert.Len(t, users, 3) // owner + 2 shared users
}
func TestResidenceRepository_CountByOwner(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
testutil.CreateTestResidence(t, db, owner.ID, "House 1")
testutil.CreateTestResidence(t, db, owner.ID, "House 2")
testutil.CreateTestResidence(t, db, owner.ID, "House 3")
count, err := repo.CountByOwner(owner.ID)
require.NoError(t, err)
assert.Equal(t, int64(3), count)
}
func TestResidenceRepository_CreateShareCode(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
shareCode, err := repo.CreateShareCode(residence.ID, owner.ID, 24*time.Hour)
require.NoError(t, err)
assert.NotEmpty(t, shareCode.Code)
assert.Len(t, shareCode.Code, 6)
assert.True(t, shareCode.IsActive)
assert.NotNil(t, shareCode.ExpiresAt)
}
func TestResidenceRepository_FindShareCodeByCode(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
created, err := repo.CreateShareCode(residence.ID, owner.ID, 24*time.Hour)
require.NoError(t, err)
found, err := repo.FindShareCodeByCode(created.Code)
require.NoError(t, err)
assert.Equal(t, created.ID, found.ID)
assert.Equal(t, residence.ID, found.ResidenceID)
}
func TestResidenceRepository_FindShareCodeByCode_Expired(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
// Create expired code
created, err := repo.CreateShareCode(residence.ID, owner.ID, -1*time.Hour) // Already expired
require.NoError(t, err)
_, err = repo.FindShareCodeByCode(created.Code)
assert.Error(t, err) // Should fail for expired code
}
func TestResidenceRepository_GetAllResidenceTypes(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewResidenceRepository(db)
// Create residence types
types := []models.ResidenceType{
{Name: "House"},
{Name: "Apartment"},
{Name: "Condo"},
}
for _, rt := range types {
db.Create(&rt)
}
result, err := repo.GetAllResidenceTypes()
require.NoError(t, err)
assert.Len(t, result, 3)
}

View File

@@ -251,6 +251,132 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo
}, nil
}
// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display
func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int) (*models.KanbanBoard, error) {
var tasks []models.Task
err := r.db.Preload("CreatedBy").
Preload("AssignedTo").
Preload("Category").
Preload("Priority").
Preload("Status").
Preload("Frequency").
Preload("Completions").
Preload("Completions.CompletedBy").
Preload("Residence").
Where("residence_id IN ? AND is_archived = ?", residenceIDs, false).
Order("due_date ASC NULLS LAST, priority_id DESC, created_at DESC").
Find(&tasks).Error
if err != nil {
return nil, err
}
// Organize into columns
now := time.Now().UTC()
threshold := now.AddDate(0, 0, daysThreshold)
overdue := make([]models.Task, 0)
dueSoon := make([]models.Task, 0)
upcoming := make([]models.Task, 0)
inProgress := make([]models.Task, 0)
completed := make([]models.Task, 0)
cancelled := make([]models.Task, 0)
for _, task := range tasks {
if task.IsCancelled {
cancelled = append(cancelled, task)
continue
}
// Check if completed (has completions)
if len(task.Completions) > 0 {
completed = append(completed, task)
continue
}
// Check status for in-progress
if task.Status != nil && task.Status.Name == "In Progress" {
inProgress = append(inProgress, task)
continue
}
// Check due date
if task.DueDate != nil {
if task.DueDate.Before(now) {
overdue = append(overdue, task)
} else if task.DueDate.Before(threshold) {
dueSoon = append(dueSoon, task)
} else {
upcoming = append(upcoming, task)
}
} else {
upcoming = append(upcoming, task)
}
}
columns := []models.KanbanColumn{
{
Name: "overdue_tasks",
DisplayName: "Overdue",
ButtonTypes: []string{"edit", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
Color: "#FF3B30",
Tasks: overdue,
Count: len(overdue),
},
{
Name: "due_soon_tasks",
DisplayName: "Due Soon",
ButtonTypes: []string{"edit", "complete", "mark_in_progress"},
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
Color: "#FF9500",
Tasks: dueSoon,
Count: len(dueSoon),
},
{
Name: "upcoming_tasks",
DisplayName: "Upcoming",
ButtonTypes: []string{"edit", "cancel"},
Icons: map[string]string{"ios": "calendar", "android": "Event"},
Color: "#007AFF",
Tasks: upcoming,
Count: len(upcoming),
},
{
Name: "in_progress_tasks",
DisplayName: "In Progress",
ButtonTypes: []string{"edit", "complete"},
Icons: map[string]string{"ios": "hammer", "android": "Build"},
Color: "#5856D6",
Tasks: inProgress,
Count: len(inProgress),
},
{
Name: "completed_tasks",
DisplayName: "Completed",
ButtonTypes: []string{"view"},
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
Color: "#34C759",
Tasks: completed,
Count: len(completed),
},
{
Name: "cancelled_tasks",
DisplayName: "Cancelled",
ButtonTypes: []string{"uncancel", "delete"},
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
Color: "#8E8E93",
Tasks: cancelled,
Count: len(cancelled),
},
}
return &models.KanbanBoard{
Columns: columns,
DaysThreshold: daysThreshold,
ResidenceID: "all",
}, nil
}
// === Lookup Operations ===
// GetAllCategories returns all task categories
@@ -345,3 +471,79 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint)
func (r *TaskRepository) DeleteCompletion(id uint) error {
return r.db.Delete(&models.TaskCompletion{}, id).Error
}
// TaskStatistics represents aggregated task statistics
type TaskStatistics struct {
TotalTasks int
TotalPending int
TotalOverdue int
TasksDueNextWeek int
TasksDueNextMonth int
}
// GetTaskStatistics returns aggregated task statistics for multiple residences
func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint) (*TaskStatistics, error) {
if len(residenceIDs) == 0 {
return &TaskStatistics{}, nil
}
now := time.Now().UTC()
nextWeek := now.AddDate(0, 0, 7)
nextMonth := now.AddDate(0, 1, 0)
var totalTasks, totalOverdue, totalPending, tasksDueNextWeek, tasksDueNextMonth int64
// Count total active tasks (not cancelled, not archived)
err := r.db.Model(&models.Task{}).
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false).
Count(&totalTasks).Error
if err != nil {
return nil, err
}
// Count overdue tasks (due date < now, no completions)
err = r.db.Model(&models.Task{}).
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ? AND due_date < ?", residenceIDs, false, false, now).
Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")).
Count(&totalOverdue).Error
if err != nil {
return nil, err
}
// Count pending tasks (not completed, not cancelled, not archived)
err = r.db.Model(&models.Task{}).
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false).
Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")).
Count(&totalPending).Error
if err != nil {
return nil, err
}
// Count tasks due next week (due date between now and 7 days, not completed)
err = r.db.Model(&models.Task{}).
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false).
Where("due_date >= ? AND due_date < ?", now, nextWeek).
Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")).
Count(&tasksDueNextWeek).Error
if err != nil {
return nil, err
}
// Count tasks due next month (due date between now and 30 days, not completed)
err = r.db.Model(&models.Task{}).
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false).
Where("due_date >= ? AND due_date < ?", now, nextMonth).
Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")).
Count(&tasksDueNextMonth).Error
if err != nil {
return nil, err
}
return &TaskStatistics{
TotalTasks: int(totalTasks),
TotalPending: int(totalPending),
TotalOverdue: int(totalOverdue),
TasksDueNextWeek: int(tasksDueNextWeek),
TasksDueNextMonth: int(tasksDueNextMonth),
}, nil
}

View File

@@ -0,0 +1,315 @@
package repositories
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/mycrib-api/internal/models"
"github.com/treytartt/mycrib-api/internal/testutil"
)
func TestTaskRepository_Create(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Fix leaky faucet",
Description: "Kitchen faucet is dripping",
}
err := repo.Create(task)
require.NoError(t, err)
assert.NotZero(t, task.ID)
}
func TestTaskRepository_FindByID(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
found, err := repo.FindByID(task.ID)
require.NoError(t, err)
assert.Equal(t, task.ID, found.ID)
assert.Equal(t, "Test Task", found.Title)
}
func TestTaskRepository_FindByID_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
_, err := repo.FindByID(9999)
assert.Error(t, err)
}
func TestTaskRepository_FindByResidence(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence1 := testutil.CreateTestResidence(t, db, user.ID, "House 1")
residence2 := testutil.CreateTestResidence(t, db, user.ID, "House 2")
testutil.CreateTestTask(t, db, residence1.ID, user.ID, "Task 1")
testutil.CreateTestTask(t, db, residence1.ID, user.ID, "Task 2")
testutil.CreateTestTask(t, db, residence2.ID, user.ID, "Task 3")
tasks, err := repo.FindByResidence(residence1.ID)
require.NoError(t, err)
assert.Len(t, tasks, 2)
}
func TestTaskRepository_Update(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Original Title")
task.Title = "Updated Title"
task.Description = "Updated description"
err := repo.Update(task)
require.NoError(t, err)
found, err := repo.FindByID(task.ID)
require.NoError(t, err)
assert.Equal(t, "Updated Title", found.Title)
assert.Equal(t, "Updated description", found.Description)
}
func TestTaskRepository_Delete(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
err := repo.Delete(task.ID)
require.NoError(t, err)
_, err = repo.FindByID(task.ID)
assert.Error(t, err) // Should not be found
}
func TestTaskRepository_Cancel(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
assert.False(t, task.IsCancelled)
err := repo.Cancel(task.ID)
require.NoError(t, err)
found, err := repo.FindByID(task.ID)
require.NoError(t, err)
assert.True(t, found.IsCancelled)
}
func TestTaskRepository_Uncancel(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
repo.Cancel(task.ID)
err := repo.Uncancel(task.ID)
require.NoError(t, err)
found, err := repo.FindByID(task.ID)
require.NoError(t, err)
assert.False(t, found.IsCancelled)
}
func TestTaskRepository_Archive(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
err := repo.Archive(task.ID)
require.NoError(t, err)
found, err := repo.FindByID(task.ID)
require.NoError(t, err)
assert.True(t, found.IsArchived)
}
func TestTaskRepository_Unarchive(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
repo.Archive(task.ID)
err := repo.Unarchive(task.ID)
require.NoError(t, err)
found, err := repo.FindByID(task.ID)
require.NoError(t, err)
assert.False(t, found.IsArchived)
}
func TestTaskRepository_CreateCompletion(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
Notes: "Completed successfully",
}
err := repo.CreateCompletion(completion)
require.NoError(t, err)
assert.NotZero(t, completion.ID)
}
func TestTaskRepository_FindCompletionByID(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
Notes: "Test notes",
}
db.Create(completion)
found, err := repo.FindCompletionByID(completion.ID)
require.NoError(t, err)
assert.Equal(t, completion.ID, found.ID)
assert.Equal(t, "Test notes", found.Notes)
}
func TestTaskRepository_FindCompletionsByTask(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
// Create multiple completions
for i := 0; i < 3; i++ {
db.Create(&models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
})
}
completions, err := repo.FindCompletionsByTask(task.ID)
require.NoError(t, err)
assert.Len(t, completions, 3)
}
func TestTaskRepository_DeleteCompletion(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
}
db.Create(completion)
err := repo.DeleteCompletion(completion.ID)
require.NoError(t, err)
_, err = repo.FindCompletionByID(completion.ID)
assert.Error(t, err)
}
func TestTaskRepository_GetAllCategories(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
categories, err := repo.GetAllCategories()
require.NoError(t, err)
assert.Greater(t, len(categories), 0)
}
func TestTaskRepository_GetAllPriorities(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
priorities, err := repo.GetAllPriorities()
require.NoError(t, err)
assert.Greater(t, len(priorities), 0)
}
func TestTaskRepository_GetAllStatuses(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
statuses, err := repo.GetAllStatuses()
require.NoError(t, err)
assert.Greater(t, len(statuses), 0)
}
func TestTaskRepository_GetAllFrequencies(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
frequencies, err := repo.GetAllFrequencies()
require.NoError(t, err)
assert.Greater(t, len(frequencies), 0)
}
func TestTaskRepository_CountByResidence(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 1")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3")
count, err := repo.CountByResidence(residence.ID)
require.NoError(t, err)
assert.Equal(t, int64(3), count)
}

View File

@@ -80,10 +80,10 @@ func (r *UserRepository) FindByEmail(email string) (*models.User, error) {
return &user, nil
}
// FindByUsernameOrEmail finds a user by username or email
// FindByUsernameOrEmail finds a user by username or email with profile preloaded
func (r *UserRepository) FindByUsernameOrEmail(identifier string) (*models.User, error) {
var user models.User
if err := r.db.Where("LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", identifier, identifier).First(&user).Error; err != nil {
if err := r.db.Preload("Profile").Where("LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", identifier, identifier).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}

View File

@@ -0,0 +1,189 @@
package repositories
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/mycrib-api/internal/models"
"github.com/treytartt/mycrib-api/internal/testutil"
)
func TestUserRepository_Create(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewUserRepository(db)
user := &models.User{
Username: "testuser",
Email: "test@example.com",
IsActive: true,
}
user.SetPassword("password123")
err := repo.Create(user)
require.NoError(t, err)
assert.NotZero(t, user.ID)
}
func TestUserRepository_FindByID(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewUserRepository(db)
// Create user
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123")
// Find by ID
found, err := repo.FindByID(user.ID)
require.NoError(t, err)
assert.Equal(t, user.ID, found.ID)
assert.Equal(t, "testuser", found.Username)
assert.Equal(t, "test@example.com", found.Email)
}
func TestUserRepository_FindByID_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewUserRepository(db)
_, err := repo.FindByID(9999)
assert.Error(t, err)
}
func TestUserRepository_FindByUsername(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewUserRepository(db)
// Create user
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123")
// Find by username
found, err := repo.FindByUsername("testuser")
require.NoError(t, err)
assert.Equal(t, user.ID, found.ID)
}
func TestUserRepository_FindByEmail(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewUserRepository(db)
// Create user
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123")
// Find by email
found, err := repo.FindByEmail("test@example.com")
require.NoError(t, err)
assert.Equal(t, user.ID, found.ID)
}
func TestUserRepository_FindByUsernameOrEmail(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewUserRepository(db)
// Create user
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123")
tests := []struct {
name string
input string
expected uint
}{
{"find by username", "testuser", user.ID},
{"find by email", "test@example.com", user.ID},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
found, err := repo.FindByUsernameOrEmail(tt.input)
require.NoError(t, err)
assert.Equal(t, tt.expected, found.ID)
})
}
}
func TestUserRepository_Update(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewUserRepository(db)
// Create user
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123")
// Update user
user.FirstName = "John"
user.LastName = "Doe"
err := repo.Update(user)
require.NoError(t, err)
// Verify update
found, err := repo.FindByID(user.ID)
require.NoError(t, err)
assert.Equal(t, "John", found.FirstName)
assert.Equal(t, "Doe", found.LastName)
}
func TestUserRepository_ExistsByUsername(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewUserRepository(db)
// Create user
testutil.CreateTestUser(t, db, "existinguser", "existing@example.com", "password123")
tests := []struct {
name string
username string
expected bool
}{
{"existing user", "existinguser", true},
{"non-existing user", "nonexistent", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
exists, err := repo.ExistsByUsername(tt.username)
require.NoError(t, err)
assert.Equal(t, tt.expected, exists)
})
}
}
func TestUserRepository_ExistsByEmail(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewUserRepository(db)
// Create user
testutil.CreateTestUser(t, db, "existinguser", "existing@example.com", "password123")
tests := []struct {
name string
email string
expected bool
}{
{"existing email", "existing@example.com", true},
{"non-existing email", "nonexistent@example.com", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
exists, err := repo.ExistsByEmail(tt.email)
require.NoError(t, err)
assert.Equal(t, tt.expected, exists)
})
}
}
func TestUserRepository_GetOrCreateProfile(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewUserRepository(db)
// Create user
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123")
// First call should create
profile1, err := repo.GetOrCreateProfile(user.ID)
require.NoError(t, err)
assert.NotZero(t, profile1.ID)
// Second call should return same profile
profile2, err := repo.GetOrCreateProfile(user.ID)
require.NoError(t, err)
assert.Equal(t, profile1.ID, profile2.ID)
}