Files
kanban/backend/modules_test.go
egutierrez 5744b82f58 feat(jira): issue_type config + labels_map + status_map default DATA + transition tras create
- jiraConfig: campos IssueType + LabelsMap (kanban col -> labels Jira). Default
  IssueType='Tarea Tecnica' (DATA project no tiene Task).
- create(): usa c.IssueType y aplica labels iniciales. Despues del POST /issue
  ejecuta transitionToStatus para mover la card recien creada al status del
  status_map, asi no aterriza en el initial workflow status (CREADO o To Do)
  sino donde toca segun la columna kanban.
- update() y transition(): aplican labels en cada sync (PUT replaces array).
  Card que sale de Bloqueadas pierde el label 'blocked' automaticamente.
- transitionToStatus: helper compartido entre create() y transition().
- seed-jira-data: inyecta status_map por defecto para nuestras 6 columnas
  (HACIENDO -> In Progress, PNDNT FEEDBACK -> IMPLEMENTADO, HECHO -> Done,
  IDEAS -> CREADO, DEUDA TECNICA -> To Do, Bloqueadas -> In Progress) y
  labels_map (Bloqueadas -> ['blocked']).
- modules_test: mock Jira tambien responde /transitions endpoints.
2026-05-29 11:44:04 +02:00

236 lines
6.8 KiB
Go

