Files
2026-08-22 10:12:22 +02:00

390 lines
14 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestConfigRoundTripProtectsToken(t *testing.T) {
configFile := filepath.Join(t.TempDir(), "nested", "config.json")
want := Config{
BaseURL: "https://shop.example",
AccessToken: "secret-token",
TokenType: "Bearer",
ExpiresAt: time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC),
}
if err := saveConfig(configFile, want); err != nil {
t.Fatalf("saveConfig: %v", err)
}
got, err := loadConfig(configFile)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if got.AccessToken != want.AccessToken || !got.ExpiresAt.Equal(want.ExpiresAt) || got.BaseURL != want.BaseURL {
t.Fatalf("round trip = %+v, want %+v", got, want)
}
info, err := os.Stat(configFile)
if err != nil {
t.Fatalf("stat config: %v", err)
}
if mode := info.Mode().Perm(); mode != 0o600 {
t.Fatalf("config mode = %o, want 600", mode)
}
}
func TestConfigPathHonorsXDGConfigHome(t *testing.T) {
xdg := t.TempDir()
t.Setenv("CMSCTL_CONFIG", "")
t.Setenv("XDG_CONFIG_HOME", xdg)
got, err := configPath("")
if err != nil {
t.Fatalf("configPath: %v", err)
}
want := filepath.Join(xdg, "cmsctl", "config.json")
if got != want {
t.Fatalf("configPath = %q, want %q", got, want)
}
}
func TestDeviceLoginPersistsTokenAndBaseURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/auth/device":
_, _ = io.WriteString(w, `{"device_code":"device-1","user_code":"ABCD-EFGH","verification_uri_complete":"http://example/device?code=ABCD-EFGH","expires_in":10,"interval":1}`)
case "/api/auth/device/token":
_, _ = io.WriteString(w, `{"access_token":"persisted-token","token_type":"Bearer","expires_in":3600}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
configFile := filepath.Join(t.TempDir(), "config.json")
var stdout, stderr bytes.Buffer
err := runWithStdin(
[]string{"--config", configFile, "--base-url", server.URL, "auth", "login", "--scope", "read"},
strings.NewReader(""),
&stdout,
&stderr,
)
if err != nil {
t.Fatalf("login: %v (stderr=%s)", err, stderr.String())
}
cfg, err := loadConfig(configFile)
if err != nil {
t.Fatalf("load persisted config: %v", err)
}
if cfg.AccessToken != "persisted-token" {
t.Fatalf("access token = %q", cfg.AccessToken)
}
if cfg.BaseURL != server.URL {
t.Fatalf("base URL = %q, want %q", cfg.BaseURL, server.URL)
}
}
func TestClientSendsBearerToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/nodes" {
t.Errorf("path = %q, want /api/nodes", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer token-123" {
t.Errorf("authorization = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"items":[],"total":0,"offset":0,"limit":50}`)
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL, TokenType: "Bearer"}, "token-123", "", time.Second)
body, err := client.get(context.Background(), client.cmsURL("/nodes"))
if err != nil {
t.Fatalf("get: %v", err)
}
if !json.Valid(body) {
t.Fatalf("response is not JSON: %s", body)
}
}
func TestFinderImportUsesBackofficeBaseURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/catalog/admin/items/batch" {
t.Errorf("finder import path = %q, want /catalog/admin/items/batch", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"status":"ok","inserts":1}`)
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL, FinderURL: "http://reader.invalid"}, "device-token", "", time.Second)
var stdout bytes.Buffer
if err := runFinder(context.Background(), client, []string{"import", "-"}, strings.NewReader(`{"id":1,"title":"Piped"}`), &stdout); err != nil {
t.Fatalf("finder import: %v", err)
}
if !strings.Contains(stdout.String(), `"inserts":1`) {
t.Fatalf("stdout = %q", stdout.String())
}
}
func TestRunCMSCreateFromStdin(t *testing.T) {
var gotAuth, gotBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
data, _ := io.ReadAll(r.Body)
gotBody = string(data)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"node-1","dataType":"page","data":{"title":"Piped"}}`)
}))
defer server.Close()
configFile := filepath.Join(t.TempDir(), "config.json")
var stdout, stderr bytes.Buffer
err := runWithStdin(
[]string{"--config", configFile, "--base-url", server.URL, "--token", "device-token", "cms", "nodes", "create", "--input", "-"},
strings.NewReader(`{"dataType":"page","data":{"title":"Piped"}}`),
&stdout,
&stderr,
)
if err != nil {
t.Fatalf("run: %v (stderr=%s)", err, stderr.String())
}
if gotAuth != "Bearer device-token" {
t.Fatalf("authorization = %q", gotAuth)
}
if gotBody != `{"dataType":"page","data":{"title":"Piped"}}` {
t.Fatalf("request body = %q", gotBody)
}
if !strings.Contains(stdout.String(), `"node-1"`) {
t.Fatalf("stdout = %q", stdout.String())
}
}
func TestScanJSONLinesAcceptsArrayAndJSONL(t *testing.T) {
for _, input := range []string{
`[{"id":1},{"id":2}]`,
"{\"id\":1}\n{\"id\":2}\n",
} {
items, err := scanJSONLines([]byte(input))
if err != nil {
t.Fatalf("scanJSONLines(%q): %v", input, err)
}
if len(items) != 2 {
t.Fatalf("scanJSONLines(%q) = %d items, want 2", input, len(items))
}
}
}
func TestCMSPatchAndEdgeCreateUseNativeEndpoints(t *testing.T) {
var methods, paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPost {
_, _ = io.WriteString(w, `{"id":"edge-1"}`)
return
}
_, _ = io.WriteString(w, `{"id":"node-1","data":{"title":"merged"}}`)
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL}, "token", "", time.Second)
var output bytes.Buffer
if err := runCMSNodes(context.Background(), client, []string{"patch", "node-1", "--input", "-"}, strings.NewReader(`{"data":{"title":"merged"}}`), &output); err != nil {
t.Fatalf("patch: %v", err)
}
if err := runCMSEdges(context.Background(), client, []string{"create", "--from", "page-1", "--to", "node-1"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("edge create: %v", err)
}
if strings.Join(methods, ",") != "PATCH,POST" {
t.Fatalf("methods = %v", methods)
}
if strings.Join(paths, ",") != "/api/nodes/node-1,/api/edges" {
t.Fatalf("paths = %v", paths)
}
}
func TestFinderSearchPassesFiltersAndStreamItemsUsesPipe(t *testing.T) {
var searchRequest map[string]any
var streamBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/find/api/stream":
if err := json.NewDecoder(r.Body).Decode(&searchRequest); err != nil {
t.Errorf("decode search request: %v", err)
}
_, _ = io.WriteString(w, `{"totalHits":1}`)
case "/find/api/stream-items":
data, _ := io.ReadAll(r.Body)
streamBody = string(data)
_, _ = io.WriteString(w, `{"id":42}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL, FinderURL: server.URL + "/find"}, "token", "", time.Second)
var output bytes.Buffer
if err := runFinder(context.Background(), client, []string{"search", "--q", "drill", "--filters", `{"string":[{"id":"brand","value":["Dala"]}]}`, "--stock", "se", "--disable-grouping"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("finder search: %v", err)
}
if searchRequest["query"] != "drill" || searchRequest["disableGrouping"] != true {
t.Fatalf("search request = %#v", searchRequest)
}
if _, ok := searchRequest["string"]; !ok {
t.Fatalf("search request omitted string filters: %#v", searchRequest)
}
if err := runFinder(context.Background(), client, []string{"stream-items", "-"}, strings.NewReader("41\n42\n"), &output); err != nil {
t.Fatalf("stream items: %v", err)
}
if streamBody != "41\n42\n" {
t.Fatalf("stream body = %q", streamBody)
}
}
func TestCMSReadCommandsUseBackofficeBaseURL(t *testing.T) {
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{}`)
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL, FinderURL: "http://reader.invalid"}, "token", "", time.Second)
var output bytes.Buffer
if err := runCMSSchemas(context.Background(), client, []string{"get", "page"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("schema get: %v", err)
}
if err := runCMSTree(context.Background(), client, []string{"root", "--depth", "2"}, &output); err != nil {
t.Fatalf("tree: %v", err)
}
if strings.Join(paths, ",") != "/api/schemas/page,/api/tree/root" {
t.Fatalf("paths = %v", paths)
}
}
func TestFinderAdminAlwaysUsesBackofficeBaseURL(t *testing.T) {
var methods, paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"status":"ok"}`)
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL, FinderURL: "http://reader.invalid"}, "token", "", time.Second)
var output bytes.Buffer
if err := runFinder(context.Background(), client, []string{"admin", "settings", "get"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("admin settings: %v", err)
}
if err := runFinder(context.Background(), client, []string{"admin", "facets", "delete", "brand", "--yes"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("admin facet delete: %v", err)
}
if strings.Join(methods, ",") != "GET,DELETE" || strings.Join(paths, ",") != "/catalog/admin/settings,/catalog/admin/facets/brand" {
t.Fatalf("requests = %v %v", methods, paths)
}
}
func TestTodosListFiltersAndDoneCommands(t *testing.T) {
var requests []struct {
Method string
Path string
Query string
Body string
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
requests = append(requests, struct {
Method string
Path string
Query string
Body string
}{
Method: r.Method,
Path: r.URL.Path,
Query: r.URL.RawQuery,
Body: string(body),
})
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPatch {
_, _ = io.WriteString(w, `{"id":"todo-1","dataType":"todo","data":{"done":true}}`)
return
}
if r.Method == http.MethodPost {
_, _ = io.WriteString(w, `{"id":"todo-created","dataType":"todo"}`)
return
}
_, _ = io.WriteString(w, `{"items":[],"total":0}`)
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL}, "token", "", time.Second)
var output bytes.Buffer
// 1. List with --open
if err := runTodos(context.Background(), client, []string{"list", "--open", "--prio", "high", "--q", "cart"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("todos list --open: %v", err)
}
if len(requests) != 1 || requests[0].Path != "/api/todos" {
t.Fatalf("unexpected request: %+v", requests)
}
if !strings.Contains(requests[0].Query, "done=false") || !strings.Contains(requests[0].Query, "prio=high") || !strings.Contains(requests[0].Query, "q=cart") {
t.Fatalf("unexpected query string: %q", requests[0].Query)
}
// 2. List with --done=true
requests = nil
if err := runTodos(context.Background(), client, []string{"list", "--done=true"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("todos list --done=true: %v", err)
}
if len(requests) != 1 || !strings.Contains(requests[0].Query, "done=true") {
t.Fatalf("expected done=true in query: %q", requests[0].Query)
}
// 3. Mark done
requests = nil
if err := runTodos(context.Background(), client, []string{"done", "todo-123"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("todos done: %v", err)
}
if len(requests) != 1 || requests[0].Method != http.MethodPatch || requests[0].Path != "/api/nodes/todo-123" {
t.Fatalf("unexpected mark done request: %+v", requests)
}
if !strings.Contains(requests[0].Body, `"done":true`) {
t.Fatalf("expected done:true in patch body: %q", requests[0].Body)
}
// 4. Reopen
requests = nil
if err := runTodos(context.Background(), client, []string{"reopen", "todo-123"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("todos reopen: %v", err)
}
if len(requests) != 1 || requests[0].Method != http.MethodPatch || requests[0].Path != "/api/nodes/todo-123" {
t.Fatalf("unexpected reopen request: %+v", requests)
}
if !strings.Contains(requests[0].Body, `"done":false`) {
t.Fatalf("expected done:false in reopen body: %q", requests[0].Body)
}
// 5. Create
requests = nil
if err := runTodos(context.Background(), client, []string{"create", "--note", "Fix button", "--prio", "high", "--location", "Admin"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("todos create: %v", err)
}
if len(requests) != 1 || requests[0].Method != http.MethodPost || requests[0].Path != "/api/nodes" {
t.Fatalf("unexpected create request: %+v", requests)
}
if !strings.Contains(requests[0].Body, `"dataType":"todo"`) || !strings.Contains(requests[0].Body, `"note":"Fix button"`) {
t.Fatalf("unexpected create body: %q", requests[0].Body)
}
}