diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..ff7c78e --- /dev/null +++ b/.env.test.example @@ -0,0 +1,6 @@ +# e2e test credentials template. +# Copy to .env.test and fill in real tokens (that file is git-excluded). + +FIZZY_TEST_TOKEN=fizzy_your_token_here +FIZZY_TEST_ACCOUNT=1234567 +FIZZY_TEST_API_URL=https://app.fizzy.do diff --git a/.gitignore b/.gitignore index d4b89a2..2cb63a4 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ completions/ # Profiling profiles/ default.pgo + +# Local test credentials +.env.test diff --git a/Makefile b/Makefile index 3fcea08..29495b5 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,17 @@ -.PHONY: test test-unit test-e2e test-go test-file test-run build clean tidy help \ +.PHONY: test test-unit test-e2e e2e test-go test-file e2e-file test-run e2e-run build clean tidy help \ check-toolchain fmt fmt-check vet lint tidy-check race-test vuln secrets \ replace-check security check release-check release tools \ surface-snapshot surface-check lint-actions BINARY := $(CURDIR)/bin/fizzy +FIZZY_TEST_BINARY ?= $(BINARY) + +# Load local test credentials if present, but refuse tracked local secret files. +ifneq ($(shell git ls-files --error-unmatch .env.test >/dev/null 2>&1 && echo tracked),) +$(error .env.test is tracked by Git. Remove it from version control and keep local secret files untracked) +endif +-include .env.test +export FIZZY_TEST_TOKEN FIZZY_TEST_ACCOUNT FIZZY_TEST_API_URL FIZZY_TEST_BINARY VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS := -X main.version=$(VERSION) @@ -20,10 +28,13 @@ help: @echo "Usage:" @echo " make build Build the CLI" @echo " make test-unit Run unit tests (no API required)" - @echo " make test-e2e Run e2e tests (requires API credentials)" - @echo " make test Alias for test-e2e" - @echo " make test-file Run a specific e2e test file" - @echo " make test-run Run a specific e2e test by name" + @echo " make e2e Run owner-only CLI contract e2e tests" + @echo " make test-e2e Alias for e2e" + @echo " make test Alias for e2e" + @echo " make e2e-file Run a specific CLI contract e2e test file" + @echo " make test-file Alias for e2e-file" + @echo " make e2e-run Run a specific CLI contract e2e test by name" + @echo " make test-run Alias for e2e-run" @echo " make clean Remove build artifacts" @echo " make tidy Tidy dependencies" @echo "" @@ -45,17 +56,19 @@ help: @echo " make tools Install dev tools" @echo "" @echo "Environment variables (required for e2e tests):" - @echo " FIZZY_TEST_TOKEN API token" - @echo " FIZZY_TEST_ACCOUNT Account slug" - @echo " FIZZY_TEST_API_URL API base URL (default: https://app.fizzy.do)" - @echo " FIZZY_TEST_USER_ID User ID for user update/deactivate tests (optional)" + @echo " FIZZY_TEST_TOKEN API token" + @echo " FIZZY_TEST_ACCOUNT Account slug" + @echo " FIZZY_TEST_API_URL API base URL (default: https://app.fizzy.do)" + @echo " FIZZY_TEST_BINARY Prebuilt binary path (optional)" + @echo " FIZZY_E2E_KEEP_FIXTURE Set to 1 to skip final fixture teardown" + @echo " FIZZY_E2E_TEARDOWN_DELAY Delay teardown by N seconds" @echo "" @echo "Examples:" @echo " make build" @echo " make test-unit" @echo " export FIZZY_TEST_TOKEN=your-token" @echo " export FIZZY_TEST_ACCOUNT=your-account" - @echo " make test-e2e" + @echo " make e2e" # Toolchain guard — fails fast when PATH go and GOROOT go disagree check-toolchain: @@ -80,28 +93,33 @@ test-unit: check-toolchain go test -v ./internal/... # Run e2e tests (requires API credentials) -test-e2e: build +e2e: build @if [ -z "$$FIZZY_TEST_TOKEN" ]; then echo "Error: FIZZY_TEST_TOKEN not set"; exit 1; fi @if [ -z "$$FIZZY_TEST_ACCOUNT" ]; then echo "Error: FIZZY_TEST_ACCOUNT not set"; exit 1; fi - FIZZY_TEST_BINARY=$(BINARY) go test -v ./e2e/tests/... + go test -v -count=1 -timeout 10m ./e2e/cli_tests/... -# Alias for test-e2e -test: test-e2e -test-go: test-e2e +test-e2e: e2e -# Run a single test file (e.g., make test-file FILE=board) -test-file: build - @if [ -z "$(FILE)" ]; then echo "Usage: make test-file FILE=board"; exit 1; fi +test: e2e +test-go: e2e + +# Run a single test file (e.g., make e2e-file FILE=crud_board) +e2e-file: build + @if [ -z "$(FILE)" ]; then echo "Usage: make e2e-file FILE=crud_board"; exit 1; fi @if [ -z "$$FIZZY_TEST_TOKEN" ]; then echo "Error: FIZZY_TEST_TOKEN not set"; exit 1; fi @if [ -z "$$FIZZY_TEST_ACCOUNT" ]; then echo "Error: FIZZY_TEST_ACCOUNT not set"; exit 1; fi - FIZZY_TEST_BINARY=$(BINARY) go test -v ./e2e/tests/$(FILE)_test.go + go test -v -count=1 ./e2e/cli_tests/$(FILE)_test.go + +test-file: e2e-file -# Run a single test by name (e.g., make test-run NAME=TestBoardCRUD) -test-run: build - @if [ -z "$(NAME)" ]; then echo "Usage: make test-run NAME=TestBoardCRUD"; exit 1; fi +# Run a single test by name (e.g., make e2e-run NAME=TestBoardList) +e2e-run: build + @if [ -z "$(NAME)" ]; then echo "Usage: make e2e-run NAME=TestBoardList"; exit 1; fi @if [ -z "$$FIZZY_TEST_TOKEN" ]; then echo "Error: FIZZY_TEST_TOKEN not set"; exit 1; fi @if [ -z "$$FIZZY_TEST_ACCOUNT" ]; then echo "Error: FIZZY_TEST_ACCOUNT not set"; exit 1; fi - FIZZY_TEST_BINARY=$(BINARY) go test -v -run $(NAME) ./e2e/tests/... + go test -v -count=1 -run $(NAME) ./e2e/cli_tests/... + +test-run: e2e-run # Format Go source fmt: diff --git a/README.md b/README.md index 39b999c..8b26c40 100644 --- a/README.md +++ b/README.md @@ -188,9 +188,20 @@ fizzy skill install ```bash make build # Build binary make test-unit # Run unit tests (no API required) -make test-e2e # Run e2e tests (requires FIZZY_TEST_TOKEN, FIZZY_TEST_ACCOUNT) +make e2e # Run owner-only CLI contract e2e suite +make e2e-run NAME=TestBoardList ``` +E2E requirements: +- `FIZZY_TEST_TOKEN` +- `FIZZY_TEST_ACCOUNT` +- optional: `FIZZY_TEST_API_URL` +- optional: `FIZZY_TEST_BINARY` + +Useful local inspection modes: +- `FIZZY_E2E_KEEP_FIXTURE=1 make e2e` +- `FIZZY_E2E_TEARDOWN_DELAY=120 make e2e` + ## License [MIT](LICENSE) diff --git a/e2e/cli_tests/account_user_test.go b/e2e/cli_tests/account_user_test.go new file mode 100644 index 0000000..7e8d3fb --- /dev/null +++ b/e2e/cli_tests/account_user_test.go @@ -0,0 +1,124 @@ +package clitests + +import ( + "strconv" + "testing" +) + +func TestAccountShow(t *testing.T) { + assertOK(t, newHarness(t).Run("account", "show")) +} + +func TestAccountSettingsUpdateWithCurrentName(t *testing.T) { + h := newHarness(t) + show := h.Run("account", "show") + assertOK(t, show) + currentName := show.GetDataString("name") + if currentName == "" { + t.Skip("account show returned no name") + } + assertOK(t, h.Run("account", "settings-update", "--name", currentName)) + show = h.Run("account", "show") + assertOK(t, show) + if got := show.GetDataString("name"); got != currentName { + t.Fatalf("expected account name %q after settings-update, got %q", currentName, got) + } +} + +func TestAccountEntropyWithCurrentValue(t *testing.T) { + h := newHarness(t) + show := h.Run("account", "show") + assertOK(t, show) + days := show.GetDataInt("auto_postpone_period_in_days") + if days == 0 { + days = 7 + } + assertOK(t, h.Run("account", "entropy", "--auto_postpone_period_in_days", strconv.Itoa(days))) + show = h.Run("account", "show") + assertOK(t, show) + if got := show.GetDataInt("auto_postpone_period_in_days"); got != days { + t.Fatalf("expected auto_postpone_period_in_days=%d, got %d", days, got) + } +} + +func TestAccountJoinCodeShow(t *testing.T) { + assertOK(t, newHarness(t).Run("account", "join-code-show")) +} + +func TestAccountExportCreateShow(t *testing.T) { + h := newHarness(t) + create := h.Run("account", "export-create") + assertOK(t, create) + exportID := create.GetDataString("id") + if exportID == "" { + exportID = mapValueString(create.GetDataMap(), "id") + } + if exportID == "" { + t.Fatal("expected export ID in export-create response") + } + show := h.Run("account", "export-show", exportID) + assertOK(t, show) + if got := mapValueString(show.GetDataMap(), "id"); got != exportID { + t.Fatalf("expected export-show id %q, got %q", exportID, got) + } + if got := mapValueString(show.GetDataMap(), "status"); got == "" { + t.Fatal("expected export status in export-show response") + } +} + +func TestUserList(t *testing.T) { + result := newHarness(t).Run("user", "list") + assertOK(t, result) + if result.GetDataArray() == nil { + t.Fatal("expected array response") + } +} + +func TestUserShowAndUpdateOwnProfile(t *testing.T) { + h := newHarness(t) + userID := currentUserID(t, h) + show := h.Run("user", "show", userID) + assertOK(t, show) + currentName := show.GetDataString("name") + if currentName == "" { + t.Skip("user show returned no name") + } + assertOK(t, h.Run("user", "update", userID, "--name", currentName)) + show = h.Run("user", "show", userID) + assertOK(t, show) + if got := show.GetDataString("name"); got != currentName { + t.Fatalf("expected user name %q after update, got %q", currentName, got) + } +} + +func TestUserAvatarUpdateAndRemove(t *testing.T) { + h := newHarness(t) + userID := currentUserID(t, h) + fixturePath := fixtureFile(t, "test_image.png") + + show := h.Run("user", "show", userID) + assertOK(t, show) + avatarURL := show.GetDataString("avatar_url") + if avatarURL == "" { + t.Skip("user show returned no avatar_url") + } + initiallyAttached := avatarRedirects(t, avatarURL) + if initiallyAttached { + t.Cleanup(func() { + assertOK(t, newHarness(t).Run("user", "update", userID, "--avatar", fixturePath)) + if !avatarRedirects(t, avatarURL) { + t.Fatal("expected avatar to be restored") + } + }) + } + + assertOK(t, h.Run("user", "update", userID, "--avatar", fixturePath)) + if !avatarRedirects(t, avatarURL) { + t.Fatal("expected uploaded avatar endpoint to redirect to an image blob") + } + + assertOK(t, h.Run("user", "avatar-remove", userID)) + if avatarRedirects(t, avatarURL) { + t.Fatal("expected avatar endpoint to fall back to generated SVG after removal") + } +} diff --git a/e2e/cli_tests/crud_board_test.go b/e2e/cli_tests/crud_board_test.go new file mode 100644 index 0000000..065da8d --- /dev/null +++ b/e2e/cli_tests/crud_board_test.go @@ -0,0 +1,144 @@ +package clitests + +import ( + "fmt" + "testing" + "time" + + "github.com/basecamp/fizzy-cli/e2e/harness" +) + +func TestBoardList(t *testing.T) { + h := newHarness(t) + result := h.Run("board", "list") + assertOK(t, result) + if result.GetDataArray() == nil { + t.Fatal("expected array response") + } +} + +func TestBoardListAll(t *testing.T) { + assertOK(t, newHarness(t).Run("board", "list", "--all")) +} + +func TestBoardListPaginated(t *testing.T) { + assertOK(t, newHarness(t).Run("board", "list", "--page", "1")) +} + +func TestBoardShow(t *testing.T) { + result := newHarness(t).Run("board", "show", fixture.BoardID) + assertOK(t, result) + if got := result.GetDataString("id"); got != fixture.BoardID { + t.Fatalf("expected board id %q, got %q", fixture.BoardID, got) + } +} + +func TestBoardShowNotFound(t *testing.T) { + assertResult(t, newHarness(t).Run("board", "show", "nonexistent-board-id-99999"), harness.ExitNotFound) +} + +func TestBoardCreateUpdateDelete(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + updatedName := fmt.Sprintf("Updated Board %d", time.Now().UnixNano()) + result := h.Run("board", "update", boardID, "--name", updatedName) + assertOK(t, result) + + show := h.Run("board", "show", boardID) + assertOK(t, show) + if got := show.GetDataString("name"); got != updatedName { + t.Fatalf("expected updated board name %q, got %q", updatedName, got) + } + + deleteResult := h.Run("board", "delete", boardID) + assertOK(t, deleteResult) + if !deleteResult.GetDataBool("deleted") { + t.Fatal("expected deleted=true") + } + assertResult(t, h.Run("board", "show", boardID), harness.ExitNotFound) +} + +func TestBoardPublishUnpublish(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + publish := h.Run("board", "publish", boardID) + assertOK(t, publish) + publicURL := publish.GetDataString("public_url") + if publicURL == "" { + t.Fatal("expected public_url in publish response") + } + + showPublished := h.Run("board", "show", boardID) + assertOK(t, showPublished) + if got := showPublished.GetDataString("public_url"); got != publicURL { + t.Fatalf("expected published board public_url %q, got %q", publicURL, got) + } + + assertOK(t, h.Run("board", "unpublish", boardID)) + showUnpublished := h.Run("board", "show", boardID) + assertOK(t, showUnpublished) + if got := showUnpublished.GetDataString("public_url"); got != "" { + t.Fatalf("expected public_url to be cleared after unpublish, got %q", got) + } +} + +func TestBoardEntropy(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + show := h.Run("board", "show", boardID) + assertOK(t, show) + currentDays := show.GetDataInt("auto_postpone_period_in_days") + if currentDays == 0 { + currentDays = 30 + } + assertOK(t, h.Run("board", "entropy", boardID, "--auto_postpone_period_in_days", fmt.Sprintf("%d", currentDays))) + show = h.Run("board", "show", boardID) + assertOK(t, show) + if got := show.GetDataInt("auto_postpone_period_in_days"); got != currentDays { + t.Fatalf("expected auto_postpone_period_in_days=%d, got %d", currentDays, got) + } +} + +func TestBoardViews(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + + streamCard := createCard(t, h, boardID) + closedCard := createCard(t, h, boardID) + postponedCard := createCard(t, h, boardID) + assertOK(t, h.Run("card", "close", fmt.Sprintf("%d", closedCard))) + assertOK(t, h.Run("card", "postpone", fmt.Sprintf("%d", postponedCard))) + + stream := h.Run("board", "stream", "--board", boardID) + assertOK(t, stream) + if listMapByNumber(stream.GetDataArray(), streamCard) == nil { + t.Fatalf("expected stream view to include card #%d", streamCard) + } + if listMapByNumber(stream.GetDataArray(), closedCard) != nil { + t.Fatalf("expected stream view to exclude closed card #%d", closedCard) + } + if listMapByNumber(stream.GetDataArray(), postponedCard) != nil { + t.Fatalf("expected stream view to exclude postponed card #%d", postponedCard) + } + + closed := h.Run("board", "closed", "--board", boardID) + assertOK(t, closed) + if listMapByNumber(closed.GetDataArray(), closedCard) == nil { + t.Fatalf("expected closed view to include card #%d", closedCard) + } + + postponed := h.Run("board", "postponed", "--board", boardID) + assertOK(t, postponed) + if listMapByNumber(postponed.GetDataArray(), postponedCard) == nil { + t.Fatalf("expected postponed view to include card #%d", postponedCard) + } +} + +func TestBoardInvolvement(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + // There is currently no CLI command that reads back board involvement, so this + // remains a command-contract check until the CLI exposes that state. + assertOK(t, h.Run("board", "involvement", boardID, "--involvement", "watching")) + assertOK(t, h.Run("board", "involvement", boardID, "--involvement", "access_only")) +} diff --git a/e2e/cli_tests/crud_card_test.go b/e2e/cli_tests/crud_card_test.go new file mode 100644 index 0000000..ef8c01d --- /dev/null +++ b/e2e/cli_tests/crud_card_test.go @@ -0,0 +1,269 @@ +package clitests + +import ( + "strconv" + "testing" + "time" + + "github.com/basecamp/fizzy-cli/e2e/harness" +) + +func TestCardList(t *testing.T) { + assertOK(t, newHarness(t).Run("card", "list")) +} + +func TestCardListOnBoard(t *testing.T) { + result := newHarness(t).Run("card", "list", "--board", fixture.BoardID) + assertOK(t, result) + if result.GetDataArray() == nil { + t.Fatal("expected array response") + } +} + +func TestCardListAll(t *testing.T) { + assertOK(t, newHarness(t).Run("card", "list", "--board", fixture.BoardID, "--all")) +} + +func TestCardShow(t *testing.T) { + assertOK(t, newHarness(t).Run("card", "show", strconv.Itoa(fixture.CardNumber))) +} + +func TestCardShowNotFound(t *testing.T) { + assertResult(t, newHarness(t).Run("card", "show", "999999999"), harness.ExitNotFound) +} + +func TestCardLifecycle(t *testing.T) { + h := newHarness(t) + num := createCard(t, h, fixture.BoardID) + numStr := strconv.Itoa(num) + currentUser := currentUserID(t, h) + updatedTitle := "Updated Card" + tagTitle := "cli-test" + + assertOK(t, h.Run("card", "update", numStr, "--title", updatedTitle)) + show := h.Run("card", "show", numStr) + assertOK(t, show) + if got := show.GetDataString("title"); got != updatedTitle { + t.Fatalf("expected updated title %q, got %q", updatedTitle, got) + } + + assertOK(t, h.Run("card", "column", numStr, "--column", fixture.ColumnID)) + show = h.Run("card", "show", numStr) + assertOK(t, show) + if got := mapValueString(asMap(show.GetDataMap()["column"]), "id"); got != fixture.ColumnID { + t.Fatalf("expected card column %q, got %q", fixture.ColumnID, got) + } + + // Watch/unwatch currently have no CLI readback path on card show/list. + assertOK(t, h.Run("card", "watch", numStr)) + assertOK(t, h.Run("card", "unwatch", numStr)) + + // Mark-read/mark-unread likewise have no card-scoped CLI readback state today. + assertOK(t, h.Run("card", "mark-read", numStr)) + assertOK(t, h.Run("card", "mark-unread", numStr)) + + assertOK(t, h.Run("card", "pin", numStr)) + pins := h.Run("pin", "list") + assertOK(t, pins) + if listMapByNumber(pins.GetDataArray(), num) == nil { + t.Fatalf("expected pin list to include card #%d after pin", num) + } + assertOK(t, h.Run("card", "unpin", numStr)) + pins = h.Run("pin", "list") + assertOK(t, pins) + if listMapByNumber(pins.GetDataArray(), num) != nil { + t.Fatalf("expected pin list to exclude card #%d after unpin", num) + } + + assertOK(t, h.Run("card", "golden", numStr)) + show = h.Run("card", "show", numStr) + assertOK(t, show) + if !show.GetDataBool("golden") { + t.Fatal("expected card to be golden after golden command") + } + assertOK(t, h.Run("card", "ungolden", numStr)) + show = h.Run("card", "show", numStr) + assertOK(t, show) + if show.GetDataBool("golden") { + t.Fatal("expected card to no longer be golden after ungolden command") + } + + assertOK(t, h.Run("card", "tag", numStr, "--tag", tagTitle)) + tags := h.Run("tag", "list") + assertOK(t, tags) + var tagID string + for _, item := range tags.GetDataArray() { + m := asMap(item) + if mapValueString(m, "title") == tagTitle { + tagID = mapValueString(m, "id") + break + } + } + if tagID == "" { + t.Fatalf("expected tag list to include %q", tagTitle) + } + taggedCards := h.Run("card", "list", "--tag", tagID) + assertOK(t, taggedCards) + if listMapByNumber(taggedCards.GetDataArray(), num) == nil { + t.Fatalf("expected card list for tag %q to include card #%d", tagTitle, num) + } + + assertOK(t, h.Run("card", "self-assign", numStr)) + show = h.Run("card", "show", numStr) + assertOK(t, show) + if listMapByID(asSlice(show.GetDataMap()["assignees"]), currentUser) == nil { + t.Fatalf("expected assignees to include current user %q", currentUser) + } + + assertOK(t, h.Run("card", "close", numStr)) + show = h.Run("card", "show", numStr) + assertOK(t, show) + if !show.GetDataBool("closed") { + t.Fatal("expected card to be closed after close command") + } + + assertOK(t, h.Run("card", "reopen", numStr)) + show = h.Run("card", "show", numStr) + assertOK(t, show) + if show.GetDataBool("closed") { + t.Fatal("expected card to be open after reopen command") + } + + assertOK(t, h.Run("card", "postpone", numStr)) + show = h.Run("card", "show", numStr) + assertOK(t, show) + if !show.GetDataBool("postponed") { + t.Fatal("expected card to be postponed after postpone command") + } + + assertOK(t, h.Run("card", "untriage", numStr)) + show = h.Run("card", "show", numStr) + assertOK(t, show) + if show.GetDataBool("postponed") { + t.Fatal("expected card to no longer be postponed after untriage command") + } +} + +func TestCardAssignToCurrentUser(t *testing.T) { + h := newHarness(t) + num := createCard(t, h, fixture.BoardID) + userID := currentUserID(t, h) + + assertOK(t, h.Run("card", "assign", strconv.Itoa(num), "--user", userID)) + + show := h.Run("card", "show", strconv.Itoa(num)) + assertOK(t, show) + assignees := asSlice(show.GetDataMap()["assignees"]) + if len(assignees) == 0 { + t.Fatal("expected assigned card to include assignees") + } + for _, item := range assignees { + if mapValueString(asMap(item), "id") == userID { + return + } + } + t.Fatalf("expected assignees to include user %q", userID) +} + +func TestCardImageRemove(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + ref := uploadFixture(t, h, "test_image.png") + result := h.Run("card", "create", + "--board", boardID, + "--title", "Image Remove Card "+strconv.FormatInt(time.Now().UnixNano(), 10), + "--image", ref.SignedID, + ) + assertOK(t, result) + num := result.GetNumberFromLocation() + if num == 0 { + num = result.GetDataInt("number") + } + if num == 0 { + t.Fatal("no card number in create response") + } + t.Cleanup(func() { newHarness(t).Run("card", "delete", strconv.Itoa(num)) }) + + showWithImage := h.Run("card", "show", strconv.Itoa(num)) + assertOK(t, showWithImage) + if got := showWithImage.GetDataString("image_url"); got == "" { + t.Fatal("expected image_url before image removal") + } + + assertOK(t, h.Run("card", "image-remove", strconv.Itoa(num))) + + showWithoutImage := h.Run("card", "show", strconv.Itoa(num)) + assertOK(t, showWithoutImage) + if got := showWithoutImage.GetDataString("image_url"); got != "" { + t.Fatalf("expected image_url to be cleared, got %q", got) + } +} + +func TestCardMoveBetweenBoards(t *testing.T) { + h := newHarness(t) + destinationBoardID := createBoard(t, h) + num := createCard(t, h, fixture.BoardID) + assertOK(t, h.Run("card", "move", strconv.Itoa(num), "--to", destinationBoardID)) + show := h.Run("card", "show", strconv.Itoa(num)) + assertOK(t, show) + if got := mapValueString(asMap(show.GetDataMap()["board"]), "id"); got != destinationBoardID { + t.Fatalf("expected moved card board %q, got %q", destinationBoardID, got) + } +} + +func TestCardAttachmentsShow(t *testing.T) { + assertOK(t, newHarness(t).Run("card", "attachments", "show", strconv.Itoa(fixture.CardNumber))) +} + +func TestCardDelete(t *testing.T) { + h := newHarness(t) + num := createCard(t, h, fixture.BoardID) + deleteResult := h.Run("card", "delete", strconv.Itoa(num)) + assertOK(t, deleteResult) + if !deleteResult.GetDataBool("deleted") { + t.Fatal("expected deleted=true") + } + assertResult(t, h.Run("card", "show", strconv.Itoa(num)), harness.ExitNotFound) +} + +func TestCardCreateRoundTrip(t *testing.T) { + h := newHarness(t) + result := h.Run("card", "create", "--board", fixture.BoardID, "--title", "Round Trip Card", "--description", "created by cli tests") + assertOK(t, result) + num := result.GetNumberFromLocation() + if num == 0 { + num = result.GetDataInt("number") + } + if num == 0 { + t.Fatal("no card number in create response") + } + t.Cleanup(func() { newHarness(t).Run("card", "delete", strconv.Itoa(num)) }) + show := h.Run("card", "show", strconv.Itoa(num)) + assertOK(t, show) + if got := show.GetDataString("title"); got != "Round Trip Card" { + t.Fatalf("expected card title %q, got %q", "Round Trip Card", got) + } + if got := mapValueString(asMap(show.GetDataMap()["board"]), "id"); got != fixture.BoardID { + t.Fatalf("expected card board %q, got %q", fixture.BoardID, got) + } +} + +func TestCardCreateWithUniqueTitle(t *testing.T) { + h := newHarness(t) + title := "Unique Card " + strconv.FormatInt(time.Now().UnixNano(), 10) + result := h.Run("card", "create", "--board", fixture.BoardID, "--title", title) + assertOK(t, result) + num := result.GetNumberFromLocation() + if num == 0 { + num = result.GetDataInt("number") + } + if num == 0 { + t.Fatal("no card number in create response") + } + t.Cleanup(func() { newHarness(t).Run("card", "delete", strconv.Itoa(num)) }) + show := h.Run("card", "show", strconv.Itoa(num)) + assertOK(t, show) + if got := show.GetDataString("title"); got != title { + t.Fatalf("expected card title %q, got %q", title, got) + } +} diff --git a/e2e/cli_tests/crud_subresources_test.go b/e2e/cli_tests/crud_subresources_test.go new file mode 100644 index 0000000..f3f4d60 --- /dev/null +++ b/e2e/cli_tests/crud_subresources_test.go @@ -0,0 +1,286 @@ +package clitests + +import ( + "strconv" + "testing" + "time" + + "github.com/basecamp/fizzy-cli/e2e/harness" +) + +func TestColumnCRUDAndMove(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + leftID := createColumn(t, h, boardID, "Left") + rightID := createColumn(t, h, boardID, "Right") + + list := h.Run("column", "list", "--board", boardID) + assertOK(t, list) + initial := list.GetDataArray() + if initial == nil { + t.Fatal("expected array response") + } + if listIndexByID(initial, leftID) == -1 || listIndexByID(initial, rightID) == -1 { + t.Fatal("expected initial column list to include both columns") + } + + show := h.Run("column", "show", leftID, "--board", boardID) + assertOK(t, show) + if got := show.GetDataString("name"); got != "Left" { + t.Fatalf("expected column name %q, got %q", "Left", got) + } + + assertOK(t, h.Run("column", "update", leftID, "--board", boardID, "--name", "Renamed Left")) + show = h.Run("column", "show", leftID, "--board", boardID) + assertOK(t, show) + if got := show.GetDataString("name"); got != "Renamed Left" { + t.Fatalf("expected renamed column, got %q", got) + } + + assertOK(t, h.Run("column", "move-right", leftID)) + afterMoveRight := h.Run("column", "list", "--board", boardID) + assertOK(t, afterMoveRight) + if listIndexByID(afterMoveRight.GetDataArray(), leftID) <= listIndexByID(afterMoveRight.GetDataArray(), rightID) { + t.Fatal("expected left column to appear after right column after move-right") + } + + assertOK(t, h.Run("column", "move-left", leftID)) + afterMoveLeft := h.Run("column", "list", "--board", boardID) + assertOK(t, afterMoveLeft) + if listIndexByID(afterMoveLeft.GetDataArray(), leftID) >= listIndexByID(afterMoveLeft.GetDataArray(), rightID) { + t.Fatal("expected left column to appear before right column after move-left") + } + + assertOK(t, h.Run("column", "delete", rightID, "--board", boardID)) + assertResult(t, h.Run("column", "show", rightID, "--board", boardID), harness.ExitNotFound) +} + +func TestCommentCRUD(t *testing.T) { + h := newHarness(t) + cardNum := fixture.CardNumber + cardStr := strconv.Itoa(cardNum) + + list := h.Run("comment", "list", "--card", cardStr) + assertOK(t, list) + assertOK(t, h.Run("comment", "show", fixture.CommentID, "--card", cardStr)) + + commentID := createComment(t, h, cardNum, "CLI comment") + assertOK(t, h.Run("comment", "update", commentID, "--card", cardStr, "--body", "Updated CLI comment")) + show := h.Run("comment", "show", commentID, "--card", cardStr) + assertOK(t, show) + if got := bodyPlainText(show.GetDataMap()); got != "Updated CLI comment" { + t.Fatalf("expected updated comment body %q, got %q", "Updated CLI comment", got) + } + + assertOK(t, h.Run("comment", "delete", commentID, "--card", cardStr)) + comments := h.Run("comment", "list", "--card", cardStr) + assertOK(t, comments) + if listMapByID(comments.GetDataArray(), commentID) != nil { + t.Fatalf("expected deleted comment %q to be absent from list", commentID) + } +} + +func TestStepCRUD(t *testing.T) { + h := newHarness(t) + cardStr := strconv.Itoa(fixture.CardNumber) + list := h.Run("step", "list", "--card", cardStr) + assertOK(t, list) + assertOK(t, h.Run("step", "show", fixture.StepID, "--card", cardStr)) + + stepID := createStep(t, h, fixture.CardNumber, "CLI step") + assertOK(t, h.Run("step", "update", stepID, "--card", cardStr, "--content", "Updated CLI step")) + show := h.Run("step", "show", stepID, "--card", cardStr) + assertOK(t, show) + if got := show.GetDataString("content"); got != "Updated CLI step" { + t.Fatalf("expected updated step content %q, got %q", "Updated CLI step", got) + } + + assertOK(t, h.Run("step", "delete", stepID, "--card", cardStr)) + steps := h.Run("step", "list", "--card", cardStr) + assertOK(t, steps) + if listMapByID(steps.GetDataArray(), stepID) != nil { + t.Fatalf("expected deleted step %q to be absent from list", stepID) + } +} + +func TestReactionCRUD(t *testing.T) { + h := newHarness(t) + userID := currentUserID(t, h) + cardStr := strconv.Itoa(fixture.CardNumber) + cardReactionContent := "+1" + commentReactionContent := "heart" + + cardReactionsBefore := h.Run("reaction", "list", "--card", cardStr) + assertOK(t, cardReactionsBefore) + commentReactionsBefore := h.Run("reaction", "list", "--card", cardStr, "--comment", fixture.CommentID) + assertOK(t, commentReactionsBefore) + + cardReaction := h.Run("reaction", "create", "--card", cardStr, "--content", cardReactionContent) + assertOK(t, cardReaction) + cardReactions := h.Run("reaction", "list", "--card", cardStr) + assertOK(t, cardReactions) + cardReactionID := addedReactionID(cardReactionsBefore.GetDataArray(), cardReactions.GetDataArray(), cardReactionContent, userID) + if cardReactionID == "" { + t.Fatal("expected exactly one created card reaction for the current user to appear in list") + } + t.Cleanup(func() { + if cardReactionID != "" { + newHarness(t).Run("reaction", "delete", cardReactionID, "--card", cardStr) + } + }) + if listMapByID(cardReactions.GetDataArray(), cardReactionID) == nil { + t.Fatalf("expected card reaction list to include %q", cardReactionID) + } + deletedCardReactionID := cardReactionID + assertOK(t, h.Run("reaction", "delete", cardReactionID, "--card", cardStr)) + cardReactionID = "" + cardReactions = h.Run("reaction", "list", "--card", cardStr) + assertOK(t, cardReactions) + if listMapByID(cardReactions.GetDataArray(), deletedCardReactionID) != nil { + t.Fatalf("expected deleted card reaction %q to be absent from list", deletedCardReactionID) + } + + commentReaction := h.Run("reaction", "create", "--card", cardStr, "--comment", fixture.CommentID, "--content", commentReactionContent) + assertOK(t, commentReaction) + commentReactions := h.Run("reaction", "list", "--card", cardStr, "--comment", fixture.CommentID) + assertOK(t, commentReactions) + commentReactionID := addedReactionID(commentReactionsBefore.GetDataArray(), commentReactions.GetDataArray(), commentReactionContent, userID) + if commentReactionID == "" { + t.Fatal("expected exactly one created comment reaction for the current user to appear in list") + } + t.Cleanup(func() { + if commentReactionID != "" { + newHarness(t).Run("reaction", "delete", commentReactionID, "--card", cardStr, "--comment", fixture.CommentID) + } + }) + if listMapByID(commentReactions.GetDataArray(), commentReactionID) == nil { + t.Fatalf("expected comment reaction list to include %q", commentReactionID) + } + deletedCommentReactionID := commentReactionID + assertOK(t, h.Run("reaction", "delete", commentReactionID, "--card", cardStr, "--comment", fixture.CommentID)) + commentReactionID = "" + commentReactions = h.Run("reaction", "list", "--card", cardStr, "--comment", fixture.CommentID) + assertOK(t, commentReactions) + if listMapByID(commentReactions.GetDataArray(), deletedCommentReactionID) != nil { + t.Fatalf("expected deleted comment reaction %q to be absent from list", deletedCommentReactionID) + } +} + +func TestNotificationCommands(t *testing.T) { + h := newHarness(t) + assertOK(t, h.Run("notification", "list")) + assertOK(t, h.Run("notification", "tray")) + assertOK(t, h.Run("notification", "settings-show")) + + show := h.Run("notification", "settings-show") + assertOK(t, show) + currentFreq := show.GetDataString("bundle_email_frequency") + if currentFreq == "" { + currentFreq = "never" + } + assertOK(t, h.Run("notification", "settings-update", "--bundle-email-frequency", currentFreq)) + updatedSettings := h.Run("notification", "settings-show") + assertOK(t, updatedSettings) + if got := updatedSettings.GetDataString("bundle_email_frequency"); got != currentFreq { + t.Fatalf("expected bundle_email_frequency %q, got %q", currentFreq, got) + } + + id := notificationID(t, h) + assertOK(t, h.Run("notification", "unread", id)) + tray := h.Run("notification", "tray", "--include-read") + assertOK(t, tray) + notif := listMapByID(tray.GetDataArray(), id) + if notif == nil { + t.Fatalf("expected notification tray to include %q", id) + } + if read, ok := notif["read"].(bool); !ok || read { + t.Fatal("expected notification to be unread after unread command") + } + + assertOK(t, h.Run("notification", "read", id)) + tray = h.Run("notification", "tray", "--include-read") + assertOK(t, tray) + notif = listMapByID(tray.GetDataArray(), id) + if notif == nil { + t.Fatalf("expected notification tray to include %q after read", id) + } + if read, ok := notif["read"].(bool); !ok || !read { + t.Fatal("expected notification to be read after read command") + } + + assertOK(t, h.Run("notification", "unread", id)) + assertOK(t, h.Run("notification", "read-all")) + tray = h.Run("notification", "tray", "--include-read") + assertOK(t, tray) + notif = listMapByID(tray.GetDataArray(), id) + if notif == nil { + t.Fatalf("expected notification tray to include %q after read-all", id) + } + if read, ok := notif["read"].(bool); !ok || !read { + t.Fatal("expected notification to be read after read-all") + } +} + +func TestTagAndPinLists(t *testing.T) { + h := newHarness(t) + cardNum := createCard(t, h, fixture.BoardID) + cardStr := strconv.Itoa(cardNum) + tagTitle := "cli-test" + + assertOK(t, h.Run("card", "tag", cardStr, "--tag", tagTitle)) + tagResult := h.Run("tag", "list") + assertOK(t, tagResult) + if tagResult.GetDataArray() == nil { + t.Fatal("expected tag list array response") + } + var tagID string + for _, item := range tagResult.GetDataArray() { + m := asMap(item) + if mapValueString(m, "title") == tagTitle { + tagID = mapValueString(m, "id") + break + } + } + if tagID == "" { + t.Fatalf("expected tag list to include %q", tagTitle) + } + taggedCards := h.Run("card", "list", "--tag", tagID) + assertOK(t, taggedCards) + if listMapByNumber(taggedCards.GetDataArray(), cardNum) == nil { + t.Fatalf("expected tagged card list to include card #%d", cardNum) + } + + assertOK(t, h.Run("card", "pin", cardStr)) + pinResult := h.Run("pin", "list") + assertOK(t, pinResult) + if pinResult.GetDataArray() == nil { + t.Fatal("expected pin list array response") + } + if listMapByNumber(pinResult.GetDataArray(), cardNum) == nil { + t.Fatalf("expected pin list to include card #%d", cardNum) + } + assertOK(t, h.Run("card", "unpin", cardStr)) +} + +func TestCommentAndStepCreationOnThrowawayCard(t *testing.T) { + h := newHarness(t) + cardNum := createCard(t, h, fixture.BoardID) + cardStr := strconv.Itoa(cardNum) + commentBody := "Throwaway card comment " + strconv.FormatInt(time.Now().UnixNano(), 10) + stepContent := "Throwaway card step " + strconv.FormatInt(time.Now().UnixNano(), 10) + commentID := createComment(t, h, cardNum, commentBody) + stepID := createStep(t, h, cardNum, stepContent) + if commentID == "" || stepID == "" { + t.Fatal("expected comment and step IDs") + } + commentShow := h.Run("comment", "show", commentID, "--card", cardStr) + assertOK(t, commentShow) + if got := bodyPlainText(commentShow.GetDataMap()); got != commentBody { + t.Fatalf("expected comment body %q, got %q", commentBody, got) + } + stepShow := h.Run("step", "show", stepID, "--card", cardStr) + assertOK(t, stepShow) + if got := stepShow.GetDataString("content"); got != stepContent { + t.Fatalf("expected step content %q, got %q", stepContent, got) + } +} diff --git a/e2e/cli_tests/helpers_test.go b/e2e/cli_tests/helpers_test.go new file mode 100644 index 0000000..3b71f46 --- /dev/null +++ b/e2e/cli_tests/helpers_test.go @@ -0,0 +1,386 @@ +package clitests + +import ( + "fmt" + "net/http" + "strconv" + "testing" + "time" + + "github.com/basecamp/fizzy-cli/e2e/harness" +) + +func newHarness(t *testing.T) *harness.Harness { + t.Helper() + return harness.NewWithConfig(t, cfg) +} + +func assertResult(t *testing.T, result *harness.Result, wantExit int) { + t.Helper() + if result.ExitCode != wantExit { + t.Fatalf("expected exit code %d, got %d\nstdout: %s\nstderr: %s", wantExit, result.ExitCode, result.Stdout, result.Stderr) + } + if result.Response == nil { + t.Fatalf("expected JSON response envelope, got none\nstdout: %s\nstderr: %s", result.Stdout, result.Stderr) + } + wantOK := wantExit == harness.ExitSuccess + if result.Response.OK != wantOK { + t.Fatalf("expected ok=%v, got %v (error: %q)", wantOK, result.Response.OK, result.Response.Error) + } +} + +func assertOK(t *testing.T, result *harness.Result) { + t.Helper() + assertResult(t, result, harness.ExitSuccess) +} + +func createBoard(t *testing.T, h *harness.Harness) string { + t.Helper() + name := fmt.Sprintf("CLI Throwaway Board %d", time.Now().UnixNano()) + r := h.Run("board", "create", "--name", name) + assertOK(t, r) + id := r.GetIDFromLocation() + if id == "" { + id = r.GetDataString("id") + } + if id == "" { + t.Fatal("no board ID in create response") + } + t.Cleanup(func() { newHarness(t).Run("board", "delete", id) }) + return id +} + +func createColumn(t *testing.T, h *harness.Harness, boardID, name string) string { + t.Helper() + if name == "" { + name = fmt.Sprintf("CLI Throwaway Column %d", time.Now().UnixNano()) + } + r := h.Run("column", "create", "--board", boardID, "--name", name) + assertOK(t, r) + id := r.GetIDFromLocation() + if id == "" { + id = r.GetDataString("id") + } + if id == "" { + t.Fatal("no column ID in create response") + } + t.Cleanup(func() { newHarness(t).Run("column", "delete", id, "--board", boardID) }) + return id +} + +func createCard(t *testing.T, h *harness.Harness, boardID string) int { + t.Helper() + if boardID == "" { + boardID = fixture.BoardID + } + r := h.Run("card", "create", "--board", boardID, "--title", fmt.Sprintf("CLI Throwaway Card %d", time.Now().UnixNano())) + assertOK(t, r) + num := r.GetNumberFromLocation() + if num == 0 { + num = r.GetDataInt("number") + } + if num == 0 { + t.Fatal("no card number in create response") + } + t.Cleanup(func() { newHarness(t).Run("card", "delete", strconv.Itoa(num)) }) + return num +} + +func createComment(t *testing.T, h *harness.Harness, cardNumber int, body string) string { + t.Helper() + if body == "" { + body = fmt.Sprintf("CLI Throwaway Comment %d", time.Now().UnixNano()) + } + r := h.Run("comment", "create", "--card", strconv.Itoa(cardNumber), "--body", body) + assertOK(t, r) + id := r.GetIDFromLocation() + if id == "" { + id = r.GetDataString("id") + } + if id == "" { + t.Fatal("no comment ID in create response") + } + t.Cleanup(func() { newHarness(t).Run("comment", "delete", id, "--card", strconv.Itoa(cardNumber)) }) + return id +} + +func createStep(t *testing.T, h *harness.Harness, cardNumber int, content string) string { + t.Helper() + if content == "" { + content = fmt.Sprintf("CLI Throwaway Step %d", time.Now().UnixNano()) + } + r := h.Run("step", "create", "--card", strconv.Itoa(cardNumber), "--content", content) + assertOK(t, r) + id := r.GetIDFromLocation() + if id == "" { + id = r.GetDataString("id") + } + if id == "" { + t.Fatal("no step ID in create response") + } + t.Cleanup(func() { newHarness(t).Run("step", "delete", id, "--card", strconv.Itoa(cardNumber)) }) + return id +} + +func stringifyID(v any) string { + switch x := v.(type) { + case string: + return x + case float64: + return strconv.Itoa(int(x)) + case int: + return strconv.Itoa(x) + case int64: + return strconv.FormatInt(x, 10) + default: + return fmt.Sprintf("%v", v) + } +} + +func mapValueString(m map[string]any, keys ...string) string { + for _, key := range keys { + if v, ok := m[key]; ok { + if s := stringifyID(v); s != "" && s != "" { + return s + } + } + } + return "" +} + +func asMap(v any) map[string]any { + m, _ := v.(map[string]any) + return m +} + +func asSlice(v any) []any { + s, _ := v.([]any) + return s +} + +func listMapByID(items []any, id string) map[string]any { + for _, item := range items { + m := asMap(item) + if m != nil && mapValueString(m, "id") == id { + return m + } + } + return nil +} + +func listMapByNumber(items []any, number int) map[string]any { + want := strconv.Itoa(number) + for _, item := range items { + m := asMap(item) + if m != nil && mapValueString(m, "number") == want { + return m + } + } + return nil +} + +func listIndexByID(items []any, id string) int { + for i, item := range items { + m := asMap(item) + if m != nil && mapValueString(m, "id") == id { + return i + } + } + return -1 +} + +func listAddedID(before, after []any) string { + seen := make(map[string]struct{}, len(before)) + for _, item := range before { + m := asMap(item) + if m == nil { + continue + } + if id := mapValueString(m, "id"); id != "" { + seen[id] = struct{}{} + } + } + for _, item := range after { + m := asMap(item) + if m == nil { + continue + } + id := mapValueString(m, "id") + if id == "" { + continue + } + if _, ok := seen[id]; !ok { + return id + } + } + return "" +} + +func addedReactionID(before, after []any, content, userID string) string { + seen := make(map[string]struct{}, len(before)) + for _, item := range before { + m := asMap(item) + if m == nil { + continue + } + if id := mapValueString(m, "id"); id != "" { + seen[id] = struct{}{} + } + } + + matches := []string{} + for _, item := range after { + m := asMap(item) + if m == nil { + continue + } + id := mapValueString(m, "id") + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + if mapValueString(m, "content") != content { + continue + } + reacter := asMap(m["reacter"]) + if userID != "" && mapValueString(reacter, "id") != userID { + continue + } + matches = append(matches, id) + } + if len(matches) == 1 { + return matches[0] + } + return "" +} + +func bodyPlainText(m map[string]any) string { + if m == nil { + return "" + } + body := asMap(m["body"]) + if body == nil { + return "" + } + return mapValueString(body, "plain_text") +} + +func currentUserID(t *testing.T, h *harness.Harness) string { + t.Helper() + identity := h.Run("identity", "show") + assertOK(t, identity) + data := identity.GetDataMap() + if data == nil { + t.Skip("identity response had no object payload") + } + + // Prefer account-scoped ID from accounts[].user.id. + if accounts := asSlice(data["accounts"]); len(accounts) > 0 { + var fallback string + for _, item := range accounts { + account := asMap(item) + if account == nil { + continue + } + user := asMap(account["user"]) + if user == nil { + continue + } + id := mapValueString(user, "id") + if id == "" { + continue + } + if fallback == "" { + fallback = id + } + accountID := mapValueString(account, "id") + slug := mapValueString(account, "slug") + name := mapValueString(account, "name") + if cfg.Account == accountID || cfg.Account == slug || cfg.Account == name { + return id + } + } + if fallback != "" { + return fallback + } + } + + identityName := mapValueString(data, "name") + identityEmail := mapValueString(data, "email", "email_address") + users := h.Run("user", "list") + assertOK(t, users) + for _, item := range users.GetDataArray() { + user := asMap(item) + if user == nil { + continue + } + name := mapValueString(user, "name") + email := mapValueString(user, "email", "email_address") + if identityEmail != "" && email == identityEmail { + if id := mapValueString(user, "id"); id != "" { + return id + } + } + if identityName != "" && name == identityName { + if id := mapValueString(user, "id"); id != "" { + return id + } + } + } + + t.Skip("could not determine current account-scoped user ID") + return "" +} + +func notificationID(t *testing.T, h *harness.Harness) string { + t.Helper() + for _, args := range [][]string{{"notification", "tray", "--include-read"}, {"notification", "list"}} { + result := h.Run(args...) + if result.ExitCode != harness.ExitSuccess { + continue + } + for _, item := range result.GetDataArray() { + m := asMap(item) + if m == nil { + continue + } + if id := mapValueString(m, "id"); id != "" { + return id + } + } + } + t.Skip("no notifications available") + return "" +} + +func avatarRedirects(t *testing.T, avatarURL string) bool { + t.Helper() + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + for attempt := 0; attempt < 10; attempt++ { + req, err := http.NewRequest(http.MethodHead, avatarURL, nil) + if err != nil { + t.Fatalf("build avatar HEAD request: %v", err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("fetch avatar headers: %v", err) + } + resp.Body.Close() + switch { + case resp.StatusCode == http.StatusOK: + return false + case resp.StatusCode/100 == 3: + return true + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("avatar endpoint %s did not settle on 200 or 3xx", avatarURL) + return false +} diff --git a/e2e/cli_tests/main_test.go b/e2e/cli_tests/main_test.go new file mode 100644 index 0000000..2244a7d --- /dev/null +++ b/e2e/cli_tests/main_test.go @@ -0,0 +1,120 @@ +// Package clitests contains owner-only end-to-end tests for the Fizzy CLI. +// +// Required environment variables: +// - FIZZY_TEST_TOKEN +// - FIZZY_TEST_ACCOUNT +// +// Optional: +// - FIZZY_TEST_API_URL +// - FIZZY_TEST_BINARY +package clitests + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/basecamp/fizzy-cli/e2e/harness" +) + +var ( + cfg *harness.Config + fixture *harness.SharedFixture +) + +func TestMain(m *testing.M) { + os.Exit(runMain(m)) +} + +func runMain(m *testing.M) int { + cfg = harness.LoadConfig() + if missing := cfg.MissingVars(); len(missing) > 0 { + fmt.Fprintf(os.Stderr, "Skipping CLI e2e tests — missing env vars: %v\n", missing) + fmt.Fprintln(os.Stderr, "Set FIZZY_TEST_TOKEN and FIZZY_TEST_ACCOUNT to run these tests.") + return 0 + } + + if !fileExists(cfg.BinaryPath) { + repoRoot, err := harness.RepoRoot() + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + return 1 + } + tmpDir, err := os.MkdirTemp("", "fizzy-e2e-cli-build-*") + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + return 1 + } + defer os.RemoveAll(tmpDir) + binPath := filepath.Join(tmpDir, "fizzy") + cmd := exec.Command("go", "build", "-o", binPath, "./cmd/fizzy") + cmd.Dir = repoRoot + if out, err := cmd.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "failed to build binary: %v\n%s\n", err, string(out)) + return 1 + } + _ = os.Setenv("FIZZY_TEST_BINARY", binPath) + cfg.BinaryPath = binPath + } + + var err error + fixture, err = harness.NewSharedFixture(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "fixture setup failed: %v\n", err) + return 1 + } + printFixtureInfo() + + code := m.Run() + + if os.Getenv("FIZZY_E2E_KEEP_FIXTURE") == "1" { + fmt.Fprintln(os.Stderr, "Keeping CLI e2e fixture (FIZZY_E2E_KEEP_FIXTURE=1)") + printFixtureInfo() + return code + } + if delay := teardownDelay(); delay > 0 { + fmt.Fprintf(os.Stderr, "Delaying CLI e2e fixture teardown for %s\n", delay) + printFixtureInfo() + time.Sleep(delay) + } + if err := fixture.Teardown(); err != nil { + fmt.Fprintf(os.Stderr, "warning: fixture teardown error: %v\n", err) + } + return code +} + +func printFixtureInfo() { + if fixture == nil { + return + } + fmt.Fprintf(os.Stderr, "CLI e2e fixture board: %s\n", fixture.BoardID) + if fixture.BoardID != "" { + fmt.Fprintf(os.Stderr, "CLI e2e fixture board URL: %s/%s/boards/%s\n", strings.TrimRight(cfg.APIURL, "/"), cfg.Account, fixture.BoardID) + } + if fixture.CardNumber != 0 { + fmt.Fprintf(os.Stderr, "CLI e2e fixture card URL: %s/%s/cards/%d\n", strings.TrimRight(cfg.APIURL, "/"), cfg.Account, fixture.CardNumber) + } +} + +func teardownDelay() time.Duration { + raw := os.Getenv("FIZZY_E2E_TEARDOWN_DELAY") + if raw == "" { + return 0 + } + seconds, err := strconv.Atoi(raw) + if err != nil || seconds <= 0 { + fmt.Fprintf(os.Stderr, "ignoring invalid FIZZY_E2E_TEARDOWN_DELAY=%q\n", raw) + return 0 + } + return time.Duration(seconds) * time.Second +} + +func fileExists(path string) bool { + st, err := os.Stat(path) + return err == nil && !st.IsDir() +} diff --git a/e2e/cli_tests/output_contract_test.go b/e2e/cli_tests/output_contract_test.go new file mode 100644 index 0000000..a68b2b6 --- /dev/null +++ b/e2e/cli_tests/output_contract_test.go @@ -0,0 +1,241 @@ +package clitests + +import ( + "encoding/json" + "strconv" + "strings" + "testing" + + "github.com/basecamp/fizzy-cli/e2e/harness" +) + +func assertQuietList(t *testing.T, stdout string) { + t.Helper() + s := strings.TrimSpace(stdout) + if s == "" || s == "null" { + return + } + var arr []any + if err := json.Unmarshal([]byte(s), &arr); err != nil { + t.Fatalf("--quiet list: expected JSON array, got parse error: %v\nstdout: %s", err, s) + } + var envelope map[string]any + if json.Unmarshal([]byte(s), &envelope) == nil { + if _, hasOK := envelope["ok"]; hasOK { + t.Fatal("--quiet list: output must not contain the 'ok' envelope key") + } + } +} + +func assertQuietObject(t *testing.T, stdout string) { + t.Helper() + s := strings.TrimSpace(stdout) + if s == "" || s == "null" { + return + } + var obj map[string]any + if err := json.Unmarshal([]byte(s), &obj); err != nil { + t.Fatalf("--quiet object: expected JSON object, got parse error: %v\nstdout: %s", err, s) + } + if _, hasOK := obj["ok"]; hasOK { + t.Fatal("--quiet object: output must not contain the 'ok' envelope key") + } +} + +func assertNonJSON(t *testing.T, stdout, flag string) { + t.Helper() + s := strings.TrimSpace(stdout) + if s == "" { + t.Fatalf("--%s: produced empty output", flag) + } + var v any + if err := json.Unmarshal([]byte(s), &v); err == nil { + t.Fatalf("--%s: output must not be valid JSON", flag) + } +} + +func assertIDsOnly(t *testing.T, stdout string) { + t.Helper() + s := strings.TrimSpace(stdout) + if s == "" { + return + } + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "{") || strings.HasPrefix(line, "[") { + t.Fatalf("--ids-only: line looks like JSON: %q", line) + } + } +} + +func assertCount(t *testing.T, stdout string) { + t.Helper() + s := strings.TrimSpace(stdout) + if s == "" { + t.Fatal("--count: produced empty output") + } + n, err := strconv.Atoi(s) + if err != nil { + t.Fatalf("--count: output %q is not an integer: %v", s, err) + } + if n < 0 { + t.Fatalf("--count: got negative value %d", n) + } +} + +func assertLimitedList(t *testing.T, stdout string, max int) { + t.Helper() + s := strings.TrimSpace(stdout) + if s == "" || s == "null" { + return + } + var arr []any + if err := json.Unmarshal([]byte(s), &arr); err != nil { + t.Fatalf("--limit: expected JSON array: %v\nstdout: %s", err, s) + } + if len(arr) > max { + t.Fatalf("--limit %d: got %d items", max, len(arr)) + } +} + +func assertVerbose(t *testing.T, result *harness.Result) { + t.Helper() + if result.Response == nil { + t.Fatal("--verbose: expected JSON response envelope") + } + if len(result.Response.Breadcrumbs) == 0 { + t.Fatal("--verbose: expected at least one breadcrumb in response") + } +} + +func assertJQScalar(t *testing.T, stdout string) { + t.Helper() + if strings.TrimSpace(stdout) == "" { + t.Fatal("--jq: produced empty output") + } +} + +type listFlagTest struct { + name string + extra []string + check func(t *testing.T, result *harness.Result) +} + +type showFlagTest struct { + name string + extra []string + check func(t *testing.T, result *harness.Result) +} + +func listFlagSuite() []listFlagTest { + return []listFlagTest{ + {"json", []string{"--json"}, func(t *testing.T, r *harness.Result) { + if r.Response == nil { + t.Fatal("--json: expected JSON envelope") + } + }}, + {"quiet", []string{"--quiet"}, func(t *testing.T, r *harness.Result) { assertQuietList(t, r.Stdout) }}, + {"markdown", []string{"--markdown"}, func(t *testing.T, r *harness.Result) { assertNonJSON(t, r.Stdout, "markdown") }}, + {"styled", []string{"--styled"}, func(t *testing.T, r *harness.Result) { assertNonJSON(t, r.Stdout, "styled") }}, + {"ids-only", []string{"--ids-only"}, func(t *testing.T, r *harness.Result) { assertIDsOnly(t, r.Stdout) }}, + {"count", []string{"--count"}, func(t *testing.T, r *harness.Result) { assertCount(t, r.Stdout) }}, + {"limit-1", []string{"--quiet", "--limit", "1"}, func(t *testing.T, r *harness.Result) { assertLimitedList(t, r.Stdout, 1) }}, + {"verbose", []string{"--verbose"}, func(t *testing.T, r *harness.Result) { assertVerbose(t, r) }}, + {"jq-data-length", []string{"--jq", ".data | length"}, func(t *testing.T, r *harness.Result) { assertCount(t, r.Stdout) }}, + {"quiet-jq-length", []string{"--quiet", "--jq", "length"}, func(t *testing.T, r *harness.Result) { assertCount(t, r.Stdout) }}, + } +} + +func showFlagSuite() []showFlagTest { + return []showFlagTest{ + {"json", []string{"--json"}, func(t *testing.T, r *harness.Result) { + if r.Response == nil { + t.Fatal("--json: expected JSON envelope") + } + }}, + {"quiet", []string{"--quiet"}, func(t *testing.T, r *harness.Result) { assertQuietObject(t, r.Stdout) }}, + {"markdown", []string{"--markdown"}, func(t *testing.T, r *harness.Result) { assertNonJSON(t, r.Stdout, "markdown") }}, + {"styled", []string{"--styled"}, func(t *testing.T, r *harness.Result) { assertNonJSON(t, r.Stdout, "styled") }}, + {"ids-only", []string{"--ids-only"}, func(t *testing.T, r *harness.Result) { assertIDsOnly(t, r.Stdout) }}, + {"count", []string{"--count"}, func(t *testing.T, r *harness.Result) { assertCount(t, r.Stdout) }}, + {"verbose", []string{"--verbose"}, func(t *testing.T, r *harness.Result) { assertVerbose(t, r) }}, + {"jq-data-id", []string{"--jq", ".data.id"}, func(t *testing.T, r *harness.Result) { assertJQScalar(t, r.Stdout) }}, + {"quiet-jq-id", []string{"--quiet", "--jq", ".id"}, func(t *testing.T, r *harness.Result) { assertJQScalar(t, r.Stdout) }}, + } +} + +func TestOutputContractListCommands(t *testing.T) { + h := newHarness(t) + cardNum := strconv.Itoa(fixture.CardNumber) + cmds := []struct { + name string + args []string + }{ + {"board-list", []string{"board", "list"}}, + {"board-closed", []string{"board", "closed", "--board", fixture.BoardID}}, + {"board-postponed", []string{"board", "postponed", "--board", fixture.BoardID}}, + {"board-stream", []string{"board", "stream", "--board", fixture.BoardID}}, + {"card-list", []string{"card", "list", "--board", fixture.BoardID}}, + {"column-list", []string{"column", "list", "--board", fixture.BoardID}}, + {"comment-list", []string{"comment", "list", "--card", cardNum}}, + {"step-list", []string{"step", "list", "--card", cardNum}}, + {"reaction-list", []string{"reaction", "list", "--card", cardNum}}, + {"user-list", []string{"user", "list"}}, + {"notification-list", []string{"notification", "list"}}, + {"pin-list", []string{"pin", "list"}}, + {"tag-list", []string{"tag", "list"}}, + {"search", []string{"search", "test"}}, + } + + for _, cmd := range cmds { + cmd := cmd + t.Run(cmd.name, func(t *testing.T) { + for _, f := range listFlagSuite() { + f := f + t.Run(f.name, func(t *testing.T) { + args := append(append([]string(nil), cmd.args...), f.extra...) + result := h.Run(args...) + if result.ExitCode != harness.ExitSuccess { + t.Fatalf("expected exit code 0, got %d\nstdout: %s\nstderr: %s", result.ExitCode, result.Stdout, result.Stderr) + } + f.check(t, result) + }) + } + }) + } +} + +func TestOutputContractShowCommands(t *testing.T) { + h := newHarness(t) + cardNum := strconv.Itoa(fixture.CardNumber) + cmds := []struct { + name string + args []string + }{ + {"board-show", []string{"board", "show", fixture.BoardID}}, + {"card-show", []string{"card", "show", cardNum}}, + {"column-show", []string{"column", "show", fixture.ColumnID, "--board", fixture.BoardID}}, + {"comment-show", []string{"comment", "show", fixture.CommentID, "--card", cardNum}}, + {"step-show", []string{"step", "show", fixture.StepID, "--card", cardNum}}, + } + + for _, cmd := range cmds { + cmd := cmd + t.Run(cmd.name, func(t *testing.T) { + for _, f := range showFlagSuite() { + f := f + t.Run(f.name, func(t *testing.T) { + args := append(append([]string(nil), cmd.args...), f.extra...) + result := h.Run(args...) + if result.ExitCode != harness.ExitSuccess { + t.Fatalf("expected exit code 0, got %d\nstdout: %s\nstderr: %s", result.ExitCode, result.Stdout, result.Stderr) + } + f.check(t, result) + }) + } + }) + } +} diff --git a/e2e/cli_tests/search_and_auth_test.go b/e2e/cli_tests/search_and_auth_test.go new file mode 100644 index 0000000..908d37c --- /dev/null +++ b/e2e/cli_tests/search_and_auth_test.go @@ -0,0 +1,31 @@ +package clitests + +import ( + "testing" + + "github.com/basecamp/fizzy-cli/e2e/harness" +) + +func TestSearch(t *testing.T) { + h := newHarness(t) + assertOK(t, h.Run("search", "test")) + assertOK(t, h.Run("search", "test", "--board", fixture.BoardID)) + assertOK(t, h.Run("search", "test", "--all")) +} + +func TestAuthInvalidToken(t *testing.T) { + badCfg := *cfg + badCfg.Token = "fizzy_invalid_token" + h := harness.NewWithConfig(t, &badCfg) + assertResult(t, h.Run("board", "list"), harness.ExitAuthFailure) +} + +func TestAuthMissingToken(t *testing.T) { + missingCfg := *cfg + missingCfg.Token = "" + h := harness.NewWithConfig(t, &missingCfg) + assertResult(t, h.RunWithEnv(map[string]string{ + "FIZZY_TOKEN": "", + "FIZZY_ACCOUNT": "", + }, "board", "list"), harness.ExitAuthFailure) +} diff --git a/e2e/cli_tests/syntax_contract_test.go b/e2e/cli_tests/syntax_contract_test.go new file mode 100644 index 0000000..f458f06 --- /dev/null +++ b/e2e/cli_tests/syntax_contract_test.go @@ -0,0 +1,69 @@ +package clitests + +import ( + "strconv" + "testing" + + "github.com/basecamp/fizzy-cli/e2e/harness" +) + +func TestBoardBoardScopedCommandsUseBoardFlag(t *testing.T) { + h := newHarness(t) + for name, args := range map[string][]string{ + "closed": {"board", "closed", "--board", fixture.BoardID}, + "postponed": {"board", "postponed", "--board", fixture.BoardID}, + "stream": {"board", "stream", "--board", fixture.BoardID}, + } { + t.Run(name, func(t *testing.T) { + assertOK(t, h.Run(args...)) + }) + } +} + +func TestCardAttachmentsUseShowSubcommand(t *testing.T) { + h := newHarness(t) + assertOK(t, h.Run("card", "attachments", "show", strconv.Itoa(fixture.CardNumber))) +} + +func TestColumnMoveUsesColumnIDOnly(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + leftID := createColumn(t, h, boardID, "Left") + rightID := createColumn(t, h, boardID, "Right") + assertOK(t, h.Run("column", "move-right", leftID)) + assertOK(t, h.Run("column", "move-left", rightID)) +} + +func TestNotificationReadUnreadUseNotificationID(t *testing.T) { + h := newHarness(t) + id := notificationID(t, h) + assertOK(t, h.Run("notification", "read", id)) + assertOK(t, h.Run("notification", "unread", id)) +} + +func TestTagListDoesNotTakeBoardFlag(t *testing.T) { + h := newHarness(t) + result := h.Run("tag", "list", "--board", fixture.BoardID) + assertResult(t, result, harness.ExitUsage) +} + +func TestBoardCreateRequiresName(t *testing.T) { + h := newHarness(t) + assertResult(t, h.Run("board", "create"), harness.ExitUsage) +} + +func TestCardCreateRequiresTitle(t *testing.T) { + h := newHarness(t) + assertResult(t, h.Run("card", "create", "--board", fixture.BoardID), harness.ExitUsage) +} + +func TestNotificationUnreadRequiresID(t *testing.T) { + h := newHarness(t) + assertResult(t, h.Run("notification", "unread"), harness.ExitUsage) +} + +func TestAccountEntropyRejectsInvalidZeroValue(t *testing.T) { + h := newHarness(t) + result := h.Run("account", "entropy", "--auto_postpone_period_in_days", "0") + assertResult(t, result, harness.ExitUsage) +} diff --git a/e2e/cli_tests/upload_attachment_test.go b/e2e/cli_tests/upload_attachment_test.go new file mode 100644 index 0000000..f9807ae --- /dev/null +++ b/e2e/cli_tests/upload_attachment_test.go @@ -0,0 +1,169 @@ +package clitests + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/basecamp/fizzy-cli/e2e/harness" +) + +type uploadRef struct { + Path string + Filename string + SignedID string + AttachableSGID string +} + +func fixtureFile(t *testing.T, name string) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + path := filepath.Join(wd, "..", "testdata", "fixtures", name) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Skipf("test fixture not found at %s", path) + } + return path +} + +func uploadFixture(t *testing.T, h *harness.Harness, name string) uploadRef { + t.Helper() + path := fixtureFile(t, name) + result := h.Run("upload", "file", path) + assertOK(t, result) + + ref := uploadRef{ + Path: path, + Filename: filepath.Base(path), + SignedID: result.GetDataString("signed_id"), + AttachableSGID: result.GetDataString("attachable_sgid"), + } + if ref.SignedID == "" { + t.Fatal("expected signed_id in upload response") + } + if ref.AttachableSGID == "" { + // Current attachment embedding uses attachable_sgid; keep the error explicit. + t.Fatal("expected attachable_sgid in upload response") + } + return ref +} + +func createCardWithAttachment(t *testing.T, h *harness.Harness, boardID string, ref uploadRef) int { + t.Helper() + description := fmt.Sprintf(``, ref.AttachableSGID) + result := h.Run("card", "create", + "--board", boardID, + "--title", fmt.Sprintf("Attachment Card %d", time.Now().UnixNano()), + "--description", description, + ) + assertOK(t, result) + num := result.GetNumberFromLocation() + if num == 0 { + num = result.GetDataInt("number") + } + if num == 0 { + t.Fatal("no card number in create response") + } + t.Cleanup(func() { newHarness(t).Run("card", "delete", strconv.Itoa(num)) }) + return num +} + +func TestUploadFile(t *testing.T) { + h := newHarness(t) + for _, fixtureName := range []string{"test_image.png", "test_document.txt"} { + fixtureName := fixtureName + t.Run(fixtureName, func(t *testing.T) { + ref := uploadFixture(t, h, fixtureName) + if ref.SignedID == "" || ref.AttachableSGID == "" { + t.Fatalf("expected both signed_id and attachable_sgid for %s", fixtureName) + } + }) + } +} + +func TestUploadFileNotFound(t *testing.T) { + h := newHarness(t) + result := h.Run("upload", "file", "/path/to/nonexistent/file.png") + if result.ExitCode == harness.ExitSuccess { + t.Fatal("expected failure for non-existent file") + } + if result.Response != nil && result.Response.OK { + t.Fatal("expected ok=false for non-existent file") + } +} + +func TestCardAttachmentRoundTrip(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + ref := uploadFixture(t, h, "test_image.png") + cardNumber := createCardWithAttachment(t, h, boardID, ref) + cardStr := strconv.Itoa(cardNumber) + + show := h.Run("card", "attachments", "show", cardStr) + assertOK(t, show) + attachments := show.GetDataArray() + if len(attachments) == 0 { + t.Fatal("expected at least one card attachment") + } + first := asMap(attachments[0]) + if first == nil { + t.Fatalf("expected attachment object, got %T", attachments[0]) + } + if got := mapValueString(first, "filename"); got != ref.Filename { + t.Fatalf("expected filename %q, got %q", ref.Filename, got) + } + + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "downloaded-card-attachment.png") + download := h.Run("card", "attachments", "download", cardStr, "1", "-o", outputPath) + assertOK(t, download) + if _, err := os.Stat(outputPath); err != nil { + t.Fatalf("expected downloaded file at %s: %v", outputPath, err) + } +} + +func TestCommentAttachmentRoundTrip(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + cardNumber := createCard(t, h, boardID) + ref := uploadFixture(t, h, "test_image.png") + body := fmt.Sprintf(``, ref.AttachableSGID) + commentID := createComment(t, h, cardNumber, body) + cardStr := strconv.Itoa(cardNumber) + + show := h.Run("comment", "attachments", "show", "--card", cardStr) + assertOK(t, show) + attachments := show.GetDataArray() + if len(attachments) == 0 { + t.Fatal("expected at least one comment attachment") + } + first := asMap(attachments[0]) + if first == nil { + t.Fatalf("expected attachment object, got %T", attachments[0]) + } + if got := mapValueString(first, "filename"); got != ref.Filename { + t.Fatalf("expected filename %q, got %q", ref.Filename, got) + } + if got := mapValueString(first, "comment_id"); got != commentID { + t.Fatalf("expected comment_id %q, got %q", commentID, got) + } + + combined := h.Run("card", "attachments", "show", cardStr, "--include-comments") + assertOK(t, combined) + if len(combined.GetDataArray()) == 0 { + t.Fatal("expected card attachment view with --include-comments to include comment attachment") + } + + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "downloaded-comment-attachment.png") + download := h.Run("comment", "attachments", "download", "--card", cardStr, "1", "-o", outputPath) + assertOK(t, download) + if _, err := os.Stat(outputPath); err != nil { + t.Fatalf("expected downloaded file at %s: %v", outputPath, err) + } +} diff --git a/e2e/cli_tests/utility_commands_test.go b/e2e/cli_tests/utility_commands_test.go new file mode 100644 index 0000000..ad53706 --- /dev/null +++ b/e2e/cli_tests/utility_commands_test.go @@ -0,0 +1,76 @@ +package clitests + +import "testing" + +func TestConfigShow(t *testing.T) { + result := newHarness(t).Run("config", "show") + assertOK(t, result) + data := result.GetDataMap() + if data == nil { + t.Fatal("expected config show to return an object") + } + if stringifyID(data["profile"]) == "" { + t.Fatal("expected resolved profile in config show response") + } + if stringifyID(data["api_url"]) == "" { + t.Fatal("expected api_url in config show response") + } +} + +func TestConfigExplain(t *testing.T) { + result := newHarness(t).Run("config", "explain") + assertOK(t, result) + data := result.GetDataMap() + if data == nil { + t.Fatal("expected config explain to return an object") + } + if asMap(data["token"]) == nil { + t.Fatal("expected token explanation in config explain response") + } + if asMap(data["profile"]) == nil { + t.Fatal("expected profile explanation in config explain response") + } +} + +func TestDoctor(t *testing.T) { + result := newHarness(t).Run("doctor") + assertOK(t, result) + data := result.GetDataMap() + if data == nil { + t.Fatal("expected doctor to return an object") + } + checks := asSlice(data["checks"]) + if len(checks) == 0 { + t.Fatal("expected doctor to report checks") + } + foundAuthentication := false + for _, item := range checks { + check := asMap(item) + if check == nil { + continue + } + if stringifyID(check["name"]) == "Authentication" { + foundAuthentication = true + break + } + } + if !foundAuthentication { + t.Fatal("expected doctor checks to include Authentication") + } +} + +func TestCommands(t *testing.T) { + result := newHarness(t).Run("commands") + assertOK(t, result) + if len(result.GetDataArray()) == 0 { + t.Fatal("expected commands to return at least one command group") + } +} + +func TestVersion(t *testing.T) { + result := newHarness(t).Run("version") + assertOK(t, result) + if result.GetDataString("version") == "" { + t.Fatal("expected version string in response") + } +} diff --git a/e2e/cli_tests/webhook_test.go b/e2e/cli_tests/webhook_test.go new file mode 100644 index 0000000..a039cf4 --- /dev/null +++ b/e2e/cli_tests/webhook_test.go @@ -0,0 +1,79 @@ +package clitests + +import ( + "strconv" + "testing" + "time" + + "github.com/basecamp/fizzy-cli/e2e/harness" +) + +func TestWebhookCRUD(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + name := "CLI Test Hook " + strconv.FormatInt(time.Now().UnixNano(), 10) + + create := h.Run("webhook", "create", + "--board", boardID, + "--name", name, + "--url", "https://example.com/fizzy-cli-webhook", + ) + assertOK(t, create) + webhookID := create.GetIDFromLocation() + if webhookID == "" { + webhookID = create.GetDataString("id") + } + if webhookID == "" { + t.Fatal("no webhook ID in create response") + } + deleted := false + t.Cleanup(func() { + if !deleted { + newHarness(t).Run("webhook", "delete", "--board", boardID, webhookID) + } + }) + + show := h.Run("webhook", "show", "--board", boardID, webhookID) + assertOK(t, show) + if got := show.GetDataString("name"); got != name { + t.Fatalf("expected webhook name %q, got %q", name, got) + } + if got := show.GetDataString("payload_url"); got == "" { + t.Fatal("expected payload_url in webhook show response") + } + + list := h.Run("webhook", "list", "--board", boardID) + assertOK(t, list) + found := false + for _, item := range list.GetDataArray() { + if mapValueString(asMap(item), "id") == webhookID { + found = true + break + } + } + if !found { + t.Fatalf("expected webhook list to include %q", webhookID) + } + + updatedName := name + " Updated" + update := h.Run("webhook", "update", "--board", boardID, webhookID, "--name", updatedName, "--actions", "card_closed") + assertOK(t, update) + + showUpdated := h.Run("webhook", "show", "--board", boardID, webhookID) + assertOK(t, showUpdated) + if got := showUpdated.GetDataString("name"); got != updatedName { + t.Fatalf("expected updated webhook name %q, got %q", updatedName, got) + } + actions := asSlice(showUpdated.GetDataMap()["subscribed_actions"]) + if len(actions) != 1 || stringifyID(actions[0]) != "card_closed" { + t.Fatalf("expected subscribed_actions [card_closed], got %v", actions) + } + + deleteResult := h.Run("webhook", "delete", "--board", boardID, webhookID) + assertOK(t, deleteResult) + deleted = true + if !deleteResult.GetDataBool("deleted") { + t.Fatal("expected deleted=true") + } + assertResult(t, h.Run("webhook", "show", "--board", boardID, webhookID), harness.ExitNotFound) +} diff --git a/e2e/harness/fixture.go b/e2e/harness/fixture.go new file mode 100644 index 0000000..bb60d35 --- /dev/null +++ b/e2e/harness/fixture.go @@ -0,0 +1,157 @@ +package harness + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// SharedFixture is a pre-built world state created once per owner-only CLI +// test run. Resources are created via Execute() directly (no *testing.T +// required) so the fixture can be set up inside TestMain. +type SharedFixture struct { + // configHome is an isolated temp directory used as HOME so the CLI reads + // no config from the developer's real home directory. + configHome string + + // BoardID is the root board for CLI tests. + BoardID string + + // ColumnID is a custom column on BoardID. + ColumnID string + + // CardNumber is a card created on BoardID. + CardNumber int + + // CommentID is a comment left on CardNumber. + CommentID string + + // StepID is a step on CardNumber. + StepID string + + cfg *Config +} + +// NewSharedFixture builds the shared fixture using the provided credentials. +// Returns an error describing the first setup step that fails. +func NewSharedFixture(cfg *Config) (*SharedFixture, error) { + tmpDir, err := os.MkdirTemp("", "fizzy-e2e-fixture-*") + if err != nil { + return nil, fmt.Errorf("create fixture config home: %w", err) + } + f := &SharedFixture{cfg: cfg, configHome: tmpDir} + if err := f.setup(); err != nil { + if teardownErr := f.Teardown(); teardownErr != nil { + _ = os.RemoveAll(tmpDir) + return nil, fmt.Errorf("%w\ncleanup: %v", err, teardownErr) + } + return nil, err + } + return f, nil +} + +// Teardown removes all fixture resources. Deleting the board cascades to all +// child resources (cards, columns, comments, steps, reactions). +func (f *SharedFixture) Teardown() error { + var errs []string + if f.BoardID != "" { + r := f.run("board", "delete", f.BoardID) + if r.ExitCode != ExitSuccess && r.ExitCode != ExitNotFound { + errs = append(errs, fmt.Sprintf("delete fixture board %s: exit %d\nstderr: %s", f.BoardID, r.ExitCode, r.Stderr)) + } + } + if err := os.RemoveAll(f.configHome); err != nil { + errs = append(errs, fmt.Sprintf("remove fixture config home %s: %v", f.configHome, err)) + } + if len(errs) > 0 { + return fmt.Errorf("%s", strings.Join(errs, "\n")) + } + return nil +} + +func (f *SharedFixture) setup() error { + boardName := fmt.Sprintf("CLI E2E Board %d", time.Now().UnixNano()) + r := f.run("board", "create", "--name", boardName) + if r.ExitCode != ExitSuccess { + return fmt.Errorf("create board: exit %d\nstderr: %s", r.ExitCode, r.Stderr) + } + f.BoardID = r.GetIDFromLocation() + if f.BoardID == "" { + f.BoardID = r.GetDataString("id") + } + if f.BoardID == "" { + return fmt.Errorf("no board ID in create response (location: %q)", r.GetLocation()) + } + + r = f.run("column", "create", "--board", f.BoardID, "--name", "In Progress") + if r.ExitCode != ExitSuccess { + return fmt.Errorf("create column: exit %d\nstderr: %s", r.ExitCode, r.Stderr) + } + f.ColumnID = r.GetIDFromLocation() + if f.ColumnID == "" { + f.ColumnID = r.GetDataString("id") + } + if f.ColumnID == "" { + return fmt.Errorf("no column ID in create response") + } + + r = f.run("card", "create", "--board", f.BoardID, "--title", "Owner Test Card") + if r.ExitCode != ExitSuccess { + return fmt.Errorf("create card: exit %d\nstderr: %s", r.ExitCode, r.Stderr) + } + f.CardNumber = r.GetNumberFromLocation() + if f.CardNumber == 0 { + f.CardNumber = r.GetDataInt("number") + } + if f.CardNumber == 0 { + return fmt.Errorf("no card number in create response") + } + + r = f.run("comment", "create", + "--card", strconv.Itoa(f.CardNumber), + "--body", "Owner test comment") + if r.ExitCode != ExitSuccess { + return fmt.Errorf("create comment: exit %d\nstderr: %s", r.ExitCode, r.Stderr) + } + f.CommentID = r.GetIDFromLocation() + if f.CommentID == "" { + f.CommentID = r.GetDataString("id") + } + if f.CommentID == "" { + return fmt.Errorf("no comment ID in create response") + } + + r = f.run("step", "create", + "--card", strconv.Itoa(f.CardNumber), + "--content", "Test step") + if r.ExitCode != ExitSuccess { + return fmt.Errorf("create step: exit %d\nstderr: %s", r.ExitCode, r.Stderr) + } + f.StepID = r.GetIDFromLocation() + if f.StepID == "" { + f.StepID = r.GetDataString("id") + } + if f.StepID == "" { + return fmt.Errorf("no step ID in create response") + } + + return nil +} + +func (f *SharedFixture) run(args ...string) *Result { + fullArgs := make([]string, len(args), len(args)+4) + copy(fullArgs, args) + fullArgs = append(fullArgs, "--token", f.cfg.Token, "--api-url", f.cfg.APIURL) + env := map[string]string{ + "FIZZY_PROFILE": f.cfg.Account, + "FIZZY_NO_KEYRING": "1", + "HOME": f.configHome, + "XDG_CONFIG_HOME": filepath.Join(f.configHome, "config"), + "XDG_DATA_HOME": filepath.Join(f.configHome, "data"), + "XDG_STATE_HOME": filepath.Join(f.configHome, "state"), + } + return Execute(f.cfg.BinaryPath, fullArgs, env) +} diff --git a/e2e/harness/harness.go b/e2e/harness/harness.go index 10cf4e6..353a6e6 100644 --- a/e2e/harness/harness.go +++ b/e2e/harness/harness.go @@ -120,6 +120,18 @@ func LoadConfig() *Config { } } +// MissingVars returns names of any required env vars that are not set. +func (c *Config) MissingVars() []string { + var missing []string + if c.Token == "" { + missing = append(missing, "FIZZY_TEST_TOKEN") + } + if c.Account == "" { + missing = append(missing, "FIZZY_TEST_ACCOUNT") + } + return missing +} + func getEnvOrDefault(key, defaultValue string) string { if v := os.Getenv(key); v != "" { return v @@ -235,9 +247,9 @@ func (h *Harness) RunWithoutAuth(args ...string) *Result { // buildArgs builds the full argument list with global options. func (h *Harness) buildArgs(args ...string) []string { - globalArgs := []string{ - "--token", h.Token, - "--api-url", h.APIURL, + globalArgs := []string{"--api-url", h.APIURL} + if h.Token != "" { + globalArgs = append(globalArgs, "--token", h.Token) } // Append global args after the command args return append(args, globalArgs...) @@ -248,8 +260,13 @@ func (h *Harness) buildArgs(args ...string) []string { func (h *Harness) globalEnv() map[string]string { return map[string]string{ "FIZZY_PROFILE": h.Account, + "FIZZY_ACCOUNT": "", + "FIZZY_TOKEN": "", "FIZZY_NO_KEYRING": "1", "HOME": h.configHome, + "XDG_CONFIG_HOME": filepath.Join(h.configHome, "config"), + "XDG_DATA_HOME": filepath.Join(h.configHome, "data"), + "XDG_STATE_HOME": filepath.Join(h.configHome, "state"), } } diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index 10bf7bc..71df842 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "os/exec" + "strings" "syscall" ) @@ -17,7 +18,23 @@ func Execute(binaryPath string, args []string, env map[string]string) *Result { cmd.Stderr = &stderr // Set up environment - cmd.Env = os.Environ() + baseEnv := os.Environ() + if len(env) > 0 { + overrides := make(map[string]struct{}, len(env)) + for k := range env { + overrides[k] = struct{}{} + } + filtered := baseEnv[:0] + for _, entry := range baseEnv { + key, _, _ := strings.Cut(entry, "=") + if _, ok := overrides[key]; ok { + continue + } + filtered = append(filtered, entry) + } + baseEnv = filtered + } + cmd.Env = append([]string(nil), baseEnv...) for k, v := range env { cmd.Env = append(cmd.Env, k+"="+v) } diff --git a/e2e/tests/attachment_test.go b/e2e/tests/attachment_test.go deleted file mode 100644 index d792326..0000000 --- a/e2e/tests/attachment_test.go +++ /dev/null @@ -1,515 +0,0 @@ -package tests - -import ( - "fmt" - "os" - "path/filepath" - "strconv" - "testing" - "time" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -// createAttachmentTestBoard creates a board for attachment tests and adds it to cleanup -func createAttachmentTestBoard(t *testing.T, h *harness.Harness) string { - t.Helper() - name := fmt.Sprintf("Attachment Test Board %d", time.Now().UnixNano()) - result := h.Run("board", "create", "--name", name) - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create test board: %s\nstdout: %s", result.Stderr, result.Stdout) - } - boardID := result.GetIDFromLocation() - if boardID == "" { - boardID = result.GetDataString("id") - } - if boardID == "" { - t.Fatalf("no board ID returned (location: %s)", result.GetLocation()) - } - h.Cleanup.AddBoard(boardID) - return boardID -} - -// createCardWithAttachment creates a card with an attachment for testing. -// Returns the card number and the expected filename. -func createCardWithAttachment(t *testing.T, h *harness.Harness, boardID string) (int, string) { - t.Helper() - - // Get the path to the test image fixture - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_image.png") - - // Check if fixture exists - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - // Upload the file first - uploadResult := h.Run("upload", "file", fixturePath) - if uploadResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to upload file: %s\nstdout: %s", uploadResult.Stderr, uploadResult.Stdout) - } - - attachableSGID := uploadResult.GetDataString("attachable_sgid") - if attachableSGID == "" { - t.Fatalf("no attachable_sgid returned from upload\nstdout: %s", uploadResult.Stdout) - } - - // Create a card with the attachment in description - title := fmt.Sprintf("Attachment Test Card %d", time.Now().UnixNano()) - description := fmt.Sprintf(``, attachableSGID) - - cardResult := h.Run("card", "create", "--board", boardID, "--title", title, "--description", description) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card with attachment: %s\nstdout: %s", cardResult.Stderr, cardResult.Stdout) - } - - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - if cardNumber == 0 { - t.Fatalf("failed to get card number from create (location: %s)", cardResult.GetLocation()) - } - - return cardNumber, "test_image.png" -} - -// createCardWithMultipleAttachments creates a card with two attachments. -func createCardWithMultipleAttachments(t *testing.T, h *harness.Harness, boardID string) (int, []string) { - t.Helper() - - wd, _ := os.Getwd() - imagePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_image.png") - docPath := filepath.Join(wd, "..", "testdata", "fixtures", "test_document.txt") - - // Check if fixtures exist - if _, err := os.Stat(imagePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", imagePath) - } - if _, err := os.Stat(docPath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", docPath) - } - - // Upload both files - uploadImage := h.Run("upload", "file", imagePath) - if uploadImage.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to upload image: %s", uploadImage.Stderr) - } - imageSGID := uploadImage.GetDataString("attachable_sgid") - - uploadDoc := h.Run("upload", "file", docPath) - if uploadDoc.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to upload document: %s", uploadDoc.Stderr) - } - docSGID := uploadDoc.GetDataString("attachable_sgid") - - // Create card with both attachments - title := fmt.Sprintf("Multi Attachment Test %d", time.Now().UnixNano()) - description := fmt.Sprintf(`

