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

63
internal/models/admin.go Normal file
View File

@@ -0,0 +1,63 @@
package models
import (
"time"
"golang.org/x/crypto/bcrypt"
)
// AdminRole represents the role of an admin user
type AdminRole string
const (
AdminRoleAdmin AdminRole = "admin"
AdminRoleSuperAdmin AdminRole = "super_admin"
)
// AdminUser represents an administrator for the admin panel
type AdminUser struct {
ID uint `gorm:"primaryKey" json:"id"`
Email string `gorm:"uniqueIndex;size:254;not null" json:"email"`
Password string `gorm:"size:128;not null" json:"-"`
FirstName string `gorm:"size:100" json:"first_name"`
LastName string `gorm:"size:100" json:"last_name"`
Role AdminRole `gorm:"size:20;default:'admin'" json:"role"`
IsActive bool `gorm:"default:true" json:"is_active"`
LastLogin *time.Time `json:"last_login,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the table name for AdminUser
func (AdminUser) TableName() string {
return "admin_users"
}
// SetPassword hashes and sets the password
func (a *AdminUser) SetPassword(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
a.Password = string(hash)
return nil
}
// CheckPassword verifies the password against the stored hash
func (a *AdminUser) CheckPassword(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(a.Password), []byte(password))
return err == nil
}
// FullName returns the admin's full name
func (a *AdminUser) FullName() string {
if a.FirstName == "" && a.LastName == "" {
return a.Email
}
return a.FirstName + " " + a.LastName
}
// IsSuperAdmin checks if the admin has super admin privileges
func (a *AdminUser) IsSuperAdmin() bool {
return a.Role == AdminRoleSuperAdmin
}

View File

@@ -0,0 +1,113 @@
package models
import (
"encoding/json"
"testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
)
func TestResidence_TableName(t *testing.T) {
r := Residence{}
assert.Equal(t, "residence_residence", r.TableName())
}
func TestResidenceType_TableName(t *testing.T) {
rt := ResidenceType{}
assert.Equal(t, "residence_residencetype", rt.TableName())
}
func TestResidenceShareCode_TableName(t *testing.T) {
sc := ResidenceShareCode{}
assert.Equal(t, "residence_residencesharecode", sc.TableName())
}
func TestResidence_JSONSerialization(t *testing.T) {
bedrooms := 3
bathrooms := decimal.NewFromFloat(2.5)
sqft := 2000
yearBuilt := 2020
residence := Residence{
Name: "Test House",
StreetAddress: "123 Main St",
City: "Austin",
StateProvince: "TX",
PostalCode: "78701",
Country: "USA",
Bedrooms: &bedrooms,
Bathrooms: &bathrooms,
SquareFootage: &sqft,
YearBuilt: &yearBuilt,
IsActive: true,
IsPrimary: true,
}
residence.ID = 1
data, err := json.Marshal(residence)
assert.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
assert.NoError(t, err)
// Check JSON field names
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, "Test House", result["name"])
assert.Equal(t, "123 Main St", result["street_address"])
assert.Equal(t, "Austin", result["city"])
assert.Equal(t, "TX", result["state_province"])
assert.Equal(t, "78701", result["postal_code"])
assert.Equal(t, "USA", result["country"])
assert.Equal(t, float64(3), result["bedrooms"])
assert.Equal(t, "2.5", result["bathrooms"]) // Decimal serializes as string
assert.Equal(t, float64(2000), result["square_footage"])
assert.Equal(t, float64(2020), result["year_built"])
assert.Equal(t, true, result["is_active"])
assert.Equal(t, true, result["is_primary"])
}
func TestResidenceType_JSONSerialization(t *testing.T) {
rt := ResidenceType{
Name: "House",
}
rt.ID = 1
data, err := json.Marshal(rt)
assert.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
assert.NoError(t, err)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, "House", result["name"])
}
func TestResidence_NilOptionalFields(t *testing.T) {
residence := Residence{
Name: "Test House",
StreetAddress: "123 Main St",
City: "Austin",
StateProvince: "TX",
PostalCode: "78701",
Country: "USA",
IsActive: true,
IsPrimary: false,
// All optional fields are nil
}
data, err := json.Marshal(residence)
assert.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
assert.NoError(t, err)
// Nil pointer fields should serialize as null
assert.Nil(t, result["bedrooms"])
assert.Nil(t, result["bathrooms"])
assert.Nil(t, result["square_footage"])
assert.Nil(t, result["year_built"])
}

View File

@@ -27,6 +27,7 @@ func (SubscriptionSettings) TableName() string {
type UserSubscription struct {
BaseModel
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"-"`
Tier SubscriptionTier `gorm:"column:tier;size:10;default:'free'" json:"tier"`
// In-App Purchase data

View File

@@ -0,0 +1,275 @@
package models
import (
"encoding/json"
"testing"
"time"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
)
func TestTask_TableName(t *testing.T) {
task := Task{}
assert.Equal(t, "task_task", task.TableName())
}
func TestTaskCategory_TableName(t *testing.T) {
cat := TaskCategory{}
assert.Equal(t, "task_taskcategory", cat.TableName())
}
func TestTaskPriority_TableName(t *testing.T) {
p := TaskPriority{}
assert.Equal(t, "task_taskpriority", p.TableName())
}
func TestTaskStatus_TableName(t *testing.T) {
s := TaskStatus{}
assert.Equal(t, "task_taskstatus", s.TableName())
}
func TestTaskFrequency_TableName(t *testing.T) {
f := TaskFrequency{}
assert.Equal(t, "task_taskfrequency", f.TableName())
}
func TestTaskCompletion_TableName(t *testing.T) {
c := TaskCompletion{}
assert.Equal(t, "task_taskcompletion", c.TableName())
}
func TestContractor_TableName(t *testing.T) {
c := Contractor{}
assert.Equal(t, "task_contractor", c.TableName())
}
func TestContractorSpecialty_TableName(t *testing.T) {
s := ContractorSpecialty{}
assert.Equal(t, "task_contractorspecialty", s.TableName())
}
func TestDocument_TableName(t *testing.T) {
d := Document{}
assert.Equal(t, "task_document", d.TableName())
}
func TestTask_JSONSerialization(t *testing.T) {
dueDate := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
cost := decimal.NewFromFloat(150.50)
task := Task{
ResidenceID: 1,
CreatedByID: 1,
Title: "Fix leaky faucet",
Description: "Kitchen faucet is dripping",
DueDate: &dueDate,
EstimatedCost: &cost,
IsCancelled: false,
IsArchived: false,
}
task.ID = 1
data, err := json.Marshal(task)
assert.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
assert.NoError(t, err)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, float64(1), result["residence_id"])
assert.Equal(t, float64(1), result["created_by_id"])
assert.Equal(t, "Fix leaky faucet", result["title"])
assert.Equal(t, "Kitchen faucet is dripping", result["description"])
assert.Equal(t, "150.5", result["estimated_cost"]) // Decimal serializes as string
assert.Equal(t, false, result["is_cancelled"])
assert.Equal(t, false, result["is_archived"])
}
func TestTaskCategory_JSONSerialization(t *testing.T) {
cat := TaskCategory{
Name: "Plumbing",
Description: "Plumbing related tasks",
Icon: "wrench",
Color: "#3498db",
DisplayOrder: 1,
}
cat.ID = 1
data, err := json.Marshal(cat)
assert.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
assert.NoError(t, err)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, "Plumbing", result["name"])
assert.Equal(t, "Plumbing related tasks", result["description"])
assert.Equal(t, "wrench", result["icon"])
assert.Equal(t, "#3498db", result["color"])
assert.Equal(t, float64(1), result["display_order"])
}
func TestTaskPriority_JSONSerialization(t *testing.T) {
priority := TaskPriority{
Name: "High",
Level: 3,
Color: "#e74c3c",
DisplayOrder: 3,
}
priority.ID = 3
data, err := json.Marshal(priority)
assert.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
assert.NoError(t, err)
assert.Equal(t, float64(3), result["id"])
assert.Equal(t, "High", result["name"])
assert.Equal(t, float64(3), result["level"])
assert.Equal(t, "#e74c3c", result["color"])
}
func TestTaskStatus_JSONSerialization(t *testing.T) {
status := TaskStatus{
Name: "In Progress",
Description: "Task is being worked on",
Color: "#3498db",
DisplayOrder: 2,
}
status.ID = 2
data, err := json.Marshal(status)
assert.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
assert.NoError(t, err)
assert.Equal(t, float64(2), result["id"])
assert.Equal(t, "In Progress", result["name"])
assert.Equal(t, "Task is being worked on", result["description"])
assert.Equal(t, "#3498db", result["color"])
}
func TestTaskFrequency_JSONSerialization(t *testing.T) {
days := 7
freq := TaskFrequency{
Name: "Weekly",
Days: &days,
DisplayOrder: 3,
}
freq.ID = 3
data, err := json.Marshal(freq)
assert.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
assert.NoError(t, err)
assert.Equal(t, float64(3), result["id"])
assert.Equal(t, "Weekly", result["name"])
assert.Equal(t, float64(7), result["days"])
}
func TestTaskCompletion_JSONSerialization(t *testing.T) {
completedAt := time.Date(2024, 6, 15, 14, 30, 0, 0, time.UTC)
cost := decimal.NewFromFloat(125.00)
completion := TaskCompletion{
TaskID: 1,
CompletedByID: 2,
CompletedAt: completedAt,
Notes: "Fixed the leak",
ActualCost: &cost,
}
completion.ID = 1
data, err := json.Marshal(completion)
assert.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
assert.NoError(t, err)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, float64(1), result["task_id"])
assert.Equal(t, float64(2), result["completed_by_id"])
assert.Equal(t, "Fixed the leak", result["notes"])
assert.Equal(t, "125", result["actual_cost"]) // Decimal serializes as string
}
func TestContractor_JSONSerialization(t *testing.T) {
contractor := Contractor{
ResidenceID: 1,
CreatedByID: 1,
Name: "Mike's Plumbing",
Company: "Mike's Plumbing Co.",
Phone: "+1-555-1234",
Email: "mike@plumbing.com",
Website: "https://mikesplumbing.com",
Notes: "Great service",
IsFavorite: true,
IsActive: true,
}
contractor.ID = 1
data, err := json.Marshal(contractor)
assert.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
assert.NoError(t, err)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, float64(1), result["residence_id"])
assert.Equal(t, "Mike's Plumbing", result["name"])
assert.Equal(t, "Mike's Plumbing Co.", result["company"])
assert.Equal(t, "+1-555-1234", result["phone"])
assert.Equal(t, "mike@plumbing.com", result["email"])
assert.Equal(t, "https://mikesplumbing.com", result["website"])
assert.Equal(t, true, result["is_favorite"])
assert.Equal(t, true, result["is_active"])
}
func TestDocument_JSONSerialization(t *testing.T) {
purchaseDate := time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC)
expiryDate := time.Date(2028, 6, 15, 0, 0, 0, 0, time.UTC)
price := decimal.NewFromFloat(5000.00)
doc := Document{
ResidenceID: 1,
CreatedByID: 1,
Title: "HVAC Warranty",
Description: "Warranty for central air",
DocumentType: "warranty",
FileURL: "/uploads/hvac.pdf",
FileName: "hvac.pdf",
PurchaseDate: &purchaseDate,
ExpiryDate: &expiryDate,
PurchasePrice: &price,
Vendor: "Cool Air HVAC",
SerialNumber: "HVAC-123",
}
doc.ID = 1
data, err := json.Marshal(doc)
assert.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
assert.NoError(t, err)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, "HVAC Warranty", result["title"])
assert.Equal(t, "warranty", result["document_type"])
assert.Equal(t, "/uploads/hvac.pdf", result["file_url"])
assert.Equal(t, "Cool Air HVAC", result["vendor"])
assert.Equal(t, "HVAC-123", result["serial_number"])
assert.Equal(t, "5000", result["purchase_price"]) // Decimal serializes as string
}

