Files
honeyDueAPI/internal/models/task_test.go
Trey t 42a5533a56 Fix 113 hardening issues across entire Go backend
Security:
- Replace all binding: tags with validate: + c.Validate() in admin handlers
- Add rate limiting to auth endpoints (login, register, password reset)
- Add security headers (HSTS, XSS protection, nosniff, frame options)
- Wire Google Pub/Sub token verification into webhook handler
- Replace ParseUnverified with proper OIDC/JWKS key verification
- Verify inner Apple JWS signatures in webhook handler
- Add io.LimitReader (1MB) to all webhook body reads
- Add ownership verification to file deletion
- Move hardcoded admin credentials to env vars
- Add uniqueIndex to User.Email
- Hide ConfirmationCode from JSON serialization
- Mask confirmation codes in admin responses
- Use http.DetectContentType for upload validation
- Fix path traversal in storage service
- Replace os.Getenv with Viper in stripe service
- Sanitize Redis URLs before logging
- Separate DEBUG_FIXED_CODES from DEBUG flag
- Reject weak SECRET_KEY in production
- Add host check on /_next/* proxy routes
- Use explicit localhost CORS origins in debug mode
- Replace err.Error() with generic messages in all admin error responses

Critical fixes:
- Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth
- Fix user_customuser -> auth_user table names in raw SQL
- Fix dashboard verified query to use UserProfile model
- Add escapeLikeWildcards() to prevent SQL wildcard injection

Bug fixes:
- Add bounds checks for days/expiring_soon query params (1-3650)
- Add receipt_data/transaction_id empty-check to RestoreSubscription
- Change Active bool -> *bool in device handler
- Check all unchecked GORM/FindByIDWithProfile errors
- Add validation for notification hour fields (0-23)
- Add max=10000 validation on task description updates

Transactions & data integrity:
- Wrap registration flow in transaction
- Wrap QuickComplete in transaction
- Move image creation inside completion transaction
- Wrap SetSpecialties in transaction
- Wrap GetOrCreateToken in transaction
- Wrap completion+image deletion in transaction

Performance:
- Batch completion summaries (2 queries vs 2N)
- Reuse single http.Client in IAP validation
- Cache dashboard counts (30s TTL)
- Batch COUNT queries in admin user list
- Add Limit(500) to document queries
- Add reminder_stage+due_date filters to reminder queries
- Parse AllowedTypes once at init
- In-memory user cache in auth middleware (30s TTL)
- Timezone change detection cache
- Optimize P95 with per-endpoint sorted buffers
- Replace crypto/md5 with hash/fnv for ETags

Code quality:
- Add sync.Once to all monitoring Stop()/Close() methods
- Replace 8 fmt.Printf with zerolog in auth service
- Log previously discarded errors
- Standardize delete response shapes
- Route hardcoded English through i18n
- Remove FileURL from DocumentResponse (keep MediaURL only)
- Thread user timezone through kanban board responses
- Initialize empty slices to prevent null JSON
- Extract shared field map for task Update/UpdateTx
- Delete unused SoftDeleteModel, min(), formatCron, legacy handlers

Worker & jobs:
- Wire Asynq email infrastructure into worker
- Register HandleReminderLogCleanup with daily 3AM cron
- Use per-user timezone in HandleSmartReminder
- Replace direct DB queries with repository calls
- Delete legacy reminder handlers (~200 lines)
- Delete unused task type constants

Dependencies:
- Replace archived jung-kurt/gofpdf with go-pdf/fpdf
- Replace unmaintained gomail.v2 with wneessen/go-mail
- Add TODO for Echo jwt v3 transitive dep removal

Test infrastructure:
- Fix MakeRequest/SeedLookupData error handling
- Replace os.Exit(0) with t.Skip() in scope/consistency tests
- Add 11 new FCM v1 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:14:13 -05:00

274 lines
7.6 KiB
Go

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 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 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) {
residenceID := uint(1)
contractor := Contractor{
ResidenceID: &residenceID,
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
}
func timePtr(t time.Time) *time.Time {
return &t
}
func TestTask_IsOverdueAt_DayBased(t *testing.T) {
// Test that IsOverdueAt uses day-based comparison
now := time.Date(2025, 12, 16, 15, 0, 0, 0, time.UTC) // 3 PM UTC
// Task due today (midnight) - NOT overdue
todayMidnight := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)
taskToday := &Task{NextDueDate: timePtr(todayMidnight)}
assert.False(t, taskToday.IsOverdueAt(now), "Task due today should NOT be overdue")
// Task due yesterday - IS overdue
yesterday := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
taskYesterday := &Task{NextDueDate: timePtr(yesterday)}
assert.True(t, taskYesterday.IsOverdueAt(now), "Task due yesterday should be overdue")
// Task due tomorrow - NOT overdue
tomorrow := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
taskTomorrow := &Task{NextDueDate: timePtr(tomorrow)}
assert.False(t, taskTomorrow.IsOverdueAt(now), "Task due tomorrow should NOT be overdue")
}