5744b82f58
- 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.
236 lines
6.8 KiB
Go
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")
|
|
}
|
|
}
|