mirror of
https://github.com/chenasraf/cospend-cli.git
synced 2026-05-18 01:39:03 +00:00
feat: initial commit
This commit is contained in:
13
.github/FUNDING.yml
vendored
Executable file
13
.github/FUNDING.yml
vendored
Executable file
@@ -0,0 +1,13 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: chenasraf
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: casraf
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom:
|
||||
- "https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=TSH3C3ABGQM22¤cy_code=ILS&source=url"
|
||||
12
.github/workflows/manual-homebrew-release.yml
vendored
Executable file
12
.github/workflows/manual-homebrew-release.yml
vendored
Executable file
@@ -0,0 +1,12 @@
|
||||
name: Manual Homebrew Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
homebrew:
|
||||
uses: chenasraf/workflows/.github/workflows/manual-homebrew-release.yml@master
|
||||
with:
|
||||
homebrew-tap-repo: chenasraf/homebrew-tap
|
||||
secrets:
|
||||
REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }}
|
||||
20
.github/workflows/release.yml
vendored
Executable file
20
.github/workflows/release.yml
vendored
Executable file
@@ -0,0 +1,20 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: chenasraf/workflows/.github/workflows/go-release.yml@master
|
||||
with:
|
||||
name: cospend-cli
|
||||
homebrew-tap-repo: chenasraf/homebrew-tap
|
||||
secrets:
|
||||
REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }}
|
||||
44
.github/workflows/test.yml
vendored
Executable file
44
.github/workflows/test.yml
vendored
Executable file
@@ -0,0 +1,44 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Build
|
||||
run: go build -v
|
||||
|
||||
- name: Test
|
||||
run: go test -v ./...
|
||||
|
||||
- name: Create dist/ dir
|
||||
run: mkdir dist
|
||||
|
||||
- name: Generate build files
|
||||
uses: chenasraf/go-cross-build@v1
|
||||
with:
|
||||
platforms: 'linux/amd64, darwin/amd64, windows/amd64' # , darwin/arm64' # '
|
||||
package: ''
|
||||
name: 'cospend-cli'
|
||||
compress: 'true'
|
||||
dest: 'dist'
|
||||
- name: Upload builds
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
.env.keys
|
||||
.envrc
|
||||
cospend-cli
|
||||
cospend
|
||||
52
1
Normal file
52
1
Normal file
@@ -0,0 +1,52 @@
|
||||
[DEBUG] Request: GET https://spider.casraf.dev/ocs/v2.php/apps/cospend/api/v1/projects/home-2026
|
||||
[DEBUG] Request: GET https://spider.casraf.dev/ocs/v2.php/apps/cospend/api/v1/projects/home-2026
|
||||
[DEBUG] Headers: OCS-APIRequest=true, Accept=application/json, Auth=Basic casraf:***
|
||||
[DEBUG] Headers: OCS-APIRequest=true, Accept=application/json, Auth=Basic casraf:***
|
||||
[DEBUG] Response: 200 200 OK
|
||||
[DEBUG] Response: 200 200 OK
|
||||
[DEBUG] Response body: {"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"active_members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"balance":{"9":4357.2,"11":-4357.2,"13":0},"nb_bills":34,"total_spent":11292.17,"nb_trashbin_bills":0,"shares":[{"id":4,"projectid":"home-2026","userid":"dekeltsa","type":"u","accesslevel":4,"manuallyAdded":true,"label":null,"password":null,"userCloudId":null,"state":null,"name":"Dekel Eden-Tsalik"}],"currencies":[{"id":9,"name":"$","exchange_rate":3.0989945931841},{"id":11,"name":"\u20ac","exchange_rate":3.6588141198027},{"id":12,"name":"\u00a3","exchange_rate":4.2384035900296}],"categories":{"37":{"id":37,"projectid":"home-2026","name":"Grocery","color":"#ffaa00","icon":"\ud83d\uded2","order":0},"39":{"id":39,"projectid":"home-2026","name":"Rent","color":"#da8733","icon":"\ud83c\udfe0","order":0},"40":{"id":40,"projectid":"home-2026","name":"Bill","color":"#4aa6b0","icon":"\ud83c\udf29","order":0},"42":{"id":42,"projectid":"home-2026","name":"Health","color":"#bf090c","icon":"\ud83d\udc9a","order":0},"43":{"id":43,"projectid":"home-2026","name":"Shopping","color":"#e167d1","icon":"\ud83d\udecd","order":0},"46":{"id":46,"projectid":"home-2026","name":"Transport","color":"#6f2ee1","icon":"\ud83d\ude8c","order":0},"45":{"id":45,"projectid":"home-2026","name":"Pets","color":"#5de1a3","icon":"\ud83d\udc08","order":0},"41":{"id":41,"projectid":"home-2026","name":"Subscriptions","color":"#0055ff","icon":"\ud83d\udcb3","order":0},"38":{"id":38,"projectid":"home-2026","name":"Going Out","color":"#aa55ff","icon":"\ud83c\udf89","order":0},"44":{"id":44,"projectid":"home-2026","name":"Food","color":"#d0d5e1","icon":"\ud83c\udf74","order":0},"48":{"id":48,"projectid":"home-2026","name":"Cleaning","color":"#F8A4E0","icon":"\ud83e\uddf9","order":10},"60":{"id":60,"projectid":"home-2026","name":"Tufting","color":"#00740E","icon":"\ud83e\udea1","order":11}},"paymentmodes":{"16":{"id":16,"projectid":"home-2026","name":"Credit card","color":"#FF7F50","icon":"\ud83d\udcb3","order":0,"old_id":"c"},"17":{"id":17,"projectid":"home-2026","name":"Cash","color":"#556B2F","icon":"\ud83d\udcb5","order":0,"old_id":"b"},"18":{"id":18,"projectid":"home-2026","name":"Check","color":"#A9A9A9","icon":"\ud83c\udfab","order":0,"old_id":"f"},"19":{"id":19,"projectid":"home-2026","name":"Transfer","color":"#00CED1","icon":"\u21c4","order":0,"old_id":"t"},"20":{"id":20,"projectid":"home-2026","name":"Online service","color":"#9932CC","icon":"\ud83c\udf0e","order":0,"old_id":"o"}},"id":"home-2026","userid":"casraf","name":"Home 2026","email":"","autoexport":"m","lastchanged":1770107790,"deletiondisabled":false,"categorysort":"a","paymentmodesort":"a","currencyname":"\u20aa","archived_ts":null,"myaccesslevel":4}}}
|
||||
[DEBUG] Response body: {"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"active_members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"balance":{"9":4357.2,"11":-4357.2,"13":0},"nb_bills":34,"total_spent":11292.17,"nb_trashbin_bills":0,"shares":[{"id":4,"projectid":"home-2026","userid":"dekeltsa","type":"u","accesslevel":4,"manuallyAdded":true,"label":null,"password":null,"userCloudId":null,"state":null,"name":"Dekel Eden-Tsalik"}],"currencies":[{"id":9,"name":"$","exchange_rate":3.0989945931841},{"id":11,"name":"\u20ac","exchange_rate":3.6588141198027},{"id":12,"name":"\u00a3","exchange_rate":4.2384035900296}],"categories":{"37":{"id":37,"projectid":"home-2026","name":"Grocery","color":"#ffaa00","icon":"\ud83d\uded2","order":0},"39":{"id":39,"projectid":"home-2026","name":"Rent","color":"#da8733","icon":"\ud83c\udfe0","order":0},"40":{"id":40,"projectid":"home-2026","name":"Bill","color":"#4aa6b0","icon":"\ud83c\udf29","order":0},"42":{"id":42,"projectid":"home-2026","name":"Health","color":"#bf090c","icon":"\ud83d\udc9a","order":0},"43":{"id":43,"projectid":"home-2026","name":"Shopping","color":"#e167d1","icon":"\ud83d\udecd","order":0},"46":{"id":46,"projectid":"home-2026","name":"Transport","color":"#6f2ee1","icon":"\ud83d\ude8c","order":0},"45":{"id":45,"projectid":"home-2026","name":"Pets","color":"#5de1a3","icon":"\ud83d\udc08","order":0},"41":{"id":41,"projectid":"home-2026","name":"Subscriptions","color":"#0055ff","icon":"\ud83d\udcb3","order":0},"38":{"id":38,"projectid":"home-2026","name":"Going Out","color":"#aa55ff","icon":"\ud83c\udf89","order":0},"44":{"id":44,"projectid":"home-2026","name":"Food","color":"#d0d5e1","icon":"\ud83c\udf74","order":0},"48":{"id":48,"projectid":"home-2026","name":"Cleaning","color":"#F8A4E0","icon":"\ud83e\uddf9","order":10},"60":{"id":60,"projectid":"home-2026","name":"Tufting","color":"#00740E","icon":"\ud83e\udea1","order":11}},"paymentmodes":{"16":{"id":16,"projectid":"home-2026","name":"Credit card","color":"#FF7F50","icon":"\ud83d\udcb3","order":0,"old_id":"c"},"17":{"id":17,"projectid":"home-2026","name":"Cash","color":"#556B2F","icon":"\ud83d\udcb5","order":0,"old_id":"b"},"18":{"id":18,"projectid":"home-2026","name":"Check","color":"#A9A9A9","icon":"\ud83c\udfab","order":0,"old_id":"f"},"19":{"id":19,"projectid":"home-2026","name":"Transfer","color":"#00CED1","icon":"\u21c4","order":0,"old_id":"t"},"20":{"id":20,"projectid":"home-2026","name":"Online service","color":"#9932CC","icon":"\ud83c\udf0e","order":0,"old_id":"o"}},"id":"home-2026","userid":"casraf","name":"Home 2026","email":"","autoexport":"m","lastchanged":1770107790,"deletiondisabled":false,"categorysort":"a","paymentmodesort":"a","currencyname":"\u20aa","archived_ts":null,"myaccesslevel":4}}}
|
||||
[DEBUG] Project data: {"active_members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"balance":{"9":4357.2,"11":-4357.2,"13":0},"nb_bills":34,"total_spent":11292.17,"nb_trashbin_bills":0,"shares":[{"id":4,"projectid":"home-2026","userid":"dekeltsa","type":"u","accesslevel":4,"manuallyAdded":true,"label":null,"password":null,"userCloudId":null,"state":null,"name":"Dekel Eden-Tsalik"}],"currencies":[{"id":9,"name":"$","exchange_rate":3.0989945931841},{"id":11,"name":"\u20ac","exchange_rate":3.6588141198027},{"id":12,"name":"\u00a3","exchange_rate":4.2384035900296}],"categories":{"37":{"id":37,"projectid":"home-2026","name":"Grocery","color":"#ffaa00","icon":"\ud83d\uded2","order":0},"39":{"id":39,"projectid":"home-2026","name":"Rent","color":"#da8733","icon":"\ud83c\udfe0","order":0},"40":{"id":40,"projectid":"home-2026","name":"Bill","color":"#4aa6b0","icon":"\ud83c\udf29","order":0},"42":{"id":42,"projectid":"home-2026","name":"Health","color":"#bf090c","icon":"\ud83d\udc9a","order":0},"43":{"id":43,"projectid":"home-2026","name":"Shopping","color":"#e167d1","icon":"\ud83d\udecd","order":0},"46":{"id":46,"projectid":"home-2026","name":"Transport","color":"#6f2ee1","icon":"\ud83d\ude8c","order":0},"45":{"id":45,"projectid":"home-2026","name":"Pets","color":"#5de1a3","icon":"\ud83d\udc08","order":0},"41":{"id":41,"projectid":"home-2026","name":"Subscriptions","color":"#0055ff","icon":"\ud83d\udcb3","order":0},"38":{"id":38,"projectid":"home-2026","name":"Going Out","color":"#aa55ff","icon":"\ud83c\udf89","order":0},"44":{"id":44,"projectid":"home-2026","name":"Food","color":"#d0d5e1","icon":"\ud83c\udf74","order":0},"48":{"id":48,"projectid":"home-2026","name":"Cleaning","color":"#F8A4E0","icon":"\ud83e\uddf9","order":10},"60":{"id":60,"projectid":"home-2026","name":"Tufting","color":"#00740E","icon":"\ud83e\udea1","order":11}},"paymentmodes":{"16":{"id":16,"projectid":"home-2026","name":"Credit card","color":"#FF7F50","icon":"\ud83d\udcb3","order":0,"old_id":"c"},"17":{"id":17,"projectid":"home-2026","name":"Cash","color":"#556B2F","icon":"\ud83d\udcb5","order":0,"old_id":"b"},"18":{"id":18,"projectid":"home-2026","name":"Check","color":"#A9A9A9","icon":"\ud83c\udfab","order":0,"old_id":"f"},"19":{"id":19,"projectid":"home-2026","name":"Transfer","color":"#00CED1","icon":"\u21c4","order":0,"old_id":"t"},"20":{"id":20,"projectid":"home-2026","name":"Online service","color":"#9932CC","icon":"\ud83c\udf0e","order":0,"old_id":"o"}},"id":"home-2026","userid":"casraf","name":"Home 2026","email":"","autoexport":"m","lastchanged":1770107790,"deletiondisabled":false,"categorysort":"a","paymentmodesort":"a","currencyname":"\u20aa","archived_ts":null,"myaccesslevel":4}
|
||||
Error: fetching project: decoding project data: json: cannot unmarshal object into Go struct field Project.categories of type []api.Category
|
||||
[DEBUG] Project data: {"active_members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"balance":{"9":4357.2,"11":-4357.2,"13":0},"nb_bills":34,"total_spent":11292.17,"nb_trashbin_bills":0,"shares":[{"id":4,"projectid":"home-2026","userid":"dekeltsa","type":"u","accesslevel":4,"manuallyAdded":true,"label":null,"password":null,"userCloudId":null,"state":null,"name":"Dekel Eden-Tsalik"}],"currencies":[{"id":9,"name":"$","exchange_rate":3.0989945931841},{"id":11,"name":"\u20ac","exchange_rate":3.6588141198027},{"id":12,"name":"\u00a3","exchange_rate":4.2384035900296}],"categories":{"37":{"id":37,"projectid":"home-2026","name":"Grocery","color":"#ffaa00","icon":"\ud83d\uded2","order":0},"39":{"id":39,"projectid":"home-2026","name":"Rent","color":"#da8733","icon":"\ud83c\udfe0","order":0},"40":{"id":40,"projectid":"home-2026","name":"Bill","color":"#4aa6b0","icon":"\ud83c\udf29","order":0},"42":{"id":42,"projectid":"home-2026","name":"Health","color":"#bf090c","icon":"\ud83d\udc9a","order":0},"43":{"id":43,"projectid":"home-2026","name":"Shopping","color":"#e167d1","icon":"\ud83d\udecd","order":0},"46":{"id":46,"projectid":"home-2026","name":"Transport","color":"#6f2ee1","icon":"\ud83d\ude8c","order":0},"45":{"id":45,"projectid":"home-2026","name":"Pets","color":"#5de1a3","icon":"\ud83d\udc08","order":0},"41":{"id":41,"projectid":"home-2026","name":"Subscriptions","color":"#0055ff","icon":"\ud83d\udcb3","order":0},"38":{"id":38,"projectid":"home-2026","name":"Going Out","color":"#aa55ff","icon":"\ud83c\udf89","order":0},"44":{"id":44,"projectid":"home-2026","name":"Food","color":"#d0d5e1","icon":"\ud83c\udf74","order":0},"48":{"id":48,"projectid":"home-2026","name":"Cleaning","color":"#F8A4E0","icon":"\ud83e\uddf9","order":10},"60":{"id":60,"projectid":"home-2026","name":"Tufting","color":"#00740E","icon":"\ud83e\udea1","order":11}},"paymentmodes":{"16":{"id":16,"projectid":"home-2026","name":"Credit card","color":"#FF7F50","icon":"\ud83d\udcb3","order":0,"old_id":"c"},"17":{"id":17,"projectid":"home-2026","name":"Cash","color":"#556B2F","icon":"\ud83d\udcb5","order":0,"old_id":"b"},"18":{"id":18,"projectid":"home-2026","name":"Check","color":"#A9A9A9","icon":"\ud83c\udfab","order":0,"old_id":"f"},"19":{"id":19,"projectid":"home-2026","name":"Transfer","color":"#00CED1","icon":"\u21c4","order":0,"old_id":"t"},"20":{"id":20,"projectid":"home-2026","name":"Online service","color":"#9932CC","icon":"\ud83c\udf0e","order":0,"old_id":"o"}},"id":"home-2026","userid":"casraf","name":"Home 2026","email":"","autoexport":"m","lastchanged":1770107790,"deletiondisabled":false,"categorysort":"a","paymentmodesort":"a","currencyname":"\u20aa","archived_ts":null,"myaccesslevel":4}
|
||||
Error: fetching project: decoding project data: json: cannot unmarshal object into Go struct field Project.categories of type []api.Category
|
||||
Usage:
|
||||
cospend-cli list [flags]
|
||||
|
||||
Aliases:
|
||||
list, ls
|
||||
|
||||
Flags:
|
||||
-a, --amount string Filter by amount (e.g., 50, >30, <=100, =25)
|
||||
-b, --by string Filter by paying member username
|
||||
-c, --category string Filter by category
|
||||
-f, --for stringArray Filter by owed member username (repeatable)
|
||||
-h, --help help for list
|
||||
-m, --method string Filter by payment method
|
||||
-n, --name string Filter by name (case-insensitive, contains)
|
||||
-p, --project string Project ID (required)
|
||||
|
||||
Global Flags:
|
||||
-d, --debug Enable debug output
|
||||
|
||||
fetching project: decoding project data: json: cannot unmarshal object into Go struct field Project.categories of type []api.Category
|
||||
Usage:
|
||||
cospend-cli list [flags]
|
||||
|
||||
Aliases:
|
||||
list, ls
|
||||
|
||||
Flags:
|
||||
-a, --amount string Filter by amount (e.g., 50, >30, <=100, =25)
|
||||
-b, --by string Filter by paying member username
|
||||
-c, --category string Filter by category
|
||||
-f, --for stringArray Filter by owed member username (repeatable)
|
||||
-h, --help help for list
|
||||
-m, --method string Filter by payment method
|
||||
-n, --name string Filter by name (case-insensitive, contains)
|
||||
-p, --project string Project ID (required)
|
||||
|
||||
Global Flags:
|
||||
-d, --debug Enable debug output
|
||||
|
||||
fetching project: decoding project data: json: cannot unmarshal object into Go struct field Project.categories of type []api.Category
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright © 2026 Chen Asraf
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
58
Makefile
Executable file
58
Makefile
Executable file
@@ -0,0 +1,58 @@
|
||||
BIN := $(subst -cli,,$(notdir $(CURDIR)))
|
||||
|
||||
all:
|
||||
@if [ ! -f ".git/hooks/pre-commit" ]; then \
|
||||
$(MAKE) precommit-install; \
|
||||
fi
|
||||
$(MAKE) build
|
||||
$(MAKE) run
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
go build -o $(BIN)
|
||||
|
||||
.PHONY: run
|
||||
run: build
|
||||
./$(BIN)
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
.PHONY: install
|
||||
install: build
|
||||
cp $(BIN) ~/.local/bin/
|
||||
|
||||
.PHONY: uninstall
|
||||
uninstall:
|
||||
rm -f ~/.local/bin/$(BIN)
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
.PHONY: precommit-install
|
||||
precommit-install:
|
||||
@echo "Installing pre-commit hooks..."
|
||||
@echo "#!/bin/sh\n\nmake precommit" > .git/hooks/pre-commit
|
||||
@chmod +x .git/hooks/pre-commit
|
||||
@echo "Pre-commit hooks installed."
|
||||
|
||||
.PHONY: precommit
|
||||
precommit:
|
||||
@STAGED_FILES=$$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.go$$'); \
|
||||
if [ -z "$$STAGED_FILES" ]; then \
|
||||
echo "No staged Go files to check."; \
|
||||
else \
|
||||
set -e; \
|
||||
echo "Running pre-commit checks..."; \
|
||||
echo "go fmt"; \
|
||||
go fmt ./...; \
|
||||
git add $$STAGED_FILES; \
|
||||
echo "go vet"; \
|
||||
go vet ./...; \
|
||||
echo "golangci-lint"; \
|
||||
golangci-lint run ./...; \
|
||||
echo "go test"; \
|
||||
go test -v ./...; \
|
||||
fi
|
||||
282
README.md
Normal file
282
README.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# cospend-cli
|
||||
|
||||
**`cospend-cli`** is a command-line interface for managing expenses in
|
||||
[Nextcloud Cospend](https://apps.nextcloud.com/apps/cospend) projects. It provides a quick way to
|
||||
add and list expenses directly from your terminal without opening the web interface.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Add** and **list** expenses in Cospend projects via the **REST API**
|
||||
- **Filter** expenses by payer, owed members, amount, name, category, or payment method
|
||||
- Resolve categories, payment methods, and members by **name or ID**
|
||||
- **Case-insensitive** matching for all lookups
|
||||
- **Currency code support** (e.g., `usd`, `eur`, `gbp`) with automatic symbol resolution
|
||||
- **Local caching** of project data with 1-hour TTL for faster subsequent calls
|
||||
- Cross-platform support: **macOS**, **Linux**, and **Windows**
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Download Precompiled Binaries
|
||||
|
||||
Precompiled binaries for `cospend-cli` are available for **Linux**, **macOS**, and **Windows**:
|
||||
|
||||
- Visit the [Releases Page](https://github.com/chenasraf/cospend-cli/releases/latest) to download
|
||||
the latest version for your platform.
|
||||
|
||||
### Homebrew (macOS/Linux)
|
||||
|
||||
```bash
|
||||
brew install chenasraf/tap/cospend-cli
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
go install github.com/chenasraf/cospend-cli@latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Quick Setup (Recommended)
|
||||
|
||||
Run the interactive setup wizard:
|
||||
|
||||
```bash
|
||||
cospend init
|
||||
```
|
||||
|
||||
This will prompt for your Nextcloud credentials and save them to a config file.
|
||||
|
||||
You can specify the config format with `--format`:
|
||||
|
||||
```bash
|
||||
cospend init --format yaml
|
||||
cospend init --format toml
|
||||
cospend init --format json # default
|
||||
```
|
||||
|
||||
### Config File
|
||||
|
||||
The config file is searched in the following locations (in order of preference):
|
||||
|
||||
| OS | Primary Location | Fallback Location |
|
||||
| ------- | ------------------------------------------------- | -------------------------------------------- |
|
||||
| Linux | `~/.config/cospend/cospend.{json,yaml,toml}` | - |
|
||||
| macOS | `~/Library/Application Support/cospend/cospend.*` | `~/.config/cospend/cospend.{json,yaml,toml}` |
|
||||
| Windows | `%APPDATA%\cospend\cospend.*` | - |
|
||||
|
||||
Example config files:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "https://cloud.example.com",
|
||||
"user": "alice",
|
||||
"password": "your-app-password"
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
domain: https://cloud.example.com
|
||||
user: alice
|
||||
password: your-app-password
|
||||
```
|
||||
|
||||
```toml
|
||||
domain = "https://cloud.example.com"
|
||||
user = "alice"
|
||||
password = "your-app-password"
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can also use environment variables, which override config file values:
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------- | ------------------------------------ |
|
||||
| `NEXTCLOUD_DOMAIN` | Your Nextcloud instance URL |
|
||||
| `NEXTCLOUD_USER` | Your Nextcloud username |
|
||||
| `NEXTCLOUD_PASSWORD` | Your Nextcloud password or app token |
|
||||
|
||||
```bash
|
||||
export NEXTCLOUD_DOMAIN="https://cloud.example.com"
|
||||
export NEXTCLOUD_USER="alice"
|
||||
export NEXTCLOUD_PASSWORD="your-app-password"
|
||||
```
|
||||
|
||||
> **Tip:** For security, consider using a Nextcloud
|
||||
> [app password](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#managing-devices)
|
||||
> instead of your main password.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Adding Expenses
|
||||
|
||||
```bash
|
||||
cospend add <name> <amount> [flags]
|
||||
```
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# Add a simple expense
|
||||
cospend add "Groceries" 25.50 -p myproject
|
||||
|
||||
# Add an expense with category and split between members
|
||||
cospend add "Dinner" 45.00 -p myproject -c restaurant -f alice -f bob
|
||||
|
||||
# Add an expense paid by someone else
|
||||
cospend add "Gas" 60.00 -p roadtrip -b charlie -f alice -f bob -f charlie
|
||||
|
||||
# Add an expense with payment method and comment
|
||||
cospend add "Hotel" 150.00 -p vacation -m "credit card" -o "2 nights"
|
||||
|
||||
# Add an expense in a different currency
|
||||
cospend add "Souvenirs" 30.00 -p vacation -C usd
|
||||
```
|
||||
|
||||
#### Add Command Flags
|
||||
|
||||
| Short | Long | Description |
|
||||
| ----- | ------------ | --------------------------------------------------------- |
|
||||
| `-p` | `--project` | Project ID (required) |
|
||||
| `-c` | `--category` | Category by ID or case-insensitive name |
|
||||
| `-b` | `--by` | Paying member username (defaults to authenticated user) |
|
||||
| `-f` | `--for` | Owed member username (repeatable; defaults to payer only) |
|
||||
| `-C` | `--convert` | Currency to convert to (by ID, name, or code like `usd`) |
|
||||
| `-m` | `--method` | Payment method by ID or case-insensitive name |
|
||||
| `-o` | `--comment` | Additional details about the bill |
|
||||
| `-h` | `--help` | Display help information |
|
||||
|
||||
---
|
||||
|
||||
### Listing Expenses
|
||||
|
||||
```bash
|
||||
cospend list [flags]
|
||||
cospend ls [flags] # alias
|
||||
```
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# List all expenses in a project
|
||||
cospend list -p myproject
|
||||
|
||||
# Filter by paying member
|
||||
cospend list -p myproject -b alice
|
||||
|
||||
# Filter by category
|
||||
cospend list -p myproject -c groceries
|
||||
|
||||
# Filter by amount (supports =, >, <, >=, <=)
|
||||
cospend list -p myproject --amount ">50"
|
||||
cospend list -p myproject --amount "<=100"
|
||||
|
||||
# Filter by name (case-insensitive, contains)
|
||||
cospend list -p myproject -n dinner
|
||||
|
||||
# Combine multiple filters
|
||||
cospend list -p myproject -b alice -c restaurant --amount ">=20"
|
||||
```
|
||||
|
||||
#### List Command Flags
|
||||
|
||||
| Short | Long | Description |
|
||||
| ----- | ------------ | --------------------------------------------------- |
|
||||
| `-p` | `--project` | Project ID (required) |
|
||||
| `-b` | `--by` | Filter by paying member username |
|
||||
| `-f` | `--for` | Filter by owed member username (repeatable) |
|
||||
| `-a` | `--amount` | Filter by amount (e.g., `50`, `>30`, `<=100`, `=25`) |
|
||||
| `-n` | `--name` | Filter by name (case-insensitive, contains) |
|
||||
| `-c` | `--category` | Filter by category name or ID |
|
||||
| `-m` | `--method` | Filter by payment method name or ID |
|
||||
| `-h` | `--help` | Display help information |
|
||||
|
||||
The output includes the bill ID for each expense, which can be used with the delete command.
|
||||
|
||||
---
|
||||
|
||||
### Deleting Expenses
|
||||
|
||||
```bash
|
||||
cospend delete <bill_id> [flags]
|
||||
cospend rm <bill_id> [flags] # alias
|
||||
```
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# Delete a bill by ID (use 'cospend list' to find bill IDs)
|
||||
cospend delete 123 -p myproject
|
||||
```
|
||||
|
||||
#### Delete Command Flags
|
||||
|
||||
| Short | Long | Description |
|
||||
| ----- | ----------- | --------------------- |
|
||||
| `-p` | `--project` | Project ID (required) |
|
||||
| `-h` | `--help` | Display help information |
|
||||
|
||||
---
|
||||
|
||||
## Caching
|
||||
|
||||
Project data (members, categories, payment methods, currencies) is cached locally to avoid repeated
|
||||
API calls. The cache is stored in:
|
||||
|
||||
| OS | Location |
|
||||
| ------- | ------------------------------- |
|
||||
| Linux | `~/.cache/cospend-cli/` |
|
||||
| macOS | `~/Library/Caches/cospend-cli/` |
|
||||
| Windows | `%LOCALAPPDATA%\cospend-cli\` |
|
||||
|
||||
Cache entries expire after **1 hour**. To force a refresh, simply delete the cache file for your
|
||||
project.
|
||||
|
||||
---
|
||||
|
||||
## Currency Codes
|
||||
|
||||
When using the `-C` flag, you can specify currencies by:
|
||||
|
||||
1. **Numeric ID** - The Cospend currency ID
|
||||
2. **Name** - The currency name as configured in Cospend (case-insensitive)
|
||||
3. **Currency code** - Standard codes like `usd`, `eur`, `gbp`, `jpy`, etc.
|
||||
|
||||
Currency codes are automatically mapped to their symbols (e.g., `usd` -> `$`, `eur` -> `€`) and
|
||||
matched against your project's configured currencies.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
I am developing this package on my free time, so any support, whether code, issues, or just stars is
|
||||
very helpful to sustaining its life. If you are feeling incredibly generous and would like to donate
|
||||
just a small amount to help sustain this project, I would be very very thankful!
|
||||
|
||||
<a href='https://ko-fi.com/casraf' target='_blank'>
|
||||
<img height='36' style='border:0px;height:36px;'
|
||||
src='https://cdn.ko-fi.com/cdn/kofi1.png?v=3'
|
||||
alt='Buy Me a Coffee at ko-fi.com' />
|
||||
</a>
|
||||
|
||||
I welcome any issues or pull requests on GitHub. If you find a bug, or would like a new feature,
|
||||
don't hesitate to open an appropriate issue and I will do my best to reply promptly.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
`cospend-cli` is licensed under the [MIT License](/LICENSE).
|
||||
161
cmd/add.go
Normal file
161
cmd/add.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/api"
|
||||
"github.com/chenasraf/cospend-cli/internal/cache"
|
||||
"github.com/chenasraf/cospend-cli/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
category string
|
||||
paidBy string
|
||||
paidFor []string
|
||||
convertTo string
|
||||
paymentMethod string
|
||||
comment string
|
||||
)
|
||||
|
||||
// NewAddCommand creates the add command
|
||||
func NewAddCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <name> <amount>",
|
||||
Short: "Add an expense to a Cospend project",
|
||||
Long: `Add an expense to a Cospend project.
|
||||
|
||||
Examples:
|
||||
cospend add "Groceries" 25.50 -p myproject
|
||||
cospend add "Dinner" 45.00 -p myproject -c restaurant -b alice -f bob -f charlie`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runAdd,
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&category, "category", "c", "", "Category by ID or name")
|
||||
cmd.Flags().StringVarP(&paidBy, "by", "b", "", "Paying member username (defaults to authenticated user)")
|
||||
cmd.Flags().StringArrayVarP(&paidFor, "for", "f", nil, "Owed member username (repeatable; defaults to payer only)")
|
||||
cmd.Flags().StringVarP(&convertTo, "convert", "C", "", "Currency to convert to")
|
||||
cmd.Flags().StringVarP(&paymentMethod, "method", "m", "", "Payment method by ID or name")
|
||||
cmd.Flags().StringVarP(&comment, "comment", "o", "", "Additional details about the bill")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runAdd(cmd *cobra.Command, args []string) error {
|
||||
if ProjectID == "" {
|
||||
return fmt.Errorf("project is required (use -p or --project)")
|
||||
}
|
||||
|
||||
expenseName := args[0]
|
||||
amountStr := args[1]
|
||||
|
||||
// Parse amount
|
||||
amount, err := strconv.ParseFloat(amountStr, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid amount: %s", amountStr)
|
||||
}
|
||||
|
||||
// Parameters validated, silence usage for subsequent errors
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get API client
|
||||
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 {
|
||||
// Non-fatal: log warning but continue
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to cache project: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve payer
|
||||
payerUsername := paidBy
|
||||
if payerUsername == "" {
|
||||
payerUsername = cfg.User
|
||||
}
|
||||
payerID, err := cache.ResolveMember(project, payerUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving payer: %w", err)
|
||||
}
|
||||
|
||||
// Resolve owed members
|
||||
var owedIDs []int
|
||||
if len(paidFor) == 0 {
|
||||
// Default to payer only
|
||||
owedIDs = []int{payerID}
|
||||
} else {
|
||||
for _, username := range paidFor {
|
||||
memberID, err := cache.ResolveMember(project, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving owed member: %w", err)
|
||||
}
|
||||
owedIDs = append(owedIDs, memberID)
|
||||
}
|
||||
}
|
||||
|
||||
// Build bill
|
||||
bill := api.Bill{
|
||||
What: expenseName,
|
||||
Amount: amount,
|
||||
PayerID: payerID,
|
||||
OwedTo: owedIDs,
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
}
|
||||
|
||||
// Resolve optional category
|
||||
if category != "" {
|
||||
categoryID, err := cache.ResolveCategory(project, category)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving category: %w", err)
|
||||
}
|
||||
bill.CategoryID = categoryID
|
||||
}
|
||||
|
||||
// Resolve optional payment method
|
||||
if paymentMethod != "" {
|
||||
methodID, err := cache.ResolvePaymentMode(project, paymentMethod)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving payment method: %w", err)
|
||||
}
|
||||
bill.PaymentModeID = methodID
|
||||
}
|
||||
|
||||
// Resolve optional currency
|
||||
if convertTo != "" {
|
||||
currencyID, err := cache.ResolveCurrency(project, convertTo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving currency: %w", err)
|
||||
}
|
||||
bill.OriginalCurrencyID = currencyID
|
||||
}
|
||||
|
||||
// Add optional comment
|
||||
if comment != "" {
|
||||
bill.Comment = comment
|
||||
}
|
||||
|
||||
// Create the bill
|
||||
if err := client.CreateBill(ProjectID, bill); err != nil {
|
||||
return fmt.Errorf("creating bill: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully added expense: %s (%.2f)\n", expenseName, amount)
|
||||
return nil
|
||||
}
|
||||
475
cmd/add_test.go
Normal file
475
cmd/add_test.go
Normal file
@@ -0,0 +1,475 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/api"
|
||||
)
|
||||
|
||||
// OCSResponse for test responses
|
||||
type ocsResponse struct {
|
||||
OCS struct {
|
||||
Meta struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
} `json:"meta"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
} `json:"ocs"`
|
||||
}
|
||||
|
||||
func makeOCSResponse(statusCode int, data any) ocsResponse {
|
||||
dataBytes, _ := json.Marshal(data)
|
||||
resp := ocsResponse{}
|
||||
resp.OCS.Meta.Status = "ok"
|
||||
resp.OCS.Meta.StatusCode = statusCode
|
||||
resp.OCS.Meta.Message = "OK"
|
||||
resp.OCS.Data = dataBytes
|
||||
return resp
|
||||
}
|
||||
|
||||
func resetFlags() {
|
||||
// Reset global flag variables between tests
|
||||
ProjectID = ""
|
||||
category = ""
|
||||
paidBy = ""
|
||||
paidFor = nil
|
||||
convertTo = ""
|
||||
paymentMethod = ""
|
||||
comment = ""
|
||||
}
|
||||
|
||||
func setupTestEnv(t *testing.T, domain string) func() {
|
||||
t.Helper()
|
||||
|
||||
// Reset flags
|
||||
resetFlags()
|
||||
|
||||
// Set test env vars (t.Setenv auto-restores after test)
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", domain)
|
||||
t.Setenv("NEXTCLOUD_USER", "testuser")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "testpass")
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
|
||||
return func() {
|
||||
resetFlags()
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAddCommand(t *testing.T) {
|
||||
resetFlags()
|
||||
defer resetFlags()
|
||||
|
||||
cmd := NewAddCommand()
|
||||
|
||||
if cmd.Use != "add <name> <amount>" {
|
||||
t.Errorf("Wrong Use: %s", cmd.Use)
|
||||
}
|
||||
|
||||
// Check flags exist (project is now a persistent flag on root)
|
||||
flags := []string{"category", "by", "for", "convert", "method", "comment"}
|
||||
for _, flag := range flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("Missing flag: %s", flag)
|
||||
}
|
||||
}
|
||||
|
||||
// Check short flags (project is now on root)
|
||||
shortFlags := map[string]string{
|
||||
"c": "category",
|
||||
"b": "by",
|
||||
"f": "for",
|
||||
"C": "convert",
|
||||
"m": "method",
|
||||
"o": "comment",
|
||||
}
|
||||
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 TestAddCommandMissingProject(t *testing.T) {
|
||||
resetFlags()
|
||||
defer resetFlags()
|
||||
|
||||
cmd := NewAddCommand()
|
||||
cmd.SetArgs([]string{"Test expense", "10.00"})
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing project flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommandInvalidAmount(t *testing.T) {
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewAddCommand()
|
||||
cmd.SetArgs([]string{"Test expense", "not-a-number"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid amount")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommandSuccess(t *testing.T) {
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
{ID: 2, Name: "Alice", UserID: "alice"},
|
||||
},
|
||||
Categories: []api.Category{
|
||||
{ID: 1, Name: "Food"},
|
||||
},
|
||||
PaymentModes: []api.PaymentMode{
|
||||
{ID: 1, Name: "Cash"},
|
||||
},
|
||||
}
|
||||
|
||||
var receivedBill map[string]string
|
||||
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/apps/cospend/api/v1/projects/test-project/bills" {
|
||||
_ = r.ParseForm()
|
||||
receivedBill = make(map[string]string)
|
||||
for k, v := range r.Form {
|
||||
if len(v) > 0 {
|
||||
receivedBill[k] = v[0]
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]int{"id": 1}))
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewAddCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"Groceries", "25.50"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify bill data
|
||||
if receivedBill["what"] != "Groceries" {
|
||||
t.Errorf("Wrong what: %s", receivedBill["what"])
|
||||
}
|
||||
if receivedBill["amount"] != "25.50" {
|
||||
t.Errorf("Wrong amount: %s", receivedBill["amount"])
|
||||
}
|
||||
if receivedBill["payer"] != "1" {
|
||||
t.Errorf("Wrong payer: %s", receivedBill["payer"])
|
||||
}
|
||||
// Default owed to payer
|
||||
if receivedBill["payedFor"] != "1" {
|
||||
t.Errorf("Wrong payedFor: %s", receivedBill["payedFor"])
|
||||
}
|
||||
|
||||
// Check output
|
||||
if !bytes.Contains(stdout.Bytes(), []byte("Successfully added expense")) {
|
||||
t.Errorf("Missing success message in output: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommandWithAllFlags(t *testing.T) {
|
||||
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"},
|
||||
},
|
||||
Currencies: []api.Currency{
|
||||
{ID: 2, Name: "€"},
|
||||
},
|
||||
}
|
||||
|
||||
var receivedBill map[string]string
|
||||
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/apps/cospend/api/v1/projects/test-project/bills" {
|
||||
_ = r.ParseForm()
|
||||
receivedBill = make(map[string]string)
|
||||
for k, v := range r.Form {
|
||||
if len(v) > 0 {
|
||||
receivedBill[k] = v[0]
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]int{"id": 1}))
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewAddCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{
|
||||
"Dinner",
|
||||
"45.00",
|
||||
"-c", "restaurant",
|
||||
"-b", "alice",
|
||||
"-f", "alice",
|
||||
"-f", "bob",
|
||||
"-m", "credit card",
|
||||
"-o", "Team dinner",
|
||||
"-C", "eur",
|
||||
})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify bill data
|
||||
if receivedBill["what"] != "Dinner" {
|
||||
t.Errorf("Wrong what: %s", receivedBill["what"])
|
||||
}
|
||||
if receivedBill["amount"] != "45.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"] != "2,3" { // Alice and Bob
|
||||
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"] != "Team dinner" {
|
||||
t.Errorf("Wrong comment: %s", receivedBill["comment"])
|
||||
}
|
||||
if receivedBill["original_currency_id"] != "2" {
|
||||
t.Errorf("Wrong original_currency_id: %s", receivedBill["original_currency_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommandMemberNotFound(t *testing.T) {
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewAddCommand()
|
||||
cmd.SetArgs([]string{"Test", "10.00", "-b", "nonexistent"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent member")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommandCategoryNotFound(t *testing.T) {
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
},
|
||||
Categories: []api.Category{
|
||||
{ID: 1, Name: "Food"},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewAddCommand()
|
||||
cmd.SetArgs([]string{"Test", "10.00", "-c", "nonexistent"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommandPaymentModeNotFound(t *testing.T) {
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
},
|
||||
PaymentModes: []api.PaymentMode{
|
||||
{ID: 1, Name: "Cash"},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewAddCommand()
|
||||
cmd.SetArgs([]string{"Test", "10.00", "-m", "bitcoin"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent payment mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommandCurrencyNotFound(t *testing.T) {
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
},
|
||||
Currencies: []api.Currency{
|
||||
{ID: 1, Name: "$"},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewAddCommand()
|
||||
cmd.SetArgs([]string{"Test", "10.00", "-C", "btc"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent currency")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommandMissingEnvVars(t *testing.T) {
|
||||
resetFlags()
|
||||
defer resetFlags()
|
||||
|
||||
// Clear all env vars using t.Setenv (restores automatically)
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", "")
|
||||
t.Setenv("NEXTCLOUD_USER", "")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "")
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewAddCommand()
|
||||
cmd.SetArgs([]string{"Test", "10.00"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing env vars")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommandAPIError(t *testing.T) {
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Return error for bill creation
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Internal Server Error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewAddCommand()
|
||||
cmd.SetArgs([]string{"Test", "10.00"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error from API")
|
||||
}
|
||||
}
|
||||
7
cmd/common.go
Normal file
7
cmd/common.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package cmd
|
||||
|
||||
// Debug enables debug output when true
|
||||
var Debug bool
|
||||
|
||||
// ProjectID is the project to operate on (shared across commands)
|
||||
var ProjectID string
|
||||
65
cmd/delete.go
Normal file
65
cmd/delete.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/api"
|
||||
"github.com/chenasraf/cospend-cli/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewDeleteCommand creates the delete command
|
||||
func NewDeleteCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <bill_id>",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "Delete an expense from a Cospend project",
|
||||
Long: `Delete an expense from a Cospend project by its bill ID.
|
||||
|
||||
Use 'cospend list' to find the bill ID you want to delete.
|
||||
|
||||
Examples:
|
||||
cospend delete 123 -p myproject`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runDelete,
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDelete(cmd *cobra.Command, args []string) error {
|
||||
if ProjectID == "" {
|
||||
return fmt.Errorf("project is required (use -p or --project)")
|
||||
}
|
||||
|
||||
billIDStr := args[0]
|
||||
|
||||
// Parse bill ID
|
||||
billID, err := strconv.Atoi(billIDStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid bill ID: %s", billIDStr)
|
||||
}
|
||||
|
||||
// Parameters validated, silence usage for subsequent errors
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get API client
|
||||
client := api.NewClient(cfg)
|
||||
client.Debug = Debug
|
||||
client.DebugWriter = cmd.ErrOrStderr()
|
||||
|
||||
// Delete the bill
|
||||
if err := client.DeleteBill(ProjectID, billID); err != nil {
|
||||
return fmt.Errorf("deleting bill: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully deleted bill #%d\n", billID)
|
||||
return nil
|
||||
}
|
||||
157
cmd/delete_test.go
Normal file
157
cmd/delete_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewDeleteCommand(t *testing.T) {
|
||||
cmd := NewDeleteCommand()
|
||||
|
||||
if cmd.Use != "delete <bill_id>" {
|
||||
t.Errorf("Use = %v, want %v", cmd.Use, "delete <bill_id>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCommandMissingProject(t *testing.T) {
|
||||
resetDeleteFlags()
|
||||
|
||||
cmd := NewDeleteCommand()
|
||||
cmd.SetArgs([]string{"123"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing project flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCommandMissingBillID(t *testing.T) {
|
||||
resetDeleteFlags()
|
||||
|
||||
ProjectID = "myproject"
|
||||
cmd := NewDeleteCommand()
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing bill ID argument")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCommandInvalidBillID(t *testing.T) {
|
||||
resetDeleteFlags()
|
||||
|
||||
// Create mock server
|
||||
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 := NewDeleteCommand()
|
||||
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 TestDeleteCommandSuccess(t *testing.T) {
|
||||
resetDeleteFlags()
|
||||
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" {
|
||||
t.Errorf("Expected DELETE method, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/ocs/v2.php/apps/cospend/api/v1/projects/myproject/bills/123" {
|
||||
t.Errorf("Unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := map[string]interface{}{
|
||||
"ocs": map[string]interface{}{
|
||||
"meta": map[string]interface{}{
|
||||
"status": "ok",
|
||||
"statuscode": 200,
|
||||
"message": "OK",
|
||||
},
|
||||
"data": "OK",
|
||||
},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", server.URL)
|
||||
t.Setenv("NEXTCLOUD_USER", "testuser")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "testpass")
|
||||
|
||||
ProjectID = "myproject"
|
||||
cmd := NewDeleteCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"123"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !bytes.Contains([]byte(output), []byte("Successfully deleted bill #123")) {
|
||||
t.Errorf("Expected success message in output, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCommandAPIError(t *testing.T) {
|
||||
resetDeleteFlags()
|
||||
|
||||
// Create mock server that returns an error
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := map[string]interface{}{
|
||||
"ocs": map[string]interface{}{
|
||||
"meta": map[string]interface{}{
|
||||
"status": "failure",
|
||||
"statuscode": 404,
|
||||
"message": "Bill not found",
|
||||
},
|
||||
"data": nil,
|
||||
},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", server.URL)
|
||||
t.Setenv("NEXTCLOUD_USER", "testuser")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "testpass")
|
||||
|
||||
ProjectID = "myproject"
|
||||
cmd := NewDeleteCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetErr(buf)
|
||||
cmd.SetArgs([]string{"999"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for API failure")
|
||||
}
|
||||
}
|
||||
|
||||
func resetDeleteFlags() {
|
||||
ProjectID = ""
|
||||
}
|
||||
149
cmd/init.go
Normal file
149
cmd/init.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var configFormat string
|
||||
|
||||
// NewInitCommand creates the init command
|
||||
func NewInitCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize configuration file",
|
||||
Long: `Initialize a configuration file with your Nextcloud credentials.
|
||||
|
||||
This command will interactively prompt for your Nextcloud domain, username,
|
||||
and password, then save them to a config file.
|
||||
|
||||
Config file location:
|
||||
Linux: ~/.config/cospend/cospend.{ext}
|
||||
macOS: ~/Library/Application Support/cospend/cospend.{ext}
|
||||
Windows: %APPDATA%\cospend\cospend.{ext}`,
|
||||
RunE: runInit,
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&configFormat, "format", "f", "json", "Config file format (json, yaml, toml)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runInit(cmd *cobra.Command, _ []string) error {
|
||||
// Validate format
|
||||
switch configFormat {
|
||||
case "json", "yaml", "yml", "toml":
|
||||
// valid
|
||||
default:
|
||||
return fmt.Errorf("unsupported format: %s (use json, yaml, or toml)", configFormat)
|
||||
}
|
||||
|
||||
// Parameters validated, silence usage for subsequent errors
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
// Check if config already exists
|
||||
if existingPath := config.GetConfigPath(); existingPath != "" {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config file already exists: %s\n", existingPath)
|
||||
overwrite, err := promptYesNo(cmd, "Overwrite?")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !overwrite {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Aborted.")
|
||||
return nil
|
||||
}
|
||||
// Remove existing config
|
||||
if err := os.Remove(existingPath); err != nil {
|
||||
return fmt.Errorf("removing existing config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Setting up Cospend CLI configuration...")
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
|
||||
// Prompt for domain
|
||||
domain, err := promptString(cmd, "Nextcloud domain (e.g., https://cloud.example.com)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
domain = strings.TrimRight(domain, "/")
|
||||
|
||||
// Prompt for username
|
||||
user, err := promptString(cmd, "Username")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prompt for password (hidden input)
|
||||
password, err := promptPassword(cmd, "Password (or app token)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Domain: domain,
|
||||
User: user,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
path, err := config.Save(cfg, configFormat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("saving config: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Configuration saved to: %s\n", path)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "You can now use cospend commands without setting environment variables.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptString(cmd *cobra.Command, prompt string) (string, error) {
|
||||
reader := bufio.NewReader(cmd.InOrStdin())
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s: ", prompt)
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(input), nil
|
||||
}
|
||||
|
||||
func promptPassword(cmd *cobra.Command, prompt string) (string, error) {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s: ", prompt)
|
||||
|
||||
// Try to read password with hidden input
|
||||
if f, ok := cmd.InOrStdin().(*os.File); ok && term.IsTerminal(int(f.Fd())) {
|
||||
password, err := term.ReadPassword(int(f.Fd()))
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout()) // Print newline after hidden input
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(password), nil
|
||||
}
|
||||
|
||||
// Fallback to regular input (for non-terminal/testing)
|
||||
reader := bufio.NewReader(cmd.InOrStdin())
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(input), nil
|
||||
}
|
||||
|
||||
func promptYesNo(cmd *cobra.Command, prompt string) (bool, error) {
|
||||
reader := bufio.NewReader(cmd.InOrStdin())
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s [y/N]: ", prompt)
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
input = strings.TrimSpace(strings.ToLower(input))
|
||||
return input == "y" || input == "yes", nil
|
||||
}
|
||||
348
cmd/list.go
Normal file
348
cmd/list.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"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/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
listPaidBy string
|
||||
listPaidFor []string
|
||||
listAmount string
|
||||
listName string
|
||||
listPaymentMethod string
|
||||
listCategory string
|
||||
)
|
||||
|
||||
// amountFilter holds parsed amount filter criteria
|
||||
type amountFilter struct {
|
||||
operator string
|
||||
value float64
|
||||
}
|
||||
|
||||
// NewListCommand creates the list command
|
||||
func NewListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List expenses in a Cospend project",
|
||||
Long: `List expenses in a Cospend project with optional filters.
|
||||
|
||||
Examples:
|
||||
cospend list -p myproject
|
||||
cospend list -p myproject -b alice
|
||||
cospend list -p myproject -c groceries
|
||||
cospend list -p myproject --amount ">50"
|
||||
cospend list -p myproject --amount "<=100" -n dinner`,
|
||||
RunE: runList,
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&listPaidBy, "by", "b", "", "Filter by paying member username")
|
||||
cmd.Flags().StringArrayVarP(&listPaidFor, "for", "f", nil, "Filter by owed member username (repeatable)")
|
||||
cmd.Flags().StringVarP(&listAmount, "amount", "a", "", "Filter by amount (e.g., 50, >30, <=100, =25)")
|
||||
cmd.Flags().StringVarP(&listName, "name", "n", "", "Filter by name (case-insensitive, contains)")
|
||||
cmd.Flags().StringVarP(&listPaymentMethod, "method", "m", "", "Filter by payment method")
|
||||
cmd.Flags().StringVarP(&listCategory, "category", "c", "", "Filter by category")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(cmd *cobra.Command, _ []string) error {
|
||||
if ProjectID == "" {
|
||||
return fmt.Errorf("project is required (use -p or --project)")
|
||||
}
|
||||
|
||||
// Parameters validated, silence usage for subsequent errors
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get API client
|
||||
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 bills
|
||||
bills, err := client.GetBills(ProjectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching bills: %w", err)
|
||||
}
|
||||
|
||||
// Build filters
|
||||
filters, err := buildFilters(project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
filteredBills := applyFilters(bills, filters)
|
||||
|
||||
// Print table
|
||||
printBillsTable(cmd, project, filteredBills)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// billFilter is a function that returns true if a bill should be included
|
||||
type billFilter func(bill api.BillResponse) bool
|
||||
|
||||
func buildFilters(project *api.Project) ([]billFilter, error) {
|
||||
var filters []billFilter
|
||||
|
||||
// Filter by payer
|
||||
if listPaidBy != "" {
|
||||
payerID, err := cache.ResolveMember(project, listPaidBy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving payer filter: %w", err)
|
||||
}
|
||||
filters = append(filters, func(bill api.BillResponse) bool {
|
||||
return bill.PayerID == payerID
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by owed members
|
||||
if len(listPaidFor) > 0 {
|
||||
var owedIDs []int
|
||||
for _, username := range listPaidFor {
|
||||
memberID, err := cache.ResolveMember(project, username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving owed member filter: %w", err)
|
||||
}
|
||||
owedIDs = append(owedIDs, memberID)
|
||||
}
|
||||
filters = append(filters, func(bill api.BillResponse) bool {
|
||||
for _, requiredID := range owedIDs {
|
||||
found := false
|
||||
for _, ower := range bill.Owers {
|
||||
if ower.ID == requiredID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by amount
|
||||
if listAmount != "" {
|
||||
af, err := parseAmountFilter(listAmount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing amount filter: %w", err)
|
||||
}
|
||||
filters = append(filters, func(bill api.BillResponse) bool {
|
||||
return matchAmount(bill.Amount, af)
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by name (case-insensitive contains)
|
||||
if listName != "" {
|
||||
lowerName := strings.ToLower(listName)
|
||||
filters = append(filters, func(bill api.BillResponse) bool {
|
||||
return strings.Contains(strings.ToLower(bill.What), lowerName)
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by payment method
|
||||
if listPaymentMethod != "" {
|
||||
methodID, err := cache.ResolvePaymentMode(project, listPaymentMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving payment method filter: %w", err)
|
||||
}
|
||||
filters = append(filters, func(bill api.BillResponse) bool {
|
||||
return bill.PaymentModeID == methodID
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if listCategory != "" {
|
||||
categoryID, err := cache.ResolveCategory(project, listCategory)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving category filter: %w", err)
|
||||
}
|
||||
filters = append(filters, func(bill api.BillResponse) bool {
|
||||
return bill.CategoryID == categoryID
|
||||
})
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func applyFilters(bills []api.BillResponse, filters []billFilter) []api.BillResponse {
|
||||
if len(filters) == 0 {
|
||||
return bills
|
||||
}
|
||||
|
||||
var result []api.BillResponse
|
||||
for _, bill := range bills {
|
||||
include := true
|
||||
for _, filter := range filters {
|
||||
if !filter(bill) {
|
||||
include = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if include {
|
||||
result = append(result, bill)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseAmountFilter(s string) (amountFilter, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
// Match operators: >=, <=, >, <, =, or just a number
|
||||
re := regexp.MustCompile(`^(>=|<=|>|<|=)?(.+)$`)
|
||||
matches := re.FindStringSubmatch(s)
|
||||
if matches == nil {
|
||||
return amountFilter{}, fmt.Errorf("invalid amount filter format: %s", s)
|
||||
}
|
||||
|
||||
operator := matches[1]
|
||||
if operator == "" {
|
||||
operator = "="
|
||||
}
|
||||
|
||||
value, err := strconv.ParseFloat(strings.TrimSpace(matches[2]), 64)
|
||||
if err != nil {
|
||||
return amountFilter{}, fmt.Errorf("invalid amount value: %s", matches[2])
|
||||
}
|
||||
|
||||
return amountFilter{operator: operator, value: value}, nil
|
||||
}
|
||||
|
||||
func matchAmount(amount float64, af amountFilter) bool {
|
||||
switch af.operator {
|
||||
case "=":
|
||||
return amount == af.value
|
||||
case ">":
|
||||
return amount > af.value
|
||||
case "<":
|
||||
return amount < af.value
|
||||
case ">=":
|
||||
return amount >= af.value
|
||||
case "<=":
|
||||
return amount <= af.value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillResponse) {
|
||||
if len(bills) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No bills found.")
|
||||
return
|
||||
}
|
||||
|
||||
// Sort by date (newest first), then by timestamp for same-date entries
|
||||
sort.Slice(bills, func(i, j int) bool {
|
||||
if bills[i].Date != bills[j].Date {
|
||||
return bills[i].Date > bills[j].Date
|
||||
}
|
||||
return bills[i].Timestamp > bills[j].Timestamp
|
||||
})
|
||||
|
||||
// Build lookup maps for names
|
||||
memberNames := make(map[int]string)
|
||||
for _, m := range project.Members {
|
||||
memberNames[m.ID] = m.Name
|
||||
}
|
||||
|
||||
categoryNames := make(map[int]string)
|
||||
for _, c := range project.Categories {
|
||||
categoryNames[c.ID] = c.Name
|
||||
}
|
||||
|
||||
paymentModeNames := make(map[int]string)
|
||||
for _, pm := range project.PaymentModes {
|
||||
paymentModeNames[pm.ID] = pm.Name
|
||||
}
|
||||
|
||||
table := NewTable("ID", "DATE", "NAME", "AMOUNT", "PAID BY", "PAID FOR", "CATEGORY", "METHOD")
|
||||
|
||||
for _, bill := range bills {
|
||||
// Get payer name
|
||||
payerName := memberNames[bill.PayerID]
|
||||
if payerName == "" {
|
||||
payerName = fmt.Sprintf("#%d", bill.PayerID)
|
||||
}
|
||||
|
||||
// Get owed member names
|
||||
var owerNames []string
|
||||
for _, ower := range bill.Owers {
|
||||
name := memberNames[ower.ID]
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("#%d", ower.ID)
|
||||
}
|
||||
owerNames = append(owerNames, name)
|
||||
}
|
||||
owersStr := strings.Join(owerNames, ", ")
|
||||
|
||||
// Get category name
|
||||
catName := categoryNames[bill.CategoryID]
|
||||
if catName == "" && bill.CategoryID != 0 {
|
||||
catName = fmt.Sprintf("#%d", bill.CategoryID)
|
||||
}
|
||||
if catName == "" {
|
||||
catName = "-"
|
||||
}
|
||||
|
||||
// Get payment method name
|
||||
methodName := paymentModeNames[bill.PaymentModeID]
|
||||
if methodName == "" && bill.PaymentModeID != 0 {
|
||||
methodName = fmt.Sprintf("#%d", bill.PaymentModeID)
|
||||
}
|
||||
if methodName == "" {
|
||||
methodName = "-"
|
||||
}
|
||||
|
||||
// Truncate name if too long
|
||||
name := bill.What
|
||||
if len(name) > 30 {
|
||||
name = name[:27] + "..."
|
||||
}
|
||||
|
||||
table.AddRow(
|
||||
fmt.Sprintf("%d", bill.ID),
|
||||
bill.Date,
|
||||
name,
|
||||
fmt.Sprintf("%.2f", bill.Amount),
|
||||
payerName,
|
||||
owersStr,
|
||||
catName,
|
||||
methodName,
|
||||
)
|
||||
}
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
table.Render(out)
|
||||
_, _ = fmt.Fprintf(out, "\nTotal: %d bill(s)\n", len(bills))
|
||||
}
|
||||
286
cmd/list_test.go
Normal file
286
cmd/list_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/api"
|
||||
)
|
||||
|
||||
func TestParseAmountFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantOp string
|
||||
wantVal float64
|
||||
wantErr bool
|
||||
}{
|
||||
{"plain number", "50", "=", 50, false},
|
||||
{"equals", "=25", "=", 25, false},
|
||||
{"greater than", ">30", ">", 30, false},
|
||||
{"less than", "<100", "<", 100, false},
|
||||
{"greater or equal", ">=50", ">=", 50, false},
|
||||
{"less or equal", "<=75.5", "<=", 75.5, false},
|
||||
{"with spaces", " >= 100 ", ">=", 100, false},
|
||||
{"decimal", "25.99", "=", 25.99, false},
|
||||
{"invalid number", ">abc", "", 0, true},
|
||||
{"empty string", "", "", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
af, err := parseAmountFilter(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseAmountFilter() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr {
|
||||
if af.operator != tt.wantOp {
|
||||
t.Errorf("parseAmountFilter() operator = %v, want %v", af.operator, tt.wantOp)
|
||||
}
|
||||
if af.value != tt.wantVal {
|
||||
t.Errorf("parseAmountFilter() value = %v, want %v", af.value, tt.wantVal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
amount float64
|
||||
filter amountFilter
|
||||
want bool
|
||||
}{
|
||||
{"equals match", 50, amountFilter{"=", 50}, true},
|
||||
{"equals no match", 50, amountFilter{"=", 51}, false},
|
||||
{"greater match", 60, amountFilter{">", 50}, true},
|
||||
{"greater no match", 50, amountFilter{">", 50}, false},
|
||||
{"greater edge", 50, amountFilter{">", 49.99}, true},
|
||||
{"less match", 40, amountFilter{"<", 50}, true},
|
||||
{"less no match", 50, amountFilter{"<", 50}, false},
|
||||
{"greater equal match exact", 50, amountFilter{">=", 50}, true},
|
||||
{"greater equal match above", 51, amountFilter{">=", 50}, true},
|
||||
{"greater equal no match", 49, amountFilter{">=", 50}, false},
|
||||
{"less equal match exact", 50, amountFilter{"<=", 50}, true},
|
||||
{"less equal match below", 49, amountFilter{"<=", 50}, true},
|
||||
{"less equal no match", 51, amountFilter{"<=", 50}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := matchAmount(tt.amount, tt.filter); got != tt.want {
|
||||
t.Errorf("matchAmount() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFilters(t *testing.T) {
|
||||
bills := []api.BillResponse{
|
||||
{ID: 1, What: "Groceries", Amount: 50, PayerID: 1, CategoryID: 1},
|
||||
{ID: 2, What: "Dinner", Amount: 100, PayerID: 2, CategoryID: 2},
|
||||
{ID: 3, What: "Lunch", Amount: 25, PayerID: 1, CategoryID: 2},
|
||||
{ID: 4, What: "Coffee", Amount: 5, PayerID: 3, CategoryID: 1},
|
||||
}
|
||||
|
||||
t.Run("no filters", func(t *testing.T) {
|
||||
result := applyFilters(bills, nil)
|
||||
if len(result) != 4 {
|
||||
t.Errorf("applyFilters() returned %d bills, want 4", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single filter", func(t *testing.T) {
|
||||
filters := []billFilter{
|
||||
func(b api.BillResponse) bool { return b.PayerID == 1 },
|
||||
}
|
||||
result := applyFilters(bills, filters)
|
||||
if len(result) != 2 {
|
||||
t.Errorf("applyFilters() returned %d bills, want 2", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple filters AND", func(t *testing.T) {
|
||||
filters := []billFilter{
|
||||
func(b api.BillResponse) bool { return b.PayerID == 1 },
|
||||
func(b api.BillResponse) bool { return b.Amount > 30 },
|
||||
}
|
||||
result := applyFilters(bills, filters)
|
||||
if len(result) != 1 {
|
||||
t.Errorf("applyFilters() returned %d bills, want 1", len(result))
|
||||
}
|
||||
if result[0].ID != 1 {
|
||||
t.Errorf("applyFilters() returned bill ID %d, want 1", result[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filter with no matches", func(t *testing.T) {
|
||||
filters := []billFilter{
|
||||
func(b api.BillResponse) bool { return b.Amount > 1000 },
|
||||
}
|
||||
result := applyFilters(bills, filters)
|
||||
if len(result) != 0 {
|
||||
t.Errorf("applyFilters() returned %d bills, want 0", len(result))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrintBillsTable(t *testing.T) {
|
||||
// Reset global flags
|
||||
resetListFlags()
|
||||
|
||||
project := &api.Project{
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "Alice", UserID: "alice"},
|
||||
{ID: 2, Name: "Bob", UserID: "bob"},
|
||||
},
|
||||
Categories: []api.Category{
|
||||
{ID: 1, Name: "Food"},
|
||||
{ID: 2, Name: "Transport"},
|
||||
},
|
||||
PaymentModes: []api.PaymentMode{
|
||||
{ID: 1, Name: "Cash"},
|
||||
{ID: 2, Name: "Card"},
|
||||
},
|
||||
}
|
||||
|
||||
bills := []api.BillResponse{
|
||||
{
|
||||
ID: 1,
|
||||
What: "Groceries",
|
||||
Amount: 50.00,
|
||||
Date: "2026-02-03",
|
||||
PayerID: 1,
|
||||
Owers: []api.Ower{{ID: 1, Weight: 1}, {ID: 2, Weight: 1}},
|
||||
CategoryID: 1,
|
||||
PaymentModeID: 1,
|
||||
},
|
||||
}
|
||||
|
||||
cmd := NewListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
|
||||
printBillsTable(cmd, project, bills)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check that key elements are present
|
||||
if !bytes.Contains([]byte(output), []byte("Groceries")) {
|
||||
t.Error("Output should contain bill name 'Groceries'")
|
||||
}
|
||||
if !bytes.Contains([]byte(output), []byte("Alice")) {
|
||||
t.Error("Output should contain payer name 'Alice'")
|
||||
}
|
||||
if !bytes.Contains([]byte(output), []byte("Bob")) {
|
||||
t.Error("Output should contain ower name 'Bob'")
|
||||
}
|
||||
if !bytes.Contains([]byte(output), []byte("Food")) {
|
||||
t.Error("Output should contain category 'Food'")
|
||||
}
|
||||
if !bytes.Contains([]byte(output), []byte("Cash")) {
|
||||
t.Error("Output should contain payment method 'Cash'")
|
||||
}
|
||||
if !bytes.Contains([]byte(output), []byte("50.00")) {
|
||||
t.Error("Output should contain amount '50.00'")
|
||||
}
|
||||
if !bytes.Contains([]byte(output), []byte("Total: 1 bill(s)")) {
|
||||
t.Error("Output should contain total count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintBillsTableEmpty(t *testing.T) {
|
||||
resetListFlags()
|
||||
|
||||
project := &api.Project{}
|
||||
bills := []api.BillResponse{}
|
||||
|
||||
cmd := NewListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
|
||||
printBillsTable(cmd, project, bills)
|
||||
|
||||
output := buf.String()
|
||||
if !bytes.Contains([]byte(output), []byte("No bills found")) {
|
||||
t.Error("Output should indicate no bills found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFiltersNameFilter(t *testing.T) {
|
||||
resetListFlags()
|
||||
|
||||
project := &api.Project{}
|
||||
|
||||
// Set name filter
|
||||
listName = "grocery"
|
||||
|
||||
filters, err := buildFilters(project)
|
||||
if err != nil {
|
||||
t.Fatalf("buildFilters() error = %v", err)
|
||||
}
|
||||
|
||||
if len(filters) != 1 {
|
||||
t.Fatalf("buildFilters() returned %d filters, want 1", len(filters))
|
||||
}
|
||||
|
||||
// Test the filter
|
||||
bill1 := api.BillResponse{What: "Grocery shopping"}
|
||||
bill2 := api.BillResponse{What: "Dinner"}
|
||||
|
||||
if !filters[0](bill1) {
|
||||
t.Error("Filter should match 'Grocery shopping'")
|
||||
}
|
||||
if filters[0](bill2) {
|
||||
t.Error("Filter should not match 'Dinner'")
|
||||
}
|
||||
|
||||
resetListFlags()
|
||||
}
|
||||
|
||||
func TestBuildFiltersAmountFilter(t *testing.T) {
|
||||
resetListFlags()
|
||||
|
||||
project := &api.Project{}
|
||||
|
||||
// Set amount filter
|
||||
listAmount = ">50"
|
||||
|
||||
filters, err := buildFilters(project)
|
||||
if err != nil {
|
||||
t.Fatalf("buildFilters() error = %v", err)
|
||||
}
|
||||
|
||||
if len(filters) != 1 {
|
||||
t.Fatalf("buildFilters() returned %d filters, want 1", len(filters))
|
||||
}
|
||||
|
||||
// Test the filter
|
||||
bill1 := api.BillResponse{Amount: 100}
|
||||
bill2 := api.BillResponse{Amount: 50}
|
||||
bill3 := api.BillResponse{Amount: 25}
|
||||
|
||||
if !filters[0](bill1) {
|
||||
t.Error("Filter should match amount 100")
|
||||
}
|
||||
if filters[0](bill2) {
|
||||
t.Error("Filter should not match amount 50 (not strictly greater)")
|
||||
}
|
||||
if filters[0](bill3) {
|
||||
t.Error("Filter should not match amount 25")
|
||||
}
|
||||
|
||||
resetListFlags()
|
||||
}
|
||||
|
||||
func resetListFlags() {
|
||||
ProjectID = ""
|
||||
listPaidBy = ""
|
||||
listPaidFor = nil
|
||||
listAmount = ""
|
||||
listName = ""
|
||||
listPaymentMethod = ""
|
||||
listCategory = ""
|
||||
}
|
||||
77
cmd/projects.go
Normal file
77
cmd/projects.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/api"
|
||||
"github.com/chenasraf/cospend-cli/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var showAllProjects bool
|
||||
|
||||
// NewProjectsCommand creates the projects command
|
||||
func NewProjectsCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "projects",
|
||||
Aliases: []string{"proj"},
|
||||
Short: "List available Cospend projects",
|
||||
Long: `List all Cospend projects you have access to.`,
|
||||
RunE: runProjects,
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&showAllProjects, "all", "a", false, "Show all projects including archived")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runProjects(cmd *cobra.Command, _ []string) error {
|
||||
// Parameters validated, silence usage for subsequent errors
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get API client
|
||||
client := api.NewClient(cfg)
|
||||
client.Debug = Debug
|
||||
client.DebugWriter = cmd.ErrOrStderr()
|
||||
|
||||
// Fetch projects
|
||||
projects, err := client.GetProjects()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching projects: %w", err)
|
||||
}
|
||||
|
||||
// Filter out archived projects unless --all is set
|
||||
var filtered []api.ProjectSummary
|
||||
for _, proj := range projects {
|
||||
if showAllProjects || !proj.IsArchived() {
|
||||
filtered = append(filtered, proj)
|
||||
}
|
||||
}
|
||||
|
||||
// Print table
|
||||
out := cmd.OutOrStdout()
|
||||
if len(filtered) == 0 {
|
||||
_, _ = fmt.Fprintln(out, "No projects found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := NewTable("ID", "NAME", "CURRENCY")
|
||||
for _, proj := range filtered {
|
||||
currency := proj.CurrName
|
||||
if currency == "" {
|
||||
currency = "-"
|
||||
}
|
||||
table.AddRow(proj.ID, proj.Name, currency)
|
||||
}
|
||||
|
||||
table.Render(out)
|
||||
_, _ = fmt.Fprintf(out, "\nTotal: %d project(s)\n", len(filtered))
|
||||
|
||||
return nil
|
||||
}
|
||||
94
cmd/table.go
Normal file
94
cmd/table.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Table border characters
|
||||
const (
|
||||
borderHorizontal = "─"
|
||||
borderVertical = "│"
|
||||
borderTopLeft = "┌"
|
||||
borderTopRight = "┐"
|
||||
borderBottomLeft = "└"
|
||||
borderBottomRight = "┘"
|
||||
borderTopMid = "┬"
|
||||
borderBottomMid = "┴"
|
||||
borderLeftMid = "├"
|
||||
borderRightMid = "┤"
|
||||
borderCross = "┼"
|
||||
)
|
||||
|
||||
// Table handles formatted table output
|
||||
type Table struct {
|
||||
headers []string
|
||||
rows [][]string
|
||||
colWidths []int
|
||||
}
|
||||
|
||||
// NewTable creates a new table with the given headers
|
||||
func NewTable(headers ...string) *Table {
|
||||
colWidths := make([]int, len(headers))
|
||||
for i, h := range headers {
|
||||
colWidths[i] = len(h)
|
||||
}
|
||||
return &Table{
|
||||
headers: headers,
|
||||
colWidths: colWidths,
|
||||
}
|
||||
}
|
||||
|
||||
// AddRow adds a row to the table
|
||||
func (t *Table) AddRow(values ...string) {
|
||||
// Pad with empty strings if needed
|
||||
for len(values) < len(t.headers) {
|
||||
values = append(values, "")
|
||||
}
|
||||
// Truncate if too many
|
||||
if len(values) > len(t.headers) {
|
||||
values = values[:len(t.headers)]
|
||||
}
|
||||
|
||||
t.rows = append(t.rows, values)
|
||||
|
||||
// Update column widths
|
||||
for i, v := range values {
|
||||
if len(v) > t.colWidths[i] {
|
||||
t.colWidths[i] = len(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render writes the table to the given writer
|
||||
func (t *Table) Render(w io.Writer) {
|
||||
t.printBorder(w, borderTopLeft, borderTopMid, borderTopRight)
|
||||
t.printRow(w, t.headers)
|
||||
t.printBorder(w, borderLeftMid, borderCross, borderRightMid)
|
||||
|
||||
for _, row := range t.rows {
|
||||
t.printRow(w, row)
|
||||
}
|
||||
|
||||
t.printBorder(w, borderBottomLeft, borderBottomMid, borderBottomRight)
|
||||
}
|
||||
|
||||
func (t *Table) printBorder(w io.Writer, left, mid, right string) {
|
||||
_, _ = fmt.Fprint(w, left)
|
||||
for i, width := range t.colWidths {
|
||||
_, _ = fmt.Fprint(w, strings.Repeat(borderHorizontal, width+2))
|
||||
if i < len(t.colWidths)-1 {
|
||||
_, _ = fmt.Fprint(w, mid)
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprintln(w, right)
|
||||
}
|
||||
|
||||
func (t *Table) printRow(w io.Writer, values []string) {
|
||||
_, _ = fmt.Fprint(w, borderVertical)
|
||||
for i, val := range values {
|
||||
_, _ = fmt.Fprintf(w, " %-*s %s", t.colWidths[i], val, borderVertical)
|
||||
}
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
module github.com/chenasraf/cospend-cli
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/adrg/xdg v0.4.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
golang.org/x/term v0.39.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
)
|
||||
29
go.sum
Normal file
29
go.sum
Normal file
@@ -0,0 +1,29 @@
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
|
||||
github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
439
internal/api/client.go
Normal file
439
internal/api/client.go
Normal file
@@ -0,0 +1,439 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/config"
|
||||
)
|
||||
|
||||
// Client is the Cospend API client
|
||||
type Client struct {
|
||||
config *config.Config
|
||||
httpClient *http.Client
|
||||
Debug bool
|
||||
DebugWriter io.Writer
|
||||
}
|
||||
|
||||
// Member represents a project member
|
||||
type Member struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
UserID string `json:"userid"`
|
||||
Activated bool `json:"activated"`
|
||||
}
|
||||
|
||||
// Category represents a bill category
|
||||
type Category struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// PaymentMode represents a payment method
|
||||
type PaymentMode struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Currency represents a currency
|
||||
type Currency struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ExchangeRate float64 `json:"exchange_rate"`
|
||||
}
|
||||
|
||||
// Project represents a Cospend project
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Members []Member `json:"members"`
|
||||
Categories []Category // custom unmarshal
|
||||
PaymentModes []PaymentMode // custom unmarshal
|
||||
Currencies []Currency `json:"currencies"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON custom unmarshaler to handle categories/paymentmodes as object or array
|
||||
func (p *Project) UnmarshalJSON(data []byte) error {
|
||||
// Use a map for flexible parsing
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse simple fields
|
||||
if v, ok := raw["id"]; ok {
|
||||
_ = json.Unmarshal(v, &p.ID)
|
||||
}
|
||||
if v, ok := raw["name"]; ok {
|
||||
_ = json.Unmarshal(v, &p.Name)
|
||||
}
|
||||
if v, ok := raw["members"]; ok {
|
||||
_ = json.Unmarshal(v, &p.Members)
|
||||
}
|
||||
if v, ok := raw["currencies"]; ok {
|
||||
_ = json.Unmarshal(v, &p.Currencies)
|
||||
}
|
||||
|
||||
// Parse categories (can be array or object)
|
||||
if v, ok := raw["categories"]; ok {
|
||||
p.Categories = parseCategories(v)
|
||||
}
|
||||
|
||||
// Parse payment modes (can be array or object)
|
||||
if v, ok := raw["paymentmodes"]; ok {
|
||||
p.PaymentModes = parsePaymentModes(v)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON custom marshaler for Project
|
||||
func (p Project) MarshalJSON() ([]byte, error) {
|
||||
type ProjectAlias struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Members []Member `json:"members"`
|
||||
Categories []Category `json:"categories"`
|
||||
PaymentModes []PaymentMode `json:"paymentmodes"`
|
||||
Currencies []Currency `json:"currencies"`
|
||||
}
|
||||
return json.Marshal(ProjectAlias(p))
|
||||
}
|
||||
|
||||
func parseCategories(data json.RawMessage) []Category {
|
||||
// API returns categories as object keyed by ID
|
||||
var obj map[string]Category
|
||||
if err := json.Unmarshal(data, &obj); err == nil {
|
||||
result := make([]Category, 0, len(obj))
|
||||
for _, cat := range obj {
|
||||
result = append(result, cat)
|
||||
}
|
||||
return result
|
||||
}
|
||||
// Fallback to array (for tests)
|
||||
var arr []Category
|
||||
if err := json.Unmarshal(data, &arr); err == nil {
|
||||
return arr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePaymentModes(data json.RawMessage) []PaymentMode {
|
||||
// API returns payment modes as object keyed by ID
|
||||
var obj map[string]PaymentMode
|
||||
if err := json.Unmarshal(data, &obj); err == nil {
|
||||
result := make([]PaymentMode, 0, len(obj))
|
||||
for _, pm := range obj {
|
||||
result = append(result, pm)
|
||||
}
|
||||
return result
|
||||
}
|
||||
// Fallback to array (for tests)
|
||||
var arr []PaymentMode
|
||||
if err := json.Unmarshal(data, &arr); err == nil {
|
||||
return arr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bill represents a bill to create
|
||||
type Bill struct {
|
||||
What string `json:"what"`
|
||||
Amount float64 `json:"amount"`
|
||||
PayerID int `json:"payer_id"`
|
||||
OwedTo []int `json:"-"` // Will be formatted as comma-separated string
|
||||
Date string `json:"date"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
PaymentModeID int `json:"paymentmodeid,omitempty"`
|
||||
CategoryID int `json:"categoryid,omitempty"`
|
||||
OriginalCurrencyID int `json:"original_currency_id,omitempty"`
|
||||
}
|
||||
|
||||
// BillResponse represents a bill returned from the API
|
||||
type BillResponse struct {
|
||||
ID int `json:"id"`
|
||||
What string `json:"what"`
|
||||
Amount float64 `json:"amount"`
|
||||
Date string `json:"date"`
|
||||
PayerID int `json:"payer_id"`
|
||||
Owers []Ower `json:"owers"`
|
||||
Comment string `json:"comment"`
|
||||
PaymentModeID int `json:"paymentmodeid"`
|
||||
CategoryID int `json:"categoryid"`
|
||||
Repeat string `json:"repeat"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Ower represents a member who owes part of a bill
|
||||
type Ower struct {
|
||||
ID int `json:"id"`
|
||||
Weight float64 `json:"weight"`
|
||||
}
|
||||
|
||||
// OCSResponse wraps the OCS API response format
|
||||
type OCSResponse struct {
|
||||
OCS struct {
|
||||
Meta struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
} `json:"meta"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
} `json:"ocs"`
|
||||
}
|
||||
|
||||
// NewClient creates a new API client
|
||||
func NewClient(cfg *config.Config) *Client {
|
||||
return &Client{
|
||||
config: cfg,
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) debugf(format string, args ...interface{}) {
|
||||
if c.Debug && c.DebugWriter != nil {
|
||||
_, _ = fmt.Fprintf(c.DebugWriter, "[DEBUG] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(method, path string, body io.Reader) (*http.Response, error) {
|
||||
baseURL := strings.TrimSuffix(c.config.Domain, "/")
|
||||
fullURL := fmt.Sprintf("%s%s", baseURL, path)
|
||||
|
||||
c.debugf("Request: %s %s", method, fullURL)
|
||||
|
||||
req, err := http.NewRequest(method, fullURL, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.config.User, c.config.Password)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
c.debugf("Headers: OCS-APIRequest=true, Accept=application/json, Auth=Basic %s:***", c.config.User)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.debugf("Request error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.debugf("Response: %d %s", resp.StatusCode, resp.Status)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetProject fetches project details including members, categories, and payment modes
|
||||
func (c *Client) GetProject(projectID string) (*Project, error) {
|
||||
path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s", url.PathEscape(projectID))
|
||||
|
||||
resp, err := c.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching project: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, 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 nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
if ocsResp.OCS.Meta.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message)
|
||||
}
|
||||
|
||||
var project Project
|
||||
if err := json.Unmarshal(ocsResp.OCS.Data, &project); err != nil {
|
||||
return nil, fmt.Errorf("decoding project data: %w", err)
|
||||
}
|
||||
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
// ProjectSummary represents a project in the list response
|
||||
type ProjectSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CurrName string `json:"currencyname"`
|
||||
ArchivedTS *int64 `json:"archived_ts"`
|
||||
}
|
||||
|
||||
// IsArchived returns true if the project is archived
|
||||
func (p *ProjectSummary) IsArchived() bool {
|
||||
return p.ArchivedTS != nil
|
||||
}
|
||||
|
||||
// GetProjects fetches all projects the user has access to
|
||||
func (c *Client) GetProjects() ([]ProjectSummary, error) {
|
||||
path := "/ocs/v2.php/apps/cospend/api/v1/projects"
|
||||
|
||||
resp, err := c.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching projects: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, 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 nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
if ocsResp.OCS.Meta.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message)
|
||||
}
|
||||
|
||||
c.debugf("Projects response: %s", string(ocsResp.OCS.Data))
|
||||
|
||||
var projects []ProjectSummary
|
||||
if err := json.Unmarshal(ocsResp.OCS.Data, &projects); err != nil {
|
||||
return nil, fmt.Errorf("decoding projects data: %w", err)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
// CreateBill creates a new bill in the project
|
||||
func (c *Client) CreateBill(projectID string, bill Bill) error {
|
||||
path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s/bills", url.PathEscape(projectID))
|
||||
|
||||
// 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))
|
||||
data.Set("repeat", "n")
|
||||
|
||||
// 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, ","))
|
||||
|
||||
if bill.Comment != "" {
|
||||
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))
|
||||
}
|
||||
if bill.OriginalCurrencyID != 0 {
|
||||
data.Set("original_currency_id", strconv.Itoa(bill.OriginalCurrencyID))
|
||||
}
|
||||
|
||||
c.debugf("Request body: %s", data.Encode())
|
||||
|
||||
resp, err := c.doRequest("POST", path, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating 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
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).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
|
||||
}
|
||||
|
||||
// GetBills fetches all bills for a project
|
||||
func (c *Client) GetBills(projectID string) ([]BillResponse, error) {
|
||||
path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s/bills", url.PathEscape(projectID))
|
||||
|
||||
resp, err := c.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching bills: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, 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 nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
if ocsResp.OCS.Meta.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message)
|
||||
}
|
||||
|
||||
// API returns: {"nb_bills": N, "bills": [...], "allBillIds": [...], "timestamp": N}
|
||||
var billsWrapper struct {
|
||||
Bills []BillResponse `json:"bills"`
|
||||
}
|
||||
if err := json.Unmarshal(ocsResp.OCS.Data, &billsWrapper); err != nil {
|
||||
return nil, fmt.Errorf("decoding bills data: %w", err)
|
||||
}
|
||||
|
||||
return billsWrapper.Bills, 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)
|
||||
|
||||
resp, err := c.doRequest("DELETE", path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting 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
|
||||
}
|
||||
436
internal/api/client_test.go
Normal file
436
internal/api/client_test.go
Normal file
@@ -0,0 +1,436 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/config"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Domain: "https://cloud.example.com",
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
client := NewClient(cfg)
|
||||
|
||||
if client == nil {
|
||||
t.Fatal("NewClient() returned nil")
|
||||
}
|
||||
if client.config != cfg {
|
||||
t.Error("NewClient() config not set correctly")
|
||||
}
|
||||
if client.httpClient == nil {
|
||||
t.Error("NewClient() httpClient is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProject(t *testing.T) {
|
||||
projectData := Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []Member{
|
||||
{ID: 1, Name: "Alice", UserID: "alice", Activated: true},
|
||||
{ID: 2, Name: "Bob", UserID: "bob", Activated: true},
|
||||
},
|
||||
Categories: []Category{
|
||||
{ID: 1, Name: "Food"},
|
||||
{ID: 2, Name: "Transport"},
|
||||
},
|
||||
PaymentModes: []PaymentMode{
|
||||
{ID: 1, Name: "Cash"},
|
||||
{ID: 2, Name: "Credit Card"},
|
||||
},
|
||||
Currencies: []Currency{
|
||||
{ID: 1, Name: "$", ExchangeRate: 1.0},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
projectID string
|
||||
responseStatus int
|
||||
responseBody any
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful request",
|
||||
projectID: "test-project",
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: OCSResponse{
|
||||
OCS: struct {
|
||||
Meta struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
} `json:"meta"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}{
|
||||
Meta: struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Status: "ok",
|
||||
StatusCode: 200,
|
||||
Message: "OK",
|
||||
},
|
||||
Data: mustMarshal(projectData),
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "project not found",
|
||||
projectID: "nonexistent",
|
||||
responseStatus: http.StatusNotFound,
|
||||
responseBody: "Not Found",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "api error",
|
||||
projectID: "test-project",
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: OCSResponse{
|
||||
OCS: struct {
|
||||
Meta struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
} `json:"meta"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}{
|
||||
Meta: struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Status: "failure",
|
||||
StatusCode: 404,
|
||||
Message: "Project not found",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request headers
|
||||
if r.Header.Get("OCS-APIRequest") != "true" {
|
||||
t.Error("Missing OCS-APIRequest header")
|
||||
}
|
||||
|
||||
// Verify Basic Auth
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
t.Error("Missing Basic Auth")
|
||||
}
|
||||
if user != "testuser" || pass != "testpass" {
|
||||
t.Errorf("Wrong credentials: %s:%s", user, pass)
|
||||
}
|
||||
|
||||
// Verify path
|
||||
expectedPath := "/ocs/v2.php/apps/cospend/api/v1/projects/" + tt.projectID
|
||||
if r.URL.Path != expectedPath {
|
||||
t.Errorf("Wrong path: got %s, want %s", r.URL.Path, expectedPath)
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.responseStatus)
|
||||
if s, ok := tt.responseBody.(string); ok {
|
||||
_, _ = w.Write([]byte(s))
|
||||
} else {
|
||||
_ = json.NewEncoder(w).Encode(tt.responseBody)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Domain: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
client := NewClient(cfg)
|
||||
|
||||
project, err := client.GetProject(tt.projectID)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GetProject() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && project != nil {
|
||||
if project.ID != projectData.ID {
|
||||
t.Errorf("GetProject() ID = %v, want %v", project.ID, projectData.ID)
|
||||
}
|
||||
if len(project.Members) != len(projectData.Members) {
|
||||
t.Errorf("GetProject() Members count = %v, want %v", len(project.Members), len(projectData.Members))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBill(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bill Bill
|
||||
responseStatus int
|
||||
responseBody any
|
||||
wantErr bool
|
||||
checkRequest func(t *testing.T, r *http.Request)
|
||||
}{
|
||||
{
|
||||
name: "successful creation",
|
||||
bill: Bill{
|
||||
What: "Test expense",
|
||||
Amount: 25.50,
|
||||
PayerID: 1,
|
||||
OwedTo: []int{1, 2},
|
||||
Date: "2024-01-15",
|
||||
Comment: "Test comment",
|
||||
PaymentModeID: 1,
|
||||
CategoryID: 2,
|
||||
},
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: OCSResponse{
|
||||
OCS: struct {
|
||||
Meta struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
} `json:"meta"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}{
|
||||
Meta: struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Status: "ok",
|
||||
StatusCode: 200,
|
||||
Message: "OK",
|
||||
},
|
||||
Data: mustMarshal(map[string]int{"id": 123}),
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
checkRequest: func(t *testing.T, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Wrong method: got %s, want POST", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("Wrong Content-Type: %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
_ = r.ParseForm()
|
||||
if r.FormValue("what") != "Test expense" {
|
||||
t.Errorf("Wrong what: %s", r.FormValue("what"))
|
||||
}
|
||||
if r.FormValue("amount") != "25.50" {
|
||||
t.Errorf("Wrong amount: %s", r.FormValue("amount"))
|
||||
}
|
||||
if r.FormValue("payer") != "1" {
|
||||
t.Errorf("Wrong payer: %s", r.FormValue("payer"))
|
||||
}
|
||||
if r.FormValue("payedFor") != "1,2" {
|
||||
t.Errorf("Wrong payedFor: %s", r.FormValue("payedFor"))
|
||||
}
|
||||
if r.FormValue("comment") != "Test comment" {
|
||||
t.Errorf("Wrong comment: %s", r.FormValue("comment"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal bill",
|
||||
bill: Bill{
|
||||
What: "Simple expense",
|
||||
Amount: 10.00,
|
||||
PayerID: 1,
|
||||
OwedTo: []int{1},
|
||||
Date: "2024-01-15",
|
||||
},
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: OCSResponse{
|
||||
OCS: struct {
|
||||
Meta struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
} `json:"meta"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}{
|
||||
Meta: struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Status: "ok",
|
||||
StatusCode: 200,
|
||||
Message: "OK",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
checkRequest: func(t *testing.T, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
// Optional fields should be empty
|
||||
if r.FormValue("comment") != "" {
|
||||
t.Errorf("Comment should be empty: %s", r.FormValue("comment"))
|
||||
}
|
||||
if r.FormValue("paymentmodeid") != "" {
|
||||
t.Errorf("paymentmodeid should be empty: %s", r.FormValue("paymentmodeid"))
|
||||
}
|
||||
if r.FormValue("categoryid") != "" {
|
||||
t.Errorf("categoryid should be empty: %s", r.FormValue("categoryid"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
bill: Bill{
|
||||
What: "Test",
|
||||
Amount: 10.00,
|
||||
PayerID: 1,
|
||||
OwedTo: []int{1},
|
||||
Date: "2024-01-15",
|
||||
},
|
||||
responseStatus: http.StatusInternalServerError,
|
||||
responseBody: "Internal Server Error",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "api error response",
|
||||
bill: Bill{
|
||||
What: "Test",
|
||||
Amount: 10.00,
|
||||
PayerID: 1,
|
||||
OwedTo: []int{1},
|
||||
Date: "2024-01-15",
|
||||
},
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: OCSResponse{
|
||||
OCS: struct {
|
||||
Meta struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
} `json:"meta"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}{
|
||||
Meta: struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Status: "failure",
|
||||
StatusCode: 400,
|
||||
Message: "Invalid bill data",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify common headers
|
||||
if r.Header.Get("OCS-APIRequest") != "true" {
|
||||
t.Error("Missing OCS-APIRequest header")
|
||||
}
|
||||
|
||||
if tt.checkRequest != nil {
|
||||
tt.checkRequest(t, r)
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.responseStatus)
|
||||
if s, ok := tt.responseBody.(string); ok {
|
||||
_, _ = w.Write([]byte(s))
|
||||
} else {
|
||||
_ = json.NewEncoder(w).Encode(tt.responseBody)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Domain: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
client := NewClient(cfg)
|
||||
|
||||
err := client.CreateBill("test-project", tt.bill)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CreateBill() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBillWithCurrency(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.FormValue("original_currency_id") != "5" {
|
||||
t.Errorf("Wrong original_currency_id: %s", r.FormValue("original_currency_id"))
|
||||
}
|
||||
|
||||
response := OCSResponse{
|
||||
OCS: struct {
|
||||
Meta struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
} `json:"meta"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}{
|
||||
Meta: struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Status: "ok",
|
||||
StatusCode: 200,
|
||||
Message: "OK",
|
||||
},
|
||||
},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Domain: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
client := NewClient(cfg)
|
||||
|
||||
bill := Bill{
|
||||
What: "Currency test",
|
||||
Amount: 100.00,
|
||||
PayerID: 1,
|
||||
OwedTo: []int{1},
|
||||
Date: "2024-01-15",
|
||||
OriginalCurrencyID: 5,
|
||||
}
|
||||
|
||||
err := client.CreateBill("test-project", bill)
|
||||
if err != nil {
|
||||
t.Errorf("CreateBill() unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshal(v any) json.RawMessage {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
293
internal/cache/cache.go
vendored
Normal file
293
internal/cache/cache.go
vendored
Normal file
@@ -0,0 +1,293 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/chenasraf/cospend-cli/internal/api"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheTTL = 1 * time.Hour
|
||||
appName = "cospend"
|
||||
)
|
||||
|
||||
// currencyCodeToSymbol maps currency codes to their symbols
|
||||
var currencyCodeToSymbol = map[string]string{
|
||||
"aed": "د.إ",
|
||||
"afn": "؋",
|
||||
"all": "Lek",
|
||||
"amd": "դր.",
|
||||
"ars": "$",
|
||||
"aud": "$",
|
||||
"azn": "ман.",
|
||||
"bam": "KM",
|
||||
"bdt": "৳",
|
||||
"bgn": "лв.",
|
||||
"bhd": "د.ب.",
|
||||
"bif": "FBu",
|
||||
"bnd": "$",
|
||||
"bob": "Bs",
|
||||
"brl": "R$",
|
||||
"bwp": "P",
|
||||
"byn": "руб.",
|
||||
"bzd": "$",
|
||||
"cad": "$",
|
||||
"cdf": "FrCD",
|
||||
"chf": "CHF",
|
||||
"clp": "$",
|
||||
"cny": "¥",
|
||||
"cop": "$",
|
||||
"crc": "₡",
|
||||
"cup": "$",
|
||||
"cve": "CV$",
|
||||
"czk": "Kč",
|
||||
"djf": "Fdj",
|
||||
"dkk": "kr",
|
||||
"dop": "RD$",
|
||||
"dzd": "د.ج.",
|
||||
"egp": "ج.م.",
|
||||
"etb": "Br",
|
||||
"eur": "€",
|
||||
"gbp": "£",
|
||||
"gel": "GEL",
|
||||
"ghs": "GH₵",
|
||||
"gnf": "FG",
|
||||
"gtq": "Q",
|
||||
"hkd": "$",
|
||||
"hnl": "L",
|
||||
"huf": "Ft",
|
||||
"idr": "Rp",
|
||||
"ils": "₪",
|
||||
"inr": "₹",
|
||||
"iqd": "د.ع.",
|
||||
"irr": "﷼",
|
||||
"isk": "kr",
|
||||
"jmd": "$",
|
||||
"jod": "د.أ.",
|
||||
"jpy": "¥",
|
||||
"kes": "Ksh",
|
||||
"khr": "៛",
|
||||
"kmf": "FC",
|
||||
"krw": "₩",
|
||||
"kwd": "د.ك.",
|
||||
"kzt": "тңг.",
|
||||
"lbp": "ل.ل.",
|
||||
"lkr": "Rs",
|
||||
"lyd": "د.ل.",
|
||||
"mad": "د.م.",
|
||||
"mdl": "MDL",
|
||||
"mga": "MGA",
|
||||
"mkd": "MKD",
|
||||
"mmk": "K",
|
||||
"mop": "MOP$",
|
||||
"mur": "MURs",
|
||||
"mxn": "$",
|
||||
"myr": "RM",
|
||||
"mzn": "MTn",
|
||||
"nad": "N$",
|
||||
"ngn": "₦",
|
||||
"nio": "C$",
|
||||
"nok": "kr",
|
||||
"npr": "Rs",
|
||||
"nzd": "$",
|
||||
"omr": "ر.ع.",
|
||||
"pab": "B/.",
|
||||
"pen": "S/.",
|
||||
"php": "₱",
|
||||
"pkr": "₨",
|
||||
"pln": "zł",
|
||||
"pyg": "₲",
|
||||
"qar": "ر.ق.",
|
||||
"ron": "RON",
|
||||
"rsd": "дин.",
|
||||
"rub": "₽",
|
||||
"rwf": "FR",
|
||||
"sar": "﷼",
|
||||
"sdg": "SDG",
|
||||
"sek": "kr",
|
||||
"sgd": "$",
|
||||
"sos": "Ssh",
|
||||
"thb": "฿",
|
||||
"tnd": "د.ت.",
|
||||
"top": "T$",
|
||||
"try": "₺",
|
||||
"ttd": "$",
|
||||
"twd": "NT$",
|
||||
"tzs": "TSh",
|
||||
"uah": "₴",
|
||||
"ugx": "USh",
|
||||
"usd": "$",
|
||||
"uyu": "$",
|
||||
"uzs": "UZS",
|
||||
"vnd": "₫",
|
||||
"xaf": "FCFA",
|
||||
"xcd": "EC$",
|
||||
"xof": "CFA",
|
||||
"yer": "ر.ي.",
|
||||
"zar": "R",
|
||||
}
|
||||
|
||||
// CachedProject stores project data with timestamp
|
||||
type CachedProject struct {
|
||||
Project *api.Project `json:"project"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
}
|
||||
|
||||
// getCacheHome returns the cache home directory, checking XDG_CACHE_HOME env var first
|
||||
func getCacheHome() string {
|
||||
if dir := os.Getenv("XDG_CACHE_HOME"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return xdg.CacheHome
|
||||
}
|
||||
|
||||
// getCachePath returns the cache file path for a project
|
||||
func getCachePath(projectID string) (string, error) {
|
||||
cacheDir := filepath.Join(getCacheHome(), appName)
|
||||
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("creating cache directory: %w", err)
|
||||
}
|
||||
return filepath.Join(cacheDir, fmt.Sprintf("%s.json", projectID)), nil
|
||||
}
|
||||
|
||||
// Load retrieves cached project data if it exists and is not expired
|
||||
func Load(projectID string) (*api.Project, bool) {
|
||||
path, err := getCachePath(projectID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var cached CachedProject
|
||||
if err := json.Unmarshal(data, &cached); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if cache is expired
|
||||
if time.Since(cached.CachedAt) > cacheTTL {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return cached.Project, true
|
||||
}
|
||||
|
||||
// Save stores project data in the cache
|
||||
func Save(projectID string, project *api.Project) error {
|
||||
path, err := getCachePath(projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cached := CachedProject{
|
||||
Project: project,
|
||||
CachedAt: time.Now(),
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cached, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling cache data: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("writing cache file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveMember finds a member by username (case-insensitive) and returns their ID
|
||||
func ResolveMember(project *api.Project, username string) (int, error) {
|
||||
lowerUsername := strings.ToLower(username)
|
||||
for _, m := range project.Members {
|
||||
if strings.ToLower(m.Name) == lowerUsername || strings.ToLower(m.UserID) == lowerUsername {
|
||||
return m.ID, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("member not found: %s", username)
|
||||
}
|
||||
|
||||
// ResolveCategory finds a category by name (case-insensitive) or ID and returns the ID
|
||||
func ResolveCategory(project *api.Project, nameOrID string) (int, error) {
|
||||
// Try parsing as ID first
|
||||
if id, err := strconv.Atoi(nameOrID); err == nil {
|
||||
for _, c := range project.Categories {
|
||||
if c.ID == id {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try matching by name (case-insensitive)
|
||||
lowerName := strings.ToLower(nameOrID)
|
||||
for _, c := range project.Categories {
|
||||
if strings.ToLower(c.Name) == lowerName {
|
||||
return c.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("category not found: %s", nameOrID)
|
||||
}
|
||||
|
||||
// ResolvePaymentMode finds a payment mode by name (case-insensitive) or ID and returns the ID
|
||||
func ResolvePaymentMode(project *api.Project, nameOrID string) (int, error) {
|
||||
// Try parsing as ID first
|
||||
if id, err := strconv.Atoi(nameOrID); err == nil {
|
||||
for _, pm := range project.PaymentModes {
|
||||
if pm.ID == id {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try matching by name (case-insensitive)
|
||||
lowerName := strings.ToLower(nameOrID)
|
||||
for _, pm := range project.PaymentModes {
|
||||
if strings.ToLower(pm.Name) == lowerName {
|
||||
return pm.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("payment mode not found: %s", nameOrID)
|
||||
}
|
||||
|
||||
// ResolveCurrency finds a currency by name (case-insensitive), ID, or currency code symbol and returns the ID
|
||||
func ResolveCurrency(project *api.Project, nameOrID string) (int, error) {
|
||||
// Try parsing as ID first
|
||||
if id, err := strconv.Atoi(nameOrID); err == nil {
|
||||
for _, cur := range project.Currencies {
|
||||
if cur.ID == id {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try matching by name (case-insensitive)
|
||||
lowerName := strings.ToLower(nameOrID)
|
||||
for _, cur := range project.Currencies {
|
||||
if strings.ToLower(cur.Name) == lowerName {
|
||||
return cur.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try matching by currency code symbol (e.g., "usd" -> "$")
|
||||
if symbol, ok := currencyCodeToSymbol[lowerName]; ok {
|
||||
for _, cur := range project.Currencies {
|
||||
if strings.Contains(cur.Name, symbol) {
|
||||
return cur.ID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("currency not found: %s", nameOrID)
|
||||
}
|
||||
292
internal/cache/cache_test.go
vendored
Normal file
292
internal/cache/cache_test.go
vendored
Normal file
@@ -0,0 +1,292 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/api"
|
||||
)
|
||||
|
||||
func TestResolveMember(t *testing.T) {
|
||||
project := &api.Project{
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "Alice", UserID: "alice"},
|
||||
{ID: 2, Name: "Bob", UserID: "bob"},
|
||||
{ID: 3, Name: "Charlie", UserID: "charlie123"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
wantID int
|
||||
wantErr bool
|
||||
}{
|
||||
{"by name exact", "Alice", 1, false},
|
||||
{"by name lowercase", "alice", 1, false},
|
||||
{"by name uppercase", "ALICE", 1, false},
|
||||
{"by name mixed case", "aLiCe", 1, false},
|
||||
{"by userid", "bob", 2, false},
|
||||
{"by userid different from name", "charlie123", 3, false},
|
||||
{"by name when userid differs", "Charlie", 3, false},
|
||||
{"not found", "unknown", 0, true},
|
||||
{"empty string", "", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotID, err := ResolveMember(project, tt.username)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ResolveMember() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if gotID != tt.wantID {
|
||||
t.Errorf("ResolveMember() = %v, want %v", gotID, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCategory(t *testing.T) {
|
||||
project := &api.Project{
|
||||
Categories: []api.Category{
|
||||
{ID: 1, Name: "Groceries"},
|
||||
{ID: 2, Name: "Restaurant"},
|
||||
{ID: 10, Name: "Transport"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
nameOrID string
|
||||
wantID int
|
||||
wantErr bool
|
||||
}{
|
||||
{"by id", "1", 1, false},
|
||||
{"by id second", "2", 2, false},
|
||||
{"by id double digit", "10", 10, false},
|
||||
{"by name exact", "Groceries", 1, false},
|
||||
{"by name lowercase", "groceries", 1, false},
|
||||
{"by name uppercase", "RESTAURANT", 2, false},
|
||||
{"by name mixed case", "tRaNsPoRt", 10, false},
|
||||
{"id not found", "99", 0, true},
|
||||
{"name not found", "Unknown", 0, true},
|
||||
{"empty string", "", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotID, err := ResolveCategory(project, tt.nameOrID)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ResolveCategory() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if gotID != tt.wantID {
|
||||
t.Errorf("ResolveCategory() = %v, want %v", gotID, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaymentMode(t *testing.T) {
|
||||
project := &api.Project{
|
||||
PaymentModes: []api.PaymentMode{
|
||||
{ID: 1, Name: "Cash"},
|
||||
{ID: 2, Name: "Credit Card"},
|
||||
{ID: 3, Name: "Bank Transfer"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
nameOrID string
|
||||
wantID int
|
||||
wantErr bool
|
||||
}{
|
||||
{"by id", "1", 1, false},
|
||||
{"by id second", "2", 2, false},
|
||||
{"by name exact", "Cash", 1, false},
|
||||
{"by name lowercase", "cash", 1, false},
|
||||
{"by name with space", "Credit Card", 2, false},
|
||||
{"by name with space lowercase", "credit card", 2, false},
|
||||
{"by name uppercase", "BANK TRANSFER", 3, false},
|
||||
{"id not found", "99", 0, true},
|
||||
{"name not found", "Bitcoin", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotID, err := ResolvePaymentMode(project, tt.nameOrID)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ResolvePaymentMode() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if gotID != tt.wantID {
|
||||
t.Errorf("ResolvePaymentMode() = %v, want %v", gotID, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrency(t *testing.T) {
|
||||
project := &api.Project{
|
||||
Currencies: []api.Currency{
|
||||
{ID: 1, Name: "$", ExchangeRate: 1.0},
|
||||
{ID: 2, Name: "€", ExchangeRate: 0.85},
|
||||
{ID: 3, Name: "£", ExchangeRate: 0.73},
|
||||
{ID: 4, Name: "US Dollar ($)", ExchangeRate: 1.0},
|
||||
{ID: 5, Name: "Japanese Yen (¥)", ExchangeRate: 110.0},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
nameOrID string
|
||||
wantID int
|
||||
wantErr bool
|
||||
}{
|
||||
{"by id", "1", 1, false},
|
||||
{"by id second", "2", 2, false},
|
||||
{"by name exact symbol", "$", 1, false},
|
||||
{"by name exact euro", "€", 2, false},
|
||||
{"by name with description", "US Dollar ($)", 4, false},
|
||||
{"by currency code usd", "usd", 1, false},
|
||||
{"by currency code USD uppercase", "USD", 1, false},
|
||||
{"by currency code eur", "eur", 2, false},
|
||||
{"by currency code gbp", "gbp", 3, false},
|
||||
{"by currency code jpy", "jpy", 5, false},
|
||||
{"id not found", "99", 0, true},
|
||||
{"name not found", "Bitcoin", 0, true},
|
||||
{"unknown currency code", "xyz", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotID, err := ResolveCurrency(project, tt.nameOrID)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ResolveCurrency() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if gotID != tt.wantID {
|
||||
t.Errorf("ResolveCurrency() = %v, want %v", gotID, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyCodeToSymbolMapping(t *testing.T) {
|
||||
// Test that common currency codes are mapped
|
||||
expectedMappings := map[string]string{
|
||||
"usd": "$",
|
||||
"eur": "€",
|
||||
"gbp": "£",
|
||||
"jpy": "¥",
|
||||
"cny": "¥",
|
||||
"inr": "₹",
|
||||
"krw": "₩",
|
||||
"brl": "R$",
|
||||
}
|
||||
|
||||
for code, expectedSymbol := range expectedMappings {
|
||||
if symbol, ok := currencyCodeToSymbol[code]; !ok {
|
||||
t.Errorf("Currency code %q not found in mapping", code)
|
||||
} else if symbol != expectedSymbol {
|
||||
t.Errorf("Currency code %q maps to %q, want %q", code, symbol, expectedSymbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoad(t *testing.T) {
|
||||
// Use a temp directory for testing
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CACHE_HOME", tempDir)
|
||||
|
||||
project := &api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "Alice", UserID: "alice"},
|
||||
},
|
||||
Categories: []api.Category{
|
||||
{ID: 1, Name: "Food"},
|
||||
},
|
||||
PaymentModes: []api.PaymentMode{
|
||||
{ID: 1, Name: "Cash"},
|
||||
},
|
||||
Currencies: []api.Currency{
|
||||
{ID: 1, Name: "$", ExchangeRate: 1.0},
|
||||
},
|
||||
}
|
||||
|
||||
// Test Save
|
||||
err := Save("test-project", project)
|
||||
if err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
cachePath := filepath.Join(tempDir, "cospend", "test-project.json")
|
||||
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
|
||||
t.Errorf("Cache file not created at %s", cachePath)
|
||||
}
|
||||
|
||||
// Test Load
|
||||
loaded, ok := Load("test-project")
|
||||
if !ok {
|
||||
t.Fatal("Load() returned false, expected true")
|
||||
}
|
||||
|
||||
if loaded.ID != project.ID {
|
||||
t.Errorf("Load() ID = %v, want %v", loaded.ID, project.ID)
|
||||
}
|
||||
if loaded.Name != project.Name {
|
||||
t.Errorf("Load() Name = %v, want %v", loaded.Name, project.Name)
|
||||
}
|
||||
if len(loaded.Members) != len(project.Members) {
|
||||
t.Errorf("Load() Members count = %v, want %v", len(loaded.Members), len(project.Members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNonExistent(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CACHE_HOME", tempDir)
|
||||
|
||||
_, ok := Load("non-existent-project")
|
||||
if ok {
|
||||
t.Error("Load() returned true for non-existent project, expected false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExpired(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CACHE_HOME", tempDir)
|
||||
|
||||
project := &api.Project{
|
||||
ID: "expired-project",
|
||||
Name: "Expired Project",
|
||||
}
|
||||
|
||||
// Save the project
|
||||
err := Save("expired-project", project)
|
||||
if err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
// Modify the cache file to have an old timestamp
|
||||
cachePath := filepath.Join(tempDir, "cospend", "expired-project.json")
|
||||
oldTime := time.Now().Add(-2 * time.Hour) // 2 hours ago, TTL is 1 hour
|
||||
_ = os.Chtimes(cachePath, oldTime, oldTime)
|
||||
|
||||
// Manually update the cached_at field in the file
|
||||
// Replace the timestamp in the JSON (crude but works for testing)
|
||||
oldTimestamp := time.Now().Add(-2 * time.Hour).Format(time.RFC3339Nano)
|
||||
newData := []byte(`{"project":{"id":"expired-project","name":"Expired Project","members":null,"categories":null,"paymentmodes":null,"currencies":null},"cached_at":"` + oldTimestamp + `"}`)
|
||||
_ = os.WriteFile(cachePath, newData, 0644)
|
||||
|
||||
_, ok := Load("expired-project")
|
||||
if ok {
|
||||
t.Error("Load() returned true for expired cache, expected false")
|
||||
}
|
||||
}
|
||||
184
internal/config/config.go
Normal file
184
internal/config/config.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/adrg/xdg"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const appName = "cospend"
|
||||
|
||||
// Config holds the Nextcloud configuration
|
||||
type Config struct {
|
||||
Domain string `json:"domain" yaml:"domain" toml:"domain"`
|
||||
User string `json:"user" yaml:"user" toml:"user"`
|
||||
Password string `json:"password" yaml:"password" toml:"password"`
|
||||
}
|
||||
|
||||
// configExtensions lists supported config file extensions in order of preference
|
||||
var configExtensions = []string{".json", ".yaml", ".yml", ".toml"}
|
||||
|
||||
// GetConfigDir returns the primary config directory path (used for saving)
|
||||
func GetConfigDir() string {
|
||||
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
||||
return filepath.Join(dir, appName)
|
||||
}
|
||||
return filepath.Join(xdg.ConfigHome, appName)
|
||||
}
|
||||
|
||||
// getConfigDirs returns all config directories to search, in order of preference
|
||||
func getConfigDirs() []string {
|
||||
dirs := []string{GetConfigDir()}
|
||||
|
||||
// Also check ~/.config/cospend/ as fallback (even on macOS)
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
dotConfigDir := filepath.Join(home, ".config", appName)
|
||||
// Only add if it's different from the primary dir
|
||||
if dotConfigDir != dirs[0] {
|
||||
dirs = append(dirs, dotConfigDir)
|
||||
}
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
// GetConfigPath returns the path to an existing config file, or empty string if none found
|
||||
func GetConfigPath() string {
|
||||
for _, configDir := range getConfigDirs() {
|
||||
for _, ext := range configExtensions {
|
||||
path := filepath.Join(configDir, appName+ext)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// LoadFromFile reads configuration from a config file
|
||||
func LoadFromFile(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config file: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
ext := filepath.Ext(path)
|
||||
|
||||
switch ext {
|
||||
case ".json":
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing JSON config: %w", err)
|
||||
}
|
||||
case ".yaml", ".yml":
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing YAML config: %w", err)
|
||||
}
|
||||
case ".toml":
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing TOML config: %w", err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported config format: %s", ext)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Load reads configuration with the following precedence:
|
||||
// 1. Environment variables (override config file)
|
||||
// 2. Config file
|
||||
func Load() (*Config, error) {
|
||||
var cfg Config
|
||||
|
||||
// Try to load from config file first
|
||||
if configPath := GetConfigPath(); configPath != "" {
|
||||
fileCfg, err := LoadFromFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg = *fileCfg
|
||||
}
|
||||
|
||||
// Environment variables override config file values
|
||||
if domain := os.Getenv("NEXTCLOUD_DOMAIN"); domain != "" {
|
||||
cfg.Domain = domain
|
||||
}
|
||||
if user := os.Getenv("NEXTCLOUD_USER"); user != "" {
|
||||
cfg.User = user
|
||||
}
|
||||
if password := os.Getenv("NEXTCLOUD_PASSWORD"); password != "" {
|
||||
cfg.Password = password
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if cfg.Domain == "" {
|
||||
return nil, errors.New("domain is required (set in config file or NEXTCLOUD_DOMAIN env var)")
|
||||
}
|
||||
if cfg.User == "" {
|
||||
return nil, errors.New("user is required (set in config file or NEXTCLOUD_USER env var)")
|
||||
}
|
||||
if cfg.Password == "" {
|
||||
return nil, errors.New("password is required (set in config file or NEXTCLOUD_PASSWORD env var)")
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Save writes configuration to a file in the specified format
|
||||
func Save(cfg *Config, format string) (string, error) {
|
||||
configDir := GetConfigDir()
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
return "", fmt.Errorf("creating config directory: %w", err)
|
||||
}
|
||||
|
||||
var data []byte
|
||||
var ext string
|
||||
var err error
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
ext = ".json"
|
||||
data, err = json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encoding JSON: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
case "yaml", "yml":
|
||||
ext = ".yaml"
|
||||
data, err = yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encoding YAML: %w", err)
|
||||
}
|
||||
case "toml":
|
||||
ext = ".toml"
|
||||
data, err = tomlMarshal(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encoding TOML: %w", err)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported format: %s", format)
|
||||
}
|
||||
|
||||
path := filepath.Join(configDir, appName+ext)
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return "", fmt.Errorf("writing config file: %w", err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// tomlMarshal encodes config to TOML format
|
||||
func tomlMarshal(cfg *Config) ([]byte, error) {
|
||||
content := fmt.Sprintf(`domain = %q
|
||||
user = %q
|
||||
password = %q
|
||||
`, cfg.Domain, cfg.User, cfg.Password)
|
||||
return []byte(content), nil
|
||||
}
|
||||
423
internal/config/config_test.go
Normal file
423
internal/config/config_test.go
Normal file
@@ -0,0 +1,423 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadFromEnvVars(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", "https://cloud.example.com")
|
||||
t.Setenv("NEXTCLOUD_USER", "testuser")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "testpass")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Domain != "https://cloud.example.com" {
|
||||
t.Errorf("Domain = %v, want %v", cfg.Domain, "https://cloud.example.com")
|
||||
}
|
||||
if cfg.User != "testuser" {
|
||||
t.Errorf("User = %v, want %v", cfg.User, "testuser")
|
||||
}
|
||||
if cfg.Password != "testpass" {
|
||||
t.Errorf("Password = %v, want %v", cfg.Password, "testpass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromJSONFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", "")
|
||||
t.Setenv("NEXTCLOUD_USER", "")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "")
|
||||
|
||||
// Create config directory and file
|
||||
configDir := filepath.Join(tempDir, "cospend")
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
configContent := `{
|
||||
"domain": "https://json.example.com",
|
||||
"user": "jsonuser",
|
||||
"password": "jsonpass"
|
||||
}`
|
||||
configPath := filepath.Join(configDir, "cospend.json")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Domain != "https://json.example.com" {
|
||||
t.Errorf("Domain = %v, want %v", cfg.Domain, "https://json.example.com")
|
||||
}
|
||||
if cfg.User != "jsonuser" {
|
||||
t.Errorf("User = %v, want %v", cfg.User, "jsonuser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromYAMLFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", "")
|
||||
t.Setenv("NEXTCLOUD_USER", "")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "")
|
||||
|
||||
configDir := filepath.Join(tempDir, "cospend")
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
configContent := `domain: https://yaml.example.com
|
||||
user: yamluser
|
||||
password: yamlpass
|
||||
`
|
||||
configPath := filepath.Join(configDir, "cospend.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Domain != "https://yaml.example.com" {
|
||||
t.Errorf("Domain = %v, want %v", cfg.Domain, "https://yaml.example.com")
|
||||
}
|
||||
if cfg.User != "yamluser" {
|
||||
t.Errorf("User = %v, want %v", cfg.User, "yamluser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromTOMLFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", "")
|
||||
t.Setenv("NEXTCLOUD_USER", "")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "")
|
||||
|
||||
configDir := filepath.Join(tempDir, "cospend")
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
configContent := `domain = "https://toml.example.com"
|
||||
user = "tomluser"
|
||||
password = "tomlpass"
|
||||
`
|
||||
configPath := filepath.Join(configDir, "cospend.toml")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Domain != "https://toml.example.com" {
|
||||
t.Errorf("Domain = %v, want %v", cfg.Domain, "https://toml.example.com")
|
||||
}
|
||||
if cfg.User != "tomluser" {
|
||||
t.Errorf("User = %v, want %v", cfg.User, "tomluser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvVarsOverrideConfigFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", "https://env.example.com")
|
||||
t.Setenv("NEXTCLOUD_USER", "")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "")
|
||||
|
||||
configDir := filepath.Join(tempDir, "cospend")
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
configContent := `{
|
||||
"domain": "https://file.example.com",
|
||||
"user": "fileuser",
|
||||
"password": "filepass"
|
||||
}`
|
||||
configPath := filepath.Join(configDir, "cospend.json")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
// Domain should come from env var
|
||||
if cfg.Domain != "https://env.example.com" {
|
||||
t.Errorf("Domain = %v, want %v", cfg.Domain, "https://env.example.com")
|
||||
}
|
||||
// User/Password should come from file
|
||||
if cfg.User != "fileuser" {
|
||||
t.Errorf("User = %v, want %v", cfg.User, "fileuser")
|
||||
}
|
||||
if cfg.Password != "filepass" {
|
||||
t.Errorf("Password = %v, want %v", cfg.Password, "filepass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingRequired(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("HOME", tempDir) // Isolate from real home
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", "")
|
||||
t.Setenv("NEXTCLOUD_USER", "")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Error("Load() expected error for missing required fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveJSON(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
|
||||
cfg := &Config{
|
||||
Domain: "https://test.example.com",
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
path, err := Save(cfg, "json")
|
||||
if err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
expectedPath := filepath.Join(tempDir, "cospend", "cospend.json")
|
||||
if path != expectedPath {
|
||||
t.Errorf("Save() path = %v, want %v", path, expectedPath)
|
||||
}
|
||||
|
||||
// Verify file contents
|
||||
loaded, err := LoadFromFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromFile() error = %v", err)
|
||||
}
|
||||
if loaded.Domain != cfg.Domain {
|
||||
t.Errorf("Domain = %v, want %v", loaded.Domain, cfg.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveYAML(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
|
||||
cfg := &Config{
|
||||
Domain: "https://test.example.com",
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
path, err := Save(cfg, "yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
expectedPath := filepath.Join(tempDir, "cospend", "cospend.yaml")
|
||||
if path != expectedPath {
|
||||
t.Errorf("Save() path = %v, want %v", path, expectedPath)
|
||||
}
|
||||
|
||||
loaded, err := LoadFromFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromFile() error = %v", err)
|
||||
}
|
||||
if loaded.Domain != cfg.Domain {
|
||||
t.Errorf("Domain = %v, want %v", loaded.Domain, cfg.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveTOML(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
|
||||
cfg := &Config{
|
||||
Domain: "https://test.example.com",
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
path, err := Save(cfg, "toml")
|
||||
if err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
expectedPath := filepath.Join(tempDir, "cospend", "cospend.toml")
|
||||
if path != expectedPath {
|
||||
t.Errorf("Save() path = %v, want %v", path, expectedPath)
|
||||
}
|
||||
|
||||
loaded, err := LoadFromFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromFile() error = %v", err)
|
||||
}
|
||||
if loaded.Domain != cfg.Domain {
|
||||
t.Errorf("Domain = %v, want %v", loaded.Domain, cfg.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigPath(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("HOME", tempDir) // Isolate from real home
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
|
||||
// No config file exists
|
||||
if path := GetConfigPath(); path != "" {
|
||||
t.Errorf("GetConfigPath() = %v, want empty string", path)
|
||||
}
|
||||
|
||||
// Create JSON config
|
||||
configDir := filepath.Join(tempDir, "cospend")
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
jsonPath := filepath.Join(configDir, "cospend.json")
|
||||
if err := os.WriteFile(jsonPath, []byte("{}"), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
if path := GetConfigPath(); path != jsonPath {
|
||||
t.Errorf("GetConfigPath() = %v, want %v", path, jsonPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFilePrecedence(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", "")
|
||||
t.Setenv("NEXTCLOUD_USER", "")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "")
|
||||
|
||||
configDir := filepath.Join(tempDir, "cospend")
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
// Create both JSON and YAML - JSON should take precedence
|
||||
jsonContent := `{"domain": "https://json.example.com", "user": "jsonuser", "password": "jsonpass"}`
|
||||
yamlContent := `domain: https://yaml.example.com
|
||||
user: yamluser
|
||||
password: yamlpass`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(configDir, "cospend.json"), []byte(jsonContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write JSON config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "cospend.yaml"), []byte(yamlContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write YAML config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
// JSON should take precedence
|
||||
if cfg.Domain != "https://json.example.com" {
|
||||
t.Errorf("Domain = %v, want %v (JSON should take precedence)", cfg.Domain, "https://json.example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackToDotConfig(t *testing.T) {
|
||||
// Create a temp dir to act as HOME
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("HOME", tempHome)
|
||||
// Set XDG_CONFIG_HOME to a different location (simulating macOS default behavior)
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", "")
|
||||
t.Setenv("NEXTCLOUD_USER", "")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "")
|
||||
|
||||
// Create config in ~/.config/cospend/ (fallback location)
|
||||
dotConfigDir := filepath.Join(tempHome, ".config", "cospend")
|
||||
if err := os.MkdirAll(dotConfigDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create .config dir: %v", err)
|
||||
}
|
||||
|
||||
configContent := `{"domain": "https://dotconfig.example.com", "user": "dotconfiguser", "password": "dotconfigpass"}`
|
||||
configPath := filepath.Join(dotConfigDir, "cospend.json")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
// Should find config in fallback ~/.config/cospend/
|
||||
foundPath := GetConfigPath()
|
||||
if foundPath != configPath {
|
||||
t.Errorf("GetConfigPath() = %v, want %v", foundPath, configPath)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Domain != "https://dotconfig.example.com" {
|
||||
t.Errorf("Domain = %v, want %v", cfg.Domain, "https://dotconfig.example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXDGTakesPrecedenceOverDotConfig(t *testing.T) {
|
||||
// Create a temp dir to act as HOME
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("HOME", tempHome)
|
||||
// Set XDG_CONFIG_HOME
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
t.Setenv("NEXTCLOUD_DOMAIN", "")
|
||||
t.Setenv("NEXTCLOUD_USER", "")
|
||||
t.Setenv("NEXTCLOUD_PASSWORD", "")
|
||||
|
||||
// Create config in both locations
|
||||
xdgConfigDir := filepath.Join(xdgDir, "cospend")
|
||||
if err := os.MkdirAll(xdgConfigDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create XDG config dir: %v", err)
|
||||
}
|
||||
xdgContent := `{"domain": "https://xdg.example.com", "user": "xdguser", "password": "xdgpass"}`
|
||||
xdgPath := filepath.Join(xdgConfigDir, "cospend.json")
|
||||
if err := os.WriteFile(xdgPath, []byte(xdgContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write XDG config file: %v", err)
|
||||
}
|
||||
|
||||
dotConfigDir := filepath.Join(tempHome, ".config", "cospend")
|
||||
if err := os.MkdirAll(dotConfigDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create .config dir: %v", err)
|
||||
}
|
||||
dotContent := `{"domain": "https://dotconfig.example.com", "user": "dotconfiguser", "password": "dotconfigpass"}`
|
||||
if err := os.WriteFile(filepath.Join(dotConfigDir, "cospend.json"), []byte(dotContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write .config file: %v", err)
|
||||
}
|
||||
|
||||
// XDG should take precedence
|
||||
foundPath := GetConfigPath()
|
||||
if foundPath != xdgPath {
|
||||
t.Errorf("GetConfigPath() = %v, want %v (XDG should take precedence)", foundPath, xdgPath)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Domain != "https://xdg.example.com" {
|
||||
t.Errorf("Domain = %v, want %v (XDG should take precedence)", cfg.Domain, "https://xdg.example.com")
|
||||
}
|
||||
}
|
||||
38
main.go
Normal file
38
main.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/cmd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
//go:embed version.txt
|
||||
var version string
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cospend",
|
||||
Short: "A CLI tool for Nextcloud Cospend",
|
||||
Long: `cospend is a command-line interface for adding expenses to Nextcloud Cospend projects.`,
|
||||
Version: strings.TrimSpace(version),
|
||||
TraverseChildren: true,
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmd.NewAddCommand())
|
||||
rootCmd.AddCommand(cmd.NewInitCommand())
|
||||
rootCmd.AddCommand(cmd.NewListCommand())
|
||||
rootCmd.AddCommand(cmd.NewDeleteCommand())
|
||||
rootCmd.AddCommand(cmd.NewProjectsCommand())
|
||||
|
||||
rootCmd.PersistentFlags().BoolVarP(&cmd.Debug, "debug", "d", false, "Enable debug output")
|
||||
rootCmd.PersistentFlags().StringVarP(&cmd.ProjectID, "project", "p", "", "Project ID")
|
||||
rootCmd.Flags().BoolP("version", "v", false, "Print version information")
|
||||
rootCmd.SetVersionTemplate("{{.Version}}\n")
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
1
version.txt
Normal file
1
version.txt
Normal file
@@ -0,0 +1 @@
|
||||
0.0.0
|
||||
Reference in New Issue
Block a user