test(veans): add e2e suite covering init, tasks, claim, prime flows
This commit is contained in:
parent
3a7bcb2a50
commit
4c3d449a35
|
|
@ -0,0 +1,60 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
)
|
||||
|
||||
// TestClaim_AssignsBotMovesToInProgressTagsBranch exercises the full claim
|
||||
// flow: assignment, bucket transition, and branch label application.
|
||||
func TestClaim_AssignsBotMovesToInProgressTagsBranch(t *testing.T) {
|
||||
ws, h := provisionWorkspace(t)
|
||||
|
||||
out, _, code := h.Run(t, ws, "--json", "create", "claim me")
|
||||
if code != 0 {
|
||||
t.Fatalf("create exit %d\n%s", code, out)
|
||||
}
|
||||
var created client.Task
|
||||
if err := json.Unmarshal([]byte(out), &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := fmt.Sprintf("%d", created.Index)
|
||||
|
||||
// Switch the workspace's git branch so claim has something to label with.
|
||||
gitInWorkspace(t, ws, "checkout", "-q", "-b", "feat-claim-test")
|
||||
|
||||
_, errOut, code := h.Run(t, ws, "claim", id)
|
||||
if code != 0 {
|
||||
t.Fatalf("claim exit %d\n%s", code, errOut)
|
||||
}
|
||||
|
||||
server := h.GetTask(t, created.ID)
|
||||
|
||||
// Verify bucket transition by reading the workspace's .veans.yml — the
|
||||
// bot's expected In Progress bucket is stored there.
|
||||
cfg := loadConfig(t, ws)
|
||||
if server.BucketID != cfg.Buckets.InProgress {
|
||||
t.Fatalf("task not in In Progress bucket: got %d, want %d", server.BucketID, cfg.Buckets.InProgress)
|
||||
}
|
||||
|
||||
// Bot assigned.
|
||||
assigned := false
|
||||
for _, a := range server.Assignees {
|
||||
if a != nil && a.ID == cfg.Bot.UserID {
|
||||
assigned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !assigned {
|
||||
t.Fatalf("bot %d not in assignees: %+v", cfg.Bot.UserID, server.Assignees)
|
||||
}
|
||||
|
||||
// Branch label attached.
|
||||
branchLabel := "veans:branch:feat-claim-test"
|
||||
if !taskHasLabelTitle(server, branchLabel) {
|
||||
t.Fatalf("expected label %q on task; got %+v", branchLabel, server.Labels)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
// Package e2e is the integration suite for veans. It assumes a running
|
||||
// Vikunja API at VEANS_E2E_API_URL and admin/seed credentials in
|
||||
// VEANS_E2E_ADMIN_TOKEN (or VEANS_E2E_ADMIN_USER + VEANS_E2E_ADMIN_PASS).
|
||||
//
|
||||
// The suite never provisions Vikunja itself — locally, point it at a dev
|
||||
// instance; in CI, the workflow spins one up the same way the frontend
|
||||
// Playwright suite does.
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
)
|
||||
|
||||
// Harness bundles a built veans binary and an authenticated admin client
|
||||
// for verifying side effects on the server. It also tracks the base env
|
||||
// (HOME / XDG_CONFIG_HOME overrides) every runVeans invocation inherits.
|
||||
type Harness struct {
|
||||
binary string
|
||||
apiURL string
|
||||
adminToken string
|
||||
adminClient *client.Client
|
||||
suiteStartTS time.Time
|
||||
}
|
||||
|
||||
// SkipIfNotConfigured calls t.Skip if the suite hasn't been pointed at a
|
||||
// Vikunja instance. Intended for the top of TestMain / TestXxx so plain
|
||||
// `go test ./...` doesn't fail on contributors who haven't set up the env.
|
||||
func SkipIfNotConfigured(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("VEANS_E2E_API_URL") == "" {
|
||||
t.Skip("VEANS_E2E_API_URL not set — skipping e2e")
|
||||
}
|
||||
}
|
||||
|
||||
// New builds (or reuses) the veans binary, mints/loads an admin token via
|
||||
// the env, and returns a Harness ready to drive tests.
|
||||
func New(t *testing.T) *Harness {
|
||||
t.Helper()
|
||||
SkipIfNotConfigured(t)
|
||||
|
||||
apiURL := strings.TrimRight(os.Getenv("VEANS_E2E_API_URL"), "/")
|
||||
binary, err := buildOrLocate()
|
||||
if err != nil {
|
||||
t.Fatalf("locate veans binary: %v", err)
|
||||
}
|
||||
|
||||
tok := os.Getenv("VEANS_E2E_ADMIN_TOKEN")
|
||||
if tok == "" {
|
||||
user, pass := os.Getenv("VEANS_E2E_ADMIN_USER"), os.Getenv("VEANS_E2E_ADMIN_PASS")
|
||||
if user == "" || pass == "" {
|
||||
t.Fatal("set VEANS_E2E_ADMIN_TOKEN or VEANS_E2E_ADMIN_USER + VEANS_E2E_ADMIN_PASS")
|
||||
}
|
||||
c := client.New(apiURL, "")
|
||||
resp, err := c.Login(context.Background(), &client.LoginRequest{
|
||||
Username: user, Password: pass, LongToken: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin login: %v", err)
|
||||
}
|
||||
tok = resp.Token
|
||||
}
|
||||
|
||||
return &Harness{
|
||||
binary: binary,
|
||||
apiURL: apiURL,
|
||||
adminToken: tok,
|
||||
adminClient: client.New(apiURL, tok),
|
||||
suiteStartTS: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace creates a per-test git repo in a TempDir, with HOME and
|
||||
// XDG_CONFIG_HOME pointed at TempDirs so the credential store falls back
|
||||
// to its file backend rather than touching the developer's keychain.
|
||||
type Workspace struct {
|
||||
Dir string
|
||||
Home string
|
||||
XDGConfig string
|
||||
ConfigPath string
|
||||
BotUsername string
|
||||
envOverrides map[string]string
|
||||
}
|
||||
|
||||
// NewWorkspace initializes a fresh repo with `git init` + a single commit so
|
||||
// `git rev-parse --abbrev-ref HEAD` returns a real branch name.
|
||||
func (h *Harness) NewWorkspace(t *testing.T) *Workspace {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
home := t.TempDir()
|
||||
xdg := t.TempDir()
|
||||
|
||||
for _, c := range [][]string{
|
||||
{"git", "init", "-q", "-b", "main"},
|
||||
{"git", "config", "user.email", "veans-e2e@example.com"},
|
||||
{"git", "config", "user.name", "veans-e2e"},
|
||||
} {
|
||||
cmd := exec.Command(c[0], c[1:]...)
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s: %v\n%s", strings.Join(c, " "), err, out)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "README"), []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range [][]string{
|
||||
{"git", "add", "."},
|
||||
{"git", "commit", "-q", "-m", "initial"},
|
||||
} {
|
||||
cmd := exec.Command(c[0], c[1:]...)
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s: %v\n%s", strings.Join(c, " "), err, out)
|
||||
}
|
||||
}
|
||||
|
||||
return &Workspace{
|
||||
Dir: dir,
|
||||
Home: home,
|
||||
XDGConfig: xdg,
|
||||
ConfigPath: filepath.Join(dir, ".veans.yml"),
|
||||
envOverrides: map[string]string{
|
||||
"HOME": home,
|
||||
"XDG_CONFIG_HOME": xdg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the veans binary against this workspace, returning stdout,
|
||||
// stderr, and exit code.
|
||||
func (h *Harness) Run(t *testing.T, ws *Workspace, args ...string) (stdout, stderr string, exitCode int) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(h.binary, args...)
|
||||
cmd.Dir = ws.Dir
|
||||
cmd.Env = append(os.Environ(), envSlice(ws.envOverrides)...)
|
||||
var so, se bytes.Buffer
|
||||
cmd.Stdout = &so
|
||||
cmd.Stderr = &se
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return so.String(), se.String(), ee.ExitCode()
|
||||
}
|
||||
t.Fatalf("run veans %v: %v", args, err)
|
||||
}
|
||||
return so.String(), se.String(), 0
|
||||
}
|
||||
|
||||
// AdminClient returns the admin-authenticated client for verification.
|
||||
func (h *Harness) AdminClient() *client.Client { return h.adminClient }
|
||||
|
||||
// AdminToken returns the admin's bearer token (handy for --token flows).
|
||||
func (h *Harness) AdminToken() string { return h.adminToken }
|
||||
|
||||
// APIURL returns the configured Vikunja base URL.
|
||||
func (h *Harness) APIURL() string { return h.apiURL }
|
||||
|
||||
// CreateProject creates a fresh project owned by the admin user and returns
|
||||
// it. Tests use a unique title to keep results isolated across parallel runs.
|
||||
func (h *Harness) CreateProject(t *testing.T, title, identifier string) *client.Project {
|
||||
t.Helper()
|
||||
body := map[string]any{"title": title}
|
||||
if identifier != "" {
|
||||
body["identifier"] = identifier
|
||||
}
|
||||
var out client.Project
|
||||
if err := h.adminClient.Do(context.Background(), "PUT", "/projects", nil, body, &out); err != nil {
|
||||
t.Fatalf("create project %q: %v", title, err)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// FindKanbanView returns the first Kanban view of the project (Vikunja
|
||||
// auto-creates one).
|
||||
func (h *Harness) FindKanbanView(t *testing.T, projectID int64) *client.ProjectView {
|
||||
t.Helper()
|
||||
views, err := h.adminClient.ListProjectViews(context.Background(), projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("list views: %v", err)
|
||||
}
|
||||
for _, v := range views {
|
||||
if v.ViewKind == client.ViewKindKanban {
|
||||
return v
|
||||
}
|
||||
}
|
||||
t.Fatalf("no Kanban view on project %d", projectID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTask fetches a task by ID for verification.
|
||||
func (h *Harness) GetTask(t *testing.T, id int64) *client.Task {
|
||||
t.Helper()
|
||||
task, err := h.adminClient.GetTask(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("get task %d: %v", id, err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
func buildOrLocate() (string, error) {
|
||||
if env := os.Getenv("VEANS_BINARY"); env != "" {
|
||||
if abs, err := filepath.Abs(env); err == nil {
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
return abs, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
tmp, err := os.MkdirTemp("", "veans-bin-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
bin := filepath.Join(tmp, "veans")
|
||||
if runtime.GOOS == "windows" {
|
||||
bin += ".exe"
|
||||
}
|
||||
cmd := exec.Command("go", "build", "-o", bin, "./cmd/veans")
|
||||
cmd.Dir = repoRoot()
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build veans: %v\n%s", err, out)
|
||||
}
|
||||
return bin, nil
|
||||
}
|
||||
|
||||
func repoRoot() string {
|
||||
// e2e/helpers.go lives at <repo>/veans/e2e/helpers.go, so go up two.
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
return filepath.Clean(filepath.Join(filepath.Dir(file), ".."))
|
||||
}
|
||||
|
||||
func envSlice(overrides map[string]string) []string {
|
||||
out := make([]string, 0, len(overrides))
|
||||
for k, v := range overrides {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/veans/internal/config"
|
||||
"code.vikunja.io/veans/internal/credentials"
|
||||
)
|
||||
|
||||
// TestInit_HappyPath exercises the full bootstrap: pick project + view,
|
||||
// create canonical buckets, create the bot user, share the project, mint
|
||||
// the bot's token, and write .veans.yml. Verifies side effects via the
|
||||
// admin client.
|
||||
func TestInit_HappyPath(t *testing.T) {
|
||||
h := New(t)
|
||||
|
||||
// Use a unique title and identifier per run so parallel jobs don't
|
||||
// collide on the bot username.
|
||||
suffix := uniqueSuffix()
|
||||
project := h.CreateProject(t, "veans-e2e-"+suffix, "VE"+strings.ToUpper(suffix[:4]))
|
||||
view := h.FindKanbanView(t, project.ID)
|
||||
|
||||
ws := h.NewWorkspace(t)
|
||||
// Rename the workspace dir so the auto-generated bot username is unique.
|
||||
ws.BotUsername = "bot-veans-e2e-" + suffix
|
||||
|
||||
stdout, stderr, code := h.Run(t, ws,
|
||||
"init",
|
||||
"--server", h.APIURL(),
|
||||
"--token", h.AdminToken(),
|
||||
"--project", fmt.Sprintf("%d", project.ID),
|
||||
"--view", fmt.Sprintf("%d", view.ID),
|
||||
"--bot-username", ws.BotUsername,
|
||||
"--yes-buckets",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("init exit %d\nstdout:\n%s\nstderr:\n%s", code, stdout, stderr)
|
||||
}
|
||||
|
||||
// Config written?
|
||||
cfg, err := config.Load(ws.ConfigPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load .veans.yml: %v", err)
|
||||
}
|
||||
if cfg.ProjectID != project.ID || cfg.ViewID != view.ID {
|
||||
t.Fatalf("unexpected ids in config: %+v", cfg)
|
||||
}
|
||||
if cfg.Bot.Username != ws.BotUsername {
|
||||
t.Fatalf("bot username = %q, want %q", cfg.Bot.Username, ws.BotUsername)
|
||||
}
|
||||
if cfg.Buckets.Todo == 0 || cfg.Buckets.InProgress == 0 || cfg.Buckets.InReview == 0 || cfg.Buckets.Done == 0 || cfg.Buckets.Scrapped == 0 {
|
||||
t.Fatalf("buckets not fully populated: %+v", cfg.Buckets)
|
||||
}
|
||||
|
||||
// Bot token persisted in the file backend (since HOME points at a
|
||||
// fresh tmpdir, the file backend takes over from the missing keyring).
|
||||
store := credentials.NewFileBackend(ws.XDGConfig + "/veans/credentials.yml")
|
||||
tok, err := store.Get(h.APIURL(), ws.BotUsername)
|
||||
if err != nil {
|
||||
t.Fatalf("token not persisted: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(tok, "tk_") {
|
||||
t.Fatalf("bot token doesn't look like a Vikunja API token: %q", tok)
|
||||
}
|
||||
|
||||
// Bot exists on the server with the right username.
|
||||
bots, err := h.AdminClient().ListBotUsers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list bots: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, b := range bots {
|
||||
if b.Username == ws.BotUsername {
|
||||
found = true
|
||||
if b.ID != cfg.Bot.UserID {
|
||||
t.Fatalf("bot user_id mismatch: server=%d cfg=%d", b.ID, cfg.Bot.UserID)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("bot %q not found on server", ws.BotUsername)
|
||||
}
|
||||
|
||||
// Project shared with the bot at write permission.
|
||||
var shares []map[string]any
|
||||
_ = h.AdminClient().Do(context.Background(), "GET", fmt.Sprintf("/projects/%d/users", project.ID), nil, nil, &shares)
|
||||
shareFound := false
|
||||
for _, s := range shares {
|
||||
if u, _ := s["username"].(string); u == ws.BotUsername {
|
||||
if p, _ := s["permission"].(float64); int(p) >= 1 {
|
||||
shareFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !shareFound {
|
||||
t.Fatalf("project not shared with bot at write permission: %v", shares)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_NoIdentifierFallsBackToHashNN(t *testing.T) {
|
||||
h := New(t)
|
||||
|
||||
suffix := uniqueSuffix()
|
||||
project := h.CreateProject(t, "veans-e2e-noid-"+suffix, "")
|
||||
view := h.FindKanbanView(t, project.ID)
|
||||
|
||||
ws := h.NewWorkspace(t)
|
||||
ws.BotUsername = "bot-veans-noid-" + suffix
|
||||
|
||||
_, stderr, code := h.Run(t, ws,
|
||||
"init",
|
||||
"--server", h.APIURL(),
|
||||
"--token", h.AdminToken(),
|
||||
"--project", fmt.Sprintf("%d", project.ID),
|
||||
"--view", fmt.Sprintf("%d", view.ID),
|
||||
"--bot-username", ws.BotUsername,
|
||||
"--yes-buckets",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("init exit %d\n%s", code, stderr)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(ws.ConfigPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ProjectIdentifier != "" {
|
||||
t.Fatalf("expected empty identifier, got %q", cfg.ProjectIdentifier)
|
||||
}
|
||||
if got := cfg.FormatTaskID(7); got != "#7" {
|
||||
t.Fatalf("expected #7, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// uniqueSuffix returns a short random-ish slug for naming test artifacts.
|
||||
// Time-based is fine here — tests don't need cryptographic uniqueness.
|
||||
func uniqueSuffix() string {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "host"
|
||||
}
|
||||
return strings.ToLower(fmt.Sprintf("%s-%d", trunc(hostname, 4), time.Now().UnixNano()))[:18]
|
||||
}
|
||||
|
||||
func trunc(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPrime_RendersWithProjectAndBot pins the literal anchors hooks depend
|
||||
// on. Mirrors plan e2e test 12.
|
||||
func TestPrime_RendersWithProjectAndBot(t *testing.T) {
|
||||
ws, h := provisionWorkspace(t)
|
||||
cfg := loadConfig(t, ws)
|
||||
|
||||
out, _, code := h.Run(t, ws, "prime")
|
||||
if code != 0 {
|
||||
t.Fatalf("prime exit %d", code)
|
||||
}
|
||||
|
||||
mustContain := []string{
|
||||
"<EXTREMELY_IMPORTANT>",
|
||||
cfg.Bot.Username,
|
||||
"Refs:",
|
||||
"veans claim",
|
||||
"Todo",
|
||||
"In Progress",
|
||||
"In Review",
|
||||
"Done",
|
||||
"Scrapped",
|
||||
}
|
||||
for _, s := range mustContain {
|
||||
if !strings.Contains(out, s) {
|
||||
t.Errorf("prime output missing %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrime_SilentOutsideWorkspace verifies the safe-globally-installed
|
||||
// hook contract: no .veans.yml ⇒ silent + exit 0.
|
||||
func TestPrime_SilentOutsideWorkspace(t *testing.T) {
|
||||
h := New(t)
|
||||
|
||||
// A workspace with no .veans.yml — just a temp dir.
|
||||
ws := h.NewWorkspace(t)
|
||||
|
||||
stdout, stderr, code := h.Run(t, ws, "prime")
|
||||
if code != 0 {
|
||||
t.Fatalf("prime exit %d (expected 0)\n%s\n%s", code, stdout, stderr)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Fatalf("expected silent stdout outside workspace, got: %s", stdout)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/veans/internal/config"
|
||||
)
|
||||
|
||||
// provisionWorkspace runs `veans init` against a fresh project and returns
|
||||
// the workspace + harness primed for command-level e2e tests. Each test that
|
||||
// needs a working .veans.yml calls this at the top.
|
||||
func provisionWorkspace(t *testing.T) (*Workspace, *Harness) {
|
||||
t.Helper()
|
||||
h := New(t)
|
||||
suffix := uniqueSuffix()
|
||||
project := h.CreateProject(t, "veans-e2e-"+suffix, "VE"+strings.ToUpper(suffix[:4]))
|
||||
view := h.FindKanbanView(t, project.ID)
|
||||
|
||||
ws := h.NewWorkspace(t)
|
||||
ws.BotUsername = "bot-veans-e2e-" + suffix
|
||||
|
||||
_, stderr, code := h.Run(t, ws,
|
||||
"init",
|
||||
"--server", h.APIURL(),
|
||||
"--token", h.AdminToken(),
|
||||
"--project", iToS(project.ID),
|
||||
"--view", iToS(view.ID),
|
||||
"--bot-username", ws.BotUsername,
|
||||
"--yes-buckets",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("provision init failed: %s", stderr)
|
||||
}
|
||||
return ws, h
|
||||
}
|
||||
|
||||
// loadConfig reads .veans.yml out of a workspace.
|
||||
func loadConfig(t *testing.T, ws *Workspace) *config.Config {
|
||||
t.Helper()
|
||||
c, err := config.Load(ws.ConfigPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// gitInWorkspace runs git inside the workspace and fails the test on error.
|
||||
func gitInWorkspace(t *testing.T, ws *Workspace, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = ws.Dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func iToS(n int64) string {
|
||||
const digits = "0123456789"
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
negative := false
|
||||
if n < 0 {
|
||||
negative = true
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = digits[n%10]
|
||||
n /= 10
|
||||
}
|
||||
if negative {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
)
|
||||
|
||||
// TestCreateShowList_RoundTrip verifies the read+write path against a real
|
||||
// Vikunja: provision a workspace via init, create a task, show it, list it
|
||||
// (with --filter), and confirm the JSON shapes are unwrapped raw object/array.
|
||||
func TestCreateShowList_RoundTrip(t *testing.T) {
|
||||
ws, h := provisionWorkspace(t)
|
||||
|
||||
// Create a task with a description and a label.
|
||||
out, errOut, code := h.Run(t, ws,
|
||||
"--json", "create", "Test bug fix",
|
||||
"-d", "## Repro\n- [ ] step 1\n- [ ] step 2",
|
||||
"--label", "bug",
|
||||
"--priority", "3",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("create exit %d\n%s\n%s", code, out, errOut)
|
||||
}
|
||||
var created client.Task
|
||||
if err := json.Unmarshal([]byte(out), &created); err != nil {
|
||||
t.Fatalf("decode create: %v\n%s", err, out)
|
||||
}
|
||||
if created.Title != "Test bug fix" {
|
||||
t.Fatalf("created title = %q", created.Title)
|
||||
}
|
||||
if created.Priority != 3 {
|
||||
t.Fatalf("priority = %d", created.Priority)
|
||||
}
|
||||
if !taskHasLabelTitle(&created, "veans:bug") {
|
||||
t.Fatalf("expected veans:bug label on created task; got %+v", created.Labels)
|
||||
}
|
||||
|
||||
// Show with --json — should be a raw object, not enveloped.
|
||||
id := fmt.Sprintf("%d", created.Index)
|
||||
showOut, _, code := h.Run(t, ws, "--json", "show", id)
|
||||
if code != 0 {
|
||||
t.Fatalf("show exit %d", code)
|
||||
}
|
||||
var shown client.Task
|
||||
if err := json.Unmarshal([]byte(showOut), &shown); err != nil {
|
||||
t.Fatalf("decode show: %v\n%s", err, showOut)
|
||||
}
|
||||
if shown.ID != created.ID {
|
||||
t.Fatalf("show returned wrong task: %d vs %d", shown.ID, created.ID)
|
||||
}
|
||||
|
||||
// List with --json — should be a raw array.
|
||||
listOut, _, code := h.Run(t, ws, "--json", "list")
|
||||
if code != 0 {
|
||||
t.Fatalf("list exit %d", code)
|
||||
}
|
||||
var listed []*client.Task
|
||||
if err := json.Unmarshal([]byte(listOut), &listed); err != nil {
|
||||
t.Fatalf("decode list: %v\n%s", err, listOut)
|
||||
}
|
||||
if len(listed) == 0 {
|
||||
t.Fatalf("list returned empty array; expected at least our created task")
|
||||
}
|
||||
|
||||
// --filter passthrough: only items with priority > 2.
|
||||
filterOut, _, code := h.Run(t, ws, "--json", "list", "--filter", "priority > 2")
|
||||
if code != 0 {
|
||||
t.Fatalf("list --filter exit %d\n%s", code, filterOut)
|
||||
}
|
||||
var filtered []*client.Task
|
||||
if err := json.Unmarshal([]byte(filterOut), &filtered); err != nil {
|
||||
t.Fatalf("decode filter list: %v", err)
|
||||
}
|
||||
for _, ft := range filtered {
|
||||
if ft.Priority <= 2 {
|
||||
t.Fatalf("filter leaked priority=%d task into result", ft.Priority)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdate_DescriptionReplaceUniqueness pins the agent-friendly Edit-tool
|
||||
// behavior: the "old" string must match exactly once, otherwise the update
|
||||
// errors and nothing changes.
|
||||
func TestUpdate_DescriptionReplaceUniqueness(t *testing.T) {
|
||||
ws, h := provisionWorkspace(t)
|
||||
|
||||
out, errOut, code := h.Run(t, ws, "--json", "create", "checkbox task",
|
||||
"-d", "- [ ] step 1\n- [ ] step 1 (again)",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("create exit %d\n%s\n%s", code, out, errOut)
|
||||
}
|
||||
var created client.Task
|
||||
if err := json.Unmarshal([]byte(out), &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := fmt.Sprintf("%d", created.Index)
|
||||
|
||||
// Non-unique replace should fail with a validation error and a
|
||||
// non-zero exit code.
|
||||
_, stderr, code := h.Run(t, ws,
|
||||
"update", id,
|
||||
"--description-replace-old", "step 1",
|
||||
"--description-replace-new", "step 1 [x]",
|
||||
)
|
||||
if code == 0 {
|
||||
t.Fatalf("expected non-zero exit on non-unique replace")
|
||||
}
|
||||
if !strings.Contains(stderr, "VALIDATION_ERROR") {
|
||||
t.Fatalf("expected VALIDATION_ERROR in stderr, got: %s", stderr)
|
||||
}
|
||||
|
||||
// Disambiguate and try again — should succeed.
|
||||
_, _, code = h.Run(t, ws,
|
||||
"update", id,
|
||||
"--description-replace-old", "- [ ] step 1\n",
|
||||
"--description-replace-new", "- [x] step 1\n",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("disambiguated update should succeed; exit %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func taskHasLabelTitle(t *client.Task, title string) bool {
|
||||
for _, l := range t.Labels {
|
||||
if l != nil && l.Title == title {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Loading…
Reference in New Issue