package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
// withModuleKey sets KANBAN_MODULE_KEY for the duration of a test and
// restores the previous value afterwards.
func withModuleKey(t *testing.T, value string) {
t.Helper()
prev := os.Getenv(moduleKeyEnv)
t.Setenv(moduleKeyEnv, value)
t.Cleanup(func() { _ = os.Setenv(moduleKeyEnv, prev) })
}
func TestCryptoRoundTrip(t *testing.T) {
withModuleKey(t, "test-passphrase")
plain := []byte(`{"hello":"world"}`)
cipherBlob, nonce, err := encryptConfig(plain)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
got, err := decryptConfig(cipherBlob, nonce)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if string(got) != string(plain) {
t.Fatalf("roundtrip mismatch: got %q want %q", got, plain)
}
}
func TestCryptoMissingKey(t *testing.T) {
t.Setenv(moduleKeyEnv, "")
if _, _, err := encryptConfig([]byte("x")); err == nil {
t.Fatal("expected error when KANBAN_MODULE_KEY unset")
}
}
func TestSaveAndLoadModule(t *testing.T) {
withModuleKey(t, "test-passphrase")
db := setupTestDB(t)
m := &Module{
Name: "jira-test", Kind: "jira", Enabled: true,
EventFilter: []string{"card.created", "card.moved"},
Config: JSONValue{
"base_url": "https://example.atlassian.net",
"email": "x@y.z",
"api_token": "secret-123",
},
}
if err := db.saveModule(m); err != nil {
t.Fatalf("save: %v", err)
}
if m.ID == "" {
t.Fatal("ID not assigned on insert")
}
got, err := db.getModule(m.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Config["api_token"] != "secret-123" {
t.Fatalf("token roundtrip failed: %v", got.Config["api_token"])
}
}
func TestFilterMatches(t *testing.T) {
if !filterMatches([]string{"card.created"}, "card.created") {
t.Fatal("exact match")
}
if !filterMatches([]string{"*"}, "anything") {
t.Fatal("wildcard")
}
if filterMatches([]string{"card.created"}, "card.moved") {
t.Fatal("non-match should be false")
}
}
func TestCardOptOutTag(t *testing.T) {
c := cardForJira{Tags: []string{"foo", "NoJira", "bar"}}
if !c.hasTag("nojira") {
t.Fatal("nojira (case-insensitive) not detected")
}
if c.hasTag("missing") {
t.Fatal("missing tag returned true")
}
}
func TestJiraHandler_TransitionMappingMissing(t *testing.T) {
withModuleKey(t, "k")
db := setupTestDB(t)
col, _ := db.CreateColumn("Backlog")
card, _ := db.CreateCard(col.ID, "req", "t", "d", "")
// Link the card so the create-fallback path is skipped.
_ = db.setCardJiraKey(card.ID, "KAN-1")
h := &jiraHandler{}
_, err := h.transition(context.Background(), db, jiraConfig{BaseURL: "http://x"}, Event{Type: "card.moved", CardID: card.ID})
if err == nil || !strings.Contains(err.Error(), "status_map") {
t.Fatalf("expected status_map error, got %v", err)
}
}
func TestJiraHandler_TestConnectionHitsMyself(t *testing.T) {
var path string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"accountId":"abc"}`)
}))
defer srv.Close()
h := &jiraHandler{}
m := Module{Kind: "jira", Config: JSONValue{
"base_url": srv.URL,
"email": "x@y.z",
"api_token": "tok",
}}
status, err := h.TestConnection(context.Background(), m)
if err != nil {
t.Fatalf("TestConnection: %v", err)
}
if status != 200 {
t.Fatalf("status = %d, want 200", status)
}
if path != "/rest/api/3/myself" {
t.Fatalf("path = %q, want /rest/api/3/myself", path)
}
}
func TestJiraHandler_CreateLinksCardKey(t *testing.T) {
withModuleKey(t, "test-passphrase")
db := setupTestDB(t)
user, _ := db.CreateUser("alice", "passw", "Alice")
col, _ := db.CreateColumn("Todo")
card, _ := db.CreateCard(col.ID, "req", "Buy bread", "desc", user.ID)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/rest/api/3/issue":
b, _ := io.ReadAll(r.Body)
var p struct {
Fields struct {
Summary string `json:"summary"`
} `json:"fields"`
}
_ = json.Unmarshal(b, &p)
if p.Fields.Summary != "Buy bread" {
t.Errorf("summary = %q", p.Fields.Summary)
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"id":"10000","key":"KAN-1"}`)
case r.Method == http.MethodGet && r.URL.Path == "/rest/api/3/issue/KAN-1/transitions":
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"transitions":[{"id":"11","name":"Start","to":{"name":"To Do"}}]}`)
case r.Method == http.MethodPost && r.URL.Path == "/rest/api/3/issue/KAN-1/transitions":
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodPut && r.URL.Path == "/rest/api/3/issue/KAN-1":
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
h := &jiraHandler{}
mod := Module{Kind: "jira", Config: JSONValue{
"base_url": srv.URL,
"email": "x@y.z",
"api_token": "tok",
"project_key": "KAN",
"status_map": map[string]interface{}{"Todo": "To Do"},
}}
status, err := h.Handle(context.Background(), db, mod, Event{Type: "card.created", CardID: card.ID})
if err != nil {
t.Fatalf("Handle: %v", err)
}
if status != http.StatusCreated {
t.Fatalf("status = %d, want 201", status)
}
again, err := db.getCardForJira(card.ID)
if err != nil {
t.Fatalf("get card: %v", err)
}
if again.JiraKey != "KAN-1" {
t.Fatalf("jira_key = %q, want KAN-1", again.JiraKey)
}
}
func TestDispatcher_Cutoff(t *testing.T) {
withModuleKey(t, "k")
db := setupTestDB(t)
col, _ := db.CreateColumn("Todo")
// Create card BEFORE the module so cutoffOK rejects it.
card, _ := db.CreateCard(col.ID, "req", "t", "d", "")
time.Sleep(20 * time.Millisecond)
mod := Module{ID: "m", CreatedAt: nowRFC3339()}
if cutoffOK(db, mod, Event{CardID: card.ID}) {
t.Fatal("card pre-dating module should be filtered out")
}
// Once linked, cutoff should allow it.
_ = db.setCardJiraKey(card.ID, "KAN-9")
if !cutoffOK(db, mod, Event{CardID: card.ID}) {
t.Fatal("linked card must pass cutoff even if older")
}
}
func TestIsAdmin(t *testing.T) {
db := setupTestDB(t)
u, _ := db.CreateUser("egutierrez", "passw", "Egu")
// Migration 015 marks egutierrez admin via UPDATE WHERE username, but
// that only takes effect when the row already exists. In production
// the migration runs against an existing user list; in tests we create
// users after migration, so simulate the same outcome explicitly.
if _, err := db.conn.Exec(`UPDATE users SET is_admin = 1 WHERE username = ?`, "egutierrez"); err != nil {
t.Fatalf("seed admin: %v", err)
}
ok, err := db.IsAdmin(u.ID)
if err != nil {
t.Fatalf("IsAdmin: %v", err)
}
if !ok {
t.Fatal("egutierrez must be admin after seed")
}
other, _ := db.CreateUser("alice", "passw", "Alice")
ok, _ = db.IsAdmin(other.ID)
if ok {
t.Fatal("alice must not be admin by default")
}
}