package sync import ( "context" "encoding/json" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" "bountyboard/internal/domain" ) // fakeWekan implements just enough of the WeKan REST API: login, board with // members, lists, cards, card details with assignees and timestamps. func fakeWekan(t *testing.T, logins *atomic.Int64) *httptest.Server { t.Helper() mux := http.NewServeMux() authed := func(w http.ResponseWriter, r *http.Request) bool { if r.Header.Get("Authorization") != "Bearer wekan-token-1" { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return false } return true } js := func(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } mux.HandleFunc("POST /users/login", func(w http.ResponseWriter, r *http.Request) { var req struct{ Username, Password string } json.NewDecoder(r.Body).Decode(&req) if req.Username != "syncbot" || req.Password != "hunter2" { http.Error(w, `{"error":"bad credentials"}`, http.StatusUnauthorized) return } if logins != nil { logins.Add(1) } js(w, map[string]string{"id": "u-syncbot", "token": "wekan-token-1"}) }) mux.HandleFunc("GET /api/boards/board1", func(w http.ResponseWriter, r *http.Request) { if !authed(w, r) { return } js(w, map[string]any{ "title": "Sprint Board", "slug": "sprint-board", "members": []map[string]any{ {"userId": "u-clara"}, {"userId": "u-other"}, {"userId": "u-syncbot"}, }, }) }) mux.HandleFunc("GET /api/users/{id}", func(w http.ResponseWriter, r *http.Request) { if !authed(w, r) { return } names := map[string]string{"u-clara": "clara", "u-other": "othello", "u-syncbot": "syncbot"} js(w, map[string]string{"username": names[r.PathValue("id")]}) }) mux.HandleFunc("GET /api/boards/board1/lists", func(w http.ResponseWriter, r *http.Request) { if !authed(w, r) { return } js(w, []map[string]string{{"_id": "l-todo", "title": "Todo"}, {"_id": "l-doing", "title": "Doing"}}) }) mux.HandleFunc("GET /api/boards/board1/lists/{list}/cards", func(w http.ResponseWriter, r *http.Request) { if !authed(w, r) { return } switch r.PathValue("list") { case "l-todo": js(w, []map[string]string{{"_id": "card-a"}, {"_id": "card-b"}, {"_id": "card-d"}}) default: js(w, []map[string]string{{"_id": "card-c"}}) } }) cards := map[string]map[string]any{ // assigned to clara, recently modified "card-a": {"_id": "card-a", "title": "Fix the login flow", "description": "details A", "assignees": []string{"u-clara"}, "modifiedAt": time.Now().UTC().Format(time.RFC3339)}, // member-fallback assignment, old timestamp "card-b": {"_id": "card-b", "title": "Old card", "description": "details B", "assignees": []string{}, "members": []string{"u-clara"}, "modifiedAt": "2020-01-01T00:00:00Z"}, // assigned to someone else "card-c": {"_id": "card-c", "title": "Not clara's", "description": "details C", "assignees": []string{"u-other"}, "modifiedAt": time.Now().UTC().Format(time.RFC3339)}, // clara's but archived "card-d": {"_id": "card-d", "title": "Archived card", "description": "details D", "assignees": []string{"u-clara"}, "archived": true, "modifiedAt": time.Now().UTC().Format(time.RFC3339)}, } mux.HandleFunc("GET /api/boards/board1/lists/{list}/cards/{card}", func(w http.ResponseWriter, r *http.Request) { if !authed(w, r) { return } c, ok := cards[r.PathValue("card")] if !ok { http.NotFound(w, r) return } js(w, c) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv } func newWekan(t *testing.T, srv *httptest.Server) Connector { t.Helper() conn, err := NewConnector(domain.TicketingWekan, srv.URL, "board1", Credentials{"username": "syncbot", "password": "hunter2"}) if err != nil { t.Fatal(err) } return conn } func TestWekanFactoryValidation(t *testing.T) { if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "board1", Credentials{}); err == nil { t.Fatal("missing credentials must be rejected") } if _, err := NewConnector(domain.TicketingWekan, "", "board1", Credentials{"username": "u", "password": "p"}); err == nil { t.Fatal("missing baseUrl must be rejected") } if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "", Credentials{"username": "u", "password": "p"}); err == nil { t.Fatal("missing board id must be rejected") } if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "board1", Credentials{"token": "tok", "userId": "u1"}); err != nil { t.Fatalf("pre-issued token must be accepted: %v", err) } } func TestWekanTestConnection(t *testing.T) { srv := fakeWekan(t, nil) if err := newWekan(t, srv).TestConnection(context.Background()); err != nil { t.Fatalf("test connection: %v", err) } bad, err := NewConnector(domain.TicketingWekan, srv.URL, "board1", Credentials{"username": "syncbot", "password": "wrong"}) if err != nil { t.Fatal(err) } if err := bad.TestConnection(context.Background()); err == nil { t.Fatal("wrong password must fail the connection test") } } func TestWekanFetchUpdated(t *testing.T) { srv := fakeWekan(t, nil) conn := newWekan(t, srv) // since the epoch: clara gets card-a (assignee) and card-b (member // fallback); card-c is someone else's, card-d is archived tickets, err := conn.FetchUpdated(context.Background(), "clara", time.Unix(0, 0)) if err != nil { t.Fatal(err) } if len(tickets) != 2 { t.Fatalf("tickets = %d, want 2 (%+v)", len(tickets), tickets) } byKey := map[string]Ticket{} for _, tk := range tickets { byKey[tk.Key] = tk } a, ok := byKey["card-a"] if !ok { t.Fatal("card-a missing") } if a.Title != "Fix the login flow" || a.Type != "task" { t.Fatalf("card-a mapped wrong: %+v", a) } if a.URL != srv.URL+"/b/board1/sprint-board/card-a" { t.Fatalf("card url: %s", a.URL) } if a.Raw["list"] != "Todo" || a.Raw["board"] != "Sprint Board" { t.Fatalf("raw payload: %+v", a.Raw) } // recent since-watermark filters out the old card-b tickets, err = conn.FetchUpdated(context.Background(), "clara", time.Now().Add(-time.Hour)) if err != nil { t.Fatal(err) } if len(tickets) != 1 || tickets[0].Key != "card-a" { t.Fatalf("since filter: %+v", tickets) } // identity casing is forgiving if _, err := conn.FetchUpdated(context.Background(), "CLARA", time.Unix(0, 0)); err != nil { t.Fatalf("case-insensitive identity: %v", err) } // unknown identity errors clearly if _, err := conn.FetchUpdated(context.Background(), "nobody", time.Unix(0, 0)); err == nil { t.Fatal("unknown wekan user must error") } } func TestWekanListAssignedKeysAndTokenReuse(t *testing.T) { var logins atomic.Int64 srv := fakeWekan(t, &logins) conn := newWekan(t, srv) keys, err := conn.ListAssignedKeys(context.Background(), "clara") if err != nil { t.Fatal(err) } if len(keys) != 2 { t.Fatalf("keys = %v, want card-a + card-b", keys) } // several operations reuse one login token instead of re-authenticating if _, err := conn.FetchUpdated(context.Background(), "clara", time.Unix(0, 0)); err != nil { t.Fatal(err) } if logins.Load() != 1 { t.Fatalf("logins = %d, want 1 (token reuse)", logins.Load()) } }