diff options
Diffstat (limited to 'internal/commands/import_test.go')
-rw-r--r-- | internal/commands/import_test.go | 543 |
1 files changed, 543 insertions, 0 deletions
diff --git a/internal/commands/import_test.go b/internal/commands/import_test.go new file mode 100644 index 0000000..ed59f92 --- /dev/null +++ b/internal/commands/import_test.go @@ -0,0 +1,543 @@ +package commands + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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) + } + }) +} |