Text between attachments

`, - imageSGID, docSGID) - - cardResult := h.Run("card", "create", "--board", boardID, "--title", title, "--description", description) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s", cardResult.Stderr) - } - - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - - return cardNumber, []string{"test_image.png", "test_document.txt"} -} - -func TestCardAttachmentsShow(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createAttachmentTestBoard(t, h) - - t.Run("shows attachments on card with single attachment", func(t *testing.T) { - cardNumber, expectedFilename := createCardWithAttachment(t, h, boardID) - h.Cleanup.AddCard(cardNumber) - - result := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber)) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - arr := result.GetDataArray() - if arr == nil { - t.Fatalf("expected array response, got: %v", result.Response.Data) - } - - if len(arr) != 1 { - t.Errorf("expected 1 attachment, got %d", len(arr)) - } - - // Check first attachment - if len(arr) > 0 { - attachment, ok := arr[0].(map[string]any) - if !ok { - t.Fatalf("expected attachment to be a map, got %T", arr[0]) - } - - if filename := attachment["filename"].(string); filename != expectedFilename { - t.Errorf("expected filename %q, got %q", expectedFilename, filename) - } - - if index := int(attachment["index"].(float64)); index != 1 { - t.Errorf("expected index 1, got %d", index) - } - - if downloadURL := attachment["download_url"].(string); downloadURL == "" { - t.Error("expected non-empty download_url") - } - } - }) - - t.Run("shows multiple attachments", func(t *testing.T) { - cardNumber, expectedFilenames := createCardWithMultipleAttachments(t, h, boardID) - h.Cleanup.AddCard(cardNumber) - - result := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber)) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - arr := result.GetDataArray() - if len(arr) != 2 { - t.Errorf("expected 2 attachments, got %d", len(arr)) - } - - // Verify filenames - foundFilenames := make(map[string]bool) - for _, item := range arr { - attachment := item.(map[string]any) - foundFilenames[attachment["filename"].(string)] = true - } - - for _, expected := range expectedFilenames { - if !foundFilenames[expected] { - t.Errorf("expected to find attachment %q", expected) - } - } - }) - - t.Run("returns empty array for card without attachments", func(t *testing.T) { - // Create a card without attachments - title := fmt.Sprintf("No Attachment Card %d", time.Now().UnixNano()) - cardResult := h.Run("card", "create", "--board", boardID, "--title", title, "--description", "Just plain text") - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s", cardResult.Stderr) - } - - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - h.Cleanup.AddCard(cardNumber) - - result := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber)) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Data should be empty array or nil - arr := result.GetDataArray() - if arr != nil && len(arr) != 0 { - t.Errorf("expected empty array, got %d items", len(arr)) - } - }) - - t.Run("returns not found for non-existent card", func(t *testing.T) { - result := h.Run("card", "attachments", "show", "999999999") - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) -} - -func TestCardAttachmentsDownload(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createAttachmentTestBoard(t, h) - - t.Run("downloads single attachment by index", func(t *testing.T) { - cardNumber, expectedFilename := createCardWithAttachment(t, h, boardID) - h.Cleanup.AddCard(cardNumber) - - // Create temp directory for downloads - tempDir, err := os.MkdirTemp("", "fizzy-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - // Change to temp directory and run download - originalDir, _ := os.Getwd() - if err := os.Chdir(tempDir); err != nil { - t.Fatalf("failed to change to temp dir: %v", err) - } - defer os.Chdir(originalDir) - - result := h.Run("card", "attachments", "download", strconv.Itoa(cardNumber), "1") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Verify file was downloaded - downloadedFile := filepath.Join(tempDir, expectedFilename) - if _, err := os.Stat(downloadedFile); os.IsNotExist(err) { - t.Errorf("expected file %s to exist", downloadedFile) - } - - // Check response data - data := result.GetDataMap() - if data == nil { - t.Fatal("expected data map in response") - } - - downloaded := int(data["downloaded"].(float64)) - if downloaded != 1 { - t.Errorf("expected downloaded=1, got %d", downloaded) - } - }) - - t.Run("downloads all attachments when no index specified", func(t *testing.T) { - cardNumber, expectedFilenames := createCardWithMultipleAttachments(t, h, boardID) - h.Cleanup.AddCard(cardNumber) - - tempDir, err := os.MkdirTemp("", "fizzy-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - originalDir, _ := os.Getwd() - if err := os.Chdir(tempDir); err != nil { - t.Fatalf("failed to change to temp dir: %v", err) - } - defer os.Chdir(originalDir) - - result := h.Run("card", "attachments", "download", strconv.Itoa(cardNumber)) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Verify all files were downloaded - for _, filename := range expectedFilenames { - downloadedFile := filepath.Join(tempDir, filename) - if _, err := os.Stat(downloadedFile); os.IsNotExist(err) { - t.Errorf("expected file %s to exist", downloadedFile) - } - } - - // Check response data - data := result.GetDataMap() - if data == nil { - t.Error("expected data map in response") - } else { - downloaded := int(data["downloaded"].(float64)) - if downloaded != 2 { - t.Errorf("expected downloaded=2, got %d", downloaded) - } - } - }) - - t.Run("downloads with custom output filename", func(t *testing.T) { - cardNumber, _ := createCardWithAttachment(t, h, boardID) - h.Cleanup.AddCard(cardNumber) - - tempDir, err := os.MkdirTemp("", "fizzy-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - originalDir, _ := os.Getwd() - if err := os.Chdir(tempDir); err != nil { - t.Fatalf("failed to change to temp dir: %v", err) - } - defer os.Chdir(originalDir) - - customFilename := "my_custom_download.png" - result := h.Run("card", "attachments", "download", strconv.Itoa(cardNumber), "1", "-o", customFilename) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Verify file was downloaded with custom name - downloadedFile := filepath.Join(tempDir, customFilename) - if _, err := os.Stat(downloadedFile); os.IsNotExist(err) { - t.Errorf("expected file %s to exist", downloadedFile) - } - - // Check response shows custom saved_to path - data := result.GetDataMap() - if data == nil { - t.Error("expected data map in response") - } else if files, ok := data["files"].([]any); ok && len(files) > 0 { - if fileInfo, ok := files[0].(map[string]any); ok { - savedTo, _ := fileInfo["saved_to"].(string) - if savedTo != customFilename { - t.Errorf("expected saved_to=%q, got %q", customFilename, savedTo) - } - } - } - }) - - t.Run("returns error for card with no attachments", func(t *testing.T) { - // Create a card without attachments - title := fmt.Sprintf("No Attachment Card %d", time.Now().UnixNano()) - cardResult := h.Run("card", "create", "--board", boardID, "--title", title, "--description", "No files here") - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s", cardResult.Stderr) - } - - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - h.Cleanup.AddCard(cardNumber) - - result := h.Run("card", "attachments", "download", strconv.Itoa(cardNumber)) - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) - - t.Run("returns error for invalid attachment index", func(t *testing.T) { - cardNumber, _ := createCardWithAttachment(t, h, boardID) - h.Cleanup.AddCard(cardNumber) - - result := h.Run("card", "attachments", "download", strconv.Itoa(cardNumber), "abc") - - if result.ExitCode != harness.ExitInvalidArgs { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitInvalidArgs, result.ExitCode, result.Stdout) - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) - - t.Run("returns error for attachment index out of range", func(t *testing.T) { - cardNumber, _ := createCardWithAttachment(t, h, boardID) - h.Cleanup.AddCard(cardNumber) - - result := h.Run("card", "attachments", "download", strconv.Itoa(cardNumber), "99") - - if result.ExitCode != harness.ExitInvalidArgs { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitInvalidArgs, result.ExitCode, result.Stdout) - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) - - t.Run("returns error for attachment index of zero", func(t *testing.T) { - cardNumber, _ := createCardWithAttachment(t, h, boardID) - h.Cleanup.AddCard(cardNumber) - - result := h.Run("card", "attachments", "download", strconv.Itoa(cardNumber), "0") - - if result.ExitCode != harness.ExitInvalidArgs { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitInvalidArgs, result.ExitCode, result.Stdout) - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) - - t.Run("returns not found for non-existent card", func(t *testing.T) { - result := h.Run("card", "attachments", "download", "999999999", "1") - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) -} - -func TestCardAttachmentsMissingArgs(t *testing.T) { - h := harness.New(t) - - t.Run("show requires card number argument", func(t *testing.T) { - result := h.Run("card", "attachments", "show") - - // Should fail with invalid args error - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing required argument") - } - }) - - t.Run("download requires card number argument", func(t *testing.T) { - result := h.Run("card", "attachments", "download") - - // Should fail with invalid args error - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing required argument") - } - }) -} diff --git a/e2e/tests/auth_test.go b/e2e/tests/auth_test.go deleted file mode 100644 index cf7a011..0000000 --- a/e2e/tests/auth_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package tests - -import ( - "os" - "path/filepath" - "testing" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestAuthStatus(t *testing.T) { - cfg := harness.LoadConfig() - if cfg.Token == "" { - t.Skip("FIZZY_TEST_TOKEN not set") - } - - t.Run("shows authenticated status with valid token in config", func(t *testing.T) { - // Create a temp HOME with a config file containing the token - tmpDir, err := os.MkdirTemp("", "fizzy-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - configDir := filepath.Join(tmpDir, ".fizzy") - os.MkdirAll(configDir, 0755) - configPath := filepath.Join(configDir, "config.yaml") - os.WriteFile(configPath, []byte("token: "+cfg.Token+"\n"), 0600) - - result := harness.Execute(cfg.BinaryPath, []string{"auth", "status"}, map[string]string{ - "HOME": tmpDir, - }) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - data := result.GetDataMap() - if data == nil { - t.Fatal("expected data map") - } - - authenticated, ok := data["authenticated"].(bool) - if !ok || !authenticated { - t.Error("expected authenticated=true") - } - }) -} - -func TestAuthStatusWithoutToken(t *testing.T) { - cfg := harness.LoadConfig() - if cfg.BinaryPath == "" { - t.Skip("FIZZY_TEST_BINARY not set") - } - - t.Run("shows not authenticated without token", func(t *testing.T) { - // Create a temp HOME with no config file - tmpDir, err := os.MkdirTemp("", "fizzy-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - result := harness.Execute(cfg.BinaryPath, []string{"auth", "status"}, map[string]string{ - "HOME": tmpDir, - }) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - data := result.GetDataMap() - if data == nil { - t.Fatal("expected data map") - } - - authenticated, ok := data["authenticated"].(bool) - if ok && authenticated { - t.Error("expected authenticated=false or missing") - } - }) -} - -func TestAuthLogin(t *testing.T) { - cfg := harness.LoadConfig() - if cfg.Token == "" { - t.Skip("FIZZY_TEST_TOKEN not set") - } - - // Create a temporary config directory - tmpDir, err := os.MkdirTemp("", "fizzy-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - t.Run("saves token to config file", func(t *testing.T) { - // Run login with HOME set to temp directory - result := harness.Execute(cfg.BinaryPath, []string{"auth", "login", cfg.Token}, map[string]string{ - "HOME": tmpDir, - "FIZZY_PROFILE": cfg.Account, - "FIZZY_NO_KEYRING": "1", - }) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - // Check config file was created (preferred path is ~/.config/fizzy/config.yaml) - preferredPath := filepath.Join(tmpDir, ".config", "fizzy", "config.yaml") - legacyPath := filepath.Join(tmpDir, ".fizzy", "config.yaml") - _, errPreferred := os.Stat(preferredPath) - _, errLegacy := os.Stat(legacyPath) - if os.IsNotExist(errPreferred) && os.IsNotExist(errLegacy) { - t.Errorf("config file was not created at either %s or %s", preferredPath, legacyPath) - } - }) -} - -func TestAuthLogout(t *testing.T) { - cfg := harness.LoadConfig() - if cfg.Token == "" { - t.Skip("FIZZY_TEST_TOKEN not set") - } - - // Create a temporary config directory - tmpDir, err := os.MkdirTemp("", "fizzy-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - configDir := filepath.Join(tmpDir, ".fizzy") - os.MkdirAll(configDir, 0755) - - // Create a config file - configPath := filepath.Join(configDir, "config.yaml") - os.WriteFile(configPath, []byte("token: test-token\n"), 0600) - - t.Run("removes config file on logout", func(t *testing.T) { - result := harness.Execute(cfg.BinaryPath, []string{"auth", "logout"}, map[string]string{ - "HOME": tmpDir, - "FIZZY_PROFILE": cfg.Account, - "FIZZY_NO_KEYRING": "1", - }) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - // Check config file was removed - if _, err := os.Stat(configPath); !os.IsNotExist(err) { - t.Error("config file was not removed") - } - }) - - t.Run("succeeds when already logged out", func(t *testing.T) { - // Config file already removed, run logout again - result := harness.Execute(cfg.BinaryPath, []string{"auth", "logout"}, map[string]string{ - "HOME": tmpDir, - "FIZZY_PROFILE": cfg.Account, - "FIZZY_NO_KEYRING": "1", - }) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - }) -} diff --git a/e2e/tests/board_test.go b/e2e/tests/board_test.go deleted file mode 100644 index 92deaf0..0000000 --- a/e2e/tests/board_test.go +++ /dev/null @@ -1,382 +0,0 @@ -package tests - -import ( - "fmt" - "testing" - "time" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestBoardList(t *testing.T) { - h := harness.New(t) - - t.Run("returns list of boards", func(t *testing.T) { - result := h.Run("board", "list") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - // Data should be an array (may be empty) - arr := result.GetDataArray() - if arr == nil { - t.Error("expected data to be an array") - } - }) - - t.Run("supports pagination with --page", func(t *testing.T) { - result := h.Run("board", "list", "--page", "1") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - // Pagination may or may not have results depending on data - // Just verify the command works - if !result.Response.OK { - t.Error("expected ok=true") - } - }) - - t.Run("supports --all flag for fetching all pages", func(t *testing.T) { - result := h.Run("board", "list", "--all") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - // When using --all, pagination should show no next page - if ctx := result.Response.Context; ctx != nil { - if pagination, ok := ctx["pagination"].(map[string]interface{}); ok { - if hasNext, _ := pagination["has_next"].(bool); hasNext { - t.Error("with --all, expected has_next=false") - } - } - } - }) -} - -func TestBoardCRUD(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - var boardID string - boardName := fmt.Sprintf("Test Board %d", time.Now().UnixNano()) - - t.Run("create board with name", func(t *testing.T) { - result := h.Run("board", "create", "--name", boardName) - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Create returns location, not data - extract ID from location - boardID = result.GetIDFromLocation() - if boardID == "" { - // Try data.id as fallback - boardID = result.GetDataString("id") - } - if boardID == "" { - t.Fatalf("expected board ID in response (location: %s)", result.GetLocation()) - } - - h.Cleanup.AddBoard(boardID) - - // Verify the name was actually saved by fetching the board - showResult := h.Run("board", "show", boardID) - if showResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show board: %s", showResult.Stderr) - } - savedName := showResult.GetDataString("name") - if savedName != boardName { - t.Errorf("expected name %q, got %q", boardName, savedName) - } - }) - - t.Run("show board by ID", func(t *testing.T) { - if boardID == "" { - t.Skip("no board ID from create test") - } - - result := h.Run("board", "show", boardID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - id := result.GetDataString("id") - if id != boardID { - t.Errorf("expected id %q, got %q", boardID, id) - } - - if publicURL := result.GetDataString("public_url"); publicURL != "" { - t.Errorf("expected unpublished board to omit public_url, got %q", publicURL) - } - }) - - t.Run("publish and unpublish board", func(t *testing.T) { - if boardID == "" { - t.Skip("no board ID from create test") - } - - published := false - publishResult := h.Run("board", "publish", boardID) - - // Only unpublish on exit if publish succeeded - t.Cleanup(func() { - if published { - h.Run("board", "unpublish", boardID) - } - }) - - if publishResult.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, publishResult.ExitCode, publishResult.Stderr, publishResult.Stdout) - } - - if publishResult.Response == nil { - t.Fatal("expected JSON response from publish") - } - - if !publishResult.Response.OK { - t.Fatalf("expected ok=true, error: %+v", publishResult.Response.Error) - } - - published = true - - publicURL := publishResult.GetDataString("public_url") - if publicURL == "" { - t.Fatal("expected public_url in publish response") - } - - showPublished := h.Run("board", "show", boardID) - if showPublished.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show published board: %s", showPublished.Stderr) - } - if got := showPublished.GetDataString("public_url"); got != publicURL { - t.Errorf("expected public_url %q after publish, got %q", publicURL, got) - } - - // Verify unpublish works (cleanup is skipped since we unpublish here) - unpublishResult := h.Run("board", "unpublish", boardID) - if unpublishResult.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, unpublishResult.ExitCode, unpublishResult.Stderr, unpublishResult.Stdout) - } - - if unpublishResult.Response == nil { - t.Fatal("expected JSON response from unpublish") - } - - if !unpublishResult.Response.OK { - t.Fatalf("expected ok=true, error: %+v", unpublishResult.Response.Error) - } - - published = false - - if !unpublishResult.GetDataBool("unpublished") { - t.Error("expected unpublished=true") - } - - showUnpublished := h.Run("board", "show", boardID) - if showUnpublished.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show unpublished board: %s", showUnpublished.Stderr) - } - if got := showUnpublished.GetDataString("public_url"); got != "" { - t.Errorf("expected public_url to be removed after unpublish, got %q", got) - } - }) - - t.Run("update board name", func(t *testing.T) { - if boardID == "" { - t.Skip("no board ID from create test") - } - - newName := boardName + " Updated" - result := h.Run("board", "update", boardID, "--name", newName) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - // Note: Update returns success but no data - verify via show - showResult := h.Run("board", "show", boardID) - if showResult.ExitCode == harness.ExitSuccess { - name := showResult.GetDataString("name") - if name != newName { - t.Errorf("expected name %q after update, got %q", newName, name) - } - } - }) - - t.Run("delete board", func(t *testing.T) { - if boardID == "" { - t.Skip("no board ID from create test") - } - - result := h.Run("board", "delete", boardID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - deleted := result.GetDataBool("deleted") - if !deleted { - t.Error("expected deleted=true") - } - - // Remove from cleanup since we deleted it - h.Cleanup.Boards = nil - }) -} - -func TestBoardCreateWithOptions(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - t.Run("create board with all_access=false", func(t *testing.T) { - name := fmt.Sprintf("Private Board %d", time.Now().UnixNano()) - result := h.Run("board", "create", "--name", name, "--all_access", "false") - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - boardID := result.GetDataString("id") - if boardID != "" { - h.Cleanup.AddBoard(boardID) - } - - if result.Response == nil || !result.Response.OK { - t.Error("expected successful response") - } - }) - - t.Run("create board with auto_postpone_period_in_days", func(t *testing.T) { - name := fmt.Sprintf("Auto Postpone Board %d", time.Now().UnixNano()) - result := h.Run("board", "create", "--name", name, "--auto_postpone_period_in_days", "7") - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - boardID := result.GetDataString("id") - if boardID != "" { - h.Cleanup.AddBoard(boardID) - } - - if result.Response == nil || !result.Response.OK { - t.Error("expected successful response") - } - }) -} - -func TestBoardCreateMissingName(t *testing.T) { - h := harness.New(t) - - t.Run("fails without required --name option", func(t *testing.T) { - result := h.Run("board", "create") - - // Should fail with error exit code (1, 2, or 6) - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing required option") - } - }) -} - -func TestBoardShowNotFound(t *testing.T) { - h := harness.New(t) - - t.Run("returns not found for non-existent board", func(t *testing.T) { - result := h.Run("board", "show", "non-existent-board-id-12345") - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s\nstderr: %s", - harness.ExitNotFound, result.ExitCode, result.Stdout, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if result.Response.OK { - t.Error("expected ok=false") - } - - if result.Response.Error == "" { - t.Error("expected error in response") - } else if result.Response.Code != "not_found" { - t.Errorf("expected error code not_found, got %s", result.Response.Code) - } - }) -} - -func TestBoardDeleteNotFound(t *testing.T) { - h := harness.New(t) - - t.Run("returns not found for non-existent board", func(t *testing.T) { - result := h.Run("board", "delete", "non-existent-board-id-12345") - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d", harness.ExitNotFound, result.ExitCode) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if result.Response.OK { - t.Error("expected ok=false") - } - }) -} diff --git a/e2e/tests/card_test.go b/e2e/tests/card_test.go deleted file mode 100644 index c60de73..0000000 --- a/e2e/tests/card_test.go +++ /dev/null @@ -1,859 +0,0 @@ -package tests - -import ( - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -// createTestBoard creates a board for card tests and adds it to cleanup -func createTestBoard(t *testing.T, h *harness.Harness) string { - t.Helper() - name := fmt.Sprintf("Card Test Board %d", time.Now().UnixNano()) - result := h.Run("board", "create", "--name", name) - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create test board: %s\nstdout: %s", result.Stderr, result.Stdout) - } - // Create returns location, not data - extract ID from location - boardID := result.GetIDFromLocation() - if boardID == "" { - // Try data.id as fallback - boardID = result.GetDataString("id") - } - if boardID == "" { - t.Fatalf("no board ID returned (location: %s)", result.GetLocation()) - } - h.Cleanup.AddBoard(boardID) - return boardID -} - -func TestCardList(t *testing.T) { - h := harness.New(t) - - t.Run("returns list of cards", func(t *testing.T) { - result := h.Run("card", "list") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - // Data should be an array - arr := result.GetDataArray() - if arr == nil { - t.Error("expected data to be an array") - } - }) - - t.Run("supports --page option", func(t *testing.T) { - result := h.Run("card", "list", "--page", "1") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - }) - - t.Run("supports --all flag", func(t *testing.T) { - result := h.Run("card", "list", "--all") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - }) -} - -func TestCardListWithFilters(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - - t.Run("filters by board", func(t *testing.T) { - // Create two cards on different boards to ensure the filter is effective. - otherBoardID := createTestBoard(t, h) - - titleA := fmt.Sprintf("Board Filter A %d", time.Now().UnixNano()) - titleB := fmt.Sprintf("Board Filter B %d", time.Now().UnixNano()) - - createdA := h.Run("card", "create", "--board", boardID, "--title", titleA) - if createdA.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card on board A: %s\nstdout: %s", createdA.Stderr, createdA.Stdout) - } - cardA := createdA.GetNumberFromLocation() - if cardA == 0 { - cardA = createdA.GetDataInt("number") - } - if cardA != 0 { - h.Cleanup.AddCard(cardA) - } - - createdB := h.Run("card", "create", "--board", otherBoardID, "--title", titleB) - if createdB.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card on board B: %s\nstdout: %s", createdB.Stderr, createdB.Stdout) - } - cardB := createdB.GetNumberFromLocation() - if cardB == 0 { - cardB = createdB.GetDataInt("number") - } - if cardB != 0 { - h.Cleanup.AddCard(cardB) - } - - result := h.Run("card", "list", "--board", boardID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - arr := result.GetDataArray() - if arr == nil { - t.Fatalf("expected array response\nstdout: %s", result.Stdout) - } - - foundA := false - foundB := false - for _, item := range arr { - card, ok := item.(map[string]any) - if !ok { - continue - } - if title, ok := card["title"].(string); ok { - if title == titleA { - foundA = true - } - if title == titleB { - foundB = true - } - } - } - - if !foundA { - t.Errorf("expected board-filtered list to include card created on board %s (title %q)", boardID, titleA) - } - if foundB { - t.Errorf("expected board-filtered list to exclude card created on other board %s (title %q)", otherBoardID, titleB) - } - }) - - t.Run("filters by status", func(t *testing.T) { - result := h.Run("card", "list", "--indexed-by", "not_now") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - }) -} - -func TestCardCRUD(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - var cardNumber int - cardTitle := fmt.Sprintf("Test Card %d", time.Now().UnixNano()) - - t.Run("create card with title", func(t *testing.T) { - result := h.Run("card", "create", "--board", boardID, "--title", cardTitle) - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Create returns location - extract number from it - cardNumber = result.GetNumberFromLocation() - if cardNumber == 0 { - // Try data.number as fallback - cardNumber = result.GetDataInt("number") - } - if cardNumber == 0 { - t.Fatalf("expected card number in response (location: %s)", result.GetLocation()) - } - - h.Cleanup.AddCard(cardNumber) - - // Note: Create returns location, not data with title - // Verify the card exists via show command - showResult := h.Run("card", "show", strconv.Itoa(cardNumber)) - if showResult.ExitCode == harness.ExitSuccess { - title := showResult.GetDataString("title") - if title != cardTitle { - t.Errorf("expected title %q, got %q", cardTitle, title) - } - } - }) - - t.Run("show card by number", func(t *testing.T) { - if cardNumber == 0 { - t.Skip("no card number from create test") - } - - result := h.Run("card", "show", strconv.Itoa(cardNumber)) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - num := result.GetDataInt("number") - if num != cardNumber { - t.Errorf("expected number %d, got %d", cardNumber, num) - } - }) - - t.Run("update card title", func(t *testing.T) { - if cardNumber == 0 { - t.Skip("no card number from create test") - } - - newTitle := cardTitle + " Updated" - result := h.Run("card", "update", strconv.Itoa(cardNumber), "--title", newTitle) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - title := result.GetDataString("title") - if title != newTitle { - t.Errorf("expected title %q, got %q", newTitle, title) - } - }) - - t.Run("delete card", func(t *testing.T) { - if cardNumber == 0 { - t.Skip("no card number from create test") - } - - result := h.Run("card", "delete", strconv.Itoa(cardNumber)) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - deleted := result.GetDataBool("deleted") - if !deleted { - t.Error("expected deleted=true") - } - - // Remove from cleanup since we deleted it - h.Cleanup.Cards = nil - }) -} - -func TestCardCreateWithDescription(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - - t.Run("create card with description", func(t *testing.T) { - title := fmt.Sprintf("Card with Description %d", time.Now().UnixNano()) - description := "

This is a test description.

" - // API returns plain text version of description (HTML tags stripped) - expectedDescPlainText := "This is a test description." - - result := h.Run("card", "create", "--board", boardID, "--title", title, "--description", description) - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - // Create returns location - extract number from it - cardNumber := result.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = result.GetDataInt("number") - } - if cardNumber != 0 { - h.Cleanup.AddCard(cardNumber) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - // Verify the description was actually saved by fetching the card - if cardNumber != 0 { - showResult := h.Run("card", "show", strconv.Itoa(cardNumber)) - if showResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show card: %s", showResult.Stderr) - } - savedTitle := showResult.GetDataString("title") - if savedTitle != title { - t.Errorf("expected title %q, got %q", title, savedTitle) - } - // API returns plain text version - savedDesc := showResult.GetDataString("description") - if savedDesc != expectedDescPlainText { - t.Errorf("expected description %q, got %q", expectedDescPlainText, savedDesc) - } - // Verify HTML version is also returned and contains our markup - savedDescHtml := showResult.GetDataString("description_html") - if savedDescHtml == "" { - t.Error("expected description_html to be present") - } - // The HTML should contain our strong tag (Rails preserves it through Action Text) - if !strings.Contains(savedDescHtml, "") && !strings.Contains(savedDescHtml, "test") { - t.Errorf("expected description_html to contain markup, got %q", savedDescHtml) - } - } - }) - - t.Run("create card with description_file", func(t *testing.T) { - // Get the path to the test document fixture - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_document.txt") - - // Check if fixture exists - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - title := fmt.Sprintf("Card from File %d", time.Now().UnixNano()) - result := h.Run("card", "create", "--board", boardID, "--title", title, "--description_file", fixturePath) - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - // Create returns location - extract number from it - cardNumber := result.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = result.GetDataInt("number") - } - if cardNumber != 0 { - h.Cleanup.AddCard(cardNumber) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - }) -} - -func TestCardActions(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - - // Create a card for action tests - title := fmt.Sprintf("Action Test Card %d", time.Now().UnixNano()) - result := h.Run("card", "create", "--board", boardID, "--title", title) - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create test card: %s\nstdout: %s", result.Stderr, result.Stdout) - } - // Create returns location - extract number from it - cardNumber := result.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = result.GetDataInt("number") - } - if cardNumber == 0 { - t.Fatalf("failed to get card number from create (location: %s)", result.GetLocation()) - } - h.Cleanup.AddCard(cardNumber) - cardStr := strconv.Itoa(cardNumber) - - t.Run("close card", func(t *testing.T) { - result := h.Run("card", "close", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("reopen card", func(t *testing.T) { - result := h.Run("card", "reopen", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("postpone card", func(t *testing.T) { - result := h.Run("card", "postpone", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("watch card", func(t *testing.T) { - result := h.Run("card", "watch", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("unwatch card", func(t *testing.T) { - result := h.Run("card", "unwatch", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("tag card", func(t *testing.T) { - tagName := fmt.Sprintf("test-tag-%d", time.Now().UnixNano()) - result := h.Run("card", "tag", cardStr, "--tag", tagName) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("self-assign card", func(t *testing.T) { - result := h.Run("card", "self-assign", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("self-assign card again to unassign", func(t *testing.T) { - result := h.Run("card", "self-assign", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("golden card", func(t *testing.T) { - result := h.Run("card", "golden", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Verify card is now golden - showResult := h.Run("card", "show", cardStr) - if showResult.ExitCode == harness.ExitSuccess { - golden := showResult.GetDataBool("golden") - if !golden { - t.Error("expected card to be golden after marking as golden") - } - } - }) - - t.Run("ungolden card", func(t *testing.T) { - result := h.Run("card", "ungolden", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Verify card is no longer golden - showResult := h.Run("card", "show", cardStr) - if showResult.ExitCode == harness.ExitSuccess { - golden := showResult.GetDataBool("golden") - if golden { - t.Error("expected card to not be golden after removing golden status") - } - } - }) -} - -func TestCardColumn(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - - // Create a column - columnName := fmt.Sprintf("Test Column %d", time.Now().UnixNano()) - colResult := h.Run("column", "create", "--board", boardID, "--name", columnName) - if colResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create column: %s\nstdout: %s", colResult.Stderr, colResult.Stdout) - } - // Create returns location - extract ID from it - columnID := colResult.GetIDFromLocation() - if columnID == "" { - columnID = colResult.GetDataString("id") - } - if columnID == "" { - t.Fatalf("failed to get column ID (location: %s)", colResult.GetLocation()) - } - h.Cleanup.AddColumn(columnID, boardID) - - // Create a card - title := fmt.Sprintf("Column Test Card %d", time.Now().UnixNano()) - cardResult := h.Run("card", "create", "--board", boardID, "--title", title) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s\nstdout: %s", cardResult.Stderr, cardResult.Stdout) - } - // Create returns location - extract number from it - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - if cardNumber == 0 { - t.Fatalf("failed to get card number (location: %s)", cardResult.GetLocation()) - } - h.Cleanup.AddCard(cardNumber) - cardStr := strconv.Itoa(cardNumber) - - t.Run("move card to column", func(t *testing.T) { - result := h.Run("card", "column", cardStr, "--column", columnID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("untriage card (send back to triage)", func(t *testing.T) { - result := h.Run("card", "untriage", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) -} - -func TestCardShowNotFound(t *testing.T) { - h := harness.New(t) - - t.Run("returns not found for non-existent card", func(t *testing.T) { - result := h.Run("card", "show", "999999999") - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if result.Response.OK { - t.Error("expected ok=false") - } - - if result.Response.Error == "" { - t.Error("expected error in response") - } - }) -} - -func TestCardCreateMissingBoard(t *testing.T) { - h := harness.New(t) - - t.Run("fails without required --board option when no default board configured", func(t *testing.T) { - // Use a temp HOME so the global config (which may have a default board) is not found - tmpHome := t.TempDir() - result := h.RunWithEnv(map[string]string{"HOME": tmpHome}, "card", "create", "--title", "Test") - - // Should fail with error exit code - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing required option") - } - }) -} - -func TestCardCreateMissingTitle(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - - t.Run("fails without required --title option", func(t *testing.T) { - result := h.Run("card", "create", "--board", boardID) - - // Should fail with error exit code - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing required option") - } - }) -} - -func TestCardHasAttachments(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - - t.Run("plain card has_attachments is false", func(t *testing.T) { - title := fmt.Sprintf("No Attachments Card %d", time.Now().UnixNano()) - cardResult := h.Run("card", "create", "--board", boardID, "--title", title) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s\nstdout: %s", cardResult.Stderr, cardResult.Stdout) - } - - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - if cardNumber == 0 { - t.Fatalf("failed to get card number (location: %s)", cardResult.GetLocation()) - } - h.Cleanup.AddCard(cardNumber) - cardStr := strconv.Itoa(cardNumber) - - showResult := h.Run("card", "show", cardStr) - if showResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show card: %s", showResult.Stderr) - } - - hasAttachments := showResult.GetDataBool("has_attachments") - if hasAttachments { - t.Error("expected has_attachments=false for a card with no attachments") - } - }) - - t.Run("card with inline attachment has_attachments is true", func(t *testing.T) { - // Get the path to the test image fixture - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_image.png") - - // Check if fixture exists - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - // Upload the file to get an attachable_sgid - uploadResult := h.Run("upload", "file", fixturePath) - if uploadResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to upload file: %s\nstdout: %s", uploadResult.Stderr, uploadResult.Stdout) - } - - sgid := uploadResult.GetDataString("attachable_sgid") - if sgid == "" { - t.Fatalf("no attachable_sgid returned from upload\nstdout: %s", uploadResult.Stdout) - } - - // Create a card with an inline attachment in the description - title := fmt.Sprintf("Attachment Card %d", time.Now().UnixNano()) - description := fmt.Sprintf(`