View File

@@ -0,0 +1,217 @@
package models
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUser_SetPassword(t *testing.T) {
user := &User{}
err := user.SetPassword("testpassword123")
require.NoError(t, err)
assert.NotEmpty(t, user.Password)
assert.NotEqual(t, "testpassword123", user.Password) // Should be hashed
}
func TestUser_CheckPassword(t *testing.T) {
user := &User{}
err := user.SetPassword("correctpassword")
require.NoError(t, err)
tests := []struct {
name string
password string
expected bool
}{
{"correct password", "correctpassword", true},
{"wrong password", "wrongpassword", false},
{"empty password", "", false},
{"similar password", "correctpassword1", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := user.CheckPassword(tt.password)
assert.Equal(t, tt.expected, result)
})
}
}
func TestUser_GetFullName(t *testing.T) {
tests := []struct {
name string
user User
expected string
}{
{
name: "first and last name",
user: User{FirstName: "John", LastName: "Doe", Username: "johndoe"},
expected: "John Doe",
},
{
name: "first name only",
user: User{FirstName: "John", LastName: "", Username: "johndoe"},
expected: "John",
},
{
name: "username fallback",
user: User{FirstName: "", LastName: "", Username: "johndoe"},
expected: "johndoe",
},
{
name: "last name only returns username",
user: User{FirstName: "", LastName: "Doe", Username: "johndoe"},
expected: "johndoe",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.user.GetFullName()
assert.Equal(t, tt.expected, result)
})
}
}
func TestUser_TableName(t *testing.T) {
user := User{}
assert.Equal(t, "auth_user", user.TableName())
}
func TestAuthToken_TableName(t *testing.T) {
token := AuthToken{}
assert.Equal(t, "user_authtoken", token.TableName())
}
func TestUserProfile_TableName(t *testing.T) {
profile := UserProfile{}
assert.Equal(t, "user_userprofile", profile.TableName())
}
func TestConfirmationCode_IsValid(t *testing.T) {
now := time.Now().UTC()
future := now.Add(1 * time.Hour)
past := now.Add(-1 * time.Hour)
tests := []struct {
name string
code ConfirmationCode
expected bool
}{
{
name: "valid code",
code: ConfirmationCode{IsUsed: false, ExpiresAt: future},
expected: true,
},
{
name: "used code",
code: ConfirmationCode{IsUsed: true, ExpiresAt: future},
expected: false,
},
{
name: "expired code",
code: ConfirmationCode{IsUsed: false, ExpiresAt: past},
expected: false,
},
{
name: "used and expired",
code: ConfirmationCode{IsUsed: true, ExpiresAt: past},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.code.IsValid()
assert.Equal(t, tt.expected, result)
})
}
}
func TestPasswordResetCode_IsValid(t *testing.T) {
now := time.Now().UTC()
future := now.Add(1 * time.Hour)
past := now.Add(-1 * time.Hour)
tests := []struct {
name string
code PasswordResetCode
expected bool
}{
{
name: "valid code",
code: PasswordResetCode{Used: false, ExpiresAt: future, Attempts: 0, MaxAttempts: 5},
expected: true,
},
{
name: "used code",
code: PasswordResetCode{Used: true, ExpiresAt: future, Attempts: 0, MaxAttempts: 5},
expected: false,
},
{
name: "expired code",
code: PasswordResetCode{Used: false, ExpiresAt: past, Attempts: 0, MaxAttempts: 5},
expected: false,
},
{
name: "max attempts reached",
code: PasswordResetCode{Used: false, ExpiresAt: future, Attempts: 5, MaxAttempts: 5},
expected: false,
},
{
name: "attempts under max",
code: PasswordResetCode{Used: false, ExpiresAt: future, Attempts: 4, MaxAttempts: 5},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.code.IsValid()
assert.Equal(t, tt.expected, result)
})
}
}
func TestPasswordResetCode_SetAndCheckCode(t *testing.T) {
code := &PasswordResetCode{}
err := code.SetCode("123456")
require.NoError(t, err)
assert.NotEmpty(t, code.CodeHash)
// Check correct code
assert.True(t, code.CheckCode("123456"))
// Check wrong code
assert.False(t, code.CheckCode("654321"))
assert.False(t, code.CheckCode(""))
}
func TestGenerateConfirmationCode(t *testing.T) {
code := GenerateConfirmationCode()
assert.Len(t, code, 6)
// Generate multiple codes and ensure they're different
codes := make(map[string]bool)
for i := 0; i < 10; i++ {
c := GenerateConfirmationCode()
assert.Len(t, c, 6)
codes[c] = true
}
// Most codes should be unique (very unlikely to have collisions)
assert.Greater(t, len(codes), 5)
}
func TestGenerateResetToken(t *testing.T) {
token := GenerateResetToken()
assert.Len(t, token, 64) // 32 bytes = 64 hex chars
// Ensure uniqueness
token2 := GenerateResetToken()
assert.NotEqual(t, token, token2)
}