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
+48
View File
@@ -0,0 +1,48 @@
package fileutil
import (
"fmt"
"os"
)
// FindDataFile locates a data file by searching up the directory tree.
// This is useful for tests that may run from different directory depths.
//
// It searches in the following order:
// 1. Current directory
// 2. One level up (../)
// 3. Two levels up (../../)
// 4. Three levels up (../../../)
//
// Example:
//
// path, err := fileutil.FindDataFile("data/cv-en.json")
// if err != nil {
// log.Fatal(err)
// }
func FindDataFile(filename string) (string, error) {
if filename == "" {
return "", fmt.Errorf("filename cannot be empty")
}
// 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)
}
+92
View File
@@ -0,0 +1,92 @@
package fileutil_test
import (
"testing"
"github.com/juanatsap/cv-site/internal/fileutil"
)
func TestFindDataFile(t *testing.T) {
tests := []struct {
name string
filename string
wantErr bool
}{
{
name: "Existing file - cv-en.json",
filename: "data/cv-en.json",
wantErr: false,
},
{
name: "Existing file - cv-es.json",
filename: "data/cv-es.json",
wantErr: false,
},
{
name: "Existing file - ui-en.json",
filename: "data/ui-en.json",
wantErr: false,
},
{
name: "Non-existent file",
filename: "data/non-existent.json",
wantErr: true,
},
{
name: "Empty filename",
filename: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := fileutil.FindDataFile(tt.filename)
if (err != nil) != tt.wantErr {
t.Errorf("FindDataFile() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && got == "" {
t.Error("FindDataFile() returned empty path for existing file")
}
})
}
}
func TestLoadJSON(t *testing.T) {
// Test with actual CV data
t.Run("Load valid CV JSON", func(t *testing.T) {
type TestCV struct {
Personal struct {
Name string `json:"name"`
} `json:"personal"`
}
var cv TestCV
err := fileutil.LoadJSON("data/cv-en.json", &cv)
if err != nil {
t.Fatalf("LoadJSON() unexpected error: %v", err)
}
if cv.Personal.Name == "" {
t.Error("LoadJSON() loaded CV but name is empty")
}
})
// Test with non-existent file
t.Run("Load non-existent file", func(t *testing.T) {
var data map[string]interface{}
err := fileutil.LoadJSON("data/does-not-exist.json", &data)
if err == nil {
t.Error("LoadJSON() expected error for non-existent file")
}
})
// Test with invalid target
t.Run("Load with nil target", func(t *testing.T) {
err := fileutil.LoadJSON("data/cv-en.json", nil)
if err == nil {
t.Error("LoadJSON() expected error for nil target")
}
})
}
+35
View File
@@ -0,0 +1,35 @@
package fileutil
import (
"encoding/json"
"fmt"
"os"
)
// LoadJSON loads and unmarshals JSON from a file into the target struct.
// It automatically searches for the file using FindDataFile and handles
// all error wrapping with context.
//
// Example:
//
// var cv CV
// if err := fileutil.LoadJSON("data/cv-en.json", &cv); err != nil {
// log.Fatal(err)
// }
func LoadJSON(filename string, target interface{}) error {
filepath, err := FindDataFile(filename)
if err != nil {
return err
}
data, err := os.ReadFile(filepath)
if err != nil {
return fmt.Errorf("error reading file %s: %w", filename, err)
}
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("error parsing JSON from %s: %w", filename, err)
}
return nil
}