mirror of
https://github.com/chenasraf/cospend-cli.git
synced 2026-05-17 17:38:04 +00:00
feat: edit/update command
This commit is contained in:
44
README.md
44
README.md
@@ -12,7 +12,7 @@ add and list expenses directly from your terminal without opening the web interf
|
||||
|
||||
## Features
|
||||
|
||||
- **Add**, **list**, and **delete** expenses in Cospend projects via the **REST API**
|
||||
- **Add**, **edit**, **list**, and **delete** expenses in Cospend projects via the **REST API**
|
||||
- **List projects** you have access to
|
||||
- **Filter** expenses by payer, owed members, amount, name, category, payment method, or date
|
||||
- Resolve categories, payment methods, and members by **name or ID**
|
||||
@@ -273,6 +273,48 @@ The output includes the bill ID for each expense, which can be used with the del
|
||||
|
||||
---
|
||||
|
||||
### Editing Expenses
|
||||
|
||||
```bash
|
||||
cospend edit <bill_id> [flags]
|
||||
cospend update <bill_id> [flags] # alias
|
||||
```
|
||||
|
||||
Only the flags you specify will be updated; all other fields remain unchanged.
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# Update the name of a bill
|
||||
cospend edit 123 -p myproject -n "Updated name"
|
||||
|
||||
# Update amount and category
|
||||
cospend edit 123 -p myproject -a 50.00 -c restaurant
|
||||
|
||||
# Change who paid and who owes
|
||||
cospend edit 123 -p myproject -b alice -f bob -f charlie
|
||||
|
||||
# Update the date and add a comment
|
||||
cospend edit 123 -p myproject -d 2026-06-15 -o "corrected date"
|
||||
```
|
||||
|
||||
#### Edit Command Flags
|
||||
|
||||
| Short | Long | Description |
|
||||
| ----- | ------------ | ---------------------------------------------------------------------- |
|
||||
| `-p` | `--project` | Project ID (required) |
|
||||
| `-n` | `--name` | New name/description |
|
||||
| `-a` | `--amount` | New amount |
|
||||
| `-c` | `--category` | Category by ID or case-insensitive name |
|
||||
| `-b` | `--by` | Paying member username |
|
||||
| `-f` | `--for` | Owed member username (repeatable) |
|
||||
| `-m` | `--method` | Payment method by ID or case-insensitive name |
|
||||
| `-o` | `--comment` | Comment |
|
||||
| `-d` | `--date` | Date (`YYYY-MM-DD`, `MM-DD`, or relative like `-1d`, `+2w`) |
|
||||
| `-h` | `--help` | Display help information |
|
||||
|
||||
---
|
||||
|
||||
### Deleting Expenses
|
||||
|
||||
```bash
|
||||
|
||||
@@ -44,6 +44,14 @@ func resetFlags() {
|
||||
paymentMethod = ""
|
||||
comment = ""
|
||||
addDate = ""
|
||||
editName = ""
|
||||
editAmount = ""
|
||||
editCategory = ""
|
||||
editPaidBy = ""
|
||||
editPaidFor = nil
|
||||
editPaymentMethod = ""
|
||||
editComment = ""
|
||||
editDate = ""
|
||||
infoCached = false
|
||||
}
|
||||
|
||||
|
||||
241
cmd/edit.go
Normal file
241
cmd/edit.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/api"
|
||||
"github.com/chenasraf/cospend-cli/internal/cache"
|
||||
"github.com/chenasraf/cospend-cli/internal/config"
|
||||
"github.com/chenasraf/cospend-cli/internal/format"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
editName string
|
||||
editAmount string
|
||||
editCategory string
|
||||
editPaidBy string
|
||||
editPaidFor []string
|
||||
editPaymentMethod string
|
||||
editComment string
|
||||
editDate string
|
||||
)
|
||||
|
||||
// NewEditCommand creates the edit command
|
||||
func NewEditCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "edit <bill_id>",
|
||||
Aliases: []string{"update"},
|
||||
Short: "Edit an existing expense in a Cospend project",
|
||||
Long: `Edit an existing expense in a Cospend project.
|
||||
|
||||
Only specified flags will be updated; other fields remain unchanged.
|
||||
|
||||
Examples:
|
||||
cospend edit 123 -p myproject -n "Updated name"
|
||||
cospend edit 123 -p myproject -a 30.00 -c restaurant
|
||||
cospend edit 123 -p myproject -b alice -f bob -f charlie`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runEdit,
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&editName, "name", "n", "", "New name/description")
|
||||
cmd.Flags().StringVarP(&editAmount, "amount", "a", "", "New amount")
|
||||
cmd.Flags().StringVarP(&editCategory, "category", "c", "", "Category by ID or name")
|
||||
cmd.Flags().StringVarP(&editPaidBy, "by", "b", "", "Paying member username")
|
||||
cmd.Flags().StringArrayVarP(&editPaidFor, "for", "f", nil, "Owed member username (repeatable)")
|
||||
cmd.Flags().StringVarP(&editPaymentMethod, "method", "m", "", "Payment method by ID or name")
|
||||
cmd.Flags().StringVarP(&editComment, "comment", "o", "", "Comment")
|
||||
cmd.Flags().StringVarP(&editDate, "date", "d", "", "Date (YYYY-MM-DD, MM-DD, or relative like -1d, +2w)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runEdit(cmd *cobra.Command, args []string) error {
|
||||
if ProjectID == "" {
|
||||
return fmt.Errorf("project is required (use -p or --project)")
|
||||
}
|
||||
|
||||
billID, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid bill ID: %s", args[0])
|
||||
}
|
||||
|
||||
// Parameters validated, silence usage for subsequent errors
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := api.NewClient(cfg)
|
||||
client.Debug = Debug
|
||||
client.DebugWriter = cmd.ErrOrStderr()
|
||||
|
||||
// Get project (from cache or API)
|
||||
project, ok := cache.Load(ProjectID)
|
||||
if !ok {
|
||||
project, err = client.GetProject(ProjectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching project: %w", err)
|
||||
}
|
||||
if err := cache.Save(ProjectID, project); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to cache project: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all bills to find the existing one
|
||||
bills, err := client.GetBills(ProjectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching bills: %w", err)
|
||||
}
|
||||
|
||||
var existing *api.BillResponse
|
||||
for i := range bills {
|
||||
if bills[i].ID == billID {
|
||||
existing = &bills[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if existing == nil {
|
||||
return fmt.Errorf("bill #%d not found", billID)
|
||||
}
|
||||
|
||||
// Build member name lookup
|
||||
memberNames := make(map[int]string)
|
||||
for _, m := range project.Members {
|
||||
memberNames[m.ID] = m.Name
|
||||
}
|
||||
|
||||
// Start from existing values
|
||||
bill := api.Bill{
|
||||
What: existing.What,
|
||||
Amount: existing.Amount,
|
||||
PayerID: existing.PayerID,
|
||||
Date: existing.Date,
|
||||
Comment: existing.Comment,
|
||||
PaymentModeID: existing.PaymentModeID,
|
||||
CategoryID: existing.CategoryID,
|
||||
}
|
||||
for _, o := range existing.Owers {
|
||||
bill.OwedTo = append(bill.OwedTo, o.ID)
|
||||
}
|
||||
|
||||
// Apply changes for flags that were explicitly set
|
||||
if cmd.Flags().Changed("name") {
|
||||
bill.What = editName
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("amount") {
|
||||
amount, err := strconv.ParseFloat(editAmount, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid amount: %s", editAmount)
|
||||
}
|
||||
bill.Amount = amount
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("by") {
|
||||
payerID, err := cache.ResolveMember(project, editPaidBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving payer: %w", err)
|
||||
}
|
||||
bill.PayerID = payerID
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("for") {
|
||||
var owedIDs []int
|
||||
for _, username := range editPaidFor {
|
||||
memberID, err := cache.ResolveMember(project, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving owed member: %w", err)
|
||||
}
|
||||
owedIDs = append(owedIDs, memberID)
|
||||
}
|
||||
bill.OwedTo = owedIDs
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("date") {
|
||||
parsed, err := parseDate(editDate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bill.Date = parsed
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("category") {
|
||||
categoryID, err := cache.ResolveCategory(project, editCategory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving category: %w", err)
|
||||
}
|
||||
bill.CategoryID = categoryID
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("method") {
|
||||
methodID, err := cache.ResolvePaymentMode(project, editPaymentMethod)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving payment method: %w", err)
|
||||
}
|
||||
bill.PaymentModeID = methodID
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("comment") {
|
||||
bill.Comment = editComment
|
||||
}
|
||||
|
||||
// Edit the bill
|
||||
if err := client.EditBill(ProjectID, billID, bill); err != nil {
|
||||
return fmt.Errorf("editing bill: %w", err)
|
||||
}
|
||||
|
||||
// Fetch user info for locale-aware formatting
|
||||
locale := "en_US"
|
||||
userInfo, ok := cache.LoadUserInfo()
|
||||
if !ok {
|
||||
userInfo, err = client.GetUserInfo()
|
||||
if err == nil {
|
||||
_ = cache.SaveUserInfo(userInfo)
|
||||
}
|
||||
}
|
||||
if userInfo != nil && userInfo.Locale != "" {
|
||||
locale = userInfo.Locale
|
||||
} else if userInfo != nil && userInfo.Language != "" {
|
||||
locale = userInfo.Language
|
||||
}
|
||||
|
||||
formatter := format.NewAmountFormatter(locale, project.CurrencyName)
|
||||
out := cmd.OutOrStdout()
|
||||
_, _ = fmt.Fprintf(out, "Updated bill #%d\n", billID)
|
||||
_, _ = fmt.Fprintf(out, " Name: %s\n", bill.What)
|
||||
_, _ = fmt.Fprintf(out, " Amount: %s\n", formatter.Format(bill.Amount))
|
||||
_, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date)
|
||||
_, _ = fmt.Fprintf(out, " Paid by: %s\n", memberNames[bill.PayerID])
|
||||
var owerNames []string
|
||||
for _, id := range bill.OwedTo {
|
||||
owerNames = append(owerNames, memberNames[id])
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, " Paid for: %s\n", strings.Join(owerNames, ", "))
|
||||
if bill.CategoryID != 0 {
|
||||
for _, c := range project.Categories {
|
||||
if c.ID == bill.CategoryID {
|
||||
_, _ = fmt.Fprintf(out, " Category: %s\n", c.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bill.PaymentModeID != 0 {
|
||||
for _, pm := range project.PaymentModes {
|
||||
if pm.ID == bill.PaymentModeID {
|
||||
_, _ = fmt.Fprintf(out, " Method: %s\n", pm.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bill.Comment != "" {
|
||||
_, _ = fmt.Fprintf(out, " Comment: %s\n", bill.Comment)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
449
cmd/edit_test.go
Normal file
449
cmd/edit_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/api"
|
||||
)
|
||||
|
||||
func resetEditFlags() {
|
||||
ProjectID = ""
|
||||
editName = ""
|
||||
editAmount = ""
|
||||
editCategory = ""
|
||||
editPaidBy = ""
|
||||
editPaidFor = nil
|
||||
editPaymentMethod = ""
|
||||
editComment = ""
|
||||
editDate = ""
|
||||
}
|
||||
|
||||
func TestNewEditCommand(t *testing.T) {
|
||||
resetEditFlags()
|
||||
defer resetEditFlags()
|
||||
|
||||
cmd := NewEditCommand()
|
||||
|
||||
if cmd.Use != "edit <bill_id>" {
|
||||
t.Errorf("Wrong Use: %s", cmd.Use)
|
||||
}
|
||||
|
||||
if len(cmd.Aliases) != 1 || cmd.Aliases[0] != "update" {
|
||||
t.Errorf("Wrong Aliases: %v", cmd.Aliases)
|
||||
}
|
||||
|
||||
flags := []string{"name", "amount", "category", "by", "for", "method", "comment", "date"}
|
||||
for _, flag := range flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("Missing flag: %s", flag)
|
||||
}
|
||||
}
|
||||
|
||||
shortFlags := map[string]string{
|
||||
"n": "name",
|
||||
"a": "amount",
|
||||
"c": "category",
|
||||
"b": "by",
|
||||
"f": "for",
|
||||
"m": "method",
|
||||
"o": "comment",
|
||||
"d": "date",
|
||||
}
|
||||
for short, long := range shortFlags {
|
||||
flag := cmd.Flags().ShorthandLookup(short)
|
||||
if flag == nil {
|
||||
t.Errorf("Missing short flag: -%s", short)
|
||||
} else if flag.Name != long {
|
||||
t.Errorf("Short flag -%s maps to %s, want %s", short, flag.Name, long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditCommandMissingProject(t *testing.T) {
|
||||
resetEditFlags()
|
||||
defer resetEditFlags()
|
||||
|
||||
cmd := NewEditCommand()
|
||||
cmd.SetArgs([]string{"123"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing project flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditCommandMissingBillID(t *testing.T) {
|
||||
resetEditFlags()
|
||||
defer resetEditFlags()
|
||||
|
||||
ProjectID = "myproject"
|
||||
cmd := NewEditCommand()
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing bill ID argument")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditCommandInvalidBillID(t *testing.T) {
|
||||
resetEditFlags()
|
||||
defer resetEditFlags()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("API should not be called with invalid bill ID")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", server.URL)
|
||||
t.Setenv("NEXTCLOUD_USER", "testuser")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "testpass")
|
||||
|
||||
ProjectID = "myproject"
|
||||
cmd := NewEditCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetErr(buf)
|
||||
cmd.SetArgs([]string{"not-a-number"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid bill ID")
|
||||
}
|
||||
}
|
||||
|
||||
func testEditServer(t *testing.T, project api.Project, bills []api.BillResponse, onPut func(r *http.Request)) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/ocs/v2.php/cloud/user" {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]string{"locale": "en_US", "language": "en"}))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project/bills" ||
|
||||
r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project/bills/42" {
|
||||
if r.Method == "GET" {
|
||||
billsData := struct {
|
||||
Bills []api.BillResponse `json:"bills"`
|
||||
}{Bills: bills}
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, billsData))
|
||||
return
|
||||
}
|
||||
if r.Method == "PUT" {
|
||||
if onPut != nil {
|
||||
onPut(r)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, "OK"))
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestEditCommandSuccess(t *testing.T) {
|
||||
resetEditFlags()
|
||||
defer resetEditFlags()
|
||||
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
{ID: 2, Name: "Alice", UserID: "alice"},
|
||||
{ID: 3, Name: "Bob", UserID: "bob"},
|
||||
},
|
||||
Categories: []api.Category{
|
||||
{ID: 5, Name: "Restaurant"},
|
||||
},
|
||||
PaymentModes: []api.PaymentMode{
|
||||
{ID: 3, Name: "Credit Card"},
|
||||
},
|
||||
}
|
||||
|
||||
bills := []api.BillResponse{
|
||||
{
|
||||
ID: 42,
|
||||
What: "Old Dinner",
|
||||
Amount: 30.00,
|
||||
Date: "2026-01-15",
|
||||
PayerID: 1,
|
||||
Owers: []api.Ower{{ID: 1, Weight: 1}, {ID: 2, Weight: 1}},
|
||||
Comment: "old comment",
|
||||
},
|
||||
}
|
||||
|
||||
var receivedBill map[string]string
|
||||
server := testEditServer(t, project, bills, func(r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
receivedBill = make(map[string]string)
|
||||
for k, v := range r.Form {
|
||||
if len(v) > 0 {
|
||||
receivedBill[k] = v[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewEditCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{
|
||||
"42",
|
||||
"-n", "Updated Dinner",
|
||||
"-a", "50.00",
|
||||
"-b", "alice",
|
||||
"-f", "bob",
|
||||
"-c", "restaurant",
|
||||
"-m", "credit card",
|
||||
"-o", "new comment",
|
||||
"-d", "2026-06-15",
|
||||
})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if receivedBill["what"] != "Updated Dinner" {
|
||||
t.Errorf("Wrong what: %s", receivedBill["what"])
|
||||
}
|
||||
if receivedBill["amount"] != "50.00" {
|
||||
t.Errorf("Wrong amount: %s", receivedBill["amount"])
|
||||
}
|
||||
if receivedBill["payer"] != "2" { // Alice's ID
|
||||
t.Errorf("Wrong payer: %s", receivedBill["payer"])
|
||||
}
|
||||
if receivedBill["payedFor"] != "3" { // Bob only
|
||||
t.Errorf("Wrong payedFor: %s", receivedBill["payedFor"])
|
||||
}
|
||||
if receivedBill["categoryId"] != "5" {
|
||||
t.Errorf("Wrong categoryId: %s", receivedBill["categoryId"])
|
||||
}
|
||||
if receivedBill["paymentModeId"] != "3" {
|
||||
t.Errorf("Wrong paymentModeId: %s", receivedBill["paymentModeId"])
|
||||
}
|
||||
if receivedBill["comment"] != "new comment" {
|
||||
t.Errorf("Wrong comment: %s", receivedBill["comment"])
|
||||
}
|
||||
if receivedBill["date"] != "2026-06-15" {
|
||||
t.Errorf("Wrong date: %s", receivedBill["date"])
|
||||
}
|
||||
|
||||
if !bytes.Contains(stdout.Bytes(), []byte("Updated bill #42")) {
|
||||
t.Errorf("Missing success message in output: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditCommandPartialUpdate(t *testing.T) {
|
||||
resetEditFlags()
|
||||
defer resetEditFlags()
|
||||
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
{ID: 2, Name: "Alice", UserID: "alice"},
|
||||
},
|
||||
}
|
||||
|
||||
bills := []api.BillResponse{
|
||||
{
|
||||
ID: 42,
|
||||
What: "Original Name",
|
||||
Amount: 25.00,
|
||||
Date: "2026-01-15",
|
||||
PayerID: 1,
|
||||
Owers: []api.Ower{{ID: 1, Weight: 1}, {ID: 2, Weight: 1}},
|
||||
Comment: "original comment",
|
||||
},
|
||||
}
|
||||
|
||||
var receivedBill map[string]string
|
||||
server := testEditServer(t, project, bills, func(r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
receivedBill = make(map[string]string)
|
||||
for k, v := range r.Form {
|
||||
if len(v) > 0 {
|
||||
receivedBill[k] = v[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewEditCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
// Only change the name
|
||||
cmd.SetArgs([]string{"42", "-n", "New Name"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Name should be updated
|
||||
if receivedBill["what"] != "New Name" {
|
||||
t.Errorf("Wrong what: %s", receivedBill["what"])
|
||||
}
|
||||
// Other fields should be preserved from the original bill
|
||||
if receivedBill["amount"] != "25.00" {
|
||||
t.Errorf("Amount should be preserved: got %s, want 25.00", receivedBill["amount"])
|
||||
}
|
||||
if receivedBill["payer"] != "1" {
|
||||
t.Errorf("Payer should be preserved: got %s, want 1", receivedBill["payer"])
|
||||
}
|
||||
if receivedBill["payedFor"] != "1,2" {
|
||||
t.Errorf("PayedFor should be preserved: got %s, want 1,2", receivedBill["payedFor"])
|
||||
}
|
||||
if receivedBill["date"] != "2026-01-15" {
|
||||
t.Errorf("Date should be preserved: got %s, want 2026-01-15", receivedBill["date"])
|
||||
}
|
||||
if receivedBill["comment"] != "original comment" {
|
||||
t.Errorf("Comment should be preserved: got %s, want 'original comment'", receivedBill["comment"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditCommandBillNotFound(t *testing.T) {
|
||||
resetEditFlags()
|
||||
defer resetEditFlags()
|
||||
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
},
|
||||
}
|
||||
|
||||
// Empty bills list
|
||||
server := testEditServer(t, project, []api.BillResponse{}, nil)
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewEditCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"999", "-n", "whatever"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for bill not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditCommandMemberNotFound(t *testing.T) {
|
||||
resetEditFlags()
|
||||
defer resetEditFlags()
|
||||
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
},
|
||||
}
|
||||
|
||||
bills := []api.BillResponse{
|
||||
{
|
||||
ID: 42,
|
||||
What: "Test",
|
||||
Amount: 10.00,
|
||||
Date: "2026-01-15",
|
||||
PayerID: 1,
|
||||
Owers: []api.Ower{{ID: 1, Weight: 1}},
|
||||
},
|
||||
}
|
||||
|
||||
server := testEditServer(t, project, bills, nil)
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewEditCommand()
|
||||
cmd.SetArgs([]string{"42", "-b", "nonexistent"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent member")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditCommandAPIError(t *testing.T) {
|
||||
resetEditFlags()
|
||||
defer resetEditFlags()
|
||||
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
},
|
||||
}
|
||||
|
||||
bills := []api.BillResponse{
|
||||
{
|
||||
ID: 42,
|
||||
What: "Test",
|
||||
Amount: 10.00,
|
||||
Date: "2026-01-15",
|
||||
PayerID: 1,
|
||||
Owers: []api.Ower{{ID: 1, Weight: 1}},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/ocs/v2.php/cloud/user" {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]string{"locale": "en_US", "language": "en"}))
|
||||
return
|
||||
}
|
||||
if r.Method == "GET" && r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project/bills" {
|
||||
billsData := struct {
|
||||
Bills []api.BillResponse `json:"bills"`
|
||||
}{Bills: bills}
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, billsData))
|
||||
return
|
||||
}
|
||||
// Return error for PUT
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Internal Server Error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewEditCommand()
|
||||
cmd.SetArgs([]string{"42", "-n", "New Name"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error from API")
|
||||
}
|
||||
}
|
||||
@@ -469,6 +469,58 @@ func (c *Client) GetUserInfo() (*UserInfo, error) {
|
||||
return &userInfo, nil
|
||||
}
|
||||
|
||||
// EditBill updates an existing bill in the project
|
||||
func (c *Client) EditBill(projectID string, billID int, bill Bill) error {
|
||||
path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s/bills/%d", url.PathEscape(projectID), billID)
|
||||
|
||||
// Build form data
|
||||
data := url.Values{}
|
||||
data.Set("what", bill.What)
|
||||
data.Set("amount", strconv.FormatFloat(bill.Amount, 'f', 2, 64))
|
||||
data.Set("payer", strconv.Itoa(bill.PayerID))
|
||||
data.Set("date", bill.Date)
|
||||
data.Set("timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
|
||||
// Format owed member IDs as comma-separated string
|
||||
owedIDs := make([]string, len(bill.OwedTo))
|
||||
for i, id := range bill.OwedTo {
|
||||
owedIDs[i] = strconv.Itoa(id)
|
||||
}
|
||||
data.Set("payedFor", strings.Join(owedIDs, ","))
|
||||
|
||||
data.Set("comment", bill.Comment)
|
||||
if bill.PaymentModeID != 0 {
|
||||
data.Set("paymentModeId", strconv.Itoa(bill.PaymentModeID))
|
||||
}
|
||||
if bill.CategoryID != 0 {
|
||||
data.Set("categoryId", strconv.Itoa(bill.CategoryID))
|
||||
}
|
||||
|
||||
c.debugf("Request body: %s", data.Encode())
|
||||
|
||||
resp, err := c.doRequest("PUT", path, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("editing bill: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var ocsResp OCSResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
if ocsResp.OCS.Meta.StatusCode != 200 {
|
||||
return fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBill deletes a bill from the project
|
||||
func (c *Client) DeleteBill(projectID string, billID int) error {
|
||||
path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s/bills/%d", url.PathEscape(projectID), billID)
|
||||
|
||||
1
main.go
1
main.go
@@ -25,6 +25,7 @@ func main() {
|
||||
rootCmd.AddCommand(cmd.NewInitCommand())
|
||||
rootCmd.AddCommand(cmd.NewListCommand())
|
||||
rootCmd.AddCommand(cmd.NewDeleteCommand())
|
||||
rootCmd.AddCommand(cmd.NewEditCommand())
|
||||
rootCmd.AddCommand(cmd.NewProjectsCommand())
|
||||
rootCmd.AddCommand(cmd.NewInfoCommand())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user