package tui import ( "context" "database/sql" "testing" "time" "git.tjp.lol/punchcard/internal/queries" _ "modernc.org/sqlite" ) func TestValidateAndParseEntryForm(t *testing.T) { // Test timezone handling in modal forms tests := []struct { name string setupData func(*queries.Queries) (entryID int64, err error) formValues []string // Values for each form field in order expectError bool expectedStart string // Expected UTC format expectedEnd string // Expected UTC format }{ { name: "local time input converted to UTC storage", setupData: func(q *queries.Queries) (int64, error) { client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ Name: "TestClient", }) if err != nil { return 0, err } entry, err := q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ Description: sql.NullString{String: "Test work", Valid: true}, ClientID: client.ID, }) if err != nil { return 0, err } // Stop the entry so we can edit it _, err = q.StopTimeEntry(context.Background()) return entry.ID, err }, formValues: []string{ "2024-08-22 14:30:00", // Start time (local) "2024-08-22 16:45:00", // End time (local) "TestClient", // Client "", // Project "Updated description", // Description "125.50", // Rate }, expectError: false, expectedStart: "2024-08-22 14:30:00", // Should be converted to UTC format expectedEnd: "2024-08-22 16:45:00", // Should be converted to UTC format }, { name: "invalid time format should error", setupData: func(q *queries.Queries) (int64, error) { client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ Name: "TestClient", }) if err != nil { return 0, err } entry, err := q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ Description: sql.NullString{String: "Test work", Valid: true}, ClientID: client.ID, }) if err != nil { return 0, err } _, err = q.StopTimeEntry(context.Background()) return entry.ID, err }, formValues: []string{ "invalid-time-format", // Invalid start time "2024-08-22 16:45:00", // Valid end time "TestClient", // Client "", // Project "Updated description", // Description "125.50", // Rate }, expectError: true, }, { name: "empty end time for completed entry should error", setupData: func(q *queries.Queries) (int64, error) { client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ Name: "TestClient", }) if err != nil { return 0, err } entry, err := q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ Description: sql.NullString{String: "Test work", Valid: true}, ClientID: client.ID, }) if err != nil { return 0, err } _, err = q.StopTimeEntry(context.Background()) return entry.ID, err }, formValues: []string{ "2024-08-22 14:30:00", // Start time "", // Empty end time (trying to re-open completed entry) "TestClient", // Client "", // Project "Updated description", // Description "125.50", // Rate }, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { q, _, cleanup := setupTestDB(t) defer cleanup() // Setup test data entryID, err := tt.setupData(q) if err != nil { t.Fatalf("Failed to setup test data: %v", err) } // Create a modal with entry form modal := &ModalBoxModel{ Type: ModalTypeEntry, editedID: entryID, form: NewEntryEditorForm(), } // Set form field values for i, value := range tt.formValues { if i < len(modal.form.fields) { modal.form.fields[i].SetValue(value) } } // Create a minimal AppModel for testing appModel := AppModel{ queries: q, } // Test validateAndParseEntryForm params, hasErrors := modal.validateAndParseEntryForm(appModel) if tt.expectError { if !hasErrors { t.Errorf("Expected validation errors but got none") } return } if hasErrors { // Check which fields have errors for debugging for i, field := range modal.form.fields { if field.Err != nil { t.Logf("Field %d error: %v", i, field.Err) } } t.Errorf("Unexpected validation errors") return } // Verify the parameters were parsed correctly if params.EntryID != entryID { t.Errorf("Expected entry ID %d, got %d", entryID, params.EntryID) } // Verify start time conversion if tt.expectedStart != "" { // Parse the expected local time and convert to UTC for comparison expectedLocal, err := time.ParseInLocation(time.DateTime, tt.expectedStart, time.Local) if err != nil { t.Fatalf("Failed to parse expected start time: %v", err) } expectedUTC := expectedLocal.UTC().Format(time.DateTime) if params.StartTime != expectedUTC { t.Errorf("Expected start time %s (UTC), got %s", expectedUTC, params.StartTime) } } // Verify end time conversion if tt.expectedEnd != "" { expectedLocal, err := time.ParseInLocation(time.DateTime, tt.expectedEnd, time.Local) if err != nil { t.Fatalf("Failed to parse expected end time: %v", err) } expectedUTC := expectedLocal.UTC().Format(time.DateTime) if params.EndTime != expectedUTC { t.Errorf("Expected end time %s (UTC), got %s", expectedUTC, params.EndTime) } } // Verify rate was parsed correctly if tt.formValues[5] != "" { expectedRate := 12550 // 125.50 * 100 (converted to cents) if !params.HourlyRate.Valid || params.HourlyRate.Int64 != int64(expectedRate) { t.Errorf("Expected hourly rate %d cents, got %v", expectedRate, params.HourlyRate) } } }) } } func TestFormTimezoneValidation(t *testing.T) { // Test timezone-related validation in form fields tests := []struct { name string fieldType string value string expectError bool }{ { name: "valid timestamp format", fieldType: "timestamp", value: "2024-08-22 14:30:00", expectError: false, }, { name: "invalid timestamp format - missing seconds", fieldType: "timestamp", value: "2024-08-22 14:30", expectError: true, }, { name: "invalid timestamp format - wrong separator", fieldType: "timestamp", value: "2024/08/22 14:30:00", expectError: true, }, { name: "empty optional timestamp", fieldType: "optional_timestamp", value: "", expectError: false, }, { name: "valid optional timestamp", fieldType: "optional_timestamp", value: "2024-08-22 16:45:00", expectError: false, }, { name: "invalid optional timestamp", fieldType: "optional_timestamp", value: "not-a-timestamp", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var field FormField switch tt.fieldType { case "timestamp": field = newTimestampField("Test Field") case "optional_timestamp": field = newOptionalTimestampField("Test Optional Field") default: t.Fatalf("Unknown field type: %s", tt.fieldType) } // Set the value and validate field.SetValue(tt.value) err := field.Validate(tt.value) if tt.expectError { if err == nil { t.Errorf("Expected validation error for value %q, but got none", tt.value) } } else { if err != nil { t.Errorf("Expected no validation error for value %q, but got: %v", tt.value, err) } } }) } } func TestModalEntryEditingTimezone(t *testing.T) { // Test that modal editing maintains timezone consistency tests := []struct { name string setupData func(*queries.Queries) (int64, error) }{ { name: "editing preserves UTC storage", setupData: func(q *queries.Queries) (int64, error) { client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ Name: "TestClient", }) if err != nil { return 0, err } // Create entry with known UTC time _, err = q.DBTX().(*sql.DB).Exec(` INSERT INTO time_entry (start_time, end_time, client_id, description) VALUES (?, ?, ?, 'Original work') `, "2024-08-22 18:00:00", "2024-08-22 19:30:00", client.ID) if err != nil { return 0, err } // Get the entry ID var entryID int64 err = q.DBTX().(*sql.DB).QueryRow("SELECT id FROM time_entry ORDER BY id DESC LIMIT 1").Scan(&entryID) return entryID, err }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { q, db, cleanup := setupTestDB(t) defer cleanup() entryID, err := tt.setupData(q) if err != nil { t.Fatalf("Failed to setup test data: %v", err) } // Get the original entry to verify UTC storage var originalStart, originalEnd string err = db.QueryRow("SELECT start_time, end_time FROM time_entry WHERE id = ?", entryID).Scan(&originalStart, &originalEnd) if err != nil { t.Fatalf("Failed to get original entry: %v", err) } // Parse and verify original times are in a valid time format // SQLite might return different formats, so try multiple formats := []string{ time.DateTime, // "2006-01-02 15:04:05" time.RFC3339, // "2006-01-02T15:04:05Z" "2006-01-02T15:04:05", // ISO format without Z } startParsed := false for _, format := range formats { if _, err := time.Parse(format, originalStart); err == nil { startParsed = true break } } if !startParsed { t.Errorf("Original start time not in a recognized format: %s", originalStart) } endParsed := false for _, format := range formats { if _, err := time.Parse(format, originalEnd); err == nil { endParsed = true break } } if !endParsed { t.Errorf("Original end time not in a recognized format: %s", originalEnd) } // The key insight is that the modal should accept local time input // but store UTC time in the database, maintaining consistency t.Logf("Original entry stored with start=%s, end=%s (should be UTC)", originalStart, originalEnd) }) } } func TestTimezoneConsistencyAcrossEditing(t *testing.T) { // Integration test: verify that editing a time entry maintains timezone consistency // between display (local) and storage (UTC) q, db, cleanup := setupTestDB(t) defer cleanup() // Create client client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ Name: "ConsistencyTestClient", }) if err != nil { t.Fatalf("Failed to create client: %v", err) } // Create an entry with a known UTC time utcStart := "2024-08-22 20:00:00" // 8 PM UTC utcEnd := "2024-08-22 22:30:00" // 10:30 PM UTC _, err = db.Exec(` INSERT INTO time_entry (start_time, end_time, client_id, description) VALUES (?, ?, ?, 'Consistency test') `, utcStart, utcEnd, client.ID) if err != nil { t.Fatalf("Failed to create entry: %v", err) } // Get the entry ID var entryID int64 err = db.QueryRow("SELECT id FROM time_entry ORDER BY id DESC LIMIT 1").Scan(&entryID) if err != nil { t.Fatalf("Failed to get entry ID: %v", err) } // Create modal for editing modal := &ModalBoxModel{ Type: ModalTypeEntry, editedID: entryID, form: NewEntryEditorForm(), } // Simulate what the UI would do: convert UTC to local for display startUTC, _ := time.Parse(time.DateTime, utcStart) endUTC, _ := time.Parse(time.DateTime, utcEnd) startLocal := startUTC.Local().Format(time.DateTime) endLocal := endUTC.Local().Format(time.DateTime) // Set form values as if user is editing (using local time display) formValues := []string{ startLocal, // Start time in local format endLocal, // End time in local format "ConsistencyTestClient", // Client "", // Project "Updated description", // Description "100.00", // Rate } for i, value := range formValues { if i < len(modal.form.fields) { modal.form.fields[i].SetValue(value) } } // Create minimal AppModel appModel := AppModel{ queries: q, } // Validate and parse the form (should convert back to UTC) params, hasErrors := modal.validateAndParseEntryForm(appModel) if hasErrors { for i, field := range modal.form.fields { if field.Err != nil { t.Logf("Field %d error: %v", i, field.Err) } } t.Fatalf("Unexpected validation errors") } // The parsed params should contain UTC times again if params.StartTime != utcStart { t.Errorf("Expected start time to round-trip to UTC: expected %s, got %s", utcStart, params.StartTime) } if params.EndTime != utcEnd { t.Errorf("Expected end time to round-trip to UTC: expected %s, got %s", utcEnd, params.EndTime) } t.Logf("Timezone consistency verified: Local display (%s->%s) converts back to UTC storage (%s->%s)", startLocal, endLocal, params.StartTime, params.EndTime) }