See image:

`, sgid) - cardResult := h.Run("card", "create", "--board", boardID, "--title", title, "--description", description) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card with attachment: %s\nstdout: %s", cardResult.Stderr, cardResult.Stdout) - } - - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - if cardNumber == 0 { - t.Fatalf("failed to get card number (location: %s)", cardResult.GetLocation()) - } - h.Cleanup.AddCard(cardNumber) - cardStr := strconv.Itoa(cardNumber) - - showResult := h.Run("card", "show", cardStr) - if showResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show card: %s", showResult.Stderr) - } - - hasAttachments := showResult.GetDataBool("has_attachments") - if !hasAttachments { - t.Error("expected has_attachments=true for a card with an inline attachment") - } - }) -} - -func TestCardImageRemove(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - - t.Run("removes header image from card", func(t *testing.T) { - // Get the path to the test image fixture - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_image.png") - - // Check if fixture exists - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - // Upload the file first to get a signed ID for the header image - uploadResult := h.Run("upload", "file", fixturePath) - if uploadResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to upload file: %s\nstdout: %s", uploadResult.Stderr, uploadResult.Stdout) - } - - signedID := uploadResult.GetDataString("signed_id") - if signedID == "" { - t.Fatalf("no signed_id returned from upload\nstdout: %s", uploadResult.Stdout) - } - - // Create a card with header image - title := fmt.Sprintf("Image Remove Test Card %d", time.Now().UnixNano()) - cardResult := h.Run("card", "create", "--board", boardID, "--title", title, "--image", signedID) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card with image: %s\nstdout: %s", cardResult.Stderr, cardResult.Stdout) - } - - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - if cardNumber == 0 { - t.Fatalf("failed to get card number from create (location: %s)", cardResult.GetLocation()) - } - h.Cleanup.AddCard(cardNumber) - cardStr := strconv.Itoa(cardNumber) - - // Verify card has image initially - showResult := h.Run("card", "show", cardStr) - if showResult.ExitCode == harness.ExitSuccess { - imageURL := showResult.GetDataString("image_url") - if imageURL == "" { - t.Log("Warning: card may not have image_url field set immediately after creation") - } - } - - // Remove the image - result := h.Run("card", "image-remove", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("succeeds on card without image", func(t *testing.T) { - // Create a card without image - title := fmt.Sprintf("No Image Card %d", time.Now().UnixNano()) - cardResult := h.Run("card", "create", "--board", boardID, "--title", title) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s", cardResult.Stderr) - } - - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - h.Cleanup.AddCard(cardNumber) - cardStr := strconv.Itoa(cardNumber) - - // Try to remove non-existent image (should still succeed or return appropriate error) - result := h.Run("card", "image-remove", cardStr) - - // The API may return success or not found - either is acceptable - if result.ExitCode != harness.ExitSuccess && result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d or %d, got %d\nstderr: %s", - harness.ExitSuccess, harness.ExitNotFound, result.ExitCode, result.Stderr) - } - }) - - t.Run("returns not found for non-existent card", func(t *testing.T) { - result := h.Run("card", "image-remove", "999999999") - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) -} diff --git a/e2e/tests/column_test.go b/e2e/tests/column_test.go deleted file mode 100644 index 6f5ace7..0000000 --- a/e2e/tests/column_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package tests - -import ( - "fmt" - "testing" - "time" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestColumnList(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - - t.Run("returns list of columns for board", func(t *testing.T) { - result := h.Run("column", "list", "--board", boardID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - arr := result.GetDataArray() - if arr == nil { - t.Error("expected data to be an array") - } - }) - - t.Run("fails without --board option when no default board configured", func(t *testing.T) { - // Use a temp HOME so the global config (which may have a default board) is not found - tmpHome := t.TempDir() - result := h.RunWithEnv(map[string]string{"HOME": tmpHome}, "column", "list") - - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing required option") - } - }) -} - -func TestColumnCRUD(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - var columnID string - columnName := fmt.Sprintf("Test Column %d", time.Now().UnixNano()) - - t.Run("create column", func(t *testing.T) { - result := h.Run("column", "create", "--board", boardID, "--name", columnName) - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Create returns location - extract ID from it - columnID = result.GetIDFromLocation() - if columnID == "" { - // Try data.id as fallback - columnID = result.GetDataString("id") - } - if columnID == "" { - t.Fatalf("expected column ID in response (location: %s)", result.GetLocation()) - } - - h.Cleanup.AddColumn(columnID, boardID) - - // Note: Create returns location, not data with name - // Verify the column exists via show command - showResult := h.Run("column", "show", columnID, "--board", boardID) - if showResult.ExitCode == harness.ExitSuccess { - name := showResult.GetDataString("name") - if name != columnName { - t.Errorf("expected name %q, got %q", columnName, name) - } - } - }) - - t.Run("create column with color", func(t *testing.T) { - name := fmt.Sprintf("Colored Column %d", time.Now().UnixNano()) - color := "var(--color-card-4)" - result := h.Run("column", "create", "--board", boardID, "--name", name, "--color", color) - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - // Create returns location - extract ID from it - id := result.GetIDFromLocation() - if id == "" { - id = result.GetDataString("id") - } - if id != "" { - h.Cleanup.AddColumn(id, boardID) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - // Verify the name and color were actually saved - if id != "" { - showResult := h.Run("column", "show", id, "--board", boardID) - if showResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show column: %s", showResult.Stderr) - } - savedName := showResult.GetDataString("name") - if savedName != name { - t.Errorf("expected name %q, got %q", name, savedName) - } - // Color is returned as an object with "value" field - data := showResult.GetDataMap() - if colorObj, ok := data["color"].(map[string]any); ok { - savedColorValue := colorObj["value"].(string) - if savedColorValue != color { - t.Errorf("expected color value %q, got %q", color, savedColorValue) - } - } else { - t.Errorf("expected color object, got %T", data["color"]) - } - } - }) - - t.Run("show column", func(t *testing.T) { - if columnID == "" { - t.Skip("no column ID from create test") - } - - result := h.Run("column", "show", columnID, "--board", boardID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - id := result.GetDataString("id") - if id != columnID { - t.Errorf("expected id %q, got %q", columnID, id) - } - }) - - t.Run("update column", func(t *testing.T) { - if columnID == "" { - t.Skip("no column ID from create test") - } - - newName := columnName + " Updated" - result := h.Run("column", "update", columnID, "--board", boardID, "--name", newName) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - // Note: Update may return success without data - verify via show - showResult := h.Run("column", "show", columnID, "--board", boardID) - if showResult.ExitCode == harness.ExitSuccess { - name := showResult.GetDataString("name") - if name != newName { - t.Errorf("expected name %q, got %q", newName, name) - } - } - }) - - t.Run("delete column", func(t *testing.T) { - if columnID == "" { - t.Skip("no column ID from create test") - } - - result := h.Run("column", "delete", columnID, "--board", boardID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - deleted := result.GetDataBool("deleted") - if !deleted { - t.Error("expected deleted=true") - } - - // Remove the first column from cleanup since we deleted it - if len(h.Cleanup.Columns) > 0 { - h.Cleanup.Columns = h.Cleanup.Columns[1:] - } - }) -} - -func TestColumnShowNotFound(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - - t.Run("returns not found for non-existent column", func(t *testing.T) { - result := h.Run("column", "show", "non-existent-column-id", "--board", boardID) - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if result.Response.OK { - t.Error("expected ok=false") - } - }) -} diff --git a/e2e/tests/comment_attachment_test.go b/e2e/tests/comment_attachment_test.go deleted file mode 100644 index dee516a..0000000 --- a/e2e/tests/comment_attachment_test.go +++ /dev/null @@ -1,458 +0,0 @@ -package tests - -import ( - "fmt" - "os" - "path/filepath" - "strconv" - "testing" - "time" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -// createCommentWithAttachment uploads a file, creates a card, then adds a comment with the attachment. -// Returns the card number, comment ID, and expected filename. -func createCommentWithAttachment(t *testing.T, h *harness.Harness, boardID string) (int, string, string) { - t.Helper() - - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_image.png") - - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - // Upload the file - uploadResult := h.Run("upload", "file", fixturePath) - if uploadResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to upload file: %s\nstdout: %s", uploadResult.Stderr, uploadResult.Stdout) - } - - attachableSGID := uploadResult.GetDataString("attachable_sgid") - if attachableSGID == "" { - t.Fatalf("no attachable_sgid returned from upload\nstdout: %s", uploadResult.Stdout) - } - - // Create a plain card - title := fmt.Sprintf("Comment Attachment Test %d", time.Now().UnixNano()) - cardResult := h.Run("card", "create", "--board", boardID, "--title", title) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s\nstdout: %s", cardResult.Stderr, cardResult.Stdout) - } - - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - if cardNumber == 0 { - t.Fatalf("failed to get card number from create (location: %s)", cardResult.GetLocation()) - } - h.Cleanup.AddCard(cardNumber) - - // Create a comment with the attachment - body := fmt.Sprintf(``, attachableSGID) - commentResult := h.Run("comment", "create", "--card", strconv.Itoa(cardNumber), "--body", body) - if commentResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create comment with attachment: %s\nstdout: %s", commentResult.Stderr, commentResult.Stdout) - } - - commentID := commentResult.GetIDFromLocation() - if commentID == "" { - commentID = commentResult.GetDataString("id") - } - if commentID != "" { - h.Cleanup.AddComment(commentID, cardNumber) - } - - return cardNumber, commentID, "test_image.png" -} - -func TestCommentAttachmentsShow(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createAttachmentTestBoard(t, h) - - t.Run("shows attachments from comments", func(t *testing.T) { - cardNumber, _, expectedFilename := createCommentWithAttachment(t, h, boardID) - - result := h.Run("comment", "attachments", "show", "--card", strconv.Itoa(cardNumber)) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - arr := result.GetDataArray() - if arr == nil { - t.Fatalf("expected array response, got: %v", result.Response.Data) - } - - if len(arr) != 1 { - t.Errorf("expected 1 attachment, got %d", len(arr)) - } - - if len(arr) > 0 { - attachment, ok := arr[0].(map[string]any) - if !ok { - t.Fatalf("expected attachment to be a map, got %T", arr[0]) - } - - if filename := attachment["filename"].(string); filename != expectedFilename { - t.Errorf("expected filename %q, got %q", expectedFilename, filename) - } - - if index := int(attachment["index"].(float64)); index != 1 { - t.Errorf("expected index 1, got %d", index) - } - - if downloadURL := attachment["download_url"].(string); downloadURL == "" { - t.Error("expected non-empty download_url") - } - - if commentID, ok := attachment["comment_id"].(string); !ok || commentID == "" { - t.Error("expected non-empty comment_id") - } - } - }) - - t.Run("returns empty for card with no comment attachments", func(t *testing.T) { - // Create a card with a plain text comment - title := fmt.Sprintf("No Comment Attachment %d", time.Now().UnixNano()) - cardResult := h.Run("card", "create", "--board", boardID, "--title", title) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s", cardResult.Stderr) - } - - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - h.Cleanup.AddCard(cardNumber) - - commentResult := h.Run("comment", "create", "--card", strconv.Itoa(cardNumber), "--body", "Just a plain comment") - if commentResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create comment: %s", commentResult.Stderr) - } - commentID := commentResult.GetIDFromLocation() - if commentID == "" { - commentID = commentResult.GetDataString("id") - } - if commentID != "" { - h.Cleanup.AddComment(commentID, cardNumber) - } - - result := h.Run("comment", "attachments", "show", "--card", strconv.Itoa(cardNumber)) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - arr := result.GetDataArray() - if arr != nil && len(arr) != 0 { - t.Errorf("expected empty array, got %d items", len(arr)) - } - }) - - t.Run("requires card flag", func(t *testing.T) { - result := h.Run("comment", "attachments", "show") - - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing --card flag") - } - }) -} - -func TestCommentAttachmentsDownload(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createAttachmentTestBoard(t, h) - - t.Run("downloads single comment attachment by index", func(t *testing.T) { - cardNumber, _, expectedFilename := createCommentWithAttachment(t, h, boardID) - - tempDir, err := os.MkdirTemp("", "fizzy-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - originalDir, _ := os.Getwd() - if err := os.Chdir(tempDir); err != nil { - t.Fatalf("failed to change to temp dir: %v", err) - } - defer os.Chdir(originalDir) - - result := h.Run("comment", "attachments", "download", "--card", strconv.Itoa(cardNumber), "1") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Verify file was downloaded - downloadedFile := filepath.Join(tempDir, expectedFilename) - if _, err := os.Stat(downloadedFile); os.IsNotExist(err) { - t.Errorf("expected file %s to exist", downloadedFile) - } - - // Check response data - data := result.GetDataMap() - if data == nil { - t.Fatal("expected data map in response") - } - - downloaded := int(data["downloaded"].(float64)) - if downloaded != 1 { - t.Errorf("expected downloaded=1, got %d", downloaded) - } - }) - - t.Run("downloads all comment attachments", func(t *testing.T) { - cardNumber, _, _ := createCommentWithAttachment(t, h, boardID) - - tempDir, err := os.MkdirTemp("", "fizzy-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - originalDir, _ := os.Getwd() - if err := os.Chdir(tempDir); err != nil { - t.Fatalf("failed to change to temp dir: %v", err) - } - defer os.Chdir(originalDir) - - result := h.Run("comment", "attachments", "download", "--card", strconv.Itoa(cardNumber)) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - data := result.GetDataMap() - downloaded := int(data["downloaded"].(float64)) - if downloaded != 1 { - t.Errorf("expected downloaded=1, got %d", downloaded) - } - }) - - t.Run("downloads with custom output filename", func(t *testing.T) { - cardNumber, _, _ := createCommentWithAttachment(t, h, boardID) - - tempDir, err := os.MkdirTemp("", "fizzy-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - originalDir, _ := os.Getwd() - if err := os.Chdir(tempDir); err != nil { - t.Fatalf("failed to change to temp dir: %v", err) - } - defer os.Chdir(originalDir) - - customFilename := "my_comment_download.png" - result := h.Run("comment", "attachments", "download", "--card", strconv.Itoa(cardNumber), "1", "-o", customFilename) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - downloadedFile := filepath.Join(tempDir, customFilename) - if _, err := os.Stat(downloadedFile); os.IsNotExist(err) { - t.Errorf("expected file %s to exist", downloadedFile) - } - - data := result.GetDataMap() - files := data["files"].([]any) - if len(files) > 0 { - fileInfo := files[0].(map[string]any) - savedTo := fileInfo["saved_to"].(string) - if savedTo != customFilename { - t.Errorf("expected saved_to=%q, got %q", customFilename, savedTo) - } - } - }) - - t.Run("returns error for no comment attachments", func(t *testing.T) { - title := fmt.Sprintf("No Comment Attachment %d", time.Now().UnixNano()) - cardResult := h.Run("card", "create", "--board", boardID, "--title", title, "--description", "No files here") - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s", cardResult.Stderr) - } - - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - h.Cleanup.AddCard(cardNumber) - - result := h.Run("comment", "attachments", "download", "--card", strconv.Itoa(cardNumber)) - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) - - t.Run("returns error for invalid attachment index", func(t *testing.T) { - cardNumber, _, _ := createCommentWithAttachment(t, h, boardID) - - result := h.Run("comment", "attachments", "download", "--card", strconv.Itoa(cardNumber), "abc") - - if result.ExitCode != harness.ExitInvalidArgs { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitInvalidArgs, result.ExitCode, result.Stdout) - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) - - t.Run("returns error for attachment index out of range", func(t *testing.T) { - cardNumber, _, _ := createCommentWithAttachment(t, h, boardID) - - result := h.Run("comment", "attachments", "download", "--card", strconv.Itoa(cardNumber), "99") - - if result.ExitCode != harness.ExitInvalidArgs { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitInvalidArgs, result.ExitCode, result.Stdout) - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) - - t.Run("requires card flag", func(t *testing.T) { - result := h.Run("comment", "attachments", "download") - - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing --card flag") - } - }) -} - -func TestCardAttachmentsIncludeComments(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createAttachmentTestBoard(t, h) - - t.Run("show includes comment attachments with flag", func(t *testing.T) { - cardNumber, _, expectedFilename := createCommentWithAttachment(t, h, boardID) - - result := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber), "--include-comments") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - arr := result.GetDataArray() - if len(arr) != 1 { - t.Errorf("expected 1 attachment, got %d", len(arr)) - } - - if len(arr) > 0 { - attachment := arr[0].(map[string]any) - if filename := attachment["filename"].(string); filename != expectedFilename { - t.Errorf("expected filename %q, got %q", expectedFilename, filename) - } - } - }) - - t.Run("show excludes comment attachments without flag", func(t *testing.T) { - cardNumber, _, _ := createCommentWithAttachment(t, h, boardID) - - result := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber)) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - arr := result.GetDataArray() - if arr != nil && len(arr) != 0 { - t.Errorf("expected empty array (no description attachments), got %d items", len(arr)) - } - }) - - t.Run("download includes comment attachments with flag", func(t *testing.T) { - cardNumber, _, expectedFilename := createCommentWithAttachment(t, h, boardID) - - tempDir, err := os.MkdirTemp("", "fizzy-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - originalDir, _ := os.Getwd() - if err := os.Chdir(tempDir); err != nil { - t.Fatalf("failed to change to temp dir: %v", err) - } - defer os.Chdir(originalDir) - - result := h.Run("card", "attachments", "download", strconv.Itoa(cardNumber), "--include-comments") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - downloadedFile := filepath.Join(tempDir, expectedFilename) - if _, err := os.Stat(downloadedFile); os.IsNotExist(err) { - t.Errorf("expected file %s to exist", downloadedFile) - } - - data := result.GetDataMap() - downloaded := int(data["downloaded"].(float64)) - if downloaded != 1 { - t.Errorf("expected downloaded=1, got %d", downloaded) - } - }) -} diff --git a/e2e/tests/comment_test.go b/e2e/tests/comment_test.go deleted file mode 100644 index ee54eb4..0000000 --- a/e2e/tests/comment_test.go +++ /dev/null @@ -1,298 +0,0 @@ -package tests - -import ( - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -// createTestCard creates a card for comment tests and adds it to cleanup -func createTestCard(t *testing.T, h *harness.Harness, boardID string) int { - t.Helper() - title := fmt.Sprintf("Comment Test Card %d", time.Now().UnixNano()) - result := h.Run("card", "create", "--board", boardID, "--title", title) - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create test card: %s\nstdout: %s", result.Stderr, result.Stdout) - } - // Create returns location - extract number from it - cardNumber := result.GetNumberFromLocation() - if cardNumber == 0 { - // Try data.number as fallback - cardNumber = result.GetDataInt("number") - } - if cardNumber == 0 { - t.Fatalf("no card number returned (location: %s)", result.GetLocation()) - } - h.Cleanup.AddCard(cardNumber) - return cardNumber -} - -func TestCommentList(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - cardNumber := createTestCard(t, h, boardID) - cardStr := strconv.Itoa(cardNumber) - - t.Run("returns list of comments for card", func(t *testing.T) { - result := h.Run("comment", "list", "--card", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - arr := result.GetDataArray() - if arr == nil { - t.Error("expected data to be an array") - } - }) - - t.Run("supports --all flag", func(t *testing.T) { - result := h.Run("comment", "list", "--card", cardStr, "--all") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - }) - - t.Run("fails without --card option", func(t *testing.T) { - result := h.Run("comment", "list") - - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing required option") - } - }) -} - -func TestCommentCRUD(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - cardNumber := createTestCard(t, h, boardID) - cardStr := strconv.Itoa(cardNumber) - - var commentID string - commentBody := fmt.Sprintf("Test comment %d", time.Now().UnixNano()) - - t.Run("create comment with body", func(t *testing.T) { - result := h.Run("comment", "create", "--card", cardStr, "--body", commentBody) - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Create returns location - extract ID from it - commentID = result.GetIDFromLocation() - if commentID == "" { - // Try data.id as fallback - commentID = result.GetDataString("id") - } - if commentID == "" { - t.Fatalf("expected comment ID in response (location: %s)", result.GetLocation()) - } - - h.Cleanup.AddComment(commentID, cardNumber) - - // Verify the body was actually saved by fetching the comment - showResult := h.Run("comment", "show", commentID, "--card", cardStr) - if showResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show comment: %s", showResult.Stderr) - } - // Body is returned as an object with "plain_text" and "html" fields - data := showResult.GetDataMap() - if bodyObj, ok := data["body"].(map[string]any); ok { - savedBody := bodyObj["plain_text"].(string) - if savedBody != commentBody { - t.Errorf("expected body %q, got %q", commentBody, savedBody) - } - // Verify HTML version is also returned - savedHtml, ok := bodyObj["html"].(string) - if !ok || savedHtml == "" { - t.Error("expected body.html to be present") - } - // The HTML should contain our text - if !strings.Contains(savedHtml, commentBody) { - t.Errorf("expected body.html to contain %q, got %q", commentBody, savedHtml) - } - } else { - t.Errorf("expected body object, got %T", data["body"]) - } - }) - - t.Run("create comment with body_file", func(t *testing.T) { - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_document.txt") - - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - result := h.Run("comment", "create", "--card", cardStr, "--body_file", fixturePath) - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - // Create returns location - extract ID from it - id := result.GetIDFromLocation() - if id == "" { - id = result.GetDataString("id") - } - if id != "" { - h.Cleanup.AddComment(id, cardNumber) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - }) - - t.Run("show comment", func(t *testing.T) { - if commentID == "" { - t.Skip("no comment ID from create test") - } - - result := h.Run("comment", "show", commentID, "--card", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - id := result.GetDataString("id") - if id != commentID { - t.Errorf("expected id %q, got %q", commentID, id) - } - }) - - t.Run("update comment", func(t *testing.T) { - if commentID == "" { - t.Skip("no comment ID from create test") - } - - newBody := commentBody + " updated" - result := h.Run("comment", "update", commentID, "--card", cardStr, "--body", newBody) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - // Verify the body was actually updated by fetching the comment - showResult := h.Run("comment", "show", commentID, "--card", cardStr) - if showResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show comment: %s", showResult.Stderr) - } - // Body is returned as an object with "plain_text" and "html" fields - data := showResult.GetDataMap() - if bodyObj, ok := data["body"].(map[string]any); ok { - savedBody := bodyObj["plain_text"].(string) - if savedBody != newBody { - t.Errorf("expected body %q after update, got %q", newBody, savedBody) - } - // Verify HTML version is also returned and updated - savedHtml, ok := bodyObj["html"].(string) - if !ok || savedHtml == "" { - t.Error("expected body.html to be present") - } - // The HTML should contain our updated text - if !strings.Contains(savedHtml, newBody) { - t.Errorf("expected body.html to contain %q, got %q", newBody, savedHtml) - } - } else { - t.Errorf("expected body object, got %T", data["body"]) - } - }) - - t.Run("delete comment", func(t *testing.T) { - if commentID == "" { - t.Skip("no comment ID from create test") - } - - result := h.Run("comment", "delete", commentID, "--card", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - deleted := result.GetDataBool("deleted") - if !deleted { - t.Error("expected deleted=true") - } - - // Remove from cleanup since we deleted it - if len(h.Cleanup.Comments) > 0 { - h.Cleanup.Comments = h.Cleanup.Comments[:len(h.Cleanup.Comments)-1] - } - }) -} - -func TestCommentCreateMissingBody(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - cardNumber := createTestCard(t, h, boardID) - cardStr := strconv.Itoa(cardNumber) - - t.Run("fails without body or body_file", func(t *testing.T) { - result := h.Run("comment", "create", "--card", cardStr) - - // Should fail - need either body or body_file - if result.ExitCode == harness.ExitSuccess { - // If it succeeded, it might have created an empty comment - clean up - id := result.GetDataString("id") - if id != "" { - h.Cleanup.AddComment(id, cardNumber) - } - t.Error("expected failure without body") - } - }) -} diff --git a/e2e/tests/error_test.go b/e2e/tests/error_test.go deleted file mode 100644 index bdc09ef..0000000 --- a/e2e/tests/error_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package tests - -import ( - "testing" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestErrorAuthFailure(t *testing.T) { - cfg := harness.LoadConfig() - if cfg.Token == "" || cfg.Account == "" { - t.Skip("FIZZY_TEST_TOKEN or FIZZY_TEST_ACCOUNT not set") - } - - // Create harness with invalid token - h := harness.NewWithConfig(t, &harness.Config{ - BinaryPath: cfg.BinaryPath, - Token: "invalid-token-12345", - Account: cfg.Account, - APIURL: cfg.APIURL, - }) - - t.Run("returns auth exit code for auth failure", func(t *testing.T) { - result := h.Run("board", "list") - - if result.ExitCode != harness.ExitAuthFailure { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitAuthFailure, result.ExitCode, result.Stdout) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if result.Response.OK { - t.Error("expected ok=false") - } - - if result.Response.Error == "" { - t.Error("expected error in response") - } - - if result.Response.Code != "auth_required" { - t.Errorf("expected error code auth_required, got %s", result.Response.Code) - } - }) -} - -func TestErrorNotFound(t *testing.T) { - h := harness.New(t) - - testCases := []struct { - name string - args []string - }{ - {"board show", []string{"board", "show", "non-existent-id"}}, - {"card show", []string{"card", "show", "999999999"}}, - {"user show", []string{"user", "show", "non-existent-id"}}, - } - - for _, tc := range testCases { - t.Run(tc.name+" returns not_found exit code", func(t *testing.T) { - result := h.Run(tc.args...) - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if result.Response.OK { - t.Error("expected ok=false") - } - - if result.Response.Error == "" { - t.Error("expected error in response") - } - - if result.Response.Code != "not_found" { - t.Errorf("expected error code not_found, got %s", result.Response.Code) - } - }) - } -} - -func TestErrorNetworkFailure(t *testing.T) { - cfg := harness.LoadConfig() - if cfg.Token == "" || cfg.Account == "" { - t.Skip("FIZZY_TEST_TOKEN or FIZZY_TEST_ACCOUNT not set") - } - - // Create harness with invalid API URL (unreachable) - h := harness.NewWithConfig(t, &harness.Config{ - BinaryPath: cfg.BinaryPath, - Token: cfg.Token, - Account: cfg.Account, - APIURL: "http://localhost:59999", // Unlikely to be listening - }) - - t.Run("returns network exit code for network error", func(t *testing.T) { - result := h.Run("board", "list") - - if result.ExitCode != harness.ExitNetwork { - t.Errorf("expected exit code %d, got %d\nstdout: %s\nstderr: %s", - harness.ExitNetwork, result.ExitCode, result.Stdout, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if result.Response.OK { - t.Error("expected ok=false") - } - - if result.Response.Error == "" { - t.Error("expected error in response") - } - - if result.Response.Code != "network" { - t.Errorf("expected error code network, got %s", result.Response.Code) - } - }) -} - -func TestErrorResponseFormat(t *testing.T) { - h := harness.New(t) - - t.Run("error response has correct structure", func(t *testing.T) { - // Use an invalid board ID to trigger a not found error - result := h.Run("board", "show", "non-existent-board-id") - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - // Check response structure - if result.Response.OK { - t.Error("expected ok=false for error") - } - - if result.Response.Error == "" { - t.Fatal("expected error message in response") - } - - // Error should have a code - if result.Response.Code == "" { - t.Error("expected error code") - } - }) -} - -func TestSuccessResponseFormat(t *testing.T) { - h := harness.New(t) - - t.Run("success response has correct structure", func(t *testing.T) { - result := h.Run("board", "list") - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - // Check response structure - if !result.Response.OK { - t.Error("expected ok=true") - } - - // Data should be present (can be empty array) - if result.Response.Data == nil { - t.Error("expected data in response") - } - - // Error should be empty for success - if result.Response.Error != "" { - t.Error("expected no error in success response") - } - }) -} diff --git a/e2e/tests/identity_test.go b/e2e/tests/identity_test.go deleted file mode 100644 index 132d504..0000000 --- a/e2e/tests/identity_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package tests - -import ( - "testing" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestIdentityShow(t *testing.T) { - h := harness.New(t) - - t.Run("returns current identity with valid token", func(t *testing.T) { - result := h.Run("identity", "show") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - data := result.GetDataMap() - if data == nil { - t.Fatal("expected data map") - } - - // Should have accounts array - accounts, ok := data["accounts"].([]any) - if !ok { - t.Error("expected accounts array in response") - } - - if len(accounts) == 0 { - t.Error("expected at least one account") - } - - // First account should have an id and slug - if len(accounts) > 0 { - firstAccount, ok := accounts[0].(map[string]any) - if !ok { - t.Error("expected account to be a map") - } else { - if _, ok := firstAccount["id"]; !ok { - t.Error("expected account to have id") - } - if _, ok := firstAccount["slug"]; !ok { - t.Error("expected account to have slug") - } - } - } - }) -} - -func TestIdentityShowWithInvalidToken(t *testing.T) { - cfg := harness.LoadConfig() - if cfg.Token == "" || cfg.Account == "" { - t.Skip("FIZZY_TEST_TOKEN or FIZZY_TEST_ACCOUNT not set") - } - - // Create harness with invalid token - h := harness.NewWithConfig(t, &harness.Config{ - BinaryPath: cfg.BinaryPath, - Token: "invalid-token-12345", - Account: cfg.Account, - APIURL: cfg.APIURL, - }) - - t.Run("returns auth error with invalid token", func(t *testing.T) { - result := h.Run("identity", "show") - - if result.ExitCode != harness.ExitAuthFailure { - t.Errorf("expected exit code %d, got %d", harness.ExitAuthFailure, result.ExitCode) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if result.Response.OK { - t.Error("expected ok=false") - } - - if result.Response.Error == "" { - t.Error("expected error in response") - } else if result.Response.Code != "auth_required" { - t.Errorf("expected error code AUTH_ERROR, got %s", result.Response.Code) - } - }) -} diff --git a/e2e/tests/main_test.go b/e2e/tests/main_test.go deleted file mode 100644 index 376f598..0000000 --- a/e2e/tests/main_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package tests - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestMain(m *testing.M) { - cfg := harness.LoadConfig() - - if cfg.BinaryPath == "" || !fileExists(cfg.BinaryPath) { - repoRoot, err := harness.RepoRoot() - if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) - } - - tmpDir, err := os.MkdirTemp("", "fizzy-e2e-*") - if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) - } - - binPath := filepath.Join(tmpDir, "fizzy") - cmd := exec.Command("go", "build", "-o", binPath, "./cmd/fizzy") - cmd.Dir = repoRoot - if out, err := cmd.CombinedOutput(); err != nil { - _ = os.RemoveAll(tmpDir) - fmt.Fprintf(os.Stderr, "failed to build e2e binary: %v\n%s\n", err, string(out)) - os.Exit(1) - } - - _ = os.Setenv("FIZZY_TEST_BINARY", binPath) - cfg.BinaryPath = binPath - - code := m.Run() - _ = os.RemoveAll(tmpDir) - os.Exit(code) - } - - os.Exit(m.Run()) -} - -func fileExists(path string) bool { - st, err := os.Stat(path) - if err != nil { - return false - } - return !st.IsDir() -} diff --git a/e2e/tests/markdown_sanitization_test.go b/e2e/tests/markdown_sanitization_test.go deleted file mode 100644 index a4d7f4b..0000000 --- a/e2e/tests/markdown_sanitization_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package tests - -import ( - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestMarkdownSanitization(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createAttachmentTestBoard(t, h) - - t.Run("card description with backtick-wrapped action-text-attachment is escaped", func(t *testing.T) { - title := fmt.Sprintf("Markdown Sanitization Card %d", time.Now().UnixNano()) - // Simulate an LLM writing documentation about attachments using markdown backticks - description := `
2. Manually construct the ` + "`" + `` + "`" - - result := h.Run("card", "create", "--board", boardID, "--title", title, "--description", description) - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - cardNumber := result.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = result.GetDataInt("number") - } - if cardNumber != 0 { - h.Cleanup.AddCard(cardNumber) - } - - // Fetch the card and verify the attachment tag was escaped, not parsed as a real attachment - showResult := h.Run("card", "show", strconv.Itoa(cardNumber)) - if showResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show card: %s", showResult.Stderr) - } - - descHTML := showResult.GetDataString("description_html") - - // The action-text-attachment should be inside a tag, escaped as <action-text-attachment> - if strings.Contains(descHTML, ``) { - t.Errorf("expected backtick content to be converted to tag:\n%s", descHTML) - } - }) - - t.Run("comment body with backtick-wrapped action-text-attachment is escaped", func(t *testing.T) { - // Create a plain card first - title := fmt.Sprintf("Markdown Sanitization Comment Card %d", time.Now().UnixNano()) - cardResult := h.Run("card", "create", "--board", boardID, "--title", title) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s\nstdout: %s", cardResult.Stderr, cardResult.Stdout) - } - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - if cardNumber != 0 { - h.Cleanup.AddCard(cardNumber) - } - - // Create a comment with backtick-wrapped attachment tag - body := "Here is an example: ``" - - commentResult := h.Run("comment", "create", "--card", strconv.Itoa(cardNumber), "--body", body) - if commentResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create comment: %s\nstdout: %s", commentResult.Stderr, commentResult.Stdout) - } - - commentID := commentResult.GetIDFromLocation() - if commentID == "" { - commentID = commentResult.GetDataString("id") - } - if commentID != "" { - h.Cleanup.AddComment(commentID, cardNumber) - } - - // Fetch comments and verify the attachment tag was escaped - listResult := h.Run("comment", "list", "--card", strconv.Itoa(cardNumber)) - if listResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to list comments: %s", listResult.Stderr) - } - - comments := listResult.GetDataArray() - if len(comments) == 0 { - t.Fatal("expected at least one comment") - } - - comment := comments[0].(map[string]any) - bodyObj := comment["body"].(map[string]any) - bodyHTML := bodyObj["html"].(string) - - if strings.Contains(bodyHTML, ``) { - t.Errorf("expected backtick content to be converted to tag:\n%s", bodyHTML) - } - }) - - t.Run("real card attachment still works after markdown conversion", func(t *testing.T) { - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_image.png") - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - // Upload the file - uploadResult := h.Run("upload", "file", fixturePath) - if uploadResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to upload file: %s\nstdout: %s", uploadResult.Stderr, uploadResult.Stdout) - } - attachableSGID := uploadResult.GetDataString("attachable_sgid") - if attachableSGID == "" { - t.Fatalf("no attachable_sgid returned from upload") - } - - // Create card with real attachment - raw HTML, no markdown - title := fmt.Sprintf("Real Attachment Card %d", time.Now().UnixNano()) - description := fmt.Sprintf(``, attachableSGID) - - cardResult := h.Run("card", "create", "--board", boardID, "--title", title, "--description", description) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s\nstdout: %s", cardResult.Stderr, cardResult.Stdout) - } - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - if cardNumber != 0 { - h.Cleanup.AddCard(cardNumber) - } - - // Verify the attachment is actually there - attachResult := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber)) - if attachResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show attachments: %s", attachResult.Stderr) - } - - attachments := attachResult.GetDataArray() - if len(attachments) == 0 { - t.Error("expected at least one attachment on card") - } - }) - - t.Run("real attachment with backtick-wrapped attachment tag in same description", func(t *testing.T) { - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_image.png") - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - // Upload the file - uploadResult := h.Run("upload", "file", fixturePath) - if uploadResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to upload file: %s\nstdout: %s", uploadResult.Stderr, uploadResult.Stdout) - } - attachableSGID := uploadResult.GetDataString("attachable_sgid") - if attachableSGID == "" { - t.Fatalf("no attachable_sgid returned from upload") - } - - // Create card with a real attachment AND backtick-wrapped example tag - title := fmt.Sprintf("Mixed Real and Backtick Attachment %d", time.Now().UnixNano()) - description := fmt.Sprintf("

See the image:

To embed attachments use: ``

", attachableSGID) - - cardResult := h.Run("card", "create", "--board", boardID, "--title", title, "--description", description) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s\nstdout: %s", cardResult.Stderr, cardResult.Stdout) - } - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - if cardNumber != 0 { - h.Cleanup.AddCard(cardNumber) - } - - // Verify the real attachment is there - attachResult := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber)) - if attachResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show attachments: %s", attachResult.Stderr) - } - - attachments := attachResult.GetDataArray() - if len(attachments) != 1 { - t.Errorf("expected exactly 1 real attachment, got %d", len(attachments)) - } - - // Verify the backtick content was escaped (check description_html) - showResult := h.Run("card", "show", strconv.Itoa(cardNumber)) - if showResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show card: %s", showResult.Stderr) - } - descHTML := showResult.GetDataString("description_html") - if !strings.Contains(descHTML, "") { - t.Errorf("expected backtick content to be converted to tag:\n%s", descHTML) - } - }) - - t.Run("real comment attachment still works after markdown conversion", func(t *testing.T) { - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_image.png") - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - // Upload the file - uploadResult := h.Run("upload", "file", fixturePath) - if uploadResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to upload file: %s\nstdout: %s", uploadResult.Stderr, uploadResult.Stdout) - } - attachableSGID := uploadResult.GetDataString("attachable_sgid") - if attachableSGID == "" { - t.Fatalf("no attachable_sgid returned from upload") - } - - // Create a plain card - title := fmt.Sprintf("Real Comment Attachment Card %d", time.Now().UnixNano()) - cardResult := h.Run("card", "create", "--board", boardID, "--title", title) - if cardResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create card: %s\nstdout: %s", cardResult.Stderr, cardResult.Stdout) - } - cardNumber := cardResult.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = cardResult.GetDataInt("number") - } - if cardNumber != 0 { - h.Cleanup.AddCard(cardNumber) - } - - // Create comment with real attachment - raw HTML, no markdown - body := fmt.Sprintf(``, attachableSGID) - commentResult := h.Run("comment", "create", "--card", strconv.Itoa(cardNumber), "--body", body) - if commentResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create comment: %s\nstdout: %s", commentResult.Stderr, commentResult.Stdout) - } - commentID := commentResult.GetIDFromLocation() - if commentID == "" { - commentID = commentResult.GetDataString("id") - } - if commentID != "" { - h.Cleanup.AddComment(commentID, cardNumber) - } - - // Verify the attachment is actually there - attachResult := h.Run("comment", "attachments", "show", "--card", strconv.Itoa(cardNumber)) - if attachResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show comment attachments: %s", attachResult.Stderr) - } - - attachments := attachResult.GetDataArray() - if len(attachments) == 0 { - t.Error("expected at least one comment attachment") - } - }) -} diff --git a/e2e/tests/notification_test.go b/e2e/tests/notification_test.go deleted file mode 100644 index 0e6b3e1..0000000 --- a/e2e/tests/notification_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package tests - -import ( - "testing" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestNotificationList(t *testing.T) { - h := harness.New(t) - - t.Run("returns list of notifications", func(t *testing.T) { - result := h.Run("notification", "list") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - arr := result.GetDataArray() - if arr == nil { - t.Error("expected data to be an array") - } - }) - - t.Run("supports --page option", func(t *testing.T) { - result := h.Run("notification", "list", "--page", "1") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - - if result.Response == nil || !result.Response.OK { - t.Error("expected successful response") - } - }) - - t.Run("supports --all flag", func(t *testing.T) { - result := h.Run("notification", "list", "--all") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - }) -} - -func TestNotificationReadUnread(t *testing.T) { - h := harness.New(t) - - // First get a notification ID from the list (if any exist) - listResult := h.Run("notification", "list") - if listResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to list notifications: %s", listResult.Stderr) - } - - notifications := listResult.GetDataArray() - if len(notifications) == 0 { - t.Skip("no notifications available to test") - } - - firstNotification, ok := notifications[0].(map[string]any) - if !ok { - t.Fatal("expected notification to be a map") - } - - notificationID, ok := firstNotification["id"].(string) - if !ok || notificationID == "" { - t.Fatal("expected notification to have id") - } - - t.Run("mark notification as read", func(t *testing.T) { - result := h.Run("notification", "read", notificationID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("mark notification as unread", func(t *testing.T) { - result := h.Run("notification", "unread", notificationID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) -} - -func TestNotificationTray(t *testing.T) { - h := harness.New(t) - - t.Run("returns notification tray", func(t *testing.T) { - result := h.Run("notification", "tray") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - arr := result.GetDataArray() - if arr == nil { - t.Error("expected data to be an array") - } - }) - - t.Run("supports --include-read flag", func(t *testing.T) { - result := h.Run("notification", "tray", "--include-read") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - arr := result.GetDataArray() - if arr == nil { - t.Error("expected data to be an array") - } - }) -} - -func TestNotificationReadAll(t *testing.T) { - h := harness.New(t) - - t.Run("marks all notifications as read", func(t *testing.T) { - result := h.Run("notification", "read-all") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) -} - -func TestNotificationReadNotFound(t *testing.T) { - h := harness.New(t) - - t.Run("returns not found for non-existent notification", func(t *testing.T) { - result := h.Run("notification", "read", "non-existent-notification-id") - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if result.Response.OK { - t.Error("expected ok=false") - } - }) -} diff --git a/e2e/tests/pin_test.go b/e2e/tests/pin_test.go deleted file mode 100644 index 434e584..0000000 --- a/e2e/tests/pin_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package tests - -import ( - "fmt" - "strconv" - "testing" - "time" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestPinActions(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - - // Create a card for pin tests - title := fmt.Sprintf("Pin Test Card %d", time.Now().UnixNano()) - result := h.Run("card", "create", "--board", boardID, "--title", title) - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create test card: %s\nstdout: %s", result.Stderr, result.Stdout) - } - cardNumber := result.GetNumberFromLocation() - if cardNumber == 0 { - cardNumber = result.GetDataInt("number") - } - if cardNumber == 0 { - t.Fatalf("failed to get card number from create (location: %s)", result.GetLocation()) - } - h.Cleanup.AddCard(cardNumber) - cardStr := strconv.Itoa(cardNumber) - - t.Run("pin card", func(t *testing.T) { - result := h.Run("card", "pin", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("pin list includes pinned card", func(t *testing.T) { - result := h.Run("pin", "list") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - arr := result.GetDataArray() - if arr == nil { - t.Fatal("expected data to be an array") - } - - // Find our pinned card in the list - found := false - for _, item := range arr { - card, ok := item.(map[string]any) - if !ok { - continue - } - if num, ok := card["number"].(float64); ok && int(num) == cardNumber { - found = true - break - } - } - if !found { - t.Errorf("expected pinned card #%d to appear in pin list", cardNumber) - } - }) - - t.Run("unpin card", func(t *testing.T) { - result := h.Run("card", "unpin", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - }) - - t.Run("pin list excludes unpinned card", func(t *testing.T) { - result := h.Run("pin", "list") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - arr := result.GetDataArray() - if arr == nil { - // Empty array is fine - card should not be there - return - } - - for _, item := range arr { - card, ok := item.(map[string]any) - if !ok { - continue - } - if num, ok := card["number"].(float64); ok && int(num) == cardNumber { - t.Errorf("expected card #%d to NOT appear in pin list after unpinning", cardNumber) - } - } - }) -} - -func TestPinList(t *testing.T) { - h := harness.New(t) - - t.Run("returns list of pinned cards", func(t *testing.T) { - result := h.Run("pin", "list") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - // Data should be an array - arr := result.GetDataArray() - if arr == nil { - t.Error("expected data to be an array") - } - }) -} - -func TestPinNotFound(t *testing.T) { - h := harness.New(t) - - t.Run("pin non-existent card fails", func(t *testing.T) { - result := h.Run("card", "pin", "999999999") - - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for non-existent card") - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) - - t.Run("unpin non-existent card fails", func(t *testing.T) { - result := h.Run("card", "unpin", "999999999") - - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for non-existent card") - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) -} diff --git a/e2e/tests/reaction_test.go b/e2e/tests/reaction_test.go deleted file mode 100644 index 3ff5074..0000000 --- a/e2e/tests/reaction_test.go +++ /dev/null @@ -1,286 +0,0 @@ -package tests - -import ( - "fmt" - "strconv" - "testing" - "time" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -// createTestComment creates a comment for reaction tests -func createTestComment(t *testing.T, h *harness.Harness, cardNumber int) string { - t.Helper() - cardStr := strconv.Itoa(cardNumber) - body := fmt.Sprintf("Comment for reactions %d", time.Now().UnixNano()) - result := h.Run("comment", "create", "--card", cardStr, "--body", body) - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to create test comment: %s\nstdout: %s", result.Stderr, result.Stdout) - } - // Create returns location - extract ID from it - commentID := result.GetIDFromLocation() - if commentID == "" { - // Try data.id as fallback - commentID = result.GetDataString("id") - } - if commentID == "" { - t.Fatalf("no comment ID returned (location: %s)", result.GetLocation()) - } - h.Cleanup.AddComment(commentID, cardNumber) - return commentID -} - -func TestReactionList(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - cardNumber := createTestCard(t, h, boardID) - commentID := createTestComment(t, h, cardNumber) - cardStr := strconv.Itoa(cardNumber) - - t.Run("returns list of reactions for comment", func(t *testing.T) { - result := h.Run("reaction", "list", "--card", cardStr, "--comment", commentID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - arr := result.GetDataArray() - if arr == nil { - t.Error("expected data to be an array") - } - }) - - t.Run("fails without --card option", func(t *testing.T) { - result := h.Run("reaction", "list", "--comment", commentID) - - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing required option") - } - }) - -} - -// TestCardReactionList tests listing reactions directly on a card (no --comment flag) -func TestCardReactionList(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - cardNumber := createTestCard(t, h, boardID) - cardStr := strconv.Itoa(cardNumber) - - t.Run("returns list of reactions for card", func(t *testing.T) { - result := h.Run("reaction", "list", "--card", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - arr := result.GetDataArray() - if arr == nil { - t.Error("expected data to be an array") - } - }) - - t.Run("fails without --card option", func(t *testing.T) { - result := h.Run("reaction", "list") - - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing required option") - } - }) -} - -func TestReactionCRUD(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - cardNumber := createTestCard(t, h, boardID) - commentID := createTestComment(t, h, cardNumber) - cardStr := strconv.Itoa(cardNumber) - - var reactionID string - - t.Run("create reaction", func(t *testing.T) { - result := h.Run("reaction", "create", "--card", cardStr, "--comment", commentID, "--content", "👍") - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Note: Reaction create returns success but no location or data - // We need to list reactions to get the ID for cleanup/deletion - listResult := h.Run("reaction", "list", "--card", cardStr, "--comment", commentID) - if listResult.ExitCode == harness.ExitSuccess && listResult.Response != nil { - arr := listResult.GetDataArray() - if len(arr) > 0 { - // Get the last reaction (most recently created) - lastReaction := arr[len(arr)-1].(map[string]any) - if id, ok := lastReaction["id"].(string); ok { - reactionID = id - h.Cleanup.AddReaction(reactionID, cardNumber, commentID) - } - } - } - if reactionID == "" { - t.Log("Warning: could not get reaction ID for cleanup") - } - }) - - t.Run("delete reaction", func(t *testing.T) { - if reactionID == "" { - t.Skip("no reaction ID from create test") - } - - result := h.Run("reaction", "delete", reactionID, "--card", cardStr, "--comment", commentID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - deleted := result.GetDataBool("deleted") - if !deleted { - t.Error("expected deleted=true") - } - - // Remove from cleanup since we deleted it - h.Cleanup.Reactions = nil - }) -} - -func TestReactionCreateMissingContent(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - cardNumber := createTestCard(t, h, boardID) - commentID := createTestComment(t, h, cardNumber) - cardStr := strconv.Itoa(cardNumber) - - t.Run("fails without --content option for comment reaction", func(t *testing.T) { - result := h.Run("reaction", "create", "--card", cardStr, "--comment", commentID) - - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing required option") - } - }) - - t.Run("fails without --content option for card reaction", func(t *testing.T) { - result := h.Run("reaction", "create", "--card", cardStr) - - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing required option") - } - }) -} - -// TestCardReactionCRUD tests creating and deleting reactions directly on cards -func TestCardReactionCRUD(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - cardNumber := createTestCard(t, h, boardID) - cardStr := strconv.Itoa(cardNumber) - - var reactionID string - - t.Run("create card reaction", func(t *testing.T) { - result := h.Run("reaction", "create", "--card", cardStr, "--content", "🎉") - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // List reactions to get the ID for cleanup/deletion - listResult := h.Run("reaction", "list", "--card", cardStr) - if listResult.ExitCode == harness.ExitSuccess && listResult.Response != nil { - arr := listResult.GetDataArray() - if len(arr) > 0 { - // Get the last reaction (most recently created) - lastReaction := arr[len(arr)-1].(map[string]any) - if id, ok := lastReaction["id"].(string); ok { - reactionID = id - h.Cleanup.AddCardReaction(reactionID, cardNumber) - } - } - } - if reactionID == "" { - t.Log("Warning: could not get reaction ID for cleanup") - } - }) - - t.Run("delete card reaction", func(t *testing.T) { - if reactionID == "" { - t.Skip("no reaction ID from create test") - } - - result := h.Run("reaction", "delete", reactionID, "--card", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - deleted := result.GetDataBool("deleted") - if !deleted { - t.Error("expected deleted=true") - } - - // Remove from cleanup since we deleted it - h.Cleanup.Reactions = nil - }) -} diff --git a/e2e/tests/step_test.go b/e2e/tests/step_test.go deleted file mode 100644 index 8f9e2cc..0000000 --- a/e2e/tests/step_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package tests - -import ( - "fmt" - "strconv" - "testing" - "time" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestStepCRUD(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - cardNumber := createTestCard(t, h, boardID) - cardStr := strconv.Itoa(cardNumber) - - var stepID string - stepContent := fmt.Sprintf("Test step %d", time.Now().UnixNano()) - - t.Run("create step", func(t *testing.T) { - result := h.Run("step", "create", "--card", cardStr, "--content", stepContent) - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Create returns location - extract ID from it - stepID = result.GetIDFromLocation() - if stepID == "" { - // Try data.id as fallback - stepID = result.GetDataString("id") - } - if stepID == "" { - t.Fatalf("expected step ID in response (location: %s)", result.GetLocation()) - } - - h.Cleanup.AddStep(stepID, cardNumber) - - // Note: Create returns location, not data with content - // Verify the step exists via show command - showResult := h.Run("step", "show", stepID, "--card", cardStr) - if showResult.ExitCode == harness.ExitSuccess { - content := showResult.GetDataString("content") - if content != stepContent { - t.Errorf("expected content %q, got %q", stepContent, content) - } - } - }) - - t.Run("create step as completed", func(t *testing.T) { - content := fmt.Sprintf("Completed step %d", time.Now().UnixNano()) - result := h.Run("step", "create", "--card", cardStr, "--content", content, "--completed") - - if result.ExitCode != harness.ExitSuccess { - t.Fatalf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - // Create returns location - extract ID from it - id := result.GetIDFromLocation() - if id == "" { - id = result.GetDataString("id") - } - if id != "" { - h.Cleanup.AddStep(id, cardNumber) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - // Note: Create returns location, not data - verify via show - if id != "" { - showResult := h.Run("step", "show", id, "--card", cardStr) - if showResult.ExitCode == harness.ExitSuccess { - completed := showResult.GetDataBool("completed") - if !completed { - t.Error("expected completed=true") - } - } - } - }) - - t.Run("show step", func(t *testing.T) { - if stepID == "" { - t.Skip("no step ID from create test") - } - - result := h.Run("step", "show", stepID, "--card", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - id := result.GetDataString("id") - if id != stepID { - t.Errorf("expected id %q, got %q", stepID, id) - } - }) - - t.Run("update step content", func(t *testing.T) { - if stepID == "" { - t.Skip("no step ID from create test") - } - - newContent := stepContent + " updated" - result := h.Run("step", "update", stepID, "--card", cardStr, "--content", newContent) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - content := result.GetDataString("content") - if content != newContent { - t.Errorf("expected content %q, got %q", newContent, content) - } - }) - - t.Run("update step to completed", func(t *testing.T) { - if stepID == "" { - t.Skip("no step ID from create test") - } - - result := h.Run("step", "update", stepID, "--card", cardStr, "--completed") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - completed := result.GetDataBool("completed") - if !completed { - t.Error("expected completed=true") - } - }) - - t.Run("update step to not completed", func(t *testing.T) { - if stepID == "" { - t.Skip("no step ID from create test") - } - - result := h.Run("step", "update", stepID, "--card", cardStr, "--not_completed") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - completed := result.GetDataBool("completed") - if completed { - t.Error("expected completed=false") - } - }) - - t.Run("delete step", func(t *testing.T) { - if stepID == "" { - t.Skip("no step ID from create test") - } - - result := h.Run("step", "delete", stepID, "--card", cardStr) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - deleted := result.GetDataBool("deleted") - if !deleted { - t.Error("expected deleted=true") - } - - // Remove from cleanup since we deleted it - if len(h.Cleanup.Steps) > 0 { - h.Cleanup.Steps = h.Cleanup.Steps[1:] - } - }) -} - -func TestStepCreateMissingContent(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - cardNumber := createTestCard(t, h, boardID) - cardStr := strconv.Itoa(cardNumber) - - t.Run("fails without --content option", func(t *testing.T) { - result := h.Run("step", "create", "--card", cardStr) - - if result.ExitCode == harness.ExitSuccess { - t.Error("expected non-zero exit code for missing required option") - } - }) -} - -func TestStepShowNotFound(t *testing.T) { - h := harness.New(t) - defer h.Cleanup.CleanupAll(h) - - boardID := createTestBoard(t, h) - cardNumber := createTestCard(t, h, boardID) - cardStr := strconv.Itoa(cardNumber) - - t.Run("returns not found for non-existent step", func(t *testing.T) { - result := h.Run("step", "show", "non-existent-step-id", "--card", cardStr) - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if result.Response.OK { - t.Error("expected ok=false") - } - }) -} diff --git a/e2e/tests/tag_test.go b/e2e/tests/tag_test.go deleted file mode 100644 index b0ed3ef..0000000 --- a/e2e/tests/tag_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package tests - -import ( - "testing" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestTagList(t *testing.T) { - h := harness.New(t) - - t.Run("returns list of tags", func(t *testing.T) { - result := h.Run("tag", "list") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - arr := result.GetDataArray() - if arr == nil { - t.Error("expected data to be an array") - } - }) - - t.Run("supports --page option", func(t *testing.T) { - result := h.Run("tag", "list", "--page", "1") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - - if result.Response == nil || !result.Response.OK { - t.Error("expected successful response") - } - }) - - t.Run("supports --all flag", func(t *testing.T) { - result := h.Run("tag", "list", "--all") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - }) -} diff --git a/e2e/tests/upload_test.go b/e2e/tests/upload_test.go deleted file mode 100644 index 41f9e6b..0000000 --- a/e2e/tests/upload_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package tests - -import ( - "os" - "path/filepath" - "testing" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestUploadFile(t *testing.T) { - h := harness.New(t) - - // Get the path to the test image fixture - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_image.png") - - // Check if fixture exists - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - t.Run("uploads file and returns signed_id", func(t *testing.T) { - result := h.Run("upload", "file", fixturePath) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - signedID := result.GetDataString("signed_id") - if signedID == "" { - t.Error("expected signed_id in response") - } - }) -} - -func TestUploadTextFile(t *testing.T) { - h := harness.New(t) - - // Get the path to the test document fixture - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_document.txt") - - // Check if fixture exists - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - t.Run("uploads text file and returns signed_id", func(t *testing.T) { - result := h.Run("upload", "file", fixturePath) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", - harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - signedID := result.GetDataString("signed_id") - if signedID == "" { - t.Error("expected signed_id in response") - } - }) -} - -func TestUploadFileNotFound(t *testing.T) { - h := harness.New(t) - - t.Run("returns error for non-existent file", func(t *testing.T) { - result := h.Run("upload", "file", "/path/to/nonexistent/file.png") - - // Should fail with validation error or general error - if result.ExitCode == harness.ExitSuccess { - t.Error("expected failure for non-existent file") - } - - if result.Response != nil && result.Response.OK { - t.Error("expected ok=false") - } - }) -} - -func TestUploadMissingPath(t *testing.T) { - h := harness.New(t) - - t.Run("fails without file path argument", func(t *testing.T) { - result := h.Run("upload", "file") - - // Should fail with invalid args - if result.ExitCode != harness.ExitInvalidArgs && result.ExitCode != harness.ExitError { - t.Errorf("expected exit code %d or %d, got %d", - harness.ExitInvalidArgs, harness.ExitError, result.ExitCode) - } - }) -} diff --git a/e2e/tests/user_test.go b/e2e/tests/user_test.go deleted file mode 100644 index 76dd852..0000000 --- a/e2e/tests/user_test.go +++ /dev/null @@ -1,317 +0,0 @@ -package tests - -import ( - "os" - "path/filepath" - "testing" - - "github.com/basecamp/fizzy-cli/e2e/harness" -) - -func TestUserList(t *testing.T) { - h := harness.New(t) - - t.Run("returns list of users", func(t *testing.T) { - result := h.Run("user", "list") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - arr := result.GetDataArray() - if arr == nil { - t.Error("expected data to be an array") - } - - // Should have at least one user (the authenticated user) - if len(arr) == 0 { - t.Error("expected at least one user") - } - }) - - t.Run("supports --page option", func(t *testing.T) { - result := h.Run("user", "list", "--page", "1") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - - if result.Response == nil || !result.Response.OK { - t.Error("expected successful response") - } - }) - - t.Run("supports --all flag", func(t *testing.T) { - result := h.Run("user", "list", "--all") - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d", harness.ExitSuccess, result.ExitCode) - } - }) -} - -func TestUserShow(t *testing.T) { - h := harness.New(t) - - // First get a valid user ID from the list - listResult := h.Run("user", "list") - if listResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to list users: %s", listResult.Stderr) - } - - users := listResult.GetDataArray() - if len(users) == 0 { - t.Skip("no users available") - } - - firstUser, ok := users[0].(map[string]any) - if !ok { - t.Fatal("expected user to be a map") - } - - userID, ok := firstUser["id"].(string) - if !ok || userID == "" { - t.Fatal("expected user to have id") - } - - t.Run("returns user details", func(t *testing.T) { - result := h.Run("user", "show", userID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Error("expected ok=true") - } - - id := result.GetDataString("id") - if id != userID { - t.Errorf("expected id %q, got %q", userID, id) - } - }) -} - -func TestUserShowNotFound(t *testing.T) { - h := harness.New(t) - - t.Run("returns not found for non-existent user", func(t *testing.T) { - result := h.Run("user", "show", "non-existent-user-id-12345") - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", - harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if result.Response.OK { - t.Error("expected ok=false") - } - }) -} - -func TestUserUpdate(t *testing.T) { - cfg := harness.LoadConfig() - if cfg.UserID == "" { - t.Skip("FIZZY_TEST_USER_ID not set, skipping user update tests") - } - - h := harness.New(t) - userID := cfg.UserID - - // First get the current name so we can restore it - showResult := h.Run("user", "show", userID) - if showResult.ExitCode != harness.ExitSuccess { - t.Fatalf("failed to show test user: %s", showResult.Stderr) - } - originalName := showResult.GetDataString("name") - if originalName == "" { - t.Fatal("expected test user to have a name") - } - - t.Run("update user name", func(t *testing.T) { - newName := originalName + " Updated" - result := h.Run("user", "update", userID, "--name", newName) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Verify the name was updated - verifyResult := h.Run("user", "show", userID) - if verifyResult.ExitCode == harness.ExitSuccess { - name := verifyResult.GetDataString("name") - if name != newName { - t.Errorf("expected name %q, got %q", newName, name) - } - } - }) - - t.Run("restore original name", func(t *testing.T) { - result := h.Run("user", "update", userID, "--name", originalName) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Verify restored - verifyResult := h.Run("user", "show", userID) - if verifyResult.ExitCode == harness.ExitSuccess { - name := verifyResult.GetDataString("name") - if name != originalName { - t.Errorf("expected name %q, got %q", originalName, name) - } - } - }) - - t.Run("update user avatar", func(t *testing.T) { - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_image.png") - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - result := h.Run("user", "update", userID, "--avatar", fixturePath) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Verify user still has an avatar URL - verifyResult := h.Run("user", "show", userID) - if verifyResult.ExitCode == harness.ExitSuccess { - avatarURL := verifyResult.GetDataString("avatar_url") - if avatarURL == "" { - t.Error("expected user to have an avatar_url after upload") - } - } - }) - - t.Run("update name and avatar together", func(t *testing.T) { - wd, _ := os.Getwd() - fixturePath := filepath.Join(wd, "..", "testdata", "fixtures", "test_image.png") - if _, err := os.Stat(fixturePath); os.IsNotExist(err) { - t.Skipf("test fixture not found at %s", fixturePath) - } - - newName := originalName + " WithAvatar" - result := h.Run("user", "update", userID, "--name", newName, "--avatar", fixturePath) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Verify name was updated - verifyResult := h.Run("user", "show", userID) - if verifyResult.ExitCode == harness.ExitSuccess { - name := verifyResult.GetDataString("name") - if name != newName { - t.Errorf("expected name %q, got %q", newName, name) - } - } - - // Restore original name - h.Run("user", "update", userID, "--name", originalName) - }) - - t.Run("update non-existent user returns not found", func(t *testing.T) { - result := h.Run("user", "update", "non-existent-user-id-12345", "--name", "Nope") - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if result.Response.OK { - t.Error("expected ok=false") - } - }) -} - -func TestUserDeactivate(t *testing.T) { - cfg := harness.LoadConfig() - if cfg.UserID == "" { - t.Skip("FIZZY_TEST_USER_ID not set, skipping user deactivate tests") - } - - h := harness.New(t) - userID := cfg.UserID - - t.Run("deactivate non-existent user returns not found", func(t *testing.T) { - result := h.Run("user", "deactivate", "non-existent-user-id-12345") - - if result.ExitCode != harness.ExitNotFound { - t.Errorf("expected exit code %d, got %d\nstdout: %s", harness.ExitNotFound, result.ExitCode, result.Stdout) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if result.Response.OK { - t.Error("expected ok=false") - } - }) - - t.Run("deactivates user", func(t *testing.T) { - // First verify the user exists - showResult := h.Run("user", "show", userID) - if showResult.ExitCode != harness.ExitSuccess { - t.Fatalf("test user %s not found, cannot test deactivate: %s", userID, showResult.Stderr) - } - - result := h.Run("user", "deactivate", userID) - - if result.ExitCode != harness.ExitSuccess { - t.Errorf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) - } - - if result.Response == nil { - t.Fatal("expected JSON response") - } - - if !result.Response.OK { - t.Errorf("expected ok=true, error: %+v", result.Response.Error) - } - - // Verify the user is no longer accessible - verifyResult := h.Run("user", "show", userID) - if verifyResult.ExitCode != harness.ExitNotFound { - t.Errorf("expected deactivated user to return not found, got exit code %d", verifyResult.ExitCode) - } - }) -}