test(veans): add e2e suite covering init, tasks, claim, prime flows

This commit is contained in:
Tink bot 2026-05-26 22:41:35 +02:00 committed by kolaente
parent 3a7bcb2a50
commit 4c3d449a35
6 changed files with 735 additions and 0 deletions

60
veans/e2e/claim_test.go Normal file
View File

@ -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)
}
}

250
veans/e2e/helpers.go Normal file
View File

@ -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
}

157
veans/e2e/init_test.go Normal file
View File

@ -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]
}

52
veans/e2e/prime_test.go Normal file
View File

@ -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)
}
}

81
veans/e2e/shared_test.go Normal file
View File

@ -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:])
}

135
veans/e2e/tasks_test.go Normal file
View File

@ -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
}