diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d163711 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1b68a56 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + ci: + name: ci + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + version: v2.11.3 + + - name: go build + run: go build ./... + + - name: go test + run: go test -race ./... diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..215446c --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,25 @@ +name: Dependabot auto-merge + +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + runs-on: ubuntu-latest + if: github.event.pull_request.user.login == 'dependabot[bot]' + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 # v3.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge for patch and minor updates + if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..adf0b33 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,30 @@ +version: "2" + +run: + timeout: 5m + +linters: + default: standard + enable: + - gocritic + - misspell + - unconvert + settings: + errcheck: + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + - (*text/tabwriter.Writer).Flush + - (io.Closer).Close + exclusions: + rules: + - path: _test\.go + linters: + - errcheck + - gosec + +formatters: + enable: + - gofmt + - goimports diff --git a/.pinact.yaml b/.pinact.yaml new file mode 100644 index 0000000..3b20588 --- /dev/null +++ b/.pinact.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/pinact/refs/heads/main/json-schema/pinact.json +# pinact - https://github.com/suzuki-shunsuke/pinact +version: 3 +# files: +# - pattern: action.yaml +# - pattern: */action.yaml + +# separator: " # " + +ignore_actions: +# - name: slsa-framework/slsa-github-generator/\.github/workflows/generator_generic_slsa3\.yml +# ref: v\d+\.\d+\.\d+ +# - name: actions/.* +# ref: main +# - name: suzuki-shunsuke/.* +# ref: release-.* diff --git a/cmd/document_test.go b/cmd/document_test.go new file mode 100644 index 0000000..362a141 --- /dev/null +++ b/cmd/document_test.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadSearchBody_Empty(t *testing.T) { + b, err := loadSearchBody("") + if err != nil { + t.Fatalf("err = %v", err) + } + if b != nil { + t.Errorf("body = %q, want nil", string(b)) + } +} + +func TestLoadSearchBody_InlineObject(t *testing.T) { + b, err := loadSearchBody(`{"title":"x"}`) + if err != nil { + t.Fatalf("err = %v", err) + } + if string(b) != `{"title":"x"}` { + t.Errorf("body = %s", string(b)) + } +} + +func TestLoadSearchBody_InlineArray(t *testing.T) { + b, err := loadSearchBody(`[1,2,3]`) + if err != nil { + t.Fatalf("err = %v", err) + } + if string(b) != `[1,2,3]` { + t.Errorf("body = %s", string(b)) + } +} + +func TestLoadSearchBody_File(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "body.json") + content := `{"form_name":"経費"}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + b, err := loadSearchBody(path) + if err != nil { + t.Fatalf("err = %v", err) + } + if string(b) != content { + t.Errorf("body = %q", string(b)) + } +} + +func TestLoadSearchBody_FileMissing(t *testing.T) { + _, err := loadSearchBody(filepath.Join(t.TempDir(), "nope.json")) + if err == nil || !strings.Contains(err.Error(), "read --body file") { + t.Errorf("err = %v", err) + } +} + +func TestLoadSearchBody_InvalidJSON(t *testing.T) { + _, err := loadSearchBody(`{not json}`) + if err == nil || !strings.Contains(err.Error(), "not valid JSON") { + t.Errorf("err = %v", err) + } +} + +func TestLoadSearchBody_Stdin(t *testing.T) { + orig := os.Stdin + t.Cleanup(func() { os.Stdin = orig }) + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdin = r + + want := `{"from":"stdin"}` + done := make(chan error, 1) + go func() { + _, werr := io.WriteString(w, want) + _ = w.Close() + done <- werr + }() + + b, err := loadSearchBody("-") + if werr := <-done; werr != nil { + t.Fatalf("write to stdin: %v", werr) + } + if err != nil { + t.Fatalf("loadSearchBody: %v", err) + } + if string(b) != want { + t.Errorf("body = %q", string(b)) + } +} diff --git a/cmd/output_test.go b/cmd/output_test.go new file mode 100644 index 0000000..e71132f --- /dev/null +++ b/cmd/output_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "os" + "strings" + "testing" +) + +func captureStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + + done := make(chan struct{}) + var buf bytes.Buffer + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + + runErr := fn() + _ = w.Close() + <-done + os.Stdout = orig + return buf.String(), runErr +} + +func TestResolveOutputFormat_ExplicitFlagWins(t *testing.T) { + if got := resolveOutputFormat("json"); got != "json" { + t.Errorf("resolveOutputFormat(\"json\") = %q", got) + } + if got := resolveOutputFormat("table"); got != "table" { + t.Errorf("resolveOutputFormat(\"table\") = %q", got) + } +} + +func TestResolveOutputFormat_NonTTYDefaultsToJSON(t *testing.T) { + // In `go test`, stdout is not a TTY, so the default is "json". + if got := resolveOutputFormat(""); got != "json" { + t.Errorf("default = %q, want json", got) + } +} + +func TestRunJQ_SimpleFilter(t *testing.T) { + input := map[string]any{ + "form_group": []any{ + map[string]any{"id": 1.0, "name": "g1"}, + map[string]any{"id": 2.0, "name": "g2"}, + }, + } + out, err := captureStdout(t, func() error { + return runJQ(input, ".form_group[].name") + }) + if err != nil { + t.Fatalf("runJQ: %v", err) + } + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 2 { + t.Fatalf("lines = %d, want 2: %q", len(lines), out) + } + // Each line is JSON-encoded; parse to compare the string value. + for i, want := range []string{"g1", "g2"} { + var got string + if err := json.Unmarshal([]byte(lines[i]), &got); err != nil { + t.Fatalf("line[%d] not JSON: %v", i, err) + } + if got != want { + t.Errorf("line[%d] = %q, want %q", i, got, want) + } + } +} + +func TestRunJQ_InvalidExpression(t *testing.T) { + err := runJQ(map[string]any{}, ".[") + if err == nil { + t.Fatal("expected parse error, got nil") + } + if !strings.Contains(err.Error(), "invalid --jq filter") { + t.Errorf("error = %v", err) + } +} + +func TestRunJQ_RuntimeError(t *testing.T) { + // Dividing a string triggers a runtime jq error. + err := runJQ(map[string]any{"v": "abc"}, ".v / 2") + if err == nil { + t.Fatal("expected runtime error, got nil") + } + if !strings.Contains(err.Error(), "jq error") { + t.Errorf("error = %v", err) + } +} + +func TestRender_JSONPath(t *testing.T) { + payload := map[string]any{"k": "v"} + out, err := captureStdout(t, func() error { + return render(payload, "json", "", func() error { + t.Error("table fn should not be called when format=json") + return nil + }) + }) + if err != nil { + t.Fatalf("render: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(out), &decoded); err != nil { + t.Fatalf("output not JSON: %v (%s)", err, out) + } + if decoded["k"] != "v" { + t.Errorf("decoded = %v", decoded) + } +} + +func TestRender_TablePath(t *testing.T) { + called := false + _, err := captureStdout(t, func() error { + return render("unused", "table", "", func() error { + called = true + return nil + }) + }) + if err != nil { + t.Fatalf("render: %v", err) + } + if !called { + t.Error("table fn was not invoked") + } +} + +func TestRender_JQTakesPrecedence(t *testing.T) { + out, err := captureStdout(t, func() error { + return render(map[string]any{"k": "v"}, "table", ".k", func() error { + t.Error("table fn should not be called when --jq is set") + return nil + }) + }) + if err != nil { + t.Fatalf("render: %v", err) + } + if strings.TrimSpace(out) != `"v"` { + t.Errorf("output = %q, want \"v\"", out) + } +} + +func TestRender_UnknownFormat(t *testing.T) { + err := render("x", "yaml", "", func() error { return nil }) + if err == nil || !strings.Contains(err.Error(), "unknown output format") { + t.Errorf("err = %v", err) + } +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..0d47699 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "strings" + "testing" +) + +func resetAuthFlags() { + flagSubdomain = "" + flagDomainCode = "" + flagUser = "" + flagGenericAPIToken = "" + flagAPIAccessToken = "" +} + +func TestResolveAuth_AccessTokenFromEnv(t *testing.T) { + resetAuthFlags() + t.Setenv("XPOINT_API_ACCESS_TOKEN", "tok") + t.Setenv("XPOINT_GENERIC_API_TOKEN", "") + t.Setenv("XPOINT_DOMAIN_CODE", "") + t.Setenv("XPOINT_USER", "") + + auth, err := resolveAuth() + if err != nil { + t.Fatalf("resolveAuth: %v", err) + } + if auth.AccessToken != "tok" { + t.Errorf("AccessToken = %q", auth.AccessToken) + } + if auth.GenericAPIToken != "" { + t.Errorf("GenericAPIToken should be empty, got %q", auth.GenericAPIToken) + } +} + +func TestResolveAuth_GenericFromEnv(t *testing.T) { + resetAuthFlags() + t.Setenv("XPOINT_API_ACCESS_TOKEN", "") + t.Setenv("XPOINT_GENERIC_API_TOKEN", "gen") + t.Setenv("XPOINT_DOMAIN_CODE", "d") + t.Setenv("XPOINT_USER", "u") + + auth, err := resolveAuth() + if err != nil { + t.Fatalf("resolveAuth: %v", err) + } + if auth.DomainCode != "d" || auth.User != "u" || auth.GenericAPIToken != "gen" { + t.Errorf("unexpected auth: %+v", auth) + } +} + +func TestResolveAuth_BothSetIsError(t *testing.T) { + resetAuthFlags() + t.Setenv("XPOINT_API_ACCESS_TOKEN", "tok") + t.Setenv("XPOINT_GENERIC_API_TOKEN", "gen") + t.Setenv("XPOINT_DOMAIN_CODE", "d") + t.Setenv("XPOINT_USER", "u") + + _, err := resolveAuth() + if err == nil || !strings.Contains(err.Error(), "cannot specify both") { + t.Errorf("err = %v", err) + } +} + +func TestResolveAuth_GenericMissingDomainOrUser(t *testing.T) { + resetAuthFlags() + t.Setenv("XPOINT_API_ACCESS_TOKEN", "") + t.Setenv("XPOINT_GENERIC_API_TOKEN", "gen") + t.Setenv("XPOINT_DOMAIN_CODE", "") + t.Setenv("XPOINT_USER", "u") + + _, err := resolveAuth() + if err == nil || !strings.Contains(err.Error(), "DOMAIN_CODE") { + t.Errorf("err = %v", err) + } +} + +func TestResolveAuth_NoneSet(t *testing.T) { + resetAuthFlags() + t.Setenv("XPOINT_API_ACCESS_TOKEN", "") + t.Setenv("XPOINT_GENERIC_API_TOKEN", "") + t.Setenv("XPOINT_DOMAIN_CODE", "") + t.Setenv("XPOINT_USER", "") + + _, err := resolveAuth() + if err == nil || !strings.Contains(err.Error(), "authentication is required") { + t.Errorf("err = %v", err) + } +} + +func TestResolveAuth_FlagWinsOverEnv(t *testing.T) { + resetAuthFlags() + flagAPIAccessToken = "from-flag" + t.Setenv("XPOINT_API_ACCESS_TOKEN", "from-env") + + auth, err := resolveAuth() + if err != nil { + t.Fatalf("resolveAuth: %v", err) + } + if auth.AccessToken != "from-flag" { + t.Errorf("AccessToken = %q, want from-flag", auth.AccessToken) + } +} + +func TestResolveSubdomain(t *testing.T) { + resetAuthFlags() + t.Setenv("XPOINT_SUBDOMAIN", "sub1") + sub, err := resolveSubdomain() + if err != nil { + t.Fatalf("resolveSubdomain: %v", err) + } + if sub != "sub1" { + t.Errorf("sub = %q", sub) + } + + resetAuthFlags() + t.Setenv("XPOINT_SUBDOMAIN", "") + if _, err := resolveSubdomain(); err == nil { + t.Error("expected error when subdomain missing") + } +} diff --git a/internal/xpoint/client.go b/internal/xpoint/client.go index 80675ed..01c2145 100644 --- a/internal/xpoint/client.go +++ b/internal/xpoint/client.go @@ -103,15 +103,15 @@ type ApprovalsListResponse struct { // ApprovalsListParams holds query parameters for GET /api/v1/approvals. // Stat is required. Zero values for *int / string / *bool mean "omit". type ApprovalsListParams struct { - Stat int // required - FormGroupID *int // fgid - FormID *int // fid - Step *int // step - RecordNo *int // record_no - GetLine *int // get_line - ProxyUser string // proxy_user - Filter string // filter - ShowHiddenDoc *bool // show_hidden_doc + Stat int // required + FormGroupID *int // fgid + FormID *int // fid + Step *int // step + RecordNo *int // record_no + GetLine *int // get_line + ProxyUser string // proxy_user + Filter string // filter + ShowHiddenDoc *bool // show_hidden_doc } func (p ApprovalsListParams) query() url.Values { diff --git a/internal/xpoint/client_test.go b/internal/xpoint/client_test.go new file mode 100644 index 0000000..bd6e611 --- /dev/null +++ b/internal/xpoint/client_test.go @@ -0,0 +1,279 @@ +package xpoint + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestAuthApply_AccessToken(t *testing.T) { + auth := Auth{AccessToken: "tok123"} + req, err := http.NewRequest(http.MethodGet, "https://example.test/path", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + auth.apply(req) + + if got := req.Header.Get("Authorization"); got != "Bearer tok123" { + t.Errorf("Authorization header = %q, want %q", got, "Bearer tok123") + } + if got := req.Header.Get("X-ATLED-Generic-API-Token"); got != "" { + t.Errorf("X-ATLED-Generic-API-Token header should be empty, got %q", got) + } +} + +func TestAuthApply_GenericAPIToken(t *testing.T) { + auth := Auth{ + DomainCode: "dom", + User: "u001", + GenericAPIToken: "secret", + } + req, err := http.NewRequest(http.MethodGet, "https://example.test/path", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + auth.apply(req) + + want := base64.StdEncoding.EncodeToString([]byte("dom:u001:secret")) + if got := req.Header.Get("X-ATLED-Generic-API-Token"); got != want { + t.Errorf("X-ATLED-Generic-API-Token = %q, want %q", got, want) + } + if got := req.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization header should be empty, got %q", got) + } +} + +func TestApprovalsListParams_Query(t *testing.T) { + fg, fi, st, rn, gl := 10, 20, 3, 5, 100 + hidden := true + p := ApprovalsListParams{ + Stat: 10, + FormGroupID: &fg, + FormID: &fi, + Step: &st, + RecordNo: &rn, + GetLine: &gl, + ProxyUser: "proxyU", + Filter: `cr_dt between "2023-01-01" and "2023-12-31"`, + ShowHiddenDoc: &hidden, + } + q := p.query() + wants := map[string]string{ + "stat": "10", + "fgid": "10", + "fid": "20", + "step": "3", + "record_no": "5", + "get_line": "100", + "proxy_user": "proxyU", + "filter": `cr_dt between "2023-01-01" and "2023-12-31"`, + "show_hidden_doc": "true", + } + for k, want := range wants { + if got := q.Get(k); got != want { + t.Errorf("query[%q] = %q, want %q", k, got, want) + } + } +} + +func TestApprovalsListParams_Query_OmitsZero(t *testing.T) { + p := ApprovalsListParams{Stat: 10} + q := p.query() + if got := q.Get("stat"); got != "10" { + t.Errorf("stat = %q, want 10", got) + } + for _, k := range []string{"fgid", "fid", "step", "record_no", "get_line", "proxy_user", "filter", "show_hidden_doc"} { + if q.Has(k) { + t.Errorf("query should not contain %q, got %q", k, q.Get(k)) + } + } +} + +func TestSearchDocumentsParams_Query(t *testing.T) { + s, o, pg := 25, 100, 2 + p := SearchDocumentsParams{Size: &s, Offset: &o, Page: &pg} + q := p.query() + if got := q.Get("size"); got != "25" { + t.Errorf("size = %q", got) + } + if got := q.Get("offset"); got != "100" { + t.Errorf("offset = %q", got) + } + if got := q.Get("page"); got != "2" { + t.Errorf("page = %q", got) + } +} + +// clientForServer wires a Client to an httptest server. +func clientForServer(srv *httptest.Server) *Client { + c := NewClient("unused", Auth{AccessToken: "t"}) + c.baseURL = srv.URL + c.http = srv.Client() + return c +} + +func TestListAvailableForms(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/api/v1/forms" { + t.Errorf("path = %s, want /api/v1/forms", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer t" { + t.Errorf("Authorization = %q, want Bearer t", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"form_group":[{"id":10,"name":"g1","form":[{"id":1,"name":"f1","code":"c1"}]}]}`)) + })) + defer srv.Close() + + c := clientForServer(srv) + got, err := c.ListAvailableForms(context.Background()) + if err != nil { + t.Fatalf("ListAvailableForms: %v", err) + } + if len(got.FormGroup) != 1 || got.FormGroup[0].ID != 10 || got.FormGroup[0].Name != "g1" { + t.Fatalf("unexpected form_group: %+v", got.FormGroup) + } + if len(got.FormGroup[0].Form) != 1 || got.FormGroup[0].Form[0].Code != "c1" { + t.Fatalf("unexpected form: %+v", got.FormGroup[0].Form) + } +} + +func TestListApprovals_QueryAndDecode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/approvals" { + t.Errorf("path = %s", r.URL.Path) + } + if got := r.URL.Query().Get("stat"); got != "10" { + t.Errorf("stat = %q", got) + } + if got := r.URL.Query().Get("get_line"); got != "50" { + t.Errorf("get_line = %q", got) + } + _, _ = w.Write([]byte(`{"total_count":1,"approval_list":[{"docid":100,"attachment":true,"comment":false,"title1":"t1","form_name":"fn","apply_user":"佐藤","approval_user":["加藤"]}]}`)) + })) + defer srv.Close() + + c := clientForServer(srv) + gl := 50 + res, err := c.ListApprovals(context.Background(), ApprovalsListParams{Stat: 10, GetLine: &gl}) + if err != nil { + t.Fatalf("ListApprovals: %v", err) + } + if res.TotalCount != 1 || len(res.ApprovalList) != 1 { + t.Fatalf("unexpected response: %+v", res) + } + a := res.ApprovalList[0] + if a.DocID != 100 || a.Title1 != "t1" || !a.Attachment || a.ApplyUser != "佐藤" || len(a.ApprovalUser) != 1 { + t.Errorf("unexpected approval: %+v", a) + } +} + +func TestSearchDocuments_PostBodyAndQuery(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s", r.Method) + } + if r.URL.Path != "/api/v1/search/documents" { + t.Errorf("path = %s", r.URL.Path) + } + if got := r.URL.Query().Get("size"); got != "3" { + t.Errorf("size = %q", got) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q", got) + } + body, _ := io.ReadAll(r.Body) + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatalf("body not JSON: %v (%s)", err, string(body)) + } + if decoded["title"] != "経費" { + t.Errorf("body.title = %v", decoded["title"]) + } + _, _ = w.Write([]byte(`{"total_count":2,"items":[{"docid":1,"form":{"id":10,"code":"c","name":"f"},"writer":"w","title1":"t"},{"docid":2,"form":{"id":10,"code":"c","name":"f"},"writer":"w2","title1":"t2"}]}`)) + })) + defer srv.Close() + + c := clientForServer(srv) + size := 3 + res, err := c.SearchDocuments(context.Background(), + SearchDocumentsParams{Size: &size}, + json.RawMessage(`{"title":"経費"}`), + ) + if err != nil { + t.Fatalf("SearchDocuments: %v", err) + } + if res.TotalCount != 2 || len(res.Items) != 2 || res.Items[0].DocID != 1 { + t.Fatalf("unexpected response: %+v", res) + } +} + +func TestSearchDocuments_DefaultBodyIsEmptyObject(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if strings.TrimSpace(string(body)) != `{}` { + t.Errorf("default body = %q, want {}", string(body)) + } + _, _ = w.Write([]byte(`{"total_count":0,"items":[]}`)) + })) + defer srv.Close() + + c := clientForServer(srv) + if _, err := c.SearchDocuments(context.Background(), SearchDocumentsParams{}, nil); err != nil { + t.Fatalf("SearchDocuments: %v", err) + } +} + +func TestDo_ErrorResponseSurfacesStatusAndBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"invalid stat"}`)) + })) + defer srv.Close() + + c := clientForServer(srv) + _, err := c.ListApprovals(context.Background(), ApprovalsListParams{Stat: 99}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "invalid stat") { + t.Errorf("error should mention status and body, got: %v", err) + } +} + +func TestDo_AppliesAuthHeader(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-ATLED-Generic-API-Token"); got == "" { + t.Error("X-ATLED-Generic-API-Token should be set") + } + _, _ = w.Write([]byte(`{"form_group":[]}`)) + })) + defer srv.Close() + + c := NewClient("unused", Auth{DomainCode: "d", User: "u", GenericAPIToken: "t"}) + c.baseURL = srv.URL + c.http = srv.Client() + if _, err := c.ListAvailableForms(context.Background()); err != nil { + t.Fatalf("ListAvailableForms: %v", err) + } +} + +func TestNewClient_BaseURL(t *testing.T) { + c := NewClient("acme", Auth{AccessToken: "t"}) + if !strings.HasPrefix(c.baseURL, "https://acme.atledcloud.jp/xpoint") { + t.Errorf("baseURL = %q", c.baseURL) + } + // Ensure url.Parse does not choke. + if _, err := url.Parse(c.baseURL); err != nil { + t.Errorf("baseURL not parseable: %v", err) + } +}