diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa3b25..a8b2cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -219,6 +219,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 account visible to the login (every sub-account for a main login, just the login itself for a sub-account). +### Changed + +- `internal/usage`: add a `(t Traffic) IsSummary() bool` helper so + callers no longer rely on the `Day == 0` magic number to distinguish + the monthly summary row from per-day entries; the table renderer is + switched over too. Document on `Space` that `UsedWebspace` is the sum + of the four sub-buckets so future readers do not double-count. + +- `kasapi-cli usage traffic`: pre-validate `--year` (must be in + `[2000, currentYear+1]`) and `--month` (must be `1..12`) instead of + forwarding obvious typos to KAS. Closes #45. + ### Fixed - `internal/auth/source.go`: sharpen the `Heartbeat` doc comment. diff --git a/internal/cli/usage.go b/internal/cli/usage.go index d3905d9..d0ab638 100644 --- a/internal/cli/usage.go +++ b/internal/cli/usage.go @@ -1,11 +1,20 @@ package cli import ( + "fmt" + "time" + "github.com/spf13/cobra" "github.com/chmmou/kasapi-cli/internal/usage" ) +// minTrafficYear is the lower bound for --year. KAS predates this date +// but the CLI is the kasapi-cli era, so 2000 is generous enough to be +// indistinguishable from "no validation" for any realistic input while +// still rejecting obvious typos like 200 or 20256. +const minTrafficYear = 2000 + // NewUsageCmd returns the "kasapi-cli usage" subcommand tree: // space (get_space), space-detail (get_space_usage), // traffic (get_traffic). @@ -76,6 +85,17 @@ func newUsageTrafficCmd(opts *RootOptions) *cobra.Command { Use: "traffic", Short: "Show monthly HTTP/FTP traffic (get_traffic)", Args: cobra.NoArgs, + PreRunE: func(_ *cobra.Command, _ []string) error { + maxYear := time.Now().Year() + 1 + if year != 0 && (year < minTrafficYear || year > maxYear) { + return fmt.Errorf("--year must be between %d and %d, got %d", + minTrafficYear, maxYear, year) + } + if month != 0 && (month < 1 || month > 12) { + return fmt.Errorf("--month must be between 1 and 12, got %d", month) + } + return nil + }, RunE: func(cmd *cobra.Command, _ []string) error { api, err := BuildAPIClient(opts) if err != nil { diff --git a/internal/cli/usage_test.go b/internal/cli/usage_test.go new file mode 100644 index 0000000..1227d85 --- /dev/null +++ b/internal/cli/usage_test.go @@ -0,0 +1,43 @@ +package cli_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/chmmou/kasapi-cli/internal/cli" +) + +func TestUsageTrafficRejectsInvalidFlags(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want string + }{ + {"month negative", []string{"usage", "traffic", "--month", "-1"}, "--month must be between 1 and 12"}, + {"month too high", []string{"usage", "traffic", "--month", "13"}, "--month must be between 1 and 12"}, + {"year too low", []string{"usage", "traffic", "--year", "1999"}, "--year must be between"}, + {"year typo", []string{"usage", "traffic", "--year", "20256"}, "--year must be between"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewUsageCmd(opts)) + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(tc.args) + err := root.Execute() + if err == nil { + t.Fatalf("Execute returned nil, want error containing %q", tc.want) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %v, want substring %q", err, tc.want) + } + }) + } +} diff --git a/internal/usage/usage.go b/internal/usage/usage.go index b2dd841..c44cb75 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -21,6 +21,10 @@ type Caller interface { // resource type. KAS reports webspace and max_webspace as xsd:int and // the per-resource breakdowns as xsd:string-encoded numbers; we parse // both into int64 so the per-account totals add up without overflow. +// +// UsedWebspace is the sum of UsedHTDocsSpace, UsedChrootSpace, +// UsedDatabaseSpace, and UsedMailaccountSpace — do not add the +// sub-buckets to UsedWebspace when computing totals. type Space struct { AccountLogin string `json:"account_login" yaml:"account_login"` LastCalculation int64 `json:"last_calculation" yaml:"last_calculation"` @@ -68,6 +72,11 @@ type Traffic struct { Comment string `json:"comment,omitempty" yaml:"comment,omitempty"` } +// IsSummary reports whether t is the monthly summary row that KAS +// emits alongside the per-day entries. The summary is identified by a +// zero Day; literal day-zero never occurs in get_traffic responses. +func (t Traffic) IsSummary() bool { return t.Day == 0 } + // TrafficList is the typed payload of get_traffic; satisfies // cli.Tabular. type TrafficList []Traffic @@ -292,7 +301,7 @@ func (l TrafficList) TableRows() [][]string { rows := make([][]string, 0, len(l)) for _, t := range l { day := "*" - if t.Day != 0 { + if !t.IsSummary() { day = fmt.Sprintf("%02d", t.Day) } rows = append(rows, []string{ diff --git a/internal/usage/usage_test.go b/internal/usage/usage_test.go index 5530ef7..5f6778a 100644 --- a/internal/usage/usage_test.go +++ b/internal/usage/usage_test.go @@ -145,6 +145,12 @@ func TestDecodeTraffic(t *testing.T) { if day.Day != 1 { t.Errorf("day.Day = %d, want 1", day.Day) } + if !summary.IsSummary() { + t.Errorf("summary.IsSummary() = false, want true") + } + if day.IsSummary() { + t.Errorf("day.IsSummary() = true, want false") + } } func TestClientSpace(t *testing.T) {