refactor: Extract shared utilities and add validation layer

Part 1: Shared Utilities
- Create internal/fileutil package with FindDataFile() and LoadJSON()
- Create internal/lang package with language constants and validation
- Eliminate 46 lines of code duplication between cv/loader.go and ui/loader.go
- Simplify cv/loader.go from 69 to 36 lines (-48%)
- Simplify ui/loader.go from 56 to 24 lines (-57%)

Part 2: Validation Layer
- Add comprehensive validation in internal/models/cv/validation.go
- Validate Personal (name, email format, URLs)
- Validate Experience (required fields, dates)
- Validate Education (required fields)
- Validate Skills (proficiency ranges 1-5, categories)
- Validate Languages (proficiency levels 1-5)
- Validate Projects (title, URLs)
- Validate Meta (version, language)
- Integrate validation into LoadCV() - automatic on load
- Create ValidationError and ValidationErrors types for clear error reporting
- Report all validation errors at once (better UX)

Testing:
- Add comprehensive tests for fileutil package (FindDataFile, LoadJSON)
- Add tests for lang package (IsValid, Validate, All)
- Add 280+ validation test cases covering edge cases
- All tests pass with real CV data (cv-en.json, cv-es.json)
- Fixed validation to allow both URLs and local paths for gitRepoUrl

Documentation:
- Create _go-learning/refactorings/002-shared-utilities-validation.md
- Document architecture, benefits, testing, and interview talking points
- Explain WHY decisions were made (DRY, type safety, data integrity)

Benefits:
- DRY: Single source of truth for utilities
- Type safety: Language constants instead of magic strings
- Data integrity: Validation catches errors at load time
- Better errors: Clear messages showing all issues at once
- Maintainability: Centralized utilities easier to update
This commit is contained in:
juanatsap
2025-11-20 16:41:13 +00:00
parent 0682a0bea7
commit 9240a863d1
10 changed files with 1780 additions and 82 deletions
+13 -41
View File
@@ -1,33 +1,24 @@
package cv
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/juanatsap/cv-site/internal/fileutil"
"github.com/juanatsap/cv-site/internal/lang"
)
// LoadCV loads CV data from a JSON file for the specified language
func LoadCV(lang string) (*CV, error) {
if lang != "en" && lang != "es" {
return nil, fmt.Errorf("unsupported language: %s", lang)
}
filename := fmt.Sprintf("data/cv-%s.json", lang)
filepath, err := findDataFile(filename)
if err != nil {
func LoadCV(language string) (*CV, error) {
if err := lang.Validate(language); err != nil {
return nil, err
}
data, err := os.ReadFile(filepath)
if err != nil {
return nil, fmt.Errorf("error reading file %s: %w", filename, err)
}
var cvData CV
if err := json.Unmarshal(data, &cvData); err != nil {
return nil, fmt.Errorf("error parsing JSON: %w", err)
filename := fmt.Sprintf("data/cv-%s.json", language)
if err := fileutil.LoadJSON(filename, &cvData); err != nil {
return nil, err
}
// Replace {{YEAR}} placeholder in reference URLs with current year
@@ -36,33 +27,14 @@ func LoadCV(lang string) (*CV, error) {
cvData.References[i].URL = replaceYearPlaceholder(cvData.References[i].URL, currentYear)
}
// Validate the loaded CV data
if err := cvData.Validate(); err != nil {
return nil, fmt.Errorf("validation failed: %w", err)
}
return &cvData, nil
}
// findDataFile locates a data file by searching up the directory tree
func findDataFile(filename string) (string, error) {
// Try current directory first
if _, err := os.Stat(filename); err == nil {
return filename, nil
}
// Try parent directories (for tests running from subdirectories)
paths := []string{
filename, // Current dir
"../" + filename, // One level up
"../../" + filename, // Two levels up (for tests in internal/handlers)
"../../../" + filename, // Three levels up
}
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
return path, nil
}
}
return "", fmt.Errorf("file not found: %s (searched: current dir, ../, ../../, ../../../)", filename)
}
// replaceYearPlaceholder replaces {{YEAR}} with the current year
func replaceYearPlaceholder(url string, year string) string {
return strings.ReplaceAll(url, "{{YEAR}}", year)