package commands import ( "context" "database/sql" "os" "path/filepath" "strings" "testing" "time" "git.tjp.lol/punchcard/internal/queries" ) func TestImportCommand(t *testing.T) { tests := []struct { name string setupData func(*queries.Queries) error csvFile string args []string expectedOutputs []string expectError bool errorContains string validateDatabase func(*testing.T, *queries.Queries) }{ { name: "successful import with full CSV format", setupData: func(q *queries.Queries) error { return nil }, csvFile: "clockify_full.csv", args: []string{"import", "", "--source", "clockify", "--timezone", "America/New_York"}, expectedOutputs: []string{"Successfully imported 3 time entries"}, expectError: false, validateDatabase: func(t *testing.T, q *queries.Queries) { // Check clients were created clients, err := q.ListAllClients(context.Background()) if err != nil { t.Errorf("Failed to list clients: %v", err) return } expectedClients := map[string]bool{ "Acme Corp": false, "Creative Co": false, } for _, client := range clients { if _, exists := expectedClients[client.Name]; exists { expectedClients[client.Name] = true } } for clientName, found := range expectedClients { if !found { t.Errorf("Expected client %q not found", clientName) } } // Check projects were created projects, err := q.ListAllProjects(context.Background()) if err != nil { t.Errorf("Failed to list projects: %v", err) return } expectedProjects := map[string]bool{ "Project Alpha": false, "Project Beta": false, "Website Redesign": false, } for _, project := range projects { if _, exists := expectedProjects[project.Name]; exists { expectedProjects[project.Name] = true } } for projectName, found := range expectedProjects { if !found { t.Errorf("Expected project %q not found", projectName) } } }, }, { name: "successful import without billable columns", setupData: func(q *queries.Queries) error { return nil }, csvFile: "clockify_no_billable.csv", args: []string{"import", "", "--source", "clockify", "--timezone", "UTC"}, expectedOutputs: []string{"Successfully imported 2 time entries"}, expectError: false, validateDatabase: func(t *testing.T, q *queries.Queries) { // Check TechStart Inc client was created client, err := q.GetClientByName(context.Background(), "TechStart Inc") if err != nil { t.Errorf("Expected client 'TechStart Inc' not found: %v", err) return } // Check projects were created under correct client project1, err := q.GetProjectByNameAndClient(context.Background(), queries.GetProjectByNameAndClientParams{ Name: "Project Gamma", ClientID: client.ID, }) if err != nil { t.Errorf("Expected project 'Project Gamma' not found: %v", err) } project2, err := q.GetProjectByNameAndClient(context.Background(), queries.GetProjectByNameAndClientParams{ Name: "Mobile App", ClientID: client.ID, }) if err != nil { t.Errorf("Expected project 'Mobile App' not found: %v", err) } // Verify the projects belong to the correct client if project1.ClientID != client.ID { t.Errorf("Project Gamma should belong to client %d, got %d", client.ID, project1.ClientID) } if project2.ClientID != client.ID { t.Errorf("Mobile App should belong to client %d, got %d", client.ID, project2.ClientID) } }, }, { name: "timezone conversion test", setupData: func(q *queries.Queries) error { return nil }, csvFile: "clockify_full.csv", args: []string{"import", "", "--source", "clockify", "--timezone", "UTC"}, expectedOutputs: []string{"Successfully imported 3 time entries"}, expectError: false, validateDatabase: func(t *testing.T, q *queries.Queries) { // We can't easily test the exact timezone conversion without more complex setup, // but we can verify that entries were created and have valid timestamps // This is more of an integration test that the timezone parsing doesn't crash }, }, { name: "duplicate client/project handling", setupData: func(q *queries.Queries) error { // Pre-create a client that exists in the CSV _, err := q.CreateClient(context.Background(), queries.CreateClientParams{ Name: "Acme Corp", Email: sql.NullString{String: "existing@acme.com", Valid: true}, }) if err != nil { return err } return nil }, csvFile: "clockify_full.csv", args: []string{"import", "", "--source", "clockify", "--timezone", "Local"}, expectedOutputs: []string{"Successfully imported 3 time entries"}, expectError: false, validateDatabase: func(t *testing.T, q *queries.Queries) { // Check that we don't have duplicate clients clients, err := q.ListAllClients(context.Background()) if err != nil { t.Errorf("Failed to list clients: %v", err) return } acmeCount := 0 for _, client := range clients { if client.Name == "Acme Corp" { acmeCount++ } } if acmeCount != 1 { t.Errorf("Expected exactly 1 'Acme Corp' client, found %d", acmeCount) } }, }, { name: "missing client/project data handling", setupData: func(q *queries.Queries) error { return nil }, csvFile: "clockify_missing_data.csv", args: []string{"import", "", "--source", "clockify", "--timezone", "Local"}, expectedOutputs: []string{"Successfully imported 1 time entries"}, expectError: false, validateDatabase: func(t *testing.T, q *queries.Queries) { // Only the first entry should be imported (has both client and project) client, err := q.GetClientByName(context.Background(), "Valid Client") if err != nil { t.Errorf("Expected client 'Valid Client' not found: %v", err) } project, err := q.GetProjectByNameAndClient(context.Background(), queries.GetProjectByNameAndClientParams{ Name: "Valid Project", ClientID: client.ID, }) if err != nil { t.Errorf("Expected project 'Valid Project' not found: %v", err) } if project.ClientID != client.ID { t.Errorf("Project should belong to client %d, got %d", client.ID, project.ClientID) } }, }, { name: "invalid CSV format", setupData: func(q *queries.Queries) error { return nil }, csvFile: "clockify_invalid.csv", args: []string{"import", "", "--source", "clockify", "--timezone", "Local"}, expectError: true, errorContains: "wrong number of fields", }, { name: "nonexistent file", setupData: func(q *queries.Queries) error { return nil }, csvFile: "nonexistent.csv", args: []string{"import", "", "--source", "clockify", "--timezone", "Local"}, expectError: true, errorContains: "failed to open file", }, { name: "invalid timezone", setupData: func(q *queries.Queries) error { return nil }, csvFile: "clockify_full.csv", args: []string{"import", "", "--source", "clockify", "--timezone", "Invalid/Timezone"}, expectError: true, errorContains: "invalid timezone", }, { name: "missing source flag", setupData: func(q *queries.Queries) error { return nil }, csvFile: "clockify_full.csv", args: []string{"import", "", "--timezone", "Local"}, expectError: true, errorContains: "required flag(s) \"source\" not set", }, { name: "empty source flag", setupData: func(q *queries.Queries) error { return nil }, csvFile: "clockify_full.csv", args: []string{"import", "", "--source", "", "--timezone", "Local"}, expectError: true, errorContains: "required flag \"source\" not set", }, { name: "invalid source", setupData: func(q *queries.Queries) error { return nil }, csvFile: "clockify_full.csv", args: []string{"import", "", "--source", "invalid", "--timezone", "Local"}, expectError: true, errorContains: "unsupported source: invalid", }, { name: "reordered columns with extra fields", setupData: func(q *queries.Queries) error { return nil }, csvFile: "clockify_reordered.csv", args: []string{"import", "", "--source", "clockify", "--timezone", "UTC"}, expectedOutputs: []string{"Successfully imported 2 time entries"}, expectError: false, validateDatabase: func(t *testing.T, q *queries.Queries) { client, err := q.GetClientByName(context.Background(), "MegaCorp Inc") if err != nil { t.Errorf("Expected client 'MegaCorp Inc' not found: %v", err) return } project, err := q.GetProjectByNameAndClient(context.Background(), queries.GetProjectByNameAndClientParams{ Name: "Website Overhaul", ClientID: client.ID, }) if err != nil { t.Errorf("Expected project 'Website Overhaul' not found: %v", err) } if project.ClientID != client.ID { t.Errorf("Website Overhaul should belong to client %d, got %d", client.ID, project.ClientID) } }, }, { name: "extra columns beyond required", setupData: func(q *queries.Queries) error { return nil }, csvFile: "clockify_extra_columns.csv", args: []string{"import", "", "--source", "clockify", "--timezone", "Local"}, expectedOutputs: []string{"Successfully imported 2 time entries"}, expectError: false, validateDatabase: func(t *testing.T, q *queries.Queries) { client, err := q.GetClientByName(context.Background(), "DataCorp") if err != nil { t.Errorf("Expected client 'DataCorp' not found: %v", err) return } projects, err := q.ListAllProjects(context.Background()) if err != nil { t.Errorf("Failed to list projects: %v", err) return } expectedProjects := map[string]bool{ "Analytics Dashboard": false, "API Development": false, } for _, project := range projects { if _, exists := expectedProjects[project.Name]; exists { expectedProjects[project.Name] = true if project.ClientID != client.ID { t.Errorf("Project %s should belong to client %d, got %d", project.Name, client.ID, project.ClientID) } } } for projectName, found := range expectedProjects { if !found { t.Errorf("Expected project %q not found", projectName) } } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Setup fresh database for each test q, cleanup := setupTestDB(t) defer cleanup() // Setup test data if needed if tt.setupData != nil { if err := tt.setupData(q); err != nil { t.Fatalf("Failed to setup test data: %v", err) } } // Prepare file path testDataPath := filepath.Join("testdata", tt.csvFile) if tt.csvFile != "nonexistent.csv" { // Verify test file exists if _, err := os.Stat(testDataPath); os.IsNotExist(err) { t.Fatalf("Test data file %s does not exist", testDataPath) } } // Update args with actual file path args := make([]string, len(tt.args)) copy(args, tt.args) if len(args) > 1 && args[1] == "" { args[1] = testDataPath } // Execute command output, err := executeCommandWithDB(t, q, args...) // Check error expectation if tt.expectError { if err == nil { t.Errorf("Expected error but got none. Output: %q, Args: %v", output, args) } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { t.Errorf("Expected error to contain %q, got %q", tt.errorContains, err.Error()) } return } if err != nil { t.Errorf("Unexpected error: %v", err) return } // Note: Output validation is skipped because fmt.Printf writes to os.Stdout // directly and is not captured by cobra's test framework. // We rely on database validation instead for testing correctness. // Run database validation if provided if tt.validateDatabase != nil { tt.validateDatabase(t, q) } }) } } func TestClockifyDateTimeParsing(t *testing.T) { // Test the parseClockifyDateTime function with various formats loc, err := time.LoadLocation("America/New_York") if err != nil { t.Fatalf("Failed to load timezone: %v", err) } tests := []struct { name string date string timeStr string expected time.Time }{ { name: "12-hour format with AM", date: "01/15/2024", timeStr: "09:30:00 AM", expected: time.Date(2024, 1, 15, 9, 30, 0, 0, loc), }, { name: "12-hour format with PM", date: "01/15/2024", timeStr: "02:45:30 PM", expected: time.Date(2024, 1, 15, 14, 45, 30, 0, loc), }, { name: "24-hour format", date: "01/15/2024", timeStr: "15:20:45", expected: time.Date(2024, 1, 15, 15, 20, 45, 0, loc), }, { name: "single digit month/day with 12-hour format", date: "1/5/2024", timeStr: "9:00:00 AM", expected: time.Date(2024, 1, 5, 9, 0, 0, 0, loc), }, { name: "single digit month/day with 24-hour format", date: "1/5/2024", timeStr: "09:00:00", expected: time.Date(2024, 1, 5, 9, 0, 0, 0, loc), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := parseClockifyDateTime(tt.date, tt.timeStr, loc) if err != nil { t.Errorf("Unexpected error: %v", err) return } if !result.Equal(tt.expected) { t.Errorf("Expected %v, got %v", tt.expected, result) } }) } // Test error cases errorTests := []struct { name string date string timeStr string }{ {"invalid date format", "2024-01-15", "09:00:00 AM"}, {"invalid time format", "01/15/2024", "25:00:00"}, {"empty date", "", "09:00:00 AM"}, {"empty time", "01/15/2024", ""}, } for _, tt := range errorTests { t.Run(tt.name, func(t *testing.T) { _, err := parseClockifyDateTime(tt.date, tt.timeStr, loc) if err == nil { t.Errorf("Expected error for invalid input, but got none") } }) } } func TestGetOrCreateClientAndProject(t *testing.T) { // Test getOrCreateClient t.Run("create new client", func(t *testing.T) { q, cleanup := setupTestDB(t) defer cleanup() client, err := getOrCreateClient(q, "New Client") if err != nil { t.Errorf("Unexpected error: %v", err) return } if client.Name != "New Client" { t.Errorf("Expected client name 'New Client', got %q", client.Name) } }) t.Run("get existing client", func(t *testing.T) { q, cleanup := setupTestDB(t) defer cleanup() // First create a client originalClient, err := q.CreateClient(context.Background(), queries.CreateClientParams{ Name: "Existing Client", Email: sql.NullString{String: "existing@example.com", Valid: true}, }) if err != nil { t.Fatalf("Failed to create client: %v", err) } // Now try to get or create it again client, err := getOrCreateClient(q, "Existing Client") if err != nil { t.Errorf("Unexpected error: %v", err) return } if client.ID != originalClient.ID { t.Errorf("Expected to get existing client with ID %d, got %d", originalClient.ID, client.ID) } }) // Test getOrCreateProject t.Run("create new project", func(t *testing.T) { q, cleanup := setupTestDB(t) defer cleanup() // First create a client client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ Name: "Project Client", }) if err != nil { t.Fatalf("Failed to create client: %v", err) } project, err := getOrCreateProject(q, "New Project", client.ID) if err != nil { t.Errorf("Unexpected error: %v", err) return } if project.Name != "New Project" { t.Errorf("Expected project name 'New Project', got %q", project.Name) } if project.ClientID != client.ID { t.Errorf("Expected project to belong to client %d, got %d", client.ID, project.ClientID) } }) t.Run("get existing project", func(t *testing.T) { q, cleanup := setupTestDB(t) defer cleanup() // First create a client and project client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ Name: "Another Client", }) if err != nil { t.Fatalf("Failed to create client: %v", err) } originalProject, err := q.CreateProject(context.Background(), queries.CreateProjectParams{ Name: "Existing Project", ClientID: client.ID, }) if err != nil { t.Fatalf("Failed to create project: %v", err) } // Now try to get or create it again project, err := getOrCreateProject(q, "Existing Project", client.ID) if err != nil { t.Errorf("Unexpected error: %v", err) return } if project.ID != originalProject.ID { t.Errorf("Expected to get existing project with ID %d, got %d", originalProject.ID, project.ID) } }) }