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

@@ -55,7 +55,7 @@ func (s *ContractorService) GetContractor(contractorID, userID uint) (*responses
}
// ListContractors lists all contractors accessible to a user
func (s *ContractorService) ListContractors(userID uint) (*responses.ContractorListResponse, error) {
func (s *ContractorService) ListContractors(userID uint) ([]responses.ContractorResponse, error) {
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
return nil, err
@@ -67,7 +67,7 @@ func (s *ContractorService) ListContractors(userID uint) (*responses.ContractorL
}
if len(residenceIDs) == 0 {
return &responses.ContractorListResponse{Count: 0, Results: []responses.ContractorResponse{}}, nil
return []responses.ContractorResponse{}, nil
}
contractors, err := s.contractorRepo.FindByUser(residenceIDs)
@@ -75,8 +75,7 @@ func (s *ContractorService) ListContractors(userID uint) (*responses.ContractorL
return nil, err
}
resp := responses.NewContractorListResponse(contractors)
return &resp, nil
return responses.NewContractorListResponse(contractors), nil
}
// CreateContractor creates a new contractor
@@ -234,8 +233,8 @@ func (s *ContractorService) DeleteContractor(contractorID, userID uint) error {
return s.contractorRepo.Delete(contractorID)
}
// ToggleFavorite toggles the favorite status of a contractor
func (s *ContractorService) ToggleFavorite(contractorID, userID uint) (*responses.ToggleFavoriteResponse, error) {
// ToggleFavorite toggles the favorite status of a contractor and returns the updated contractor
func (s *ContractorService) ToggleFavorite(contractorID, userID uint) (*responses.ContractorResponse, error) {
contractor, err := s.contractorRepo.FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -253,24 +252,23 @@ func (s *ContractorService) ToggleFavorite(contractorID, userID uint) (*response
return nil, ErrContractorAccessDenied
}
newStatus, err := s.contractorRepo.ToggleFavorite(contractorID)
_, err = s.contractorRepo.ToggleFavorite(contractorID)
if err != nil {
return nil, err
}
message := "Contractor removed from favorites"
if newStatus {
message = "Contractor added to favorites"
// Re-fetch the contractor to get the updated state with all relations
contractor, err = s.contractorRepo.FindByID(contractorID)
if err != nil {
return nil, err
}
return &responses.ToggleFavoriteResponse{
Message: message,
IsFavorite: newStatus,
}, nil
resp := responses.NewContractorResponse(contractor)
return &resp, nil
}
// GetContractorTasks gets all tasks for a contractor
func (s *ContractorService) GetContractorTasks(contractorID, userID uint) (*responses.TaskListResponse, error) {
func (s *ContractorService) GetContractorTasks(contractorID, userID uint) ([]responses.TaskResponse, error) {
contractor, err := s.contractorRepo.FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -293,8 +291,7 @@ func (s *ContractorService) GetContractorTasks(contractorID, userID uint) (*resp
return nil, err
}
resp := responses.NewTaskListResponse(tasks)
return &resp, nil
return responses.NewTaskListResponse(tasks), nil
}
// GetSpecialties returns all contractor specialties

View File

@@ -55,7 +55,7 @@ func (s *DocumentService) GetDocument(documentID, userID uint) (*responses.Docum
}
// ListDocuments lists all documents accessible to a user
func (s *DocumentService) ListDocuments(userID uint) (*responses.DocumentListResponse, error) {
func (s *DocumentService) ListDocuments(userID uint) ([]responses.DocumentResponse, error) {
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
return nil, err
@@ -67,7 +67,7 @@ func (s *DocumentService) ListDocuments(userID uint) (*responses.DocumentListRes
}
if len(residenceIDs) == 0 {
return &responses.DocumentListResponse{Count: 0, Results: []responses.DocumentResponse{}}, nil
return []responses.DocumentResponse{}, nil
}
documents, err := s.documentRepo.FindByUser(residenceIDs)
@@ -75,12 +75,11 @@ func (s *DocumentService) ListDocuments(userID uint) (*responses.DocumentListRes
return nil, err
}
resp := responses.NewDocumentListResponse(documents)
return &resp, nil
return responses.NewDocumentListResponse(documents), nil
}
// ListWarranties lists all warranty documents
func (s *DocumentService) ListWarranties(userID uint) (*responses.DocumentListResponse, error) {
func (s *DocumentService) ListWarranties(userID uint) ([]responses.DocumentResponse, error) {
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
return nil, err
@@ -92,7 +91,7 @@ func (s *DocumentService) ListWarranties(userID uint) (*responses.DocumentListRe
}
if len(residenceIDs) == 0 {
return &responses.DocumentListResponse{Count: 0, Results: []responses.DocumentResponse{}}, nil
return []responses.DocumentResponse{}, nil
}
documents, err := s.documentRepo.FindWarranties(residenceIDs)
@@ -100,8 +99,7 @@ func (s *DocumentService) ListWarranties(userID uint) (*responses.DocumentListRe
return nil, err
}
resp := responses.NewDocumentListResponse(documents)
return &resp, nil
return responses.NewDocumentListResponse(documents), nil
}
// CreateDocument creates a new document

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"html/template"
"io"
"time"
"github.com/rs/zerolog/log"
@@ -46,6 +47,43 @@ func (s *EmailService) SendEmail(to, subject, htmlBody, textBody string) error {
return nil
}
// EmailAttachment represents an email attachment
type EmailAttachment struct {
Filename string
ContentType string
Data []byte
}
// SendEmailWithAttachment sends an email with an attachment
func (s *EmailService) SendEmailWithAttachment(to, subject, htmlBody, textBody string, attachment *EmailAttachment) error {
m := gomail.NewMessage()
m.SetHeader("From", s.cfg.From)
m.SetHeader("To", to)
m.SetHeader("Subject", subject)
m.SetBody("text/plain", textBody)
m.AddAlternative("text/html", htmlBody)
if attachment != nil {
m.Attach(attachment.Filename,
gomail.SetCopyFunc(func(w io.Writer) error {
_, err := w.Write(attachment.Data)
return err
}),
gomail.SetHeader(map[string][]string{
"Content-Type": {attachment.ContentType},
}),
)
}
if err := s.dialer.DialAndSend(m); err != nil {
log.Error().Err(err).Str("to", to).Str("subject", subject).Msg("Failed to send email with attachment")
return fmt.Errorf("failed to send email: %w", err)
}
log.Info().Str("to", to).Str("subject", subject).Str("attachment", attachment.Filename).Msg("Email with attachment sent successfully")
return nil
}
// SendWelcomeEmail sends a welcome email with verification code
func (s *EmailService) SendWelcomeEmail(to, firstName, code string) error {
subject := "Welcome to MyCrib - Verify Your Email"
@@ -342,6 +380,110 @@ The MyCrib Team
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendTasksReportEmail sends a tasks report email with PDF attachment
func (s *EmailService) SendTasksReportEmail(to, recipientName, residenceName string, totalTasks, completed, pending, overdue int, pdfData []byte) error {
subject := fmt.Sprintf("MyCrib - Tasks Report for %s", residenceName)
name := recipientName
if name == "" {
name = "there"
}
htmlBody := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.summary-box { background: #f8f9fa; border: 1px solid #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0; }
.summary-grid { display: flex; flex-wrap: wrap; gap: 20px; }
.summary-item { flex: 1; min-width: 80px; text-align: center; }
.summary-number { font-size: 28px; font-weight: bold; color: #333; }
.summary-label { font-size: 12px; color: #666; text-transform: uppercase; }
.completed { color: #28a745; }
.pending { color: #ffc107; }
.overdue { color: #dc3545; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Tasks Report</h1>
<p style="color: #666; margin: 0;">%s</p>
</div>
<p>Hi %s,</p>
<p>Here's your tasks report for <strong>%s</strong>. The full report is attached as a PDF.</p>
<div class="summary-box">
<h3 style="margin-top: 0;">Summary</h3>
<table width="100%%" cellpadding="10" cellspacing="0">
<tr>
<td align="center" style="border-right: 1px solid #e9ecef;">
<div class="summary-number">%d</div>
<div class="summary-label">Total Tasks</div>
</td>
<td align="center" style="border-right: 1px solid #e9ecef;">
<div class="summary-number completed">%d</div>
<div class="summary-label">Completed</div>
</td>
<td align="center" style="border-right: 1px solid #e9ecef;">
<div class="summary-number pending">%d</div>
<div class="summary-label">Pending</div>
</td>
<td align="center">
<div class="summary-number overdue">%d</div>
<div class="summary-label">Overdue</div>
</td>
</tr>
</table>
</div>
<p>Open the attached PDF for the complete list of tasks with details.</p>
<p>Best regards,<br>The MyCrib Team</p>
<div class="footer">
<p>&copy; %d MyCrib. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, residenceName, name, residenceName, totalTasks, completed, pending, overdue, time.Now().Year())
textBody := fmt.Sprintf(`
Tasks Report for %s
Hi %s,
Here's your tasks report for %s. The full report is attached as a PDF.
Summary:
- Total Tasks: %d
- Completed: %d
- Pending: %d
- Overdue: %d
Open the attached PDF for the complete list of tasks with details.
Best regards,
The MyCrib Team
`, residenceName, name, residenceName, totalTasks, completed, pending, overdue)
// Create filename with timestamp
filename := fmt.Sprintf("tasks_report_%s_%s.pdf",
residenceName,
time.Now().Format("2006-01-02"),
)
attachment := &EmailAttachment{
Filename: filename,
ContentType: "application/pdf",
Data: pdfData,
}
return s.SendEmailWithAttachment(to, subject, htmlBody, textBody, attachment)
}
// EmailTemplate represents an email template
type EmailTemplate struct {
name string

View File

@@ -0,0 +1,178 @@
package services
import (
"bytes"
"fmt"
"time"
"github.com/jung-kurt/gofpdf"
)
// PDFService handles PDF generation
type PDFService struct{}
// NewPDFService creates a new PDF service
func NewPDFService() *PDFService {
return &PDFService{}
}
// GenerateTasksReportPDF generates a PDF report from task report data
func (s *PDFService) GenerateTasksReportPDF(report *TasksReportResponse) ([]byte, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetMargins(15, 15, 15)
pdf.AddPage()
// Header
pdf.SetFont("Arial", "B", 20)
pdf.SetTextColor(51, 51, 51)
pdf.Cell(0, 12, "Tasks Report")
pdf.Ln(14)
// Residence name
pdf.SetFont("Arial", "", 14)
pdf.SetTextColor(102, 102, 102)
pdf.Cell(0, 8, report.ResidenceName)
pdf.Ln(10)
// Generated date
pdf.SetFont("Arial", "", 10)
pdf.Cell(0, 6, fmt.Sprintf("Generated: %s", report.GeneratedAt.Format("January 2, 2006 at 3:04 PM")))
pdf.Ln(12)
// Summary section
pdf.SetFont("Arial", "B", 14)
pdf.SetTextColor(51, 51, 51)
pdf.Cell(0, 8, "Summary")
pdf.Ln(10)
// Summary box
pdf.SetFillColor(248, 249, 250)
pdf.Rect(15, pdf.GetY(), 180, 30, "F")
y := pdf.GetY() + 5
pdf.SetXY(20, y)
pdf.SetFont("Arial", "B", 12)
pdf.SetTextColor(51, 51, 51)
// Summary columns
colWidth := 45.0
pdf.Cell(colWidth, 6, "Total Tasks")
pdf.Cell(colWidth, 6, "Completed")
pdf.Cell(colWidth, 6, "Pending")
pdf.Cell(colWidth, 6, "Overdue")
pdf.Ln(8)
pdf.SetX(20)
pdf.SetFont("Arial", "", 16)
pdf.Cell(colWidth, 8, fmt.Sprintf("%d", report.TotalTasks))
pdf.SetTextColor(40, 167, 69) // Green
pdf.Cell(colWidth, 8, fmt.Sprintf("%d", report.Completed))
pdf.SetTextColor(255, 193, 7) // Yellow/Orange
pdf.Cell(colWidth, 8, fmt.Sprintf("%d", report.Pending))
pdf.SetTextColor(220, 53, 69) // Red
pdf.Cell(colWidth, 8, fmt.Sprintf("%d", report.Overdue))
pdf.Ln(25)
// Tasks table
pdf.SetTextColor(51, 51, 51)
pdf.SetFont("Arial", "B", 14)
pdf.Cell(0, 8, "Tasks")
pdf.Ln(10)
if len(report.Tasks) == 0 {
pdf.SetFont("Arial", "I", 11)
pdf.SetTextColor(128, 128, 128)
pdf.Cell(0, 8, "No tasks found for this residence.")
} else {
// Table header
pdf.SetFont("Arial", "B", 10)
pdf.SetFillColor(233, 236, 239)
pdf.SetTextColor(51, 51, 51)
pdf.CellFormat(70, 8, "Title", "1", 0, "L", true, 0, "")
pdf.CellFormat(30, 8, "Category", "1", 0, "C", true, 0, "")
pdf.CellFormat(25, 8, "Priority", "1", 0, "C", true, 0, "")
pdf.CellFormat(25, 8, "Status", "1", 0, "C", true, 0, "")
pdf.CellFormat(30, 8, "Due Date", "1", 0, "C", true, 0, "")
pdf.Ln(-1)
// Table rows
pdf.SetFont("Arial", "", 9)
for _, task := range report.Tasks {
// Check if we need a new page
if pdf.GetY() > 270 {
pdf.AddPage()
// Repeat header
pdf.SetFont("Arial", "B", 10)
pdf.SetFillColor(233, 236, 239)
pdf.CellFormat(70, 8, "Title", "1", 0, "L", true, 0, "")
pdf.CellFormat(30, 8, "Category", "1", 0, "C", true, 0, "")
pdf.CellFormat(25, 8, "Priority", "1", 0, "C", true, 0, "")
pdf.CellFormat(25, 8, "Status", "1", 0, "C", true, 0, "")
pdf.CellFormat(30, 8, "Due Date", "1", 0, "C", true, 0, "")
pdf.Ln(-1)
pdf.SetFont("Arial", "", 9)
}
// Determine row color based on status
if task.IsCancelled {
pdf.SetFillColor(248, 215, 218) // Light red
} else if task.IsCompleted {
pdf.SetFillColor(212, 237, 218) // Light green
} else if task.IsArchived {
pdf.SetFillColor(226, 227, 229) // Light gray
} else {
pdf.SetFillColor(255, 255, 255) // White
}
// Title (truncate if too long)
title := task.Title
if len(title) > 35 {
title = title[:32] + "..."
}
// Status text
var status string
if task.IsCancelled {
status = "Cancelled"
} else if task.IsCompleted {
status = "Completed"
} else if task.IsArchived {
status = "Archived"
} else {
status = task.Status
}
// Due date
dueDate := "-"
if task.DueDate != nil {
dueDate = task.DueDate.Format("Jan 2, 2006")
}
pdf.SetTextColor(51, 51, 51)
pdf.CellFormat(70, 7, title, "1", 0, "L", true, 0, "")
pdf.CellFormat(30, 7, task.Category, "1", 0, "C", true, 0, "")
pdf.CellFormat(25, 7, task.Priority, "1", 0, "C", true, 0, "")
pdf.CellFormat(25, 7, status, "1", 0, "C", true, 0, "")
pdf.CellFormat(30, 7, dueDate, "1", 0, "C", true, 0, "")
pdf.Ln(-1)
}
}
// Footer
pdf.SetY(-25)
pdf.SetFont("Arial", "I", 9)
pdf.SetTextColor(128, 128, 128)
pdf.Cell(0, 10, fmt.Sprintf("MyCrib - Tasks Report for %s", report.ResidenceName))
pdf.Ln(5)
pdf.Cell(0, 10, fmt.Sprintf("Generated on %s", time.Now().UTC().Format("2006-01-02 15:04:05 UTC")))
// Output to buffer
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("failed to generate PDF: %w", err)
}
return buf.Bytes(), nil
}

View File

@@ -29,6 +29,7 @@ var (
type ResidenceService struct {
residenceRepo *repositories.ResidenceRepository
userRepo *repositories.UserRepository
taskRepo *repositories.TaskRepository
config *config.Config
}
@@ -41,6 +42,11 @@ func NewResidenceService(residenceRepo *repositories.ResidenceRepository, userRe
}
}
// SetTaskRepository sets the task repository (used for task statistics)
func (s *ResidenceService) SetTaskRepository(taskRepo *repositories.TaskRepository) {
s.taskRepo = taskRepo
}
// GetResidence gets a residence by ID with access check
func (s *ResidenceService) GetResidence(residenceID, userID uint) (*responses.ResidenceResponse, error) {
// Check access
@@ -65,27 +71,53 @@ func (s *ResidenceService) GetResidence(residenceID, userID uint) (*responses.Re
}
// ListResidences lists all residences accessible to a user
func (s *ResidenceService) ListResidences(userID uint) (*responses.ResidenceListResponse, error) {
func (s *ResidenceService) ListResidences(userID uint) ([]responses.ResidenceResponse, error) {
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
return nil, err
}
resp := responses.NewResidenceListResponse(residences)
return &resp, nil
return responses.NewResidenceListResponse(residences), nil
}
// GetMyResidences returns residences with additional details (tasks, completions, etc.)
// This is the "my-residences" endpoint that returns richer data
func (s *ResidenceService) GetMyResidences(userID uint) (*responses.ResidenceListResponse, error) {
func (s *ResidenceService) GetMyResidences(userID uint) (*responses.MyResidencesResponse, error) {
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
return nil, err
}
// TODO: In Phase 4, this will include tasks and completions
resp := responses.NewResidenceListResponse(residences)
return &resp, nil
residenceResponses := responses.NewResidenceListResponse(residences)
// Build summary with real task statistics
summary := responses.TotalSummary{
TotalResidences: len(residences),
}
// Get task statistics if task repository is available
if s.taskRepo != nil && len(residences) > 0 {
// Collect residence IDs
residenceIDs := make([]uint, len(residences))
for i, r := range residences {
residenceIDs[i] = r.ID
}
// Get aggregated statistics
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs)
if err == nil && stats != nil {
summary.TotalTasks = stats.TotalTasks
summary.TotalPending = stats.TotalPending
summary.TotalOverdue = stats.TotalOverdue
summary.TasksDueNextWeek = stats.TasksDueNextWeek
summary.TasksDueNextMonth = stats.TasksDueNextMonth
}
}
return &responses.MyResidencesResponse{
Residences: residenceResponses,
Summary: summary,
}, nil
}
// CreateResidence creates a new residence

View File

@@ -0,0 +1,334 @@
package services
import (
"testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/mycrib-api/internal/config"
"github.com/treytartt/mycrib-api/internal/dto/requests"
"github.com/treytartt/mycrib-api/internal/repositories"
"github.com/treytartt/mycrib-api/internal/testutil"
)
func setupResidenceService(t *testing.T) (*ResidenceService, *repositories.ResidenceRepository, *repositories.UserRepository) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
return service, residenceRepo, userRepo
}
func TestResidenceService_CreateResidence(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
req := &requests.CreateResidenceRequest{
Name: "Test House",
StreetAddress: "123 Main St",
City: "Austin",
StateProvince: "TX",
PostalCode: "78701",
}
resp, err := service.CreateResidence(req, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, "Test House", resp.Name)
assert.Equal(t, "123 Main St", resp.StreetAddress)
assert.Equal(t, "Austin", resp.City)
assert.Equal(t, "TX", resp.StateProvince)
assert.Equal(t, "USA", resp.Country) // Default country
assert.True(t, resp.IsPrimary) // Default is_primary
}
func TestResidenceService_CreateResidence_WithOptionalFields(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
bedrooms := 3
bathrooms := decimal.NewFromFloat(2.5)
sqft := 2000
isPrimary := false
req := &requests.CreateResidenceRequest{
Name: "Test House",
StreetAddress: "123 Main St",
City: "Austin",
StateProvince: "TX",
PostalCode: "78701",
Country: "Canada",
Bedrooms: &bedrooms,
Bathrooms: &bathrooms,
SquareFootage: &sqft,
IsPrimary: &isPrimary,
}
resp, err := service.CreateResidence(req, user.ID)
require.NoError(t, err)
assert.Equal(t, "Canada", resp.Country)
assert.Equal(t, 3, *resp.Bedrooms)
assert.True(t, resp.Bathrooms.Equal(decimal.NewFromFloat(2.5)))
assert.Equal(t, 2000, *resp.SquareFootage)
// First residence defaults to primary regardless of request
assert.True(t, resp.IsPrimary)
}
func TestResidenceService_GetResidence(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
resp, err := service.GetResidence(residence.ID, user.ID)
require.NoError(t, err)
assert.Equal(t, residence.ID, resp.ID)
assert.Equal(t, "Test House", resp.Name)
}
func TestResidenceService_GetResidence_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
_, err := service.GetResidence(residence.ID, otherUser.ID)
assert.ErrorIs(t, err, ErrResidenceAccessDenied)
}
func TestResidenceService_GetResidence_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
user := testutil.CreateTestUser(t, db, "user", "user@test.com", "password")
_, err := service.GetResidence(9999, user.ID)
assert.Error(t, err)
}
func TestResidenceService_ListResidences(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
testutil.CreateTestResidence(t, db, user.ID, "House 1")
testutil.CreateTestResidence(t, db, user.ID, "House 2")
resp, err := service.ListResidences(user.ID)
require.NoError(t, err)
assert.Len(t, resp, 2)
}
func TestResidenceService_UpdateResidence(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Original Name")
newName := "Updated Name"
newCity := "Dallas"
req := &requests.UpdateResidenceRequest{
Name: &newName,
City: &newCity,
}
resp, err := service.UpdateResidence(residence.ID, user.ID, req)
require.NoError(t, err)
assert.Equal(t, "Updated Name", resp.Name)
assert.Equal(t, "Dallas", resp.City)
}
func TestResidenceService_UpdateResidence_NotOwner(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
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")
// Share with user
residenceRepo.AddUser(residence.ID, sharedUser.ID)
newName := "Updated"
req := &requests.UpdateResidenceRequest{Name: &newName}
_, err := service.UpdateResidence(residence.ID, sharedUser.ID, req)
assert.ErrorIs(t, err, ErrNotResidenceOwner)
}
func TestResidenceService_DeleteResidence(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
err := service.DeleteResidence(residence.ID, user.ID)
require.NoError(t, err)
// Should not be found
_, err = service.GetResidence(residence.ID, user.ID)
assert.Error(t, err)
}
func TestResidenceService_DeleteResidence_NotOwner(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
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")
residenceRepo.AddUser(residence.ID, sharedUser.ID)
err := service.DeleteResidence(residence.ID, sharedUser.ID)
assert.ErrorIs(t, err, ErrNotResidenceOwner)
}
func TestResidenceService_GenerateShareCode(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
resp, err := service.GenerateShareCode(residence.ID, user.ID, 24)
require.NoError(t, err)
assert.NotEmpty(t, resp.ShareCode.Code)
assert.Len(t, resp.ShareCode.Code, 6)
}
func TestResidenceService_JoinWithCode(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
newUser := testutil.CreateTestUser(t, db, "newuser", "new@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
// Generate share code
shareResp, err := service.GenerateShareCode(residence.ID, owner.ID, 24)
require.NoError(t, err)
// Join with code
joinResp, err := service.JoinWithCode(shareResp.ShareCode.Code, newUser.ID)
require.NoError(t, err)
assert.Equal(t, residence.ID, joinResp.Residence.ID)
// Verify access
hasAccess, _ := residenceRepo.HasAccess(residence.ID, newUser.ID)
assert.True(t, hasAccess)
}
func TestResidenceService_JoinWithCode_AlreadyMember(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
shareResp, _ := service.GenerateShareCode(residence.ID, owner.ID, 24)
// Owner tries to join their own residence
_, err := service.JoinWithCode(shareResp.ShareCode.Code, owner.ID)
assert.ErrorIs(t, err, ErrUserAlreadyMember)
}
func TestResidenceService_GetResidenceUsers(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
user1 := testutil.CreateTestUser(t, db, "user1", "user1@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
residenceRepo.AddUser(residence.ID, user1.ID)
users, err := service.GetResidenceUsers(residence.ID, owner.ID)
require.NoError(t, err)
assert.Len(t, users, 2) // owner + shared user
}
func TestResidenceService_RemoveUser(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
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")
residenceRepo.AddUser(residence.ID, sharedUser.ID)
err := service.RemoveUser(residence.ID, sharedUser.ID, owner.ID)
require.NoError(t, err)
hasAccess, _ := residenceRepo.HasAccess(residence.ID, sharedUser.ID)
assert.False(t, hasAccess)
}
func TestResidenceService_RemoveUser_CannotRemoveOwner(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
service := NewResidenceService(residenceRepo, userRepo, cfg)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
err := service.RemoveUser(residence.ID, owner.ID, owner.ID)
assert.ErrorIs(t, err, ErrCannotRemoveOwner)
}

View File

@@ -0,0 +1,183 @@
package services
import (
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/treytartt/mycrib-api/internal/config"
)
// StorageService handles file uploads to local filesystem
type StorageService struct {
cfg *config.StorageConfig
}
// UploadResult contains information about an uploaded file
type UploadResult struct {
URL string `json:"url"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
MimeType string `json:"mime_type"`
}
// NewStorageService creates a new storage service
func NewStorageService(cfg *config.StorageConfig) (*StorageService, error) {
// Ensure upload directory exists
if err := os.MkdirAll(cfg.UploadDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create upload directory: %w", err)
}
// Create subdirectories for organization
subdirs := []string{"images", "documents", "completions"}
for _, subdir := range subdirs {
path := filepath.Join(cfg.UploadDir, subdir)
if err := os.MkdirAll(path, 0755); err != nil {
return nil, fmt.Errorf("failed to create subdirectory %s: %w", subdir, err)
}
}
log.Info().Str("upload_dir", cfg.UploadDir).Msg("Storage service initialized")
return &StorageService{cfg: cfg}, nil
}
// Upload saves a file to the local filesystem
func (s *StorageService) Upload(file *multipart.FileHeader, category string) (*UploadResult, error) {
// Validate file size
if file.Size > s.cfg.MaxFileSize {
return nil, fmt.Errorf("file size %d exceeds maximum allowed %d bytes", file.Size, s.cfg.MaxFileSize)
}
// Get MIME type
mimeType := file.Header.Get("Content-Type")
if mimeType == "" {
mimeType = "application/octet-stream"
}
// Validate MIME type
if !s.isAllowedType(mimeType) {
return nil, fmt.Errorf("file type %s is not allowed", mimeType)
}
// Generate unique filename
ext := filepath.Ext(file.Filename)
if ext == "" {
ext = s.getExtensionFromMimeType(mimeType)
}
newFilename := fmt.Sprintf("%s_%s%s", time.Now().Format("20060102"), uuid.New().String()[:8], ext)
// Determine subdirectory based on category
subdir := "images"
switch category {
case "document", "documents":
subdir = "documents"
case "completion", "completions":
subdir = "completions"
}
// Full path
destPath := filepath.Join(s.cfg.UploadDir, subdir, newFilename)
// Open source file
src, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
// Create destination file
dst, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("failed to create destination file: %w", err)
}
defer dst.Close()
// Copy file content
written, err := io.Copy(dst, src)
if err != nil {
// Clean up on error
os.Remove(destPath)
return nil, fmt.Errorf("failed to save file: %w", err)
}
// Generate URL
url := fmt.Sprintf("%s/%s/%s", s.cfg.BaseURL, subdir, newFilename)
log.Info().
Str("filename", newFilename).
Str("category", category).
Int64("size", written).
Str("mime_type", mimeType).
Msg("File uploaded successfully")
return &UploadResult{
URL: url,
FileName: file.Filename,
FileSize: written,
MimeType: mimeType,
}, nil
}
// Delete removes a file from storage
func (s *StorageService) Delete(fileURL string) error {
// Convert URL to file path
relativePath := strings.TrimPrefix(fileURL, s.cfg.BaseURL)
relativePath = strings.TrimPrefix(relativePath, "/")
fullPath := filepath.Join(s.cfg.UploadDir, relativePath)
// Security check: ensure path is within upload directory
absUploadDir, _ := filepath.Abs(s.cfg.UploadDir)
absFilePath, _ := filepath.Abs(fullPath)
if !strings.HasPrefix(absFilePath, absUploadDir) {
return fmt.Errorf("invalid file path")
}
if err := os.Remove(fullPath); err != nil {
if os.IsNotExist(err) {
return nil // File already doesn't exist
}
return fmt.Errorf("failed to delete file: %w", err)
}
log.Info().Str("path", fullPath).Msg("File deleted")
return nil
}
// isAllowedType checks if the MIME type is in the allowed list
func (s *StorageService) isAllowedType(mimeType string) bool {
allowed := strings.Split(s.cfg.AllowedTypes, ",")
for _, t := range allowed {
if strings.TrimSpace(t) == mimeType {
return true
}
}
return false
}
// getExtensionFromMimeType returns a file extension for common MIME types
func (s *StorageService) getExtensionFromMimeType(mimeType string) string {
extensions := map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"application/pdf": ".pdf",
}
if ext, ok := extensions[mimeType]; ok {
return ext
}
return ""
}
// GetUploadDir returns the upload directory path
func (s *StorageService) GetUploadDir() string {
return s.cfg.UploadDir
}

View File

@@ -75,8 +75,8 @@ func (s *TaskService) GetTask(taskID, userID uint) (*responses.TaskResponse, err
return &resp, nil
}
// ListTasks lists all tasks accessible to a user
func (s *TaskService) ListTasks(userID uint) (*responses.TaskListResponse, error) {
// ListTasks lists all tasks accessible to a user as a kanban board
func (s *TaskService) ListTasks(userID uint) (*responses.KanbanBoardResponse, error) {
// Get all residence IDs accessible to user
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
@@ -89,15 +89,21 @@ func (s *TaskService) ListTasks(userID uint) (*responses.TaskListResponse, error
}
if len(residenceIDs) == 0 {
return &responses.TaskListResponse{Count: 0, Results: []responses.TaskResponse{}}, nil
// Return empty kanban board
return &responses.KanbanBoardResponse{
Columns: []responses.KanbanColumnResponse{},
DaysThreshold: 30,
ResidenceID: "all",
}, nil
}
tasks, err := s.taskRepo.FindByUser(userID, residenceIDs)
// Get kanban data aggregated across all residences
board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, 30)
if err != nil {
return nil, err
}
resp := responses.NewTaskListResponse(tasks)
resp := responses.NewKanbanBoardResponseForAll(board)
return &resp, nil
}
@@ -146,7 +152,7 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (
StatusID: req.StatusID,
FrequencyID: req.FrequencyID,
AssignedToID: req.AssignedToID,
DueDate: req.DueDate,
DueDate: req.DueDate.ToTimePtr(),
EstimatedCost: req.EstimatedCost,
ContractorID: req.ContractorID,
}
@@ -207,7 +213,7 @@ func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRe
task.AssignedToID = req.AssignedToID
}
if req.DueDate != nil {
task.DueDate = req.DueDate
task.DueDate = req.DueDate.ToTimePtr()
}
if req.EstimatedCost != nil {
task.EstimatedCost = req.EstimatedCost
@@ -583,7 +589,7 @@ func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskC
}
// ListCompletions lists all task completions for a user
func (s *TaskService) ListCompletions(userID uint) (*responses.TaskCompletionListResponse, error) {
func (s *TaskService) ListCompletions(userID uint) ([]responses.TaskCompletionResponse, error) {
// Get all residence IDs
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
@@ -596,7 +602,7 @@ func (s *TaskService) ListCompletions(userID uint) (*responses.TaskCompletionLis
}
if len(residenceIDs) == 0 {
return &responses.TaskCompletionListResponse{Count: 0, Results: []responses.TaskCompletionResponse{}}, nil
return []responses.TaskCompletionResponse{}, nil
}
completions, err := s.taskRepo.FindCompletionsByUser(userID, residenceIDs)
@@ -604,8 +610,7 @@ func (s *TaskService) ListCompletions(userID uint) (*responses.TaskCompletionLis
return nil, err
}
resp := responses.NewTaskCompletionListResponse(completions)
return &resp, nil
return responses.NewTaskCompletionListResponse(completions), nil
}
// DeleteCompletion deletes a task completion
@@ -630,6 +635,35 @@ func (s *TaskService) DeleteCompletion(completionID, userID uint) error {
return s.taskRepo.DeleteCompletion(completionID)
}
// GetCompletionsByTask gets all completions for a specific task
func (s *TaskService) GetCompletionsByTask(taskID, userID uint) ([]responses.TaskCompletionResponse, error) {
// Get the task to check access
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
}
return nil, err
}
// Check access via residence
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
// Get completions for the task
completions, err := s.taskRepo.FindCompletionsByTask(taskID)
if err != nil {
return nil, err
}
return responses.NewTaskCompletionListResponse(completions), nil
}
// === Lookups ===
// GetCategories returns all task categories

View File

@@ -0,0 +1,459 @@
package services
import (
"testing"
"time"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/mycrib-api/internal/dto/requests"
"github.com/treytartt/mycrib-api/internal/models"
"github.com/treytartt/mycrib-api/internal/repositories"
"github.com/treytartt/mycrib-api/internal/testutil"
)
func setupTaskService(t *testing.T) (*TaskService, *repositories.TaskRepository, *repositories.ResidenceRepository) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
return service, taskRepo, residenceRepo
}
func TestTaskService_CreateTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
req := &requests.CreateTaskRequest{
ResidenceID: residence.ID,
Title: "Fix leaky faucet",
Description: "Kitchen faucet is dripping",
}
resp, err := service.CreateTask(req, user.ID)
require.NoError(t, err)
assert.NotZero(t, resp.ID)
assert.Equal(t, "Fix leaky faucet", resp.Title)
assert.Equal(t, "Kitchen faucet is dripping", resp.Description)
}
func TestTaskService_CreateTask_WithOptionalFields(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get category and priority IDs
var category models.TaskCategory
var priority models.TaskPriority
db.First(&category)
db.First(&priority)
dueDate := requests.FlexibleDate{Time: time.Now().Add(7 * 24 * time.Hour).UTC()}
cost := decimal.NewFromFloat(150.50)
req := &requests.CreateTaskRequest{
ResidenceID: residence.ID,
Title: "Fix leaky faucet",
CategoryID: &category.ID,
PriorityID: &priority.ID,
DueDate: &dueDate,
EstimatedCost: &cost,
}
resp, err := service.CreateTask(req, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp.Category)
assert.NotNil(t, resp.Priority)
assert.NotNil(t, resp.DueDate)
assert.NotNil(t, resp.EstimatedCost)
}
func TestTaskService_CreateTask_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
req := &requests.CreateTaskRequest{
ResidenceID: residence.ID,
Title: "Test Task",
}
_, err := service.CreateTask(req, otherUser.ID)
// When creating a task, residence access is checked first
assert.ErrorIs(t, err, ErrResidenceAccessDenied)
}
func TestTaskService_GetTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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")
resp, err := service.GetTask(task.ID, user.ID)
require.NoError(t, err)
assert.Equal(t, task.ID, resp.ID)
assert.Equal(t, "Test Task", resp.Title)
}
func TestTaskService_GetTask_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, owner.ID, "Test Task")
_, err := service.GetTask(task.ID, otherUser.ID)
assert.ErrorIs(t, err, ErrTaskAccessDenied)
}
func TestTaskService_ListTasks(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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")
resp, err := service.ListTasks(user.ID)
require.NoError(t, err)
assert.Len(t, resp, 3)
}
func TestTaskService_UpdateTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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")
newTitle := "Updated Title"
newDesc := "Updated description"
req := &requests.UpdateTaskRequest{
Title: &newTitle,
Description: &newDesc,
}
resp, err := service.UpdateTask(task.ID, user.ID, req)
require.NoError(t, err)
assert.Equal(t, "Updated Title", resp.Title)
assert.Equal(t, "Updated description", resp.Description)
}
func TestTaskService_DeleteTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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 := service.DeleteTask(task.ID, user.ID)
require.NoError(t, err)
_, err = service.GetTask(task.ID, user.ID)
assert.Error(t, err)
}
func TestTaskService_CancelTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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")
resp, err := service.CancelTask(task.ID, user.ID)
require.NoError(t, err)
assert.True(t, resp.IsCancelled)
}
func TestTaskService_CancelTask_AlreadyCancelled(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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")
service.CancelTask(task.ID, user.ID)
_, err := service.CancelTask(task.ID, user.ID)
assert.ErrorIs(t, err, ErrTaskAlreadyCancelled)
}
func TestTaskService_UncancelTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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")
service.CancelTask(task.ID, user.ID)
resp, err := service.UncancelTask(task.ID, user.ID)
require.NoError(t, err)
assert.False(t, resp.IsCancelled)
}
func TestTaskService_ArchiveTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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")
resp, err := service.ArchiveTask(task.ID, user.ID)
require.NoError(t, err)
assert.True(t, resp.IsArchived)
}
func TestTaskService_UnarchiveTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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")
service.ArchiveTask(task.ID, user.ID)
resp, err := service.UnarchiveTask(task.ID, user.ID)
require.NoError(t, err)
assert.False(t, resp.IsArchived)
}
func TestTaskService_MarkInProgress(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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")
resp, err := service.MarkInProgress(task.ID, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp.Status)
assert.Equal(t, "In Progress", resp.Status.Name)
}
func TestTaskService_CreateCompletion(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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")
req := &requests.CreateTaskCompletionRequest{
TaskID: task.ID,
Notes: "Completed successfully",
}
resp, err := service.CreateCompletion(req, user.ID)
require.NoError(t, err)
assert.NotZero(t, resp.ID)
assert.Equal(t, task.ID, resp.TaskID)
assert.Equal(t, "Completed successfully", resp.Notes)
}
func TestTaskService_GetCompletion(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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)
resp, err := service.GetCompletion(completion.ID, user.ID)
require.NoError(t, err)
assert.Equal(t, completion.ID, resp.ID)
assert.Equal(t, "Test notes", resp.Notes)
}
func TestTaskService_DeleteCompletion(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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 := service.DeleteCompletion(completion.ID, user.ID)
require.NoError(t, err)
_, err = service.GetCompletion(completion.ID, user.ID)
assert.Error(t, err)
}
func TestTaskService_GetCategories(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
categories, err := service.GetCategories()
require.NoError(t, err)
assert.Greater(t, len(categories), 0)
// Check JSON structure
for _, cat := range categories {
assert.NotZero(t, cat.ID)
assert.NotEmpty(t, cat.Name)
}
}
func TestTaskService_GetPriorities(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
priorities, err := service.GetPriorities()
require.NoError(t, err)
assert.Greater(t, len(priorities), 0)
// Check order by level
for i := 1; i < len(priorities); i++ {
assert.GreaterOrEqual(t, priorities[i].Level, priorities[i-1].Level)
}
}
func TestTaskService_GetStatuses(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
statuses, err := service.GetStatuses()
require.NoError(t, err)
assert.Greater(t, len(statuses), 0)
}
func TestTaskService_GetFrequencies(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
frequencies, err := service.GetFrequencies()
require.NoError(t, err)
assert.Greater(t, len(frequencies), 0)
}
func TestTaskService_SharedUserAccess(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
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")
// Share residence
residenceRepo.AddUser(residence.ID, sharedUser.ID)
// Create task as owner
task := testutil.CreateTestTask(t, db, residence.ID, owner.ID, "Test Task")
// Shared user should be able to see the task
resp, err := service.GetTask(task.ID, sharedUser.ID)
require.NoError(t, err)
assert.Equal(t, task.ID, resp.ID)
// Shared user should be able to create tasks
req := &requests.CreateTaskRequest{
ResidenceID: residence.ID,
Title: "Shared User Task",
}
_, err = service.CreateTask(req, sharedUser.ID)
require.NoError(t, err)
}