From 042993f8a33ef16794b7e3042f59f0824225c6b1 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Mon, 4 May 2026 19:38:59 +0200 Subject: [PATCH] feat: usage read module (get_space, get_space_usage, get_traffic) Implement the three KAS read endpoints for webspace and traffic counters under a new internal/usage package and a "kasapi-cli usage" subcommand tree: - usage space (get_space) lists per-account webspace totals with a computed usage ratio. - usage space-detail [--directory PATH] (get_space_usage) reports per-directory file counts and byte sums. - usage traffic [--year Y --month M] (get_traffic) returns the monthly summary plus per-day rows. Decoding notes: - get_traffic returns a Map keyed by "0" (monthly summary) and "01".."31" (daily rows). The decoder flattens it into a slice in KAS-given order (summary first), so a single TrafficList renders cleanly as JSON/YAML/table. - xsi:nil FTP traffic / hits map to zero. The summary row's Comment field carries any KAS-provided context. - Byte counts and timestamps decode into int64 so 9-digit values survive on 32-bit platforms. Tests cover all three success fixtures end-to-end (decode + Client + Tabular) plus the parameter-shape contracts on the Client (empty directory omits params; --year/--month zero-pads month). Closes #10 --- CHANGELOG.md | 12 ++ cmd/kasapi-cli/main.go | 1 + internal/cli/usage.go | 97 ++++++++++ internal/usage/usage.go | 346 +++++++++++++++++++++++++++++++++++ internal/usage/usage_test.go | 282 ++++++++++++++++++++++++++++ 5 files changed, 738 insertions(+) create mode 100644 internal/cli/usage.go create mode 100644 internal/usage/usage.go create mode 100644 internal/usage/usage_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fdcee65..0d9cff1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `internal/usage` package and `kasapi-cli usage` subcommand tree + covering the three KAS read endpoints around webspace and traffic + counters: `usage space` (`get_space`) lists per-account webspace + totals with a usage ratio; `usage space-detail [--directory PATH]` + (`get_space_usage`) reports per-directory file counts and byte sums; + `usage traffic [--year Y --month M]` (`get_traffic`) returns the + monthly summary plus per-day rows. The decoder maps the get_traffic + Map keyed by `0` / `01..31` into a slice (summary first, then days), + treats `xsi:nil` FTP fields as zero, and parses the xsd:string-encoded + byte counts into `int64` so 9-digit values survive on 32-bit + platforms. Closes #10. + - `internal/session` persistent session-token cache so a successful KasAuth login (including 2FA via `--otp`) survives across CLI invocations: a new `sessions.toml` next to the config file (mode diff --git a/cmd/kasapi-cli/main.go b/cmd/kasapi-cli/main.go index 027a716..801b630 100644 --- a/cmd/kasapi-cli/main.go +++ b/cmd/kasapi-cli/main.go @@ -17,6 +17,7 @@ func main() { root.AddCommand( cli.NewAccountCmd(opts), cli.NewServerCmd(opts), + cli.NewUsageCmd(opts), cli.NewConfigCmd(opts), ) if err := root.Execute(); err != nil { diff --git a/internal/cli/usage.go b/internal/cli/usage.go new file mode 100644 index 0000000..d3905d9 --- /dev/null +++ b/internal/cli/usage.go @@ -0,0 +1,97 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/chmmou/kasapi-cli/internal/usage" +) + +// NewUsageCmd returns the "kasapi-cli usage" subcommand tree: +// space (get_space), space-detail (get_space_usage), +// traffic (get_traffic). +func NewUsageCmd(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "usage", + Short: "Inspect webspace and traffic counters", + } + cmd.AddCommand( + newUsageSpaceCmd(opts), + newUsageSpaceDetailCmd(opts), + newUsageTrafficCmd(opts), + ) + return cmd +} + +func newUsageSpaceCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "space", + Short: "Show webspace totals per account (get_space)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := usage.NewClient(api).Space(cmd.Context()) + if err != nil { + return APIError(err, "get_space") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} + +func newUsageSpaceDetailCmd(opts *RootOptions) *cobra.Command { + var directory string + cmd := &cobra.Command{ + Use: "space-detail", + Short: "Show per-directory disk usage (get_space_usage)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := usage.NewClient(api).SpaceUsage(cmd.Context(), directory) + if err != nil { + return APIError(err, "get_space_usage") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } + cmd.Flags().StringVar(&directory, "directory", "", + "directory to drill into; empty queries the document-root level") + return cmd +} + +func newUsageTrafficCmd(opts *RootOptions) *cobra.Command { + var year, month int + cmd := &cobra.Command{ + Use: "traffic", + Short: "Show monthly HTTP/FTP traffic (get_traffic)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := usage.NewClient(api).Traffic(cmd.Context(), year, month) + if err != nil { + return APIError(err, "get_traffic") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } + cmd.Flags().IntVar(&year, "year", 0, "calendar year (e.g. 2026); 0 = current") + cmd.Flags().IntVar(&month, "month", 0, "calendar month 1..12; 0 = current") + return cmd +} diff --git a/internal/usage/usage.go b/internal/usage/usage.go new file mode 100644 index 0000000..9d28e56 --- /dev/null +++ b/internal/usage/usage.go @@ -0,0 +1,346 @@ +package usage + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/chmmou/kasapi-cli/internal/soap" +) + +// Caller is the subset of *api.Client this package depends on. The +// indirection keeps tests free of network setup: a fake Caller can +// return a *soap.Response decoded from a fixture. +type Caller interface { + Call(ctx context.Context, action string, params map[string]any) (*soap.Response, error) +} + +// Space is one entry of get_space. Each entry covers an account-login +// (the main account and each sub-account) with byte counts split by +// 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. +type Space struct { + AccountLogin string `json:"account_login" yaml:"account_login"` + LastCalculation int64 `json:"last_calculation" yaml:"last_calculation"` + UsedHTDocsSpace int64 `json:"used_htdocs_space" yaml:"used_htdocs_space"` + UsedChrootSpace int64 `json:"used_chroot_space" yaml:"used_chroot_space"` + UsedDatabaseSpace int64 `json:"used_database_space" yaml:"used_database_space"` + UsedMailaccountSpace int64 `json:"used_mailaccount_space" yaml:"used_mailaccount_space"` + UsedWebspace int64 `json:"used_webspace" yaml:"used_webspace"` + MaxWebspace int64 `json:"max_webspace" yaml:"max_webspace"` +} + +// SpaceList is the typed payload of get_space; satisfies cli.Tabular. +type SpaceList []Space + +// SpaceUsage is one entry of get_space_usage: a directory together with +// its file count and aggregate byte size. has_sub_dirs signals whether +// the entry can be drilled into with another get_space_usage call. +type SpaceUsage struct { + Directory string `json:"directory" yaml:"directory"` + Count int64 `json:"count" yaml:"count"` + Bytes int64 `json:"bytes" yaml:"bytes"` + LastCalculation int64 `json:"last_calculation" yaml:"last_calculation"` + HasSubDirs bool `json:"has_sub_dirs" yaml:"has_sub_dirs"` +} + +// SpaceUsageList is the typed payload of get_space_usage; satisfies +// cli.Tabular. +type SpaceUsageList []SpaceUsage + +// Traffic is one entry of get_traffic. KAS interleaves a monthly +// summary entry (Day == 0, AccountLogin set, Comment populated) with +// per-day rows (Day 1..31). FTP traffic / hits are returned as xsi:nil +// when no data is available; we map nil to zero. Unknown bytes counts +// thus look identical to "no traffic"; use the Comment field on the +// summary row to disambiguate when the distinction matters. +type Traffic struct { + AccountLogin string `json:"account_login,omitempty" yaml:"account_login,omitempty"` + Year int `json:"year" yaml:"year"` + Month int `json:"month" yaml:"month"` + Day int `json:"day,omitempty" yaml:"day,omitempty"` + HTTPTraffic int64 `json:"http_traffic" yaml:"http_traffic"` + FTPTraffic int64 `json:"ftp_traffic" yaml:"ftp_traffic"` + HTTPHits int64 `json:"http_hits" yaml:"http_hits"` + FTPHits int64 `json:"ftp_hits" yaml:"ftp_hits"` + Comment string `json:"comment,omitempty" yaml:"comment,omitempty"` +} + +// TrafficList is the typed payload of get_traffic; satisfies +// cli.Tabular. +type TrafficList []Traffic + +// Client groups the read endpoints scoped to webspace + traffic +// counters: get_space, get_space_usage, get_traffic. +type Client struct { + API Caller +} + +// NewClient returns a Client backed by the given Caller. +func NewClient(c Caller) *Client { return &Client{API: c} } + +// Space calls get_space and decodes the response into SpaceList. +// +// The KAS API accepts optional show_subaccounts and show_details +// parameters; both default to "Y" server-side. We omit them — callers +// that need a different scope can call (*api.Client).Call directly. +func (c *Client) Space(ctx context.Context) (SpaceList, error) { + resp, err := c.API.Call(ctx, "get_space", nil) + if err != nil { + return nil, err + } + list, err := DecodeSpace(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("usage: get_space: %w", err) + } + return list, nil +} + +// SpaceUsage calls get_space_usage for the given directory and decodes +// the response. An empty directory queries the document-root level. +func (c *Client) SpaceUsage(ctx context.Context, directory string) (SpaceUsageList, error) { + var params map[string]any + if directory != "" { + params = map[string]any{"directory": directory} + } + resp, err := c.API.Call(ctx, "get_space_usage", params) + if err != nil { + return nil, err + } + list, err := DecodeSpaceUsage(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("usage: get_space_usage: %w", err) + } + return list, nil +} + +// Traffic calls get_traffic and decodes the response. year and month +// are optional; pass 0/0 to query the current month. month is encoded +// as a zero-padded string ("01".."12") because KAS rejects "1" with a +// syntax error. +func (c *Client) Traffic(ctx context.Context, year, month int) (TrafficList, error) { + var params map[string]any + if year != 0 || month != 0 { + params = make(map[string]any, 2) + if year != 0 { + params["year"] = strconv.Itoa(year) + } + if month != 0 { + params["month"] = fmt.Sprintf("%02d", month) + } + } + resp, err := c.API.Call(ctx, "get_traffic", params) + if err != nil { + return nil, err + } + list, err := DecodeTraffic(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("usage: get_traffic: %w", err) + } + return list, nil +} + +// DecodeSpace maps the ReturnInfo of a get_space response (an Array of +// Maps) into the typed SpaceList. +func DecodeSpace(returnInfo soap.Value) (SpaceList, error) { + if returnInfo.Kind != soap.KindArray { + return nil, fmt.Errorf("usage: expected ReturnInfo array, got kind %d", returnInfo.Kind) + } + out := make(SpaceList, 0, len(returnInfo.Array)) + for i, item := range returnInfo.Array { + if item.Kind != soap.KindMap { + return nil, fmt.Errorf("usage: get_space: ReturnInfo[%d] is not a Map", i) + } + out = append(out, Space{ + AccountLogin: getString(item, "account_login"), + LastCalculation: getInt64(item, "last_calculation"), + UsedHTDocsSpace: getInt64(item, "used_htdocs_space"), + UsedChrootSpace: getInt64(item, "used_chroot_space"), + UsedDatabaseSpace: getInt64(item, "used_database_space"), + UsedMailaccountSpace: getInt64(item, "used_mailaccount_space"), + UsedWebspace: getInt64(item, "used_webspace"), + MaxWebspace: getInt64(item, "max_webspace"), + }) + } + return out, nil +} + +// DecodeSpaceUsage maps the ReturnInfo of a get_space_usage response +// (an Array of Maps) into the typed SpaceUsageList. +func DecodeSpaceUsage(returnInfo soap.Value) (SpaceUsageList, error) { + if returnInfo.Kind != soap.KindArray { + return nil, fmt.Errorf("usage: expected ReturnInfo array, got kind %d", returnInfo.Kind) + } + out := make(SpaceUsageList, 0, len(returnInfo.Array)) + for i, item := range returnInfo.Array { + if item.Kind != soap.KindMap { + return nil, fmt.Errorf("usage: get_space_usage: ReturnInfo[%d] is not a Map", i) + } + out = append(out, SpaceUsage{ + Directory: getString(item, "directory"), + Count: getInt64(item, "count"), + Bytes: getInt64(item, "bytes"), + LastCalculation: getInt64(item, "last_calculation"), + HasSubDirs: getYN(item, "has_sub_dirs"), + }) + } + return out, nil +} + +// DecodeTraffic maps the ReturnInfo of a get_traffic response (a Map of +// Maps keyed by "0" for the monthly summary and "01".."31" for daily +// rows) into the typed TrafficList. The summary entry comes first; the +// remaining entries follow in the order KAS returned them. +func DecodeTraffic(returnInfo soap.Value) (TrafficList, error) { + if returnInfo.Kind != soap.KindMap { + return nil, fmt.Errorf("usage: expected ReturnInfo map, got kind %d", returnInfo.Kind) + } + out := make(TrafficList, 0, len(returnInfo.Map)) + for _, kv := range returnInfo.Map { + if kv.Value.Kind != soap.KindMap { + return nil, fmt.Errorf("usage: get_traffic: entry %q is not a Map", kv.Key) + } + out = append(out, Traffic{ + AccountLogin: getString(kv.Value, "account_login"), + Year: getInt(kv.Value, "year"), + Month: getInt(kv.Value, "month"), + Day: getInt(kv.Value, "day"), + HTTPTraffic: getInt64(kv.Value, "http_traffic"), + FTPTraffic: getInt64(kv.Value, "ftp_traffic"), + HTTPHits: getInt64(kv.Value, "http_hits"), + FTPHits: getInt64(kv.Value, "ftp_hits"), + Comment: getString(kv.Value, "comment"), + }) + } + return out, nil +} + +func getString(m soap.Value, key string) string { + v, ok := m.Get(key) + if !ok { + return "" + } + return v.AsString() +} + +func getInt(m soap.Value, key string) int { + return int(getInt64(m, key)) +} + +func getInt64(m soap.Value, key string) int64 { + v, ok := m.Get(key) + if !ok { + return 0 + } + switch v.Kind { + case soap.KindInt: + return v.Int + case soap.KindFloat: + return int64(v.Float) + case soap.KindString: + s := strings.TrimSpace(v.String) + if s == "" { + return 0 + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0 + } + return n + } + return 0 +} + +func getYN(m soap.Value, key string) bool { + v, ok := m.Get(key) + if !ok { + return false + } + switch strings.ToUpper(strings.TrimSpace(v.AsString())) { + case "Y", "YES", "TRUE", "1": + return true + } + return false +} + +// TableHeaders returns the columns used by --output=table for SpaceList. +func (SpaceList) TableHeaders() []string { + return []string{"ACCOUNT", "USED", "MAX", "%", "HTDOCS", "DB", "MAIL"} +} + +// TableRows emits one row per Space entry. USED/MAX are kept as raw +// byte counts so the output stays unit-agnostic; the % column is the +// usage ratio rounded to one decimal so an at-a-glance read is easy. +func (l SpaceList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, s := range l { + ratio := "" + if s.MaxWebspace > 0 { + ratio = strconv.FormatFloat(float64(s.UsedWebspace)*100/float64(s.MaxWebspace), 'f', 1, 64) + } + rows = append(rows, []string{ + s.AccountLogin, + strconv.FormatInt(s.UsedWebspace, 10), + strconv.FormatInt(s.MaxWebspace, 10), + ratio, + strconv.FormatInt(s.UsedHTDocsSpace, 10), + strconv.FormatInt(s.UsedDatabaseSpace, 10), + strconv.FormatInt(s.UsedMailaccountSpace, 10), + }) + } + return rows +} + +// TableHeaders returns the columns used by --output=table for +// SpaceUsageList. +func (SpaceUsageList) TableHeaders() []string { + return []string{"DIRECTORY", "COUNT", "BYTES", "SUBDIRS"} +} + +// TableRows emits one row per SpaceUsage entry. +func (l SpaceUsageList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, u := range l { + sub := "N" + if u.HasSubDirs { + sub = "Y" + } + rows = append(rows, []string{ + u.Directory, + strconv.FormatInt(u.Count, 10), + strconv.FormatInt(u.Bytes, 10), + sub, + }) + } + return rows +} + +// TableHeaders returns the columns used by --output=table for +// TrafficList. The summary row uses "*" in the DAY column. +func (TrafficList) TableHeaders() []string { + return []string{"ACCOUNT", "YEAR", "MONTH", "DAY", "HTTP_BYTES", "FTP_BYTES", "HTTP_HITS", "FTP_HITS"} +} + +// TableRows emits one row per Traffic entry. +func (l TrafficList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, t := range l { + day := "*" + if t.Day != 0 { + day = fmt.Sprintf("%02d", t.Day) + } + rows = append(rows, []string{ + t.AccountLogin, + strconv.Itoa(t.Year), + fmt.Sprintf("%02d", t.Month), + day, + strconv.FormatInt(t.HTTPTraffic, 10), + strconv.FormatInt(t.FTPTraffic, 10), + strconv.FormatInt(t.HTTPHits, 10), + strconv.FormatInt(t.FTPHits, 10), + }) + } + return rows +} diff --git a/internal/usage/usage_test.go b/internal/usage/usage_test.go new file mode 100644 index 0000000..5530ef7 --- /dev/null +++ b/internal/usage/usage_test.go @@ -0,0 +1,282 @@ +package usage_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/chmmou/kasapi-cli/internal/soap" + "github.com/chmmou/kasapi-cli/internal/usage" +) + +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + dir := filepath.Dir(file) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("repo root not found from %q", file) + } + dir = parent + } +} + +func decodeFixture(t *testing.T, name string) *soap.Response { + t.Helper() + path := filepath.Join(repoRoot(t), "testdata", "statistic", name) + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", name, err) + } + defer func() { _ = f.Close() }() + resp, err := soap.Decode(f) + if err != nil { + t.Fatalf("decode %s: %v", name, err) + } + return resp +} + +type fakeCaller struct { + resp *soap.Response + err error + + gotAction string + gotParams map[string]any +} + +func (f *fakeCaller) Call(_ context.Context, action string, params map[string]any) (*soap.Response, error) { + f.gotAction = action + f.gotParams = params + return f.resp, f.err +} + +func TestDecodeSpace(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_space_response_success.xml") + got, err := usage.DecodeSpace(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeSpace: %v", err) + } + if len(got) != 5 { + t.Fatalf("len = %d, want 5", len(got)) + } + first := got[0] + if first.AccountLogin != "w0000001" { + t.Errorf("first.AccountLogin = %q, want w0000001", first.AccountLogin) + } + if first.UsedWebspace != 11947780 || first.MaxWebspace != 36454400 { + t.Errorf("first webspace = %d/%d, want 11947780/36454400", + first.UsedWebspace, first.MaxWebspace) + } + if first.UsedHTDocsSpace != 4843107 || first.UsedMailaccountSpace != 6877004 { + t.Errorf("first detail bytes = htdocs %d, mail %d", + first.UsedHTDocsSpace, first.UsedMailaccountSpace) + } + if first.LastCalculation != 1777688517 { + t.Errorf("first.LastCalculation = %d, want 1777688517", first.LastCalculation) + } +} + +func TestDecodeSpaceUsage(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_space_usage_response_success.xml") + got, err := usage.DecodeSpaceUsage(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeSpaceUsage: %v", err) + } + if len(got) != 13 { + t.Fatalf("len = %d, want 13", len(got)) + } + if got[0].Directory != "/directory/1/" || got[0].Count != 5 || got[0].Bytes != 60141 { + t.Errorf("got[0] = %+v", got[0]) + } + if !got[0].HasSubDirs { + t.Errorf("got[0].HasSubDirs = false, want true") + } + if got[3].HasSubDirs { + t.Errorf("got[3].HasSubDirs = true, want false (fixture says N)") + } + // /directory/2/ is the largest entry — sanity-check that 9-digit byte + // counts decode without overflow on 32-bit platforms. + if got[1].Bytes != 356123958 { + t.Errorf("got[1].Bytes = %d, want 356123958", got[1].Bytes) + } +} + +func TestDecodeTraffic(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_traffic_response_success.xml") + got, err := usage.DecodeTraffic(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeTraffic: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + summary := got[0] + if summary.Day != 0 { + t.Errorf("summary.Day = %d, want 0", summary.Day) + } + if summary.Year != 2026 || summary.Month != 5 { + t.Errorf("summary year/month = %d/%d, want 2026/5", summary.Year, summary.Month) + } + if summary.HTTPTraffic != 4104052 || summary.HTTPHits != 216 { + t.Errorf("summary http = %d bytes / %d hits", summary.HTTPTraffic, summary.HTTPHits) + } + // xsi:nil ftp_traffic + ftp_hits must round-trip as zero. + if summary.FTPTraffic != 0 || summary.FTPHits != 0 { + t.Errorf("summary ftp = %d/%d, want 0/0 (nil mapped to zero)", + summary.FTPTraffic, summary.FTPHits) + } + if summary.Comment != "traffic summary for 2026-05" { + t.Errorf("summary.Comment = %q", summary.Comment) + } + day := got[1] + if day.Day != 1 { + t.Errorf("day.Day = %d, want 1", day.Day) + } +} + +func TestClientSpace(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_space_response_success.xml") + fc := &fakeCaller{resp: resp} + list, err := usage.NewClient(fc).Space(context.Background()) + if err != nil { + t.Fatalf("Space: %v", err) + } + if fc.gotAction != "get_space" { + t.Errorf("action = %q, want get_space", fc.gotAction) + } + if fc.gotParams != nil { + t.Errorf("params = %v, want nil", fc.gotParams) + } + if len(list) != 5 { + t.Errorf("len = %d, want 5", len(list)) + } +} + +func TestClientSpaceUsageWithDirectory(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_space_usage_response_success.xml") + fc := &fakeCaller{resp: resp} + _, err := usage.NewClient(fc).SpaceUsage(context.Background(), "/htdocs") + if err != nil { + t.Fatalf("SpaceUsage: %v", err) + } + if fc.gotAction != "get_space_usage" { + t.Errorf("action = %q, want get_space_usage", fc.gotAction) + } + if dir, ok := fc.gotParams["directory"].(string); !ok || dir != "/htdocs" { + t.Errorf("params[directory] = %v, want /htdocs", fc.gotParams["directory"]) + } +} + +func TestClientSpaceUsageEmptyDirectoryOmitsParams(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_space_usage_response_success.xml") + fc := &fakeCaller{resp: resp} + if _, err := usage.NewClient(fc).SpaceUsage(context.Background(), ""); err != nil { + t.Fatalf("SpaceUsage: %v", err) + } + if fc.gotParams != nil { + t.Errorf("params = %v, want nil for empty directory", fc.gotParams) + } +} + +func TestClientTrafficZeroOmitsParams(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_traffic_response_success.xml") + fc := &fakeCaller{resp: resp} + if _, err := usage.NewClient(fc).Traffic(context.Background(), 0, 0); err != nil { + t.Fatalf("Traffic: %v", err) + } + if fc.gotParams != nil { + t.Errorf("params = %v, want nil when year and month are zero", fc.gotParams) + } +} + +func TestClientTrafficWithYearMonthZeroPads(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_traffic_response_success.xml") + fc := &fakeCaller{resp: resp} + if _, err := usage.NewClient(fc).Traffic(context.Background(), 2026, 3); err != nil { + t.Fatalf("Traffic: %v", err) + } + if fc.gotParams["year"] != "2026" { + t.Errorf("params[year] = %v, want \"2026\"", fc.gotParams["year"]) + } + if fc.gotParams["month"] != "03" { + t.Errorf("params[month] = %v, want \"03\" (zero-padded)", fc.gotParams["month"]) + } +} + +func TestClientPropagatesError(t *testing.T) { + t.Parallel() + want := errors.New("boom") + c := usage.NewClient(&fakeCaller{err: want}) + if _, err := c.Space(context.Background()); !errors.Is(err, want) { + t.Errorf("Space err = %v, want %v wrapped", err, want) + } + if _, err := c.SpaceUsage(context.Background(), ""); !errors.Is(err, want) { + t.Errorf("SpaceUsage err = %v, want %v wrapped", err, want) + } + if _, err := c.Traffic(context.Background(), 0, 0); !errors.Is(err, want) { + t.Errorf("Traffic err = %v, want %v wrapped", err, want) + } +} + +func TestSpaceListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_space_response_success.xml") + list, _ := usage.DecodeSpace(resp.Body.ReturnInfo) + rows := list.TableRows() + if len(rows) != 5 { + t.Fatalf("rows = %d, want 5", len(rows)) + } + if rows[0][0] != "w0000001" || rows[0][1] != "11947780" || rows[0][2] != "36454400" { + t.Errorf("rows[0] = %v", rows[0]) + } +} + +func TestSpaceUsageListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_space_usage_response_success.xml") + list, _ := usage.DecodeSpaceUsage(resp.Body.ReturnInfo) + rows := list.TableRows() + if len(rows) != 13 { + t.Fatalf("rows = %d, want 13", len(rows)) + } + if rows[0][0] != "/directory/1/" || rows[0][3] != "Y" { + t.Errorf("rows[0] = %v", rows[0]) + } + if rows[3][3] != "N" { + t.Errorf("rows[3] = %v, want N in subdir column", rows[3]) + } +} + +func TestTrafficListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_traffic_response_success.xml") + list, _ := usage.DecodeTraffic(resp.Body.ReturnInfo) + rows := list.TableRows() + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + if rows[0][3] != "*" { + t.Errorf("summary row DAY column = %q, want *", rows[0][3]) + } + if rows[1][3] != "01" { + t.Errorf("day row DAY column = %q, want 01", rows[1][3]) + } +}