1.2.2 - "Bug fixes and improvements"

This commit is contained in:
2025-10-01 21:34:22 -06:00
parent 23d662f97a
commit cb20efd58d
60 changed files with 3443 additions and 1423 deletions

479
sync/format_test.go Normal file
View File

@@ -0,0 +1,479 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
func TestDataFormatCompatibility(t *testing.T) {
t.Run("JSON Marshaling and Unmarshaling", func(t *testing.T) {
originalBackup := ClimbDataBackup{
ExportedAt: "2024-01-01T10:00:00Z",
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{
{
ID: "gym1",
Name: "Test Gym",
Location: stringPtr("Test Location"),
SupportedClimbTypes: []string{"BOULDER", "ROPE"},
DifficultySystems: []string{"V", "YDS"},
CustomDifficultyGrades: []string{"V0+", "V1+"},
Notes: stringPtr("Test notes"),
CreatedAt: "2024-01-01T10:00:00Z",
UpdatedAt: "2024-01-01T10:00:00Z",
},
},
Problems: []BackupProblem{
{
ID: "problem1",
GymID: "gym1",
Name: stringPtr("Test Problem"),
Description: stringPtr("A challenging problem"),
ClimbType: "BOULDER",
Difficulty: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
Tags: []string{"overhang", "crimpy"},
Location: stringPtr("Wall A"),
ImagePaths: []string{"image1.jpg", "image2.jpg"},
IsActive: true,
DateSet: stringPtr("2024-01-01"),
Notes: stringPtr("Watch the start"),
CreatedAt: "2024-01-01T10:00:00Z",
UpdatedAt: "2024-01-01T10:00:00Z",
},
},
Sessions: []BackupClimbSession{
{
ID: "session1",
GymID: "gym1",
Date: "2024-01-01",
StartTime: stringPtr("2024-01-01T10:00:00Z"),
EndTime: stringPtr("2024-01-01T12:00:00Z"),
Duration: int64Ptr(7200),
Status: "completed",
Notes: stringPtr("Great session"),
CreatedAt: "2024-01-01T10:00:00Z",
UpdatedAt: "2024-01-01T12:00:00Z",
},
},
Attempts: []BackupAttempt{
{
ID: "attempt1",
SessionID: "session1",
ProblemID: "problem1",
Result: "completed",
HighestHold: stringPtr("Top"),
Notes: stringPtr("Clean send"),
Duration: int64Ptr(300),
RestTime: int64Ptr(120),
Timestamp: "2024-01-01T10:30:00Z",
CreatedAt: "2024-01-01T10:30:00Z",
},
},
}
jsonData, err := json.Marshal(originalBackup)
if err != nil {
t.Fatalf("Failed to marshal backup: %v", err)
}
var unmarshaledBackup ClimbDataBackup
if err := json.Unmarshal(jsonData, &unmarshaledBackup); err != nil {
t.Fatalf("Failed to unmarshal backup: %v", err)
}
if originalBackup.Version != unmarshaledBackup.Version {
t.Errorf("Version mismatch: expected %s, got %s", originalBackup.Version, unmarshaledBackup.Version)
}
if len(originalBackup.Gyms) != len(unmarshaledBackup.Gyms) {
t.Errorf("Gyms count mismatch: expected %d, got %d", len(originalBackup.Gyms), len(unmarshaledBackup.Gyms))
}
if len(originalBackup.Problems) != len(unmarshaledBackup.Problems) {
t.Errorf("Problems count mismatch: expected %d, got %d", len(originalBackup.Problems), len(unmarshaledBackup.Problems))
}
if len(originalBackup.Sessions) != len(unmarshaledBackup.Sessions) {
t.Errorf("Sessions count mismatch: expected %d, got %d", len(originalBackup.Sessions), len(unmarshaledBackup.Sessions))
}
if len(originalBackup.Attempts) != len(unmarshaledBackup.Attempts) {
t.Errorf("Attempts count mismatch: expected %d, got %d", len(originalBackup.Attempts), len(unmarshaledBackup.Attempts))
}
})
t.Run("Required Fields Validation", func(t *testing.T) {
testCases := []struct {
name string
jsonInput string
shouldError bool
}{
{
name: "Valid minimal backup",
jsonInput: `{
"exportedAt": "2024-01-01T10:00:00Z",
"version": "2.0",
"formatVersion": "2.0",
"gyms": [],
"problems": [],
"sessions": [],
"attempts": []
}`,
shouldError: false,
},
{
name: "Missing version field",
jsonInput: `{
"exportedAt": "2024-01-01T10:00:00Z",
"formatVersion": "2.0",
"gyms": [],
"problems": [],
"sessions": [],
"attempts": []
}`,
shouldError: false,
},
{
name: "Invalid JSON structure",
jsonInput: `{
"exportedAt": "2024-01-01T10:00:00Z",
"version": "2.0",
"formatVersion": "2.0",
"gyms": "not an array"
}`,
shouldError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var backup ClimbDataBackup
err := json.Unmarshal([]byte(tc.jsonInput), &backup)
if tc.shouldError && err == nil {
t.Error("Expected error but got none")
}
if !tc.shouldError && err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
})
t.Run("Difficulty Grade Format", func(t *testing.T) {
testGrades := []DifficultyGrade{
{System: "V", Grade: "V0", NumericValue: 0},
{System: "V", Grade: "V5", NumericValue: 5},
{System: "V", Grade: "V10", NumericValue: 10},
{System: "YDS", Grade: "5.10a", NumericValue: 100},
{System: "YDS", Grade: "5.12d", NumericValue: 124},
{System: "Font", Grade: "6A", NumericValue: 60},
{System: "Custom", Grade: "Beginner", NumericValue: 1},
}
for _, grade := range testGrades {
jsonData, err := json.Marshal(grade)
if err != nil {
t.Errorf("Failed to marshal grade %+v: %v", grade, err)
continue
}
var unmarshaledGrade DifficultyGrade
if err := json.Unmarshal(jsonData, &unmarshaledGrade); err != nil {
t.Errorf("Failed to unmarshal grade %s: %v", string(jsonData), err)
continue
}
if grade.System != unmarshaledGrade.System {
t.Errorf("System mismatch for grade %+v: expected %s, got %s", grade, grade.System, unmarshaledGrade.System)
}
if grade.Grade != unmarshaledGrade.Grade {
t.Errorf("Grade mismatch for grade %+v: expected %s, got %s", grade, grade.Grade, unmarshaledGrade.Grade)
}
if grade.NumericValue != unmarshaledGrade.NumericValue {
t.Errorf("NumericValue mismatch for grade %+v: expected %d, got %d", grade, grade.NumericValue, unmarshaledGrade.NumericValue)
}
}
})
t.Run("Null and Optional Fields", func(t *testing.T) {
jsonWithNulls := `{
"exportedAt": "2024-01-01T10:00:00Z",
"version": "2.0",
"formatVersion": "2.0",
"gyms": [{
"id": "gym1",
"name": "Test Gym",
"location": null,
"supportedClimbTypes": ["boulder"],
"difficultySystems": ["V"],
"customDifficultyGrades": [],
"notes": null,
"createdAt": "2024-01-01T10:00:00Z",
"updatedAt": "2024-01-01T10:00:00Z"
}],
"problems": [{
"id": "problem1",
"gymId": "gym1",
"name": null,
"description": null,
"climbType": "boulder",
"difficulty": {
"system": "V",
"grade": "V5",
"numericValue": 5
},
"tags": [],
"location": null,
"imagePaths": [],
"isActive": true,
"dateSet": null,
"notes": null,
"createdAt": "2024-01-01T10:00:00Z",
"updatedAt": "2024-01-01T10:00:00Z"
}],
"sessions": [],
"attempts": []
}`
var backup ClimbDataBackup
if err := json.Unmarshal([]byte(jsonWithNulls), &backup); err != nil {
t.Fatalf("Failed to unmarshal JSON with nulls: %v", err)
}
if backup.Gyms[0].Location != nil {
t.Error("Expected location to be nil")
}
if backup.Gyms[0].Notes != nil {
t.Error("Expected notes to be nil")
}
if backup.Problems[0].Name != nil {
t.Error("Expected problem name to be nil")
}
})
t.Run("Date Format Validation", func(t *testing.T) {
validDates := []string{
"2024-01-01T10:00:00Z",
"2024-12-31T23:59:59Z",
"2024-06-15T12:30:45Z",
"2024-01-01T00:00:00Z",
}
invalidDates := []string{
"2024-01-01 10:00:00",
"2024/01/01T10:00:00Z",
"2024-1-1T10:00:00Z",
}
for _, date := range validDates {
if !isValidISODate(date) {
t.Errorf("Valid date %s was marked as invalid", date)
}
}
for _, date := range invalidDates {
if isValidISODate(date) {
t.Errorf("Invalid date %s was marked as valid", date)
}
}
})
t.Run("Field Length Limits", func(t *testing.T) {
longString := strings.Repeat("a", 10000)
gym := BackupGym{
ID: "gym1",
Name: longString,
Location: &longString,
SupportedClimbTypes: []string{"boulder"},
DifficultySystems: []string{"V"},
CustomDifficultyGrades: []string{},
Notes: &longString,
CreatedAt: "2024-01-01T10:00:00Z",
UpdatedAt: "2024-01-01T10:00:00Z",
}
jsonData, err := json.Marshal(gym)
if err != nil {
t.Errorf("Failed to marshal gym with long strings: %v", err)
}
var unmarshaledGym BackupGym
if err := json.Unmarshal(jsonData, &unmarshaledGym); err != nil {
t.Errorf("Failed to unmarshal gym with long strings: %v", err)
}
if unmarshaledGym.Name != longString {
t.Error("Long name was not preserved")
}
})
t.Run("Array Field Validation", func(t *testing.T) {
backup := ClimbDataBackup{
ExportedAt: "2024-01-01T10:00:00Z",
Version: "2.0",
FormatVersion: "2.0",
Gyms: nil,
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
}
jsonData, err := json.Marshal(backup)
if err != nil {
t.Fatalf("Failed to marshal backup with nil gyms: %v", err)
}
var unmarshaledBackup ClimbDataBackup
if err := json.Unmarshal(jsonData, &unmarshaledBackup); err != nil {
t.Fatalf("Failed to unmarshal backup with nil gyms: %v", err)
}
if len(unmarshaledBackup.Gyms) != 0 {
t.Error("Expected gyms to be empty or nil")
}
})
}
func isValidISODate(date string) bool {
// More robust ISO date validation
if !strings.Contains(date, "T") || !strings.HasSuffix(date, "Z") {
return false
}
// Check basic format: YYYY-MM-DDTHH:MM:SSZ
parts := strings.Split(date, "T")
if len(parts) != 2 {
return false
}
datePart := parts[0]
timePart := strings.TrimSuffix(parts[1], "Z")
// Date part should be YYYY-MM-DD
dateComponents := strings.Split(datePart, "-")
if len(dateComponents) != 3 || len(dateComponents[0]) != 4 || len(dateComponents[1]) != 2 || len(dateComponents[2]) != 2 {
return false
}
// Time part should be HH:MM:SS
timeComponents := strings.Split(timePart, ":")
if len(timeComponents) != 3 || len(timeComponents[0]) != 2 || len(timeComponents[1]) != 2 || len(timeComponents[2]) != 2 {
return false
}
return true
}
func TestVersionCompatibility(t *testing.T) {
testCases := []struct {
version string
formatVersion string
shouldSupport bool
}{
{"2.0", "2.0", true},
{"1.0", "1.0", true},
{"2.1", "2.0", false},
{"3.0", "2.0", false},
{"1.0", "2.0", false},
}
for _, tc := range testCases {
t.Run(tc.version+"/"+tc.formatVersion, func(t *testing.T) {
backup := ClimbDataBackup{
Version: tc.version,
FormatVersion: tc.formatVersion,
}
// Only exact version matches are supported for now
isSupported := backup.Version == "2.0" && backup.FormatVersion == "2.0"
if backup.Version == "1.0" && backup.FormatVersion == "1.0" {
isSupported = true
}
if isSupported != tc.shouldSupport {
t.Errorf("Version %s support expectation mismatch: expected %v, got %v",
tc.version, tc.shouldSupport, isSupported)
}
})
}
}
func TestClimbTypeValidation(t *testing.T) {
validClimbTypes := []string{"boulder", "sport", "trad", "toprope", "aid", "ice", "mixed"}
invalidClimbTypes := []string{"", "invalid", "BOULDER", "Sport", "unknown"}
for _, climbType := range validClimbTypes {
if !isValidClimbType(climbType) {
t.Errorf("Valid climb type %s was marked as invalid", climbType)
}
}
for _, climbType := range invalidClimbTypes {
if isValidClimbType(climbType) {
t.Errorf("Invalid climb type %s was marked as valid", climbType)
}
}
}
func isValidClimbType(climbType string) bool {
validTypes := map[string]bool{
"boulder": true,
"sport": true,
"trad": true,
"toprope": true,
"aid": true,
"ice": true,
"mixed": true,
}
return validTypes[climbType]
}
func TestAttemptResultValidation(t *testing.T) {
validResults := []string{"completed", "failed", "flash", "project", "attempt"}
invalidResults := []string{"", "invalid", "COMPLETED", "Failed", "unknown"}
for _, result := range validResults {
if !isValidAttemptResult(result) {
t.Errorf("Valid attempt result %s was marked as invalid", result)
}
}
for _, result := range invalidResults {
if isValidAttemptResult(result) {
t.Errorf("Invalid attempt result %s was marked as valid", result)
}
}
}
// Helper functions for creating pointers
func stringPtr(s string) *string {
return &s
}
func int64Ptr(i int64) *int64 {
return &i
}
func isValidAttemptResult(result string) bool {
validResults := map[string]bool{
"completed": true,
"failed": true,
"flash": true,
"project": true,
"attempt": true,
}
return validResults[result]
}

View File

@@ -1,3 +1,3 @@
module openclimb-sync
go 1.25
go 1.21

361
sync/main_test.go Normal file
View File

@@ -0,0 +1,361 @@
package main
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSyncServerAuthentication(t *testing.T) {
server := &SyncServer{authToken: "test-token"}
tests := []struct {
name string
token string
expected bool
}{
{"Valid token", "test-token", true},
{"Invalid token", "wrong-token", false},
{"Empty token", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test the authentication logic directly without HTTP
result := strings.Compare(tt.token, server.authToken) == 0
if result != tt.expected {
t.Errorf("authenticate() = %v, want %v", result, tt.expected)
}
})
}
}
func TestLoadDataNonExistentFile(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "nonexistent.json"),
}
backup, err := server.loadData()
if err != nil {
t.Errorf("loadData() error = %v, want nil", err)
}
if backup == nil {
t.Error("Expected backup to be non-nil")
}
if len(backup.Gyms) != 0 || len(backup.Problems) != 0 || len(backup.Sessions) != 0 || len(backup.Attempts) != 0 {
t.Error("Expected empty backup data")
}
if backup.Version != "2.0" || backup.FormatVersion != "2.0" {
t.Error("Expected version and format version to be 2.0")
}
}
func TestSaveAndLoadData(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "test.json"),
imagesDir: filepath.Join(tempDir, "images"),
}
testData := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{
{
ID: "gym1",
Name: "Test Gym",
},
},
Problems: []BackupProblem{
{
ID: "problem1",
GymID: "gym1",
ClimbType: "BOULDER",
Difficulty: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
IsActive: true,
},
},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
}
err := server.saveData(testData)
if err != nil {
t.Errorf("saveData() error = %v", err)
}
loadedData, err := server.loadData()
if err != nil {
t.Errorf("loadData() error = %v", err)
}
if len(loadedData.Gyms) != 1 || loadedData.Gyms[0].ID != "gym1" {
t.Error("Loaded gym data doesn't match saved data")
}
if len(loadedData.Problems) != 1 || loadedData.Problems[0].ID != "problem1" {
t.Error("Loaded problem data doesn't match saved data")
}
}
func TestMinFunction(t *testing.T) {
tests := []struct {
a, b, expected int
}{
{5, 3, 3},
{2, 8, 2},
{4, 4, 4},
{0, 1, 0},
{-1, 2, -1},
}
for _, tt := range tests {
result := min(tt.a, tt.b)
if result != tt.expected {
t.Errorf("min(%d, %d) = %d, want %d", tt.a, tt.b, result, tt.expected)
}
}
}
func TestClimbDataBackupValidation(t *testing.T) {
tests := []struct {
name string
backup ClimbDataBackup
isValid bool
}{
{
name: "Valid backup",
backup: ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
},
isValid: true,
},
{
name: "Missing version",
backup: ClimbDataBackup{
FormatVersion: "2.0",
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
},
isValid: false,
},
{
name: "Missing format version",
backup: ClimbDataBackup{
Version: "2.0",
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
},
isValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test basic validation logic
hasVersion := tt.backup.Version != ""
hasFormatVersion := tt.backup.FormatVersion != ""
isValid := hasVersion && hasFormatVersion
if isValid != tt.isValid {
t.Errorf("validation = %v, want %v", isValid, tt.isValid)
}
})
}
}
func TestBackupDataStructures(t *testing.T) {
t.Run("BackupGym", func(t *testing.T) {
gym := BackupGym{
ID: "gym1",
Name: "Test Gym",
SupportedClimbTypes: []string{"BOULDER", "ROPE"},
DifficultySystems: []string{"V", "YDS"},
CustomDifficultyGrades: []string{},
CreatedAt: "2024-01-01T10:00:00Z",
UpdatedAt: "2024-01-01T10:00:00Z",
}
if gym.ID != "gym1" {
t.Errorf("Expected gym ID 'gym1', got %s", gym.ID)
}
if len(gym.SupportedClimbTypes) != 2 {
t.Errorf("Expected 2 climb types, got %d", len(gym.SupportedClimbTypes))
}
})
t.Run("BackupProblem", func(t *testing.T) {
problem := BackupProblem{
ID: "problem1",
GymID: "gym1",
ClimbType: "BOULDER",
Difficulty: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
IsActive: true,
CreatedAt: "2024-01-01T10:00:00Z",
UpdatedAt: "2024-01-01T10:00:00Z",
}
if problem.ClimbType != "BOULDER" {
t.Errorf("Expected climb type 'BOULDER', got %s", problem.ClimbType)
}
if problem.Difficulty.Grade != "V5" {
t.Errorf("Expected difficulty 'V5', got %s", problem.Difficulty.Grade)
}
})
}
func TestDifficultyGrade(t *testing.T) {
tests := []struct {
name string
grade DifficultyGrade
expectedGrade string
expectedValue int
}{
{
name: "V-Scale grade",
grade: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
expectedGrade: "V5",
expectedValue: 5,
},
{
name: "YDS grade",
grade: DifficultyGrade{
System: "YDS",
Grade: "5.10a",
NumericValue: 10,
},
expectedGrade: "5.10a",
expectedValue: 10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.grade.Grade != tt.expectedGrade {
t.Errorf("Expected grade %s, got %s", tt.expectedGrade, tt.grade.Grade)
}
if tt.grade.NumericValue != tt.expectedValue {
t.Errorf("Expected numeric value %d, got %d", tt.expectedValue, tt.grade.NumericValue)
}
})
}
}
func TestJSONSerialization(t *testing.T) {
backup := ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{
{
ID: "gym1",
Name: "Test Gym",
},
},
Problems: []BackupProblem{
{
ID: "problem1",
GymID: "gym1",
ClimbType: "BOULDER",
Difficulty: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
IsActive: true,
},
},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
}
// Test JSON marshaling
jsonData, err := json.Marshal(backup)
if err != nil {
t.Errorf("Failed to marshal JSON: %v", err)
}
// Test JSON unmarshaling
var unmarshaledBackup ClimbDataBackup
err = json.Unmarshal(jsonData, &unmarshaledBackup)
if err != nil {
t.Errorf("Failed to unmarshal JSON: %v", err)
}
if unmarshaledBackup.Version != backup.Version {
t.Errorf("Version mismatch after JSON round-trip")
}
if len(unmarshaledBackup.Gyms) != len(backup.Gyms) {
t.Errorf("Gyms count mismatch after JSON round-trip")
}
}
func TestTimestampHandling(t *testing.T) {
now := time.Now().UTC()
timestamp := now.Format(time.RFC3339)
// Test that timestamp is in correct format
parsedTime, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
t.Errorf("Failed to parse timestamp: %v", err)
}
if parsedTime.Year() != now.Year() {
t.Errorf("Year mismatch in timestamp")
}
}
func TestFilePathHandling(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
filename string
isValid bool
}{
{"Valid filename", "test.json", true},
{"Valid path", filepath.Join(tempDir, "data.json"), true},
{"Empty filename", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isEmpty := tt.filename == ""
isValid := !isEmpty
if isValid != tt.isValid {
t.Errorf("File path validation = %v, want %v", isValid, tt.isValid)
}
})
}
}