diff options
Diffstat (limited to 'internal/commands')
-rw-r--r-- | internal/commands/archive.go | 141 | ||||
-rw-r--r-- | internal/commands/archive_test.go | 517 | ||||
-rw-r--r-- | internal/commands/in.go | 38 | ||||
-rw-r--r-- | internal/commands/root.go | 2 |
4 files changed, 693 insertions, 5 deletions
diff --git a/internal/commands/archive.go b/internal/commands/archive.go new file mode 100644 index 0000000..d6c9977 --- /dev/null +++ b/internal/commands/archive.go @@ -0,0 +1,141 @@ +package commands + +import ( + "errors" + "fmt" + + "git.tjp.lol/punchcard/internal/actions" + punchctx "git.tjp.lol/punchcard/internal/context" + + "github.com/spf13/cobra" +) + +func NewArchiveCmd() *cobra.Command { + var clientFlag, projectFlag string + + cmd := &cobra.Command{ + Use: "archive", + Short: "Archive a client or project", + Long: `Archive a client or project to hide it from the default view. + +Archived clients and projects will not be shown in the "Clients & Projects" pane +by default, but can be toggled visible. All historical time entries remain accessible. + +Examples: + punch archive --client "Acme Corp" + punch archive --client 1 + punch archive --project "Website Redesign" + punch archive --project 5`, + RunE: func(cmd *cobra.Command, args []string) error { + if clientFlag == "" && projectFlag == "" { + return errors.New("either --client or --project must be specified") + } + + if clientFlag != "" && projectFlag != "" { + return errors.New("cannot specify both --client and --project") + } + + q := punchctx.GetDB(cmd.Context()) + if q == nil { + return fmt.Errorf("database not available in context") + } + + a := actions.New(q) + + if clientFlag != "" { + client, err := a.FindClient(cmd.Context(), clientFlag) + if err != nil { + return fmt.Errorf("failed to find client: %w", err) + } + + if err := a.ArchiveClient(cmd.Context(), client.ID); err != nil { + return fmt.Errorf("failed to archive client: %w", err) + } + + cmd.Printf("Archived client: %s\n", client.Name) + } else { + project, err := a.FindProject(cmd.Context(), projectFlag) + if err != nil { + return fmt.Errorf("failed to find project: %w", err) + } + + if err := a.ArchiveProject(cmd.Context(), project.ID); err != nil { + return fmt.Errorf("failed to archive project: %w", err) + } + + cmd.Printf("Archived project: %s\n", project.Name) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&clientFlag, "client", "c", "", "Client name or ID") + cmd.Flags().StringVarP(&projectFlag, "project", "p", "", "Project name or ID") + + return cmd +} + +func NewUnarchiveCmd() *cobra.Command { + var clientFlag, projectFlag string + + cmd := &cobra.Command{ + Use: "unarchive", + Short: "Unarchive a client or project", + Long: `Unarchive a client or project to show it in the default view again. + +Examples: + punch unarchive --client "Acme Corp" + punch unarchive --client 1 + punch unarchive --project "Website Redesign" + punch unarchive --project 5`, + RunE: func(cmd *cobra.Command, args []string) error { + if clientFlag == "" && projectFlag == "" { + return errors.New("either --client or --project must be specified") + } + + if clientFlag != "" && projectFlag != "" { + return errors.New("cannot specify both --client and --project") + } + + q := punchctx.GetDB(cmd.Context()) + if q == nil { + return fmt.Errorf("database not available in context") + } + + a := actions.New(q) + + if clientFlag != "" { + client, err := a.FindClient(cmd.Context(), clientFlag) + if err != nil { + return fmt.Errorf("failed to find client: %w", err) + } + + if err := a.UnarchiveClient(cmd.Context(), client.ID); err != nil { + return fmt.Errorf("failed to unarchive client: %w", err) + } + + cmd.Printf("Unarchived client: %s\n", client.Name) + } else { + project, err := a.FindProject(cmd.Context(), projectFlag) + if err != nil { + return fmt.Errorf("failed to find project: %w", err) + } + + if err := a.UnarchiveProject(cmd.Context(), project.ID); err != nil { + return fmt.Errorf("failed to unarchive project: %w", err) + } + + cmd.Printf("Unarchived project: %s\n", project.Name) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&clientFlag, "client", "c", "", "Client name or ID") + cmd.Flags().StringVarP(&projectFlag, "project", "p", "", "Project name or ID") + + return cmd +} + diff --git a/internal/commands/archive_test.go b/internal/commands/archive_test.go new file mode 100644 index 0000000..ae9ce1f --- /dev/null +++ b/internal/commands/archive_test.go @@ -0,0 +1,517 @@ +package commands + +import ( + "context" + "strings" + "testing" + + "git.tjp.lol/punchcard/internal/queries" +) + +func TestArchiveCommand(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (clientID, projectID int64) + args []string + expectedOutputs []string + expectError bool + errorContains string + }{ + { + name: "archive client by name", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + return client.ID, 0 + }, + args: []string{"archive", "--client", "TestClient"}, + expectedOutputs: []string{"Archived client: TestClient"}, + expectError: false, + }, + { + name: "archive client by ID", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ClientByID", + }) + return client.ID, 0 + }, + args: []string{"archive", "--client", "1"}, + expectedOutputs: []string{"Archived client: ClientByID"}, + expectError: false, + }, + { + name: "archive project by name", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "TestProject", + ClientID: client.ID, + }) + return client.ID, project.ID + }, + args: []string{"archive", "--project", "TestProject"}, + expectedOutputs: []string{"Archived project: TestProject"}, + expectError: false, + }, + { + name: "archive project by ID", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ProjectByID", + ClientID: client.ID, + }) + return client.ID, project.ID + }, + args: []string{"archive", "--project", "1"}, + expectedOutputs: []string{"Archived project: ProjectByID"}, + expectError: false, + }, + { + name: "archive without flags returns error", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"archive"}, + expectError: true, + errorContains: "either --client or --project must be specified", + }, + { + name: "archive with both client and project flags returns error", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "TestProject", + ClientID: client.ID, + }) + return client.ID, project.ID + }, + args: []string{"archive", "--client", "TestClient", "--project", "TestProject"}, + expectError: true, + errorContains: "cannot specify both --client and --project", + }, + { + name: "archive nonexistent client returns error", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"archive", "--client", "NonexistentClient"}, + expectError: true, + errorContains: "failed to find client", + }, + { + name: "archive nonexistent project returns error", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"archive", "--project", "NonexistentProject"}, + expectError: true, + errorContains: "failed to find project", + }, + { + name: "archive already archived client succeeds", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "AlreadyArchived", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.ID, 0 + }, + args: []string{"archive", "--client", "AlreadyArchived"}, + expectedOutputs: []string{"Archived client: AlreadyArchived"}, + expectError: false, + }, + { + name: "archive client using short flag", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ShortFlagClient", + }) + return client.ID, 0 + }, + args: []string{"archive", "-c", "ShortFlagClient"}, + expectedOutputs: []string{"Archived client: ShortFlagClient"}, + expectError: false, + }, + { + name: "archive project using short flag", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ShortFlagProject", + ClientID: client.ID, + }) + return client.ID, project.ID + }, + args: []string{"archive", "-p", "ShortFlagProject"}, + expectedOutputs: []string{"Archived project: ShortFlagProject"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + tt.setupData(q) + + output, err := executeCommandWithDB(t, q, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } 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 + } + + found := false + for _, expectedOutput := range tt.expectedOutputs { + if strings.Contains(output, expectedOutput) { + found = true + break + } + } + + if !found { + t.Errorf("Expected output to contain one of %v, got %q", tt.expectedOutputs, output) + } + }) + } +} + +func TestUnarchiveCommand(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (clientID, projectID int64) + args []string + expectedOutputs []string + expectError bool + errorContains string + }{ + { + name: "unarchive client by name", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ArchivedClient", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.ID, 0 + }, + args: []string{"unarchive", "--client", "ArchivedClient"}, + expectedOutputs: []string{"Unarchived client: ArchivedClient"}, + expectError: false, + }, + { + name: "unarchive client by ID", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ClientByID", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.ID, 0 + }, + args: []string{"unarchive", "--client", "1"}, + expectedOutputs: []string{"Unarchived client: ClientByID"}, + expectError: false, + }, + { + name: "unarchive project by name", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ArchivedProject", + ClientID: client.ID, + }) + _ = q.ArchiveProject(context.Background(), project.ID) + return client.ID, project.ID + }, + args: []string{"unarchive", "--project", "ArchivedProject"}, + expectedOutputs: []string{"Unarchived project: ArchivedProject"}, + expectError: false, + }, + { + name: "unarchive project by ID", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ProjectByID", + ClientID: client.ID, + }) + _ = q.ArchiveProject(context.Background(), project.ID) + return client.ID, project.ID + }, + args: []string{"unarchive", "--project", "1"}, + expectedOutputs: []string{"Unarchived project: ProjectByID"}, + expectError: false, + }, + { + name: "unarchive without flags returns error", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"unarchive"}, + expectError: true, + errorContains: "either --client or --project must be specified", + }, + { + name: "unarchive with both client and project flags returns error", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "TestProject", + ClientID: client.ID, + }) + _ = q.ArchiveClient(context.Background(), client.ID) + _ = q.ArchiveProject(context.Background(), project.ID) + return client.ID, project.ID + }, + args: []string{"unarchive", "--client", "TestClient", "--project", "TestProject"}, + expectError: true, + errorContains: "cannot specify both --client and --project", + }, + { + name: "unarchive nonexistent client returns error", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"unarchive", "--client", "NonexistentClient"}, + expectError: true, + errorContains: "failed to find client", + }, + { + name: "unarchive nonexistent project returns error", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"unarchive", "--project", "NonexistentProject"}, + expectError: true, + errorContains: "failed to find project", + }, + { + name: "unarchive already active client succeeds", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ActiveClient", + }) + return client.ID, 0 + }, + args: []string{"unarchive", "--client", "ActiveClient"}, + expectedOutputs: []string{"Unarchived client: ActiveClient"}, + expectError: false, + }, + { + name: "unarchive client using short flag", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ShortFlagClient", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.ID, 0 + }, + args: []string{"unarchive", "-c", "ShortFlagClient"}, + expectedOutputs: []string{"Unarchived client: ShortFlagClient"}, + expectError: false, + }, + { + name: "unarchive project using short flag", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ShortFlagProject", + ClientID: client.ID, + }) + _ = q.ArchiveProject(context.Background(), project.ID) + return client.ID, project.ID + }, + args: []string{"unarchive", "-p", "ShortFlagProject"}, + expectedOutputs: []string{"Unarchived project: ShortFlagProject"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + tt.setupData(q) + + output, err := executeCommandWithDB(t, q, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } 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 + } + + found := false + for _, expectedOutput := range tt.expectedOutputs { + if strings.Contains(output, expectedOutput) { + found = true + break + } + } + + if !found { + t.Errorf("Expected output to contain one of %v, got %q", tt.expectedOutputs, output) + } + }) + } +} + +func TestArchiveStateVerification(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (entityID int64, isClient bool) + archiveAction func(*queries.Queries, int64) + verifyFunc func(*testing.T, *queries.Queries, int64, bool) + }{ + { + name: "client is archived after archive command", + setupData: func(q *queries.Queries) (int64, bool) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + return client.ID, true + }, + archiveAction: func(q *queries.Queries, id int64) { + _ = q.ArchiveClient(context.Background(), id) + }, + verifyFunc: func(t *testing.T, q *queries.Queries, id int64, isClient bool) { + clients, err := q.FindClient(context.Background(), queries.FindClientParams{ + ID: id, + }) + if err != nil { + t.Fatalf("Failed to find client: %v", err) + } + if len(clients) != 1 { + t.Fatalf("Expected 1 client, got %d", len(clients)) + } + if clients[0].Archived == 0 { + t.Errorf("Expected client to be archived") + } + }, + }, + { + name: "project is archived after archive command", + setupData: func(q *queries.Queries) (int64, bool) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "TestProject", + ClientID: client.ID, + }) + return project.ID, false + }, + archiveAction: func(q *queries.Queries, id int64) { + _ = q.ArchiveProject(context.Background(), id) + }, + verifyFunc: func(t *testing.T, q *queries.Queries, id int64, isClient bool) { + projects, err := q.FindProject(context.Background(), queries.FindProjectParams{ + ID: id, + }) + if err != nil { + t.Fatalf("Failed to find project: %v", err) + } + if len(projects) != 1 { + t.Fatalf("Expected 1 project, got %d", len(projects)) + } + if projects[0].Archived == 0 { + t.Errorf("Expected project to be archived") + } + }, + }, + { + name: "client is unarchived after unarchive command", + setupData: func(q *queries.Queries) (int64, bool) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ArchivedClient", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.ID, true + }, + archiveAction: func(q *queries.Queries, id int64) { + _ = q.UnarchiveClient(context.Background(), id) + }, + verifyFunc: func(t *testing.T, q *queries.Queries, id int64, isClient bool) { + clients, err := q.FindClient(context.Background(), queries.FindClientParams{ + ID: id, + }) + if err != nil { + t.Fatalf("Failed to find client: %v", err) + } + if len(clients) != 1 { + t.Fatalf("Expected 1 client, got %d", len(clients)) + } + if clients[0].Archived != 0 { + t.Errorf("Expected client to not be archived") + } + }, + }, + { + name: "project is unarchived after unarchive command", + setupData: func(q *queries.Queries) (int64, bool) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ArchivedProject", + ClientID: client.ID, + }) + _ = q.ArchiveProject(context.Background(), project.ID) + return project.ID, false + }, + archiveAction: func(q *queries.Queries, id int64) { + _ = q.UnarchiveProject(context.Background(), id) + }, + verifyFunc: func(t *testing.T, q *queries.Queries, id int64, isClient bool) { + projects, err := q.FindProject(context.Background(), queries.FindProjectParams{ + ID: id, + }) + if err != nil { + t.Fatalf("Failed to find project: %v", err) + } + if len(projects) != 1 { + t.Fatalf("Expected 1 project, got %d", len(projects)) + } + if projects[0].Archived != 0 { + t.Errorf("Expected project to not be archived") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + entityID, isClient := tt.setupData(q) + tt.archiveAction(q, entityID) + tt.verifyFunc(t, q, entityID, isClient) + }) + } +} diff --git a/internal/commands/in.go b/internal/commands/in.go index 8c5025a..8a08edf 100644 --- a/internal/commands/in.go +++ b/internal/commands/in.go @@ -1,6 +1,7 @@ package commands import ( + "errors" "fmt" "git.tjp.lol/punchcard/internal/actions" @@ -54,16 +55,43 @@ Examples: var session *actions.TimerSession var err error + // Try punching in without auto-unarchive first if clientFlag == "" && projectFlag == "" { - // Use most recent entry - session, err = a.PunchInMostRecent(cmd.Context(), description, billableRate) + session, err = a.PunchInMostRecent(cmd.Context(), description, billableRate, false) } else { - // Use specified client/project - session, err = a.PunchIn(cmd.Context(), clientFlag, projectFlag, description, billableRate) + session, err = a.PunchIn(cmd.Context(), clientFlag, projectFlag, description, billableRate, false) } + // Handle archived errors by prompting user if err != nil { - return err + if errors.Is(err, actions.ErrArchivedClient) || errors.Is(err, actions.ErrArchivedProject) { + entityType := "client" + if errors.Is(err, actions.ErrArchivedProject) { + entityType = "project" + } + + cmd.Printf("Warning: This %s is archived.\n", entityType) + cmd.Print("Continue and unarchive? (y/N): ") + + var response string + _, err := fmt.Scanln(&response) + if err != nil || (response != "y" && response != "Y") { + return fmt.Errorf("operation cancelled") + } + + // Retry with auto-unarchive enabled + if clientFlag == "" && projectFlag == "" { + session, err = a.PunchInMostRecent(cmd.Context(), description, billableRate, true) + } else { + session, err = a.PunchIn(cmd.Context(), clientFlag, projectFlag, description, billableRate, true) + } + + if err != nil { + return err + } + } else { + return err + } } // Handle different response types diff --git a/internal/commands/root.go b/internal/commands/root.go index 9ac0790..6e61980 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -26,6 +26,8 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(NewReportCmd()) cmd.AddCommand(NewSetCmd()) cmd.AddCommand(NewTUICmd()) + cmd.AddCommand(NewArchiveCmd()) + cmd.AddCommand(NewUnarchiveCmd()) return cmd } |