diff --git a/AGENTS.md b/AGENTS.md index a4ed2b87ba..bfe39f030c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -336,6 +336,7 @@ Jira and Linear are the model: per-workspace credentials, a 90s auth-health poll - Use `internal/integrations/secretadapter` instead of writing your own upsert wrapper around `secrets.SecretStore`. The adapter satisfies any per-integration `SecretStore` interface shaped as `{Reveal, Set, Delete, Exists}`. - Use `internal/integrations/healthpoll` for the auth-health loop. Implement the `Prober` interface (`ListConfiguredWorkspaces` + `RecordAuthHealth`) on a small adapter and let `healthpoll.New("name", prober, log)` own Start/Stop/ticker. Keep integration-specific loops (JQL polling, webhook reconciliation, etc.) separate, like jira's issue-watch loop. - Wire the service via a per-domain `initService(...)` helper in `cmd/kandev/services.go`, not inline in `provideServices`. +- Ship a `mock_client.go` + `mock_controller.go` next to the real client. `Provide` branches on `KANDEV_MOCK_=true` and returns the in-memory client; `RegisterMockRoutes(router, svc, log)` mounts `/api/v1//mock/*` only when the service was built with the mock. The e2e backend fixture sets the env var so Playwright tests drive the mock via `apiClient.mock*()` helpers — see jira/linear for the layout. **Frontend**: - Hooks live under `hooks/domains//`, **not** `components//`. @@ -357,4 +358,4 @@ This file is read by AI coding agents (Claude Code via `CLAUDE.md` symlink, Code --- -**Last Updated**: 2026-05-01 +**Last Updated**: 2026-05-02 diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 3e07fe60b7..9448132f4b 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -539,11 +539,13 @@ func registerSecondaryRoutes( if p.services.Jira != nil { jira.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Jira, p.log) + jira.RegisterMockRoutes(p.router, p.services.Jira, p.log) p.log.Debug("Registered JIRA handlers (HTTP + WebSocket)") } if p.services.Linear != nil { linear.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Linear, p.log) + linear.RegisterMockRoutes(p.router, p.services.Linear, p.log) p.log.Debug("Registered Linear handlers (HTTP + WebSocket)") } diff --git a/apps/backend/internal/jira/mock_client.go b/apps/backend/internal/jira/mock_client.go new file mode 100644 index 0000000000..45fbef40ca --- /dev/null +++ b/apps/backend/internal/jira/mock_client.go @@ -0,0 +1,220 @@ +package jira + +import ( + "context" + "net/http" + "strings" + "sync" +) + +// MockClient implements Client with in-memory data for E2E tests. The mock is +// shared across all workspaces in the process — that mirrors how the GitHub +// mock works and keeps the e2e seeding API simple. Per-workspace isolation is +// not needed for the scenarios these tests cover (single workspace per worker). +type MockClient struct { + mu sync.RWMutex + authResult *TestConnectionResult + tickets map[string]*JiraTicket // key → ticket + transitions map[string][]JiraTransition // ticketKey → transitions + projects []JiraProject + searchHits []JiraTicket // returned by SearchTickets regardless of JQL + doneCalls []doneTransitionCall + getError *APIError +} + +type doneTransitionCall struct { + TicketKey string + TransitionID string +} + +// NewMockClient returns a MockClient with TestAuth set to a successful result +// so a freshly-created config flips to "Authenticated" without seeding. +func NewMockClient() *MockClient { + return &MockClient{ + authResult: &TestConnectionResult{ + OK: true, + AccountID: "mock-account", + DisplayName: "Mock User", + Email: "mock@example.com", + }, + tickets: make(map[string]*JiraTicket), + transitions: make(map[string][]JiraTransition), + } +} + +// --- Client interface --- + +func (m *MockClient) TestAuth(context.Context) (*TestConnectionResult, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.authResult == nil { + return &TestConnectionResult{OK: false, Error: "mock: no auth result configured"}, nil + } + // Return a copy so callers can't mutate the canned result. + res := *m.authResult + return &res, nil +} + +func (m *MockClient) GetTicket(_ context.Context, ticketKey string) (*JiraTicket, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.getError != nil { + err := *m.getError + return nil, &err + } + t, ok := m.tickets[ticketKey] + if !ok { + return nil, &APIError{StatusCode: http.StatusNotFound, Message: "ticket not found: " + ticketKey} + } + cp := *t + return &cp, nil +} + +func (m *MockClient) ListTransitions(_ context.Context, ticketKey string) ([]JiraTransition, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]JiraTransition, len(m.transitions[ticketKey])) + copy(out, m.transitions[ticketKey]) + return out, nil +} + +func (m *MockClient) DoTransition(_ context.Context, ticketKey, transitionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.doneCalls = append(m.doneCalls, doneTransitionCall{TicketKey: ticketKey, TransitionID: transitionID}) + return nil +} + +func (m *MockClient) ListProjects(context.Context) ([]JiraProject, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]JiraProject, len(m.projects)) + copy(out, m.projects) + return out, nil +} + +// SearchTickets returns the tickets seeded via SetSearchHits. When jql is +// empty (or no seeded ticket key appears literally in the query) the full +// seeded set is returned — tests are expected to seed exactly the result +// they want to assert on. To narrow the response, mention a seeded key in +// the JQL (e.g. `key = PROJ-12`) and only matching tickets are returned. +func (m *MockClient) SearchTickets(_ context.Context, jql, _ string, maxResults int) (*SearchResult, error) { + m.mu.RLock() + defer m.mu.RUnlock() + hits := filterByJQL(m.searchHits, jql) + if maxResults > 0 && len(hits) > maxResults { + hits = hits[:maxResults] + } + out := make([]JiraTicket, len(hits)) + copy(out, hits) + return &SearchResult{Tickets: out, MaxResults: maxResults, IsLast: true}, nil +} + +// filterByJQL is a stand-in for real JQL parsing. An empty query passes every +// hit through; a non-empty query that mentions a seeded ticket key restricts +// to that key; a non-empty query that mentions no seeded key returns every +// hit (so tests don't have to construct a parseable JQL string just to fetch +// what they seeded). +func filterByJQL(hits []JiraTicket, jql string) []JiraTicket { + if jql == "" { + return hits + } + matched := make([]JiraTicket, 0, len(hits)) + for _, t := range hits { + if t.Key != "" && strings.Contains(jql, t.Key) { + matched = append(matched, t) + } + } + if len(matched) == 0 { + return hits + } + return matched +} + +// --- Setters used by MockController --- + +// SetAuthResult overrides the result returned by TestAuth (and consequently +// the auth-health probe). Pass nil to simulate an unconfigured auth state +// (returns OK=false with "mock: no auth result configured"); call Reset to +// restore the default success. +func (m *MockClient) SetAuthResult(r *TestConnectionResult) { + m.mu.Lock() + defer m.mu.Unlock() + m.authResult = r +} + +// AddTicket inserts (or replaces) a ticket keyed by its Key field. +func (m *MockClient) AddTicket(t *JiraTicket) { + m.mu.Lock() + defer m.mu.Unlock() + cp := *t + m.tickets[t.Key] = &cp +} + +// AddTransitions appends transitions available for a ticket. +func (m *MockClient) AddTransitions(ticketKey string, ts []JiraTransition) { + m.mu.Lock() + defer m.mu.Unlock() + m.transitions[ticketKey] = append(m.transitions[ticketKey], ts...) +} + +// SetProjects replaces the projects list returned by ListProjects. +func (m *MockClient) SetProjects(projects []JiraProject) { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]JiraProject, len(projects)) + copy(cp, projects) + m.projects = cp +} + +// SetSearchHits replaces the tickets returned by SearchTickets. +func (m *MockClient) SetSearchHits(hits []JiraTicket) { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]JiraTicket, len(hits)) + copy(cp, hits) + m.searchHits = cp +} + +// SetGetTicketError forces GetTicket to return the given APIError until cleared +// with nil. Lets tests assert on the popover's error-state UI. +func (m *MockClient) SetGetTicketError(err *APIError) { + m.mu.Lock() + defer m.mu.Unlock() + m.getError = err +} + +// TransitionCalls returns the recorded DoTransition calls. +func (m *MockClient) TransitionCalls() []doneTransitionCall { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]doneTransitionCall, len(m.doneCalls)) + copy(out, m.doneCalls) + return out +} + +// Reset clears every seeded value back to defaults. Called between tests. +func (m *MockClient) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.authResult = &TestConnectionResult{ + OK: true, + AccountID: "mock-account", + DisplayName: "Mock User", + Email: "mock@example.com", + } + m.tickets = make(map[string]*JiraTicket) + m.transitions = make(map[string][]JiraTransition) + m.projects = nil + m.searchHits = nil + m.doneCalls = nil + m.getError = nil +} + +// MockClientFactory always returns the same shared MockClient regardless of +// per-workspace credentials. Use this from Provide when KANDEV_MOCK_JIRA=true. +func MockClientFactory(shared *MockClient) ClientFactory { + return func(*JiraConfig, string) Client { + return shared + } +} diff --git a/apps/backend/internal/jira/mock_client_test.go b/apps/backend/internal/jira/mock_client_test.go new file mode 100644 index 0000000000..a7cd5c91be --- /dev/null +++ b/apps/backend/internal/jira/mock_client_test.go @@ -0,0 +1,85 @@ +package jira + +import ( + "context" + "errors" + "net/http" + "testing" +) + +func TestMockClient_DefaultsToSuccessfulAuth(t *testing.T) { + m := NewMockClient() + res, err := m.TestAuth(context.Background()) + if err != nil { + t.Fatalf("TestAuth: %v", err) + } + if !res.OK { + t.Fatalf("expected OK=true by default, got %+v", res) + } +} + +func TestMockClient_SetAuthResultIsReturned(t *testing.T) { + m := NewMockClient() + m.SetAuthResult(&TestConnectionResult{OK: false, Error: "401 Unauthorized"}) + res, _ := m.TestAuth(context.Background()) + if res.OK || res.Error != "401 Unauthorized" { + t.Fatalf("unexpected result: %+v", res) + } +} + +func TestMockClient_GetTicketReturnsSeeded(t *testing.T) { + m := NewMockClient() + m.AddTicket(&JiraTicket{Key: "PROJ-12", Summary: "Hello"}) + got, err := m.GetTicket(context.Background(), "PROJ-12") + if err != nil { + t.Fatalf("GetTicket: %v", err) + } + if got.Summary != "Hello" { + t.Fatalf("expected Hello, got %q", got.Summary) + } +} + +func TestMockClient_GetTicketUnknownKeyReturns404(t *testing.T) { + m := NewMockClient() + _, err := m.GetTicket(context.Background(), "NOPE-1") + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotFound { + t.Fatalf("expected APIError 404, got %v", err) + } +} + +func TestMockClient_SetGetTicketErrorOverridesLookup(t *testing.T) { + m := NewMockClient() + m.AddTicket(&JiraTicket{Key: "PROJ-1"}) + m.SetGetTicketError(&APIError{StatusCode: http.StatusUnauthorized, Message: "expired"}) + _, err := m.GetTicket(context.Background(), "PROJ-1") + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected forced 401, got %v", err) + } +} + +func TestMockClient_DoTransitionRecordsCall(t *testing.T) { + m := NewMockClient() + if err := m.DoTransition(context.Background(), "PROJ-1", "31"); err != nil { + t.Fatalf("DoTransition: %v", err) + } + calls := m.TransitionCalls() + if len(calls) != 1 || calls[0].TicketKey != "PROJ-1" || calls[0].TransitionID != "31" { + t.Fatalf("unexpected calls: %+v", calls) + } +} + +func TestMockClient_ResetClearsState(t *testing.T) { + m := NewMockClient() + m.AddTicket(&JiraTicket{Key: "X-1"}) + m.SetAuthResult(&TestConnectionResult{OK: false, Error: "boom"}) + m.Reset() + res, _ := m.TestAuth(context.Background()) + if !res.OK { + t.Fatalf("Reset did not restore default auth result: %+v", res) + } + if _, err := m.GetTicket(context.Background(), "X-1"); err == nil { + t.Fatalf("Reset did not clear tickets") + } +} diff --git a/apps/backend/internal/jira/mock_controller.go b/apps/backend/internal/jira/mock_controller.go new file mode 100644 index 0000000000..1f452104bc --- /dev/null +++ b/apps/backend/internal/jira/mock_controller.go @@ -0,0 +1,140 @@ +package jira + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" +) + +// MockController exposes HTTP endpoints that drive the in-memory MockClient +// during E2E tests. Mounted by RegisterMockRoutes only when the service was +// built with a MockClient — production builds never see these routes. +type MockController struct { + mock *MockClient + store *Store + log *logger.Logger +} + +// NewMockController wires the controller to the shared mock + store so that +// auth-health writes (which the real poller does asynchronously) can be +// triggered synchronously from tests. +func NewMockController(mock *MockClient, store *Store, log *logger.Logger) *MockController { + return &MockController{mock: mock, store: store, log: log} +} + +// RegisterRoutes mounts the mock control routes under /api/v1/jira/mock. +func (c *MockController) RegisterRoutes(router *gin.Engine) { + api := router.Group("/api/v1/jira/mock") + api.PUT("/auth-result", c.setAuthResult) + api.PUT("/auth-health", c.setAuthHealth) + api.POST("/projects", c.setProjects) + api.POST("/tickets", c.addTickets) + api.POST("/transitions", c.addTransitions) + api.POST("/search-hits", c.setSearchHits) + api.PUT("/get-ticket-error", c.setGetTicketError) + api.DELETE("/reset", c.reset) +} + +func (c *MockController) setAuthResult(ctx *gin.Context) { + var req TestConnectionResult + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + r := req + c.mock.SetAuthResult(&r) + ctx.JSON(http.StatusOK, gin.H{"set": true}) +} + +func (c *MockController) setAuthHealth(ctx *gin.Context) { + var req struct { + WorkspaceID string `json:"workspaceId"` + OK bool `json:"ok"` + Error string `json:"error"` + } + if err := ctx.ShouldBindJSON(&req); err != nil || req.WorkspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspaceId required"}) + return + } + if err := c.store.UpdateAuthHealth(ctx.Request.Context(), req.WorkspaceID, req.OK, req.Error, time.Now().UTC()); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"set": true}) +} + +func (c *MockController) setProjects(ctx *gin.Context) { + var req struct { + Projects []JiraProject `json:"projects"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.SetProjects(req.Projects) + ctx.JSON(http.StatusOK, gin.H{"count": len(req.Projects)}) +} + +func (c *MockController) addTickets(ctx *gin.Context) { + var req struct { + Tickets []JiraTicket `json:"tickets"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + for i := range req.Tickets { + c.mock.AddTicket(&req.Tickets[i]) + } + ctx.JSON(http.StatusOK, gin.H{"added": len(req.Tickets)}) +} + +func (c *MockController) addTransitions(ctx *gin.Context) { + var req struct { + TicketKey string `json:"ticketKey"` + Transitions []JiraTransition `json:"transitions"` + } + if err := ctx.ShouldBindJSON(&req); err != nil || req.TicketKey == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "ticketKey required"}) + return + } + c.mock.AddTransitions(req.TicketKey, req.Transitions) + ctx.JSON(http.StatusOK, gin.H{"added": len(req.Transitions)}) +} + +func (c *MockController) setSearchHits(ctx *gin.Context) { + var req struct { + Tickets []JiraTicket `json:"tickets"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.SetSearchHits(req.Tickets) + ctx.JSON(http.StatusOK, gin.H{"count": len(req.Tickets)}) +} + +func (c *MockController) setGetTicketError(ctx *gin.Context) { + var req struct { + StatusCode int `json:"statusCode"` + Message string `json:"message"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if req.StatusCode == 0 { + c.mock.SetGetTicketError(nil) + } else { + c.mock.SetGetTicketError(&APIError{StatusCode: req.StatusCode, Message: req.Message}) + } + ctx.JSON(http.StatusOK, gin.H{"set": true}) +} + +func (c *MockController) reset(ctx *gin.Context) { + c.mock.Reset() + ctx.JSON(http.StatusOK, gin.H{"reset": true}) +} diff --git a/apps/backend/internal/jira/provider.go b/apps/backend/internal/jira/provider.go index 7daf55faac..74447aae05 100644 --- a/apps/backend/internal/jira/provider.go +++ b/apps/backend/internal/jira/provider.go @@ -1,26 +1,62 @@ package jira import ( + "os" + + "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" ) +// mockEnvVar gates the in-memory mock client used in E2E tests. Production +// builds never set this — the real CloudClient hits Atlassian. +const mockEnvVar = "KANDEV_MOCK_JIRA" + +// MockEnabled reports whether KANDEV_MOCK_JIRA is set to "true". Exposed so +// route registration can branch on the same signal Provide uses. +func MockEnabled() bool { + return os.Getenv(mockEnvVar) == "true" +} + // Provide builds the Jira service. eventBus may be nil — used in tests and // during early boot before the bus is ready; the service falls back to a // no-op publish path. Cleanup is a no-op today — the service holds only // in-memory client caches — but the signature mirrors other integration // providers so callers can register it uniformly. +// +// When KANDEV_MOCK_JIRA=true, the service is wired to a process-wide +// MockClient and the same instance is exposed via Service.MockClient() so the +// E2E mock controller can drive it. func Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error) { store, err := NewStore(writer, reader) if err != nil { return nil, nil, err } - svc := NewService(store, secrets, DefaultClientFactory, log) + clientFn := DefaultClientFactory + var mock *MockClient + if MockEnabled() { + mock = NewMockClient() + clientFn = MockClientFactory(mock) + log.Info("jira: using in-memory mock client (KANDEV_MOCK_JIRA=true)") + } + svc := NewService(store, secrets, clientFn, log) + svc.mockClient = mock if eventBus != nil { svc.SetEventBus(eventBus) } cleanup := func() error { return nil } return svc, cleanup, nil } + +// RegisterMockRoutes mounts the mock control routes when the service was built +// with a MockClient. No-op otherwise — production builds skip this entirely. +func RegisterMockRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { + mock := svc.MockClient() + if mock == nil { + return + } + NewMockController(mock, svc.Store(), log).RegisterRoutes(router) + log.Info("registered Jira mock control endpoints") +} diff --git a/apps/backend/internal/jira/service.go b/apps/backend/internal/jira/service.go index b33769f0b4..e54ce1431a 100644 --- a/apps/backend/internal/jira/service.go +++ b/apps/backend/internal/jira/service.go @@ -35,6 +35,16 @@ type Service struct { cache map[string]Client // workspaceID → client, cleared on config change. probeHook func(workspaceID string) eventBus bus.EventBus + // mockClient is non-nil only when Provide built the service with a MockClient + // (KANDEV_MOCK_JIRA=true). Exposed via MockClient() so the e2e control routes + // can drive the same instance the clientFn returns. + mockClient *MockClient +} + +// MockClient returns the shared mock client when the service was built in mock +// mode, or nil for production builds. +func (s *Service) MockClient() *MockClient { + return s.mockClient } // ClientFactory builds a Client for the given config + secret. Overridable so diff --git a/apps/backend/internal/linear/mock_client.go b/apps/backend/internal/linear/mock_client.go new file mode 100644 index 0000000000..8a48322d58 --- /dev/null +++ b/apps/backend/internal/linear/mock_client.go @@ -0,0 +1,199 @@ +package linear + +import ( + "context" + "net/http" + "sync" +) + +// MockClient implements Client with in-memory data for E2E tests. Mirrors the +// Jira mock: a single shared instance per process, driven by HTTP control +// routes mounted in mock mode. Per-workspace isolation isn't needed for the +// scenarios these tests cover. +type MockClient struct { + mu sync.RWMutex + authResult *TestConnectionResult + teams []LinearTeam + statesByTeam map[string][]LinearWorkflowState + issues map[string]*LinearIssue // identifier (e.g. ENG-12) → issue + // issueOrder preserves insertion order so SearchIssues returns a stable + // sequence — Go map range is randomised, which would make any future test + // that asserts on result position intermittently flaky. + issueOrder []string + getError *APIError + setStateCalls []setStateCall +} + +type setStateCall struct { + IssueID string + StateID string +} + +// NewMockClient seeds a default-success TestAuth so a fresh config flips to +// "Authenticated" without explicit seeding. +func NewMockClient() *MockClient { + return &MockClient{ + authResult: &TestConnectionResult{ + OK: true, + UserID: "mock-user", + DisplayName: "Mock User", + Email: "mock@example.com", + OrgSlug: "mock-org", + OrgName: "Mock Org", + }, + statesByTeam: make(map[string][]LinearWorkflowState), + issues: make(map[string]*LinearIssue), + } +} + +// --- Client interface --- + +func (m *MockClient) TestAuth(context.Context) (*TestConnectionResult, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.authResult == nil { + return &TestConnectionResult{OK: false, Error: "mock: no auth result configured"}, nil + } + res := *m.authResult + return &res, nil +} + +func (m *MockClient) GetIssue(_ context.Context, identifier string) (*LinearIssue, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.getError != nil { + err := *m.getError + return nil, &err + } + i, ok := m.issues[identifier] + if !ok { + return nil, &APIError{StatusCode: http.StatusNotFound, Message: "issue not found: " + identifier} + } + cp := *i + return &cp, nil +} + +func (m *MockClient) ListStates(_ context.Context, teamKey string) ([]LinearWorkflowState, error) { + m.mu.RLock() + defer m.mu.RUnlock() + src := m.statesByTeam[teamKey] + out := make([]LinearWorkflowState, len(src)) + copy(out, src) + return out, nil +} + +func (m *MockClient) SetIssueState(_ context.Context, issueID, stateID string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.setStateCalls = append(m.setStateCalls, setStateCall{IssueID: issueID, StateID: stateID}) + return nil +} + +func (m *MockClient) ListTeams(context.Context) ([]LinearTeam, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]LinearTeam, len(m.teams)) + copy(out, m.teams) + return out, nil +} + +func (m *MockClient) SearchIssues(_ context.Context, _ SearchFilter, _ string, maxResults int) (*SearchResult, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]LinearIssue, 0, len(m.issueOrder)) + for _, id := range m.issueOrder { + issue, ok := m.issues[id] + if !ok { + continue + } + out = append(out, *issue) + if maxResults > 0 && len(out) >= maxResults { + break + } + } + return &SearchResult{Issues: out, MaxResults: maxResults, IsLast: true}, nil +} + +// --- Setters used by MockController --- + +// SetAuthResult overrides the result returned by TestAuth (and the auth-health +// probe). Pass nil to simulate an unconfigured auth state (returns OK=false); +// call Reset to restore the default success. +func (m *MockClient) SetAuthResult(r *TestConnectionResult) { + m.mu.Lock() + defer m.mu.Unlock() + m.authResult = r +} + +// SetTeams replaces the teams returned by ListTeams. +func (m *MockClient) SetTeams(teams []LinearTeam) { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]LinearTeam, len(teams)) + copy(cp, teams) + m.teams = cp +} + +// SetStates replaces the workflow states returned by ListStates for a team. +func (m *MockClient) SetStates(teamKey string, states []LinearWorkflowState) { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]LinearWorkflowState, len(states)) + copy(cp, states) + m.statesByTeam[teamKey] = cp +} + +// AddIssue inserts (or replaces) an issue keyed by its Identifier. +func (m *MockClient) AddIssue(issue *LinearIssue) { + m.mu.Lock() + defer m.mu.Unlock() + cp := *issue + if _, exists := m.issues[issue.Identifier]; !exists { + m.issueOrder = append(m.issueOrder, issue.Identifier) + } + m.issues[issue.Identifier] = &cp +} + +// SetGetIssueError forces GetIssue to return the given APIError. Pass nil to clear. +func (m *MockClient) SetGetIssueError(err *APIError) { + m.mu.Lock() + defer m.mu.Unlock() + m.getError = err +} + +// SetStateCalls returns recorded SetIssueState calls. +func (m *MockClient) SetStateCalls() []setStateCall { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]setStateCall, len(m.setStateCalls)) + copy(out, m.setStateCalls) + return out +} + +// Reset clears every seeded value back to defaults. +func (m *MockClient) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.authResult = &TestConnectionResult{ + OK: true, + UserID: "mock-user", + DisplayName: "Mock User", + Email: "mock@example.com", + OrgSlug: "mock-org", + OrgName: "Mock Org", + } + m.teams = nil + m.statesByTeam = make(map[string][]LinearWorkflowState) + m.issues = make(map[string]*LinearIssue) + m.issueOrder = nil + m.getError = nil + m.setStateCalls = nil +} + +// MockClientFactory returns a ClientFactory that always hands back the shared +// MockClient regardless of credentials. +func MockClientFactory(shared *MockClient) ClientFactory { + return func(*LinearConfig, string) Client { + return shared + } +} diff --git a/apps/backend/internal/linear/mock_client_test.go b/apps/backend/internal/linear/mock_client_test.go new file mode 100644 index 0000000000..11ad55817d --- /dev/null +++ b/apps/backend/internal/linear/mock_client_test.go @@ -0,0 +1,97 @@ +package linear + +import ( + "context" + "errors" + "net/http" + "testing" +) + +func TestMockClient_DefaultsToSuccessfulAuth(t *testing.T) { + m := NewMockClient() + res, err := m.TestAuth(context.Background()) + if err != nil { + t.Fatalf("TestAuth: %v", err) + } + if !res.OK { + t.Fatalf("expected OK=true by default, got %+v", res) + } +} + +func TestMockClient_SetAuthResultIsReturned(t *testing.T) { + m := NewMockClient() + m.SetAuthResult(&TestConnectionResult{OK: false, Error: "401 Unauthorized"}) + res, _ := m.TestAuth(context.Background()) + if res.OK || res.Error != "401 Unauthorized" { + t.Fatalf("unexpected result: %+v", res) + } +} + +func TestMockClient_TeamsAndStatesAreSeparate(t *testing.T) { + m := NewMockClient() + m.SetTeams([]LinearTeam{{ID: "t1", Key: "ENG", Name: "Engineering"}}) + m.SetStates("ENG", []LinearWorkflowState{{ID: "s1", Name: "Todo", Type: "unstarted"}}) + teams, _ := m.ListTeams(context.Background()) + if len(teams) != 1 || teams[0].Key != "ENG" { + t.Fatalf("teams unexpected: %+v", teams) + } + states, _ := m.ListStates(context.Background(), "ENG") + if len(states) != 1 || states[0].Name != "Todo" { + t.Fatalf("states unexpected: %+v", states) + } + other, _ := m.ListStates(context.Background(), "DESIGN") + if len(other) != 0 { + t.Fatalf("states for unknown team should be empty, got %+v", other) + } +} + +func TestMockClient_GetIssueRoundtrip(t *testing.T) { + m := NewMockClient() + m.AddIssue(&LinearIssue{Identifier: "ENG-12", Title: "Test issue"}) + got, err := m.GetIssue(context.Background(), "ENG-12") + if err != nil { + t.Fatalf("GetIssue: %v", err) + } + if got.Title != "Test issue" { + t.Fatalf("unexpected title %q", got.Title) + } +} + +func TestMockClient_GetIssueUnknownReturns404(t *testing.T) { + m := NewMockClient() + _, err := m.GetIssue(context.Background(), "NOPE-1") + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %v", err) + } +} + +func TestMockClient_SetIssueStateRecordsCall(t *testing.T) { + m := NewMockClient() + if err := m.SetIssueState(context.Background(), "issue-id", "state-id"); err != nil { + t.Fatalf("SetIssueState: %v", err) + } + calls := m.SetStateCalls() + if len(calls) != 1 || calls[0].IssueID != "issue-id" || calls[0].StateID != "state-id" { + t.Fatalf("unexpected calls: %+v", calls) + } +} + +func TestMockClient_ResetClearsState(t *testing.T) { + m := NewMockClient() + m.AddIssue(&LinearIssue{Identifier: "X-1"}) + m.SetTeams([]LinearTeam{{Key: "X"}}) + m.SetAuthResult(&TestConnectionResult{OK: false, Error: "boom"}) + m.Reset() + res, _ := m.TestAuth(context.Background()) + if !res.OK { + t.Fatalf("Reset did not restore default auth result: %+v", res) + } + teams, _ := m.ListTeams(context.Background()) + if len(teams) != 0 { + t.Fatalf("Reset did not clear teams") + } + if _, err := m.GetIssue(context.Background(), "X-1"); err == nil { + t.Fatalf("Reset did not clear issues") + } +} diff --git a/apps/backend/internal/linear/mock_controller.go b/apps/backend/internal/linear/mock_controller.go new file mode 100644 index 0000000000..1d59e1392f --- /dev/null +++ b/apps/backend/internal/linear/mock_controller.go @@ -0,0 +1,127 @@ +package linear + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" +) + +// MockController exposes HTTP endpoints that drive the in-memory MockClient +// during E2E tests. Mounted by RegisterMockRoutes only when the service was +// built with a MockClient. +type MockController struct { + mock *MockClient + store *Store + log *logger.Logger +} + +// NewMockController wires the controller to the mock + store so auth-health +// writes can be triggered synchronously from tests. +func NewMockController(mock *MockClient, store *Store, log *logger.Logger) *MockController { + return &MockController{mock: mock, store: store, log: log} +} + +// RegisterRoutes mounts the mock control routes under /api/v1/linear/mock. +func (c *MockController) RegisterRoutes(router *gin.Engine) { + api := router.Group("/api/v1/linear/mock") + api.PUT("/auth-result", c.setAuthResult) + api.PUT("/auth-health", c.setAuthHealth) + api.POST("/teams", c.setTeams) + api.POST("/states", c.setStates) + api.POST("/issues", c.addIssues) + api.PUT("/get-issue-error", c.setGetIssueError) + api.DELETE("/reset", c.reset) +} + +func (c *MockController) setAuthResult(ctx *gin.Context) { + var req TestConnectionResult + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + r := req + c.mock.SetAuthResult(&r) + ctx.JSON(http.StatusOK, gin.H{"set": true}) +} + +func (c *MockController) setAuthHealth(ctx *gin.Context) { + var req struct { + WorkspaceID string `json:"workspaceId"` + OK bool `json:"ok"` + Error string `json:"error"` + OrgSlug string `json:"orgSlug"` + } + if err := ctx.ShouldBindJSON(&req); err != nil || req.WorkspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspaceId required"}) + return + } + if err := c.store.UpdateAuthHealth(ctx.Request.Context(), req.WorkspaceID, req.OK, req.Error, req.OrgSlug, time.Now().UTC()); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"set": true}) +} + +func (c *MockController) setTeams(ctx *gin.Context) { + var req struct { + Teams []LinearTeam `json:"teams"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.SetTeams(req.Teams) + ctx.JSON(http.StatusOK, gin.H{"count": len(req.Teams)}) +} + +func (c *MockController) setStates(ctx *gin.Context) { + var req struct { + TeamKey string `json:"teamKey"` + States []LinearWorkflowState `json:"states"` + } + if err := ctx.ShouldBindJSON(&req); err != nil || req.TeamKey == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "teamKey required"}) + return + } + c.mock.SetStates(req.TeamKey, req.States) + ctx.JSON(http.StatusOK, gin.H{"count": len(req.States)}) +} + +func (c *MockController) addIssues(ctx *gin.Context) { + var req struct { + Issues []LinearIssue `json:"issues"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + for i := range req.Issues { + c.mock.AddIssue(&req.Issues[i]) + } + ctx.JSON(http.StatusOK, gin.H{"added": len(req.Issues)}) +} + +func (c *MockController) setGetIssueError(ctx *gin.Context) { + var req struct { + StatusCode int `json:"statusCode"` + Message string `json:"message"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if req.StatusCode == 0 { + c.mock.SetGetIssueError(nil) + } else { + c.mock.SetGetIssueError(&APIError{StatusCode: req.StatusCode, Message: req.Message}) + } + ctx.JSON(http.StatusOK, gin.H{"set": true}) +} + +func (c *MockController) reset(ctx *gin.Context) { + c.mock.Reset() + ctx.JSON(http.StatusOK, gin.H{"reset": true}) +} diff --git a/apps/backend/internal/linear/provider.go b/apps/backend/internal/linear/provider.go index 70f24485b0..e9b091ca98 100644 --- a/apps/backend/internal/linear/provider.go +++ b/apps/backend/internal/linear/provider.go @@ -1,20 +1,54 @@ package linear import ( + "os" + + "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/common/logger" ) +// mockEnvVar gates the in-memory mock client used in E2E tests. +const mockEnvVar = "KANDEV_MOCK_LINEAR" + +// MockEnabled reports whether KANDEV_MOCK_LINEAR is set to "true". +func MockEnabled() bool { + return os.Getenv(mockEnvVar) == "true" +} + // Provide builds the Linear service. Cleanup is a no-op today — the service // holds only in-memory client caches — but the signature mirrors other // integration providers so callers can register it uniformly. +// +// When KANDEV_MOCK_LINEAR=true, the service is wired to a process-wide +// MockClient and the same instance is exposed via Service.MockClient() so the +// E2E mock controller can drive it. func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error) { store, err := NewStore(writer, reader) if err != nil { return nil, nil, err } - svc := NewService(store, secrets, DefaultClientFactory, log) + clientFn := DefaultClientFactory + var mock *MockClient + if MockEnabled() { + mock = NewMockClient() + clientFn = MockClientFactory(mock) + log.Info("linear: using in-memory mock client (KANDEV_MOCK_LINEAR=true)") + } + svc := NewService(store, secrets, clientFn, log) + svc.mockClient = mock cleanup := func() error { return nil } return svc, cleanup, nil } + +// RegisterMockRoutes mounts the mock control routes when the service was built +// with a MockClient. No-op otherwise. +func RegisterMockRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { + mock := svc.MockClient() + if mock == nil { + return + } + NewMockController(mock, svc.Store(), log).RegisterRoutes(router) + log.Info("registered Linear mock control endpoints") +} diff --git a/apps/backend/internal/linear/service.go b/apps/backend/internal/linear/service.go index 0dbe677d27..31b2ef960b 100644 --- a/apps/backend/internal/linear/service.go +++ b/apps/backend/internal/linear/service.go @@ -30,6 +30,16 @@ type Service struct { clientFn ClientFactory cache map[string]Client // workspaceID → client, cleared on config change. probeHook func(workspaceID string) + // mockClient is non-nil only when Provide built the service with a MockClient + // (KANDEV_MOCK_LINEAR=true). Exposed via MockClient() so the e2e control + // routes can drive the same instance the clientFn returns. + mockClient *MockClient +} + +// MockClient returns the shared mock client when the service was built in mock +// mode, or nil for production builds. +func (s *Service) MockClient() *MockClient { + return s.mockClient } // ClientFactory builds a Client for the given config + secret. Overridable so diff --git a/apps/web/components/integrations/auth-status-banner.tsx b/apps/web/components/integrations/auth-status-banner.tsx index e5c3ac797a..c642fd1bd8 100644 --- a/apps/web/components/integrations/auth-status-banner.tsx +++ b/apps/web/components/integrations/auth-status-banner.tsx @@ -42,7 +42,7 @@ export function IntegrationAuthStatusBanner({ health }: { health: IntegrationAut if (!health) return null; if (!health.checkedAt) { return ( - + Waiting for the next backend health check… @@ -51,7 +51,11 @@ export function IntegrationAuthStatusBanner({ health }: { health: IntegrationAut } if (health.ok) { return ( - + Authenticated @@ -61,7 +65,7 @@ export function IntegrationAuthStatusBanner({ health }: { health: IntegrationAut ); } return ( - + Authentication failed: {health.error || "unknown error"} diff --git a/apps/web/components/integrations/validated-popover.tsx b/apps/web/components/integrations/validated-popover.tsx index afaf95b5f0..5ffc5c569b 100644 --- a/apps/web/components/integrations/validated-popover.tsx +++ b/apps/web/components/integrations/validated-popover.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState, type ReactNode } from "react"; +import { useCallback, useRef, useState, type ButtonHTMLAttributes, type ReactNode } from "react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; @@ -31,6 +31,10 @@ export type ValidatedPopoverProps = { triggerLabel?: string; // Required for "outline-with-label", ignored for "ghost-icon". triggerAriaLabel?: string; // Used for "ghost-icon" since there's no visible label. triggerDisabled?: boolean; + // testIdPrefix scopes the data-testid attributes on trigger / input / submit + // / error so callers can target the right popover when more than one is + // mounted on the same page (e.g. a Jira import bar next to a Linear one). + testIdPrefix?: string; tooltip: string; // PopoverContent layout. align?: "start" | "end"; @@ -50,16 +54,32 @@ export type ValidatedPopoverProps = { submittingLabel: string; // e.g. "Linking...", "Loading..." }; +type TriggerButtonProps = Pick< + ValidatedPopoverProps, + | "triggerStyle" + | "triggerIcon" + | "triggerLabel" + | "triggerAriaLabel" + | "triggerDisabled" + | "testIdPrefix" +> & + ButtonHTMLAttributes; + +// TriggerButton forwards every prop the parent injects (notably radix Slot's +// onClick / onPointerDown / aria-expanded / data-state from PopoverTrigger and +// TooltipTrigger asChild) onto the inner Button. Forgetting `...rest` here +// silently drops radix's open-toggle handler, which makes the popover +// unclickable while leaving the tooltip working — easy to miss in dev. function TriggerButton({ triggerStyle, triggerIcon, triggerLabel, triggerAriaLabel, triggerDisabled, -}: Pick< - ValidatedPopoverProps, - "triggerStyle" | "triggerIcon" | "triggerLabel" | "triggerAriaLabel" | "triggerDisabled" ->) { + testIdPrefix, + ...rest +}: TriggerButtonProps) { + const triggerTestId = testIdPrefix ? `${testIdPrefix}-trigger` : undefined; if (triggerStyle === "outline-with-label") { return ( ); } +type PopoverBodyProps = { + headline: string; + placeholder: string; + value: string; + onChange: (next: string) => void; + onSubmit: () => void | Promise; + loading: boolean; + error: string | null; + submitLabel: string; + submittingLabel: string; + testIdPrefix?: string; +}; + +function PopoverBody({ + headline, + placeholder, + value, + onChange, + onSubmit, + loading, + error, + submitLabel, + submittingLabel, + testIdPrefix, +}: PopoverBodyProps) { + return ( +
+
{headline}
+ onChange(e.target.value)} + placeholder={placeholder} + className="h-8 text-xs" + data-testid={testIdPrefix ? `${testIdPrefix}-input` : undefined} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void onSubmit(); + } + }} + /> + {error && ( +

+ {error} +

+ )} +
+ +
+
+ ); +} + export function ValidatedPopover(props: ValidatedPopoverProps) { const { tooltip, @@ -100,6 +191,7 @@ export function ValidatedPopover(props: ValidatedPopoverProps) { onSuccess, submitLabel, submittingLabel, + testIdPrefix, } = props; const [open, setOpen] = useState(false); const [value, setValue] = useState(""); @@ -150,44 +242,31 @@ export function ValidatedPopover(props: ValidatedPopoverProps) { - + {tooltip} -
-
{headline}
- setValue(e.target.value)} - placeholder={placeholder} - className="h-8 text-xs" - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - void submit(); - } - }} - /> - {error && ( -

- {error} -

- )} -
- -
-
+
); diff --git a/apps/web/components/jira/jira-import-bar.tsx b/apps/web/components/jira/jira-import-bar.tsx index 785abbe30f..ccf9cdf050 100644 --- a/apps/web/components/jira/jira-import-bar.tsx +++ b/apps/web/components/jira/jira-import-bar.tsx @@ -23,6 +23,7 @@ export function JiraImportBar({ workspaceId, disabled, onImport }: JiraImportBar triggerIcon={} triggerAriaLabel="Import from Jira" triggerDisabled={disabled} + testIdPrefix="jira-import" tooltip="Import from Jira ticket URL or key" align="start" headline="Import Jira ticket" diff --git a/apps/web/components/jira/jira-settings.tsx b/apps/web/components/jira/jira-settings.tsx index 1be7b47821..65c56fc227 100644 --- a/apps/web/components/jira/jira-settings.tsx +++ b/apps/web/components/jira/jira-settings.tsx @@ -84,6 +84,7 @@ function SiteFields({ form, loading, update }: FieldsRowProps) { update("siteUrl", e.target.value)} @@ -94,6 +95,7 @@ function SiteFields({ form, loading, update }: FieldsRowProps) { update("defaultProjectKey", e.target.value.toUpperCase())} @@ -129,6 +131,7 @@ function AuthFields({ form, loading, update }: FieldsRowProps) { {testing ? "Testing..." : "Test connection"} - {hasConfig && ( @@ -316,6 +327,7 @@ function ActionBar({ variant="destructive" onClick={onDelete} className="ml-auto cursor-pointer" + data-testid="jira-delete-button" > Remove configuration diff --git a/apps/web/components/linear/linear-import-bar.tsx b/apps/web/components/linear/linear-import-bar.tsx index bc000e672f..150cc8aaf2 100644 --- a/apps/web/components/linear/linear-import-bar.tsx +++ b/apps/web/components/linear/linear-import-bar.tsx @@ -23,6 +23,7 @@ export function LinearImportBar({ workspaceId, disabled, onImport }: LinearImpor triggerIcon={} triggerAriaLabel="Import from Linear" triggerDisabled={disabled} + testIdPrefix="linear-import" tooltip="Import from Linear issue URL or identifier" align="start" headline="Import Linear issue" diff --git a/apps/web/components/linear/linear-settings.tsx b/apps/web/components/linear/linear-settings.tsx index f163f62489..9cac181a57 100644 --- a/apps/web/components/linear/linear-settings.tsx +++ b/apps/web/components/linear/linear-settings.tsx @@ -71,6 +71,7 @@ function SecretField({ {testing ? "Testing..." : "Test connection"} - {hasConfig && ( @@ -184,6 +192,7 @@ function ActionBar({ variant="destructive" onClick={onDelete} className="ml-auto cursor-pointer" + data-testid="linear-delete-button" > Remove configuration diff --git a/apps/web/e2e/fixtures/backend.ts b/apps/web/e2e/fixtures/backend.ts index e0c97e4889..7b98a7c7da 100644 --- a/apps/web/e2e/fixtures/backend.ts +++ b/apps/web/e2e/fixtures/backend.ts @@ -242,6 +242,8 @@ exec git "$@" KANDEV_DATABASE_PATH: dbPath, KANDEV_MOCK_AGENT: "only", KANDEV_MOCK_GITHUB: "true", + KANDEV_MOCK_JIRA: "true", + KANDEV_MOCK_LINEAR: "true", KANDEV_DOCKER_ENABLED: "false", KANDEV_WORKTREE_ENABLED: "true", KANDEV_WORKTREE_BASEPATH: worktreeBase, diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index 688365bd58..add877aae6 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -20,7 +20,17 @@ export type SeedData = { }; export const test = backendFixture.extend< - { testPage: Page; prCapture: PrAssetCapture }, + { + testPage: Page; + prCapture: PrAssetCapture; + /** + * Auto fixture that resets integration mock state and any persisted + * Jira/Linear configs at the top of every test. Auto fixtures run + * automatically — unlike a top-level `test.beforeEach` registered in this + * module, which Playwright only fires for tests defined in the same file. + */ + integrationCleanup: void; + }, { apiClient: ApiClient; seedData: SeedData } >({ // Worker-scoped API client @@ -116,6 +126,23 @@ export const test = backendFixture.extend< await use(capture); capture.flush(); }, + + integrationCleanup: [ + async ({ apiClient, seedData }, use) => { + await apiClient + .rawRequest("DELETE", `/api/v1/jira/config?workspace_id=${seedData.workspaceId}`) + .catch(() => undefined); + await apiClient + .rawRequest("DELETE", `/api/v1/linear/config?workspace_id=${seedData.workspaceId}`) + .catch(() => undefined); + await Promise.all([ + apiClient.mockJiraReset().catch(() => undefined), + apiClient.mockLinearReset().catch(() => undefined), + ]); + await use(); + }, + { auto: true }, + ], }); export { expect } from "@playwright/test"; diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 9b3c9a35a5..b9fd14d4d2 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -791,4 +791,201 @@ export class ApiClient { async triggerReviewWatch(watchId: string): Promise<{ new_prs: number; cleaned?: number }> { return this.request("POST", `/api/v1/github/watches/review/${watchId}/trigger`, undefined); } + + // --- Integration config seeding (real API, not mock) --- + + async setJiraConfig(payload: { + workspaceId: string; + siteUrl: string; + email: string; + authMethod?: "api_token" | "session_cookie"; + defaultProjectKey?: string; + secret: string; + }): Promise { + return this.request("POST", "/api/v1/jira/config", { + ...payload, + authMethod: payload.authMethod ?? "api_token", + }); + } + + async setLinearConfig(payload: { + workspaceId: string; + secret: string; + defaultTeamKey?: string; + }): Promise { + return this.request("POST", "/api/v1/linear/config", { + defaultTeamKey: "", + ...payload, + authMethod: "api_key", + }); + } + + /** + * Poll until the integration config reports `lastOk: true`. SetConfig kicks + * off an async auth-health probe in a goroutine, so the row's `lastOk` flips + * shortly after — but with no synchronous signal. Tests that gate UI on + * `useJiraAvailable` / `useLinearAvailable` need to await this before + * navigating, otherwise the import bar can race the first render. + */ + async waitForIntegrationAuthHealthy( + integration: "jira" | "linear", + workspaceId: string, + timeoutMs = 5_000, + ): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const res = await this.rawRequest( + "GET", + `/api/v1/${integration}/config?workspace_id=${workspaceId}`, + ); + if (res.ok && res.status === 200) { + const cfg = (await res.json()) as { hasSecret?: boolean; lastOk?: boolean }; + if (cfg.hasSecret && cfg.lastOk) return; + } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`${integration} config never reported lastOk: true within ${timeoutMs}ms`); + } + + // --- Jira Mock Control --- + + async mockJiraReset(): Promise { + await this.request("DELETE", "/api/v1/jira/mock/reset"); + } + + async mockJiraSetAuthResult(result: { + ok: boolean; + accountId?: string; + displayName?: string; + email?: string; + error?: string; + }): Promise { + await this.request("PUT", "/api/v1/jira/mock/auth-result", result); + } + + async mockJiraSetAuthHealth(args: { + workspaceId: string; + ok: boolean; + error?: string; + }): Promise { + await this.request("PUT", "/api/v1/jira/mock/auth-health", args); + } + + async mockJiraSetProjects(projects: MockJiraProject[]): Promise { + await this.request("POST", "/api/v1/jira/mock/projects", { projects }); + } + + async mockJiraAddTickets(tickets: MockJiraTicket[]): Promise { + await this.request("POST", "/api/v1/jira/mock/tickets", { tickets }); + } + + async mockJiraAddTransitions( + ticketKey: string, + transitions: MockJiraTransition[], + ): Promise { + await this.request("POST", "/api/v1/jira/mock/transitions", { + ticketKey, + transitions, + }); + } + + async mockJiraSetSearchHits(tickets: MockJiraTicket[]): Promise { + await this.request("POST", "/api/v1/jira/mock/search-hits", { tickets }); + } + + async mockJiraSetGetTicketError(args: { statusCode: number; message: string }): Promise { + await this.request("PUT", "/api/v1/jira/mock/get-ticket-error", args); + } + + // --- Linear Mock Control --- + + async mockLinearReset(): Promise { + await this.request("DELETE", "/api/v1/linear/mock/reset"); + } + + async mockLinearSetAuthResult(result: { + ok: boolean; + userId?: string; + displayName?: string; + email?: string; + orgSlug?: string; + orgName?: string; + error?: string; + }): Promise { + await this.request("PUT", "/api/v1/linear/mock/auth-result", result); + } + + async mockLinearSetAuthHealth(args: { + workspaceId: string; + ok: boolean; + error?: string; + orgSlug?: string; + }): Promise { + await this.request("PUT", "/api/v1/linear/mock/auth-health", args); + } + + async mockLinearSetTeams(teams: MockLinearTeam[]): Promise { + await this.request("POST", "/api/v1/linear/mock/teams", { teams }); + } + + async mockLinearSetStates(teamKey: string, states: MockLinearState[]): Promise { + await this.request("POST", "/api/v1/linear/mock/states", { teamKey, states }); + } + + async mockLinearAddIssues(issues: MockLinearIssue[]): Promise { + await this.request("POST", "/api/v1/linear/mock/issues", { issues }); + } + + async mockLinearSetGetIssueError(args: { statusCode: number; message: string }): Promise { + await this.request("PUT", "/api/v1/linear/mock/get-issue-error", args); + } } + +// --- Jira / Linear mock payload types --- + +export type MockJiraProject = { id: string; key: string; name: string }; + +export type MockJiraTransition = { + id: string; + name: string; + toStatusId: string; + toStatusName: string; +}; + +export type MockJiraTicket = { + key: string; + summary?: string; + description?: string; + statusId?: string; + statusName?: string; + statusCategory?: string; + projectKey?: string; + issueType?: string; + url?: string; + transitions?: MockJiraTransition[]; +}; + +export type MockLinearTeam = { id: string; key: string; name: string }; + +export type MockLinearState = { + id: string; + name: string; + type: string; + color?: string; + position?: number; +}; + +export type MockLinearIssue = { + id: string; + identifier: string; + title?: string; + description?: string; + stateId?: string; + stateName?: string; + stateType?: string; + stateCategory?: string; + teamId?: string; + teamKey?: string; + priority?: number; + url?: string; +}; diff --git a/apps/web/e2e/pages/jira-settings-page.ts b/apps/web/e2e/pages/jira-settings-page.ts new file mode 100644 index 0000000000..7d84a4f2ea --- /dev/null +++ b/apps/web/e2e/pages/jira-settings-page.ts @@ -0,0 +1,35 @@ +import { type Locator, type Page } from "@playwright/test"; + +export class JiraSettingsPage { + readonly siteInput: Locator; + readonly projectInput: Locator; + readonly emailInput: Locator; + readonly secretInput: Locator; + readonly testButton: Locator; + readonly saveButton: Locator; + readonly deleteButton: Locator; + readonly statusBanner: Locator; + + constructor(private page: Page) { + this.siteInput = page.getByTestId("jira-site-input"); + this.projectInput = page.getByTestId("jira-project-input"); + this.emailInput = page.getByTestId("jira-email-input"); + this.secretInput = page.getByTestId("jira-secret-input"); + this.testButton = page.getByTestId("jira-test-button"); + this.saveButton = page.getByTestId("jira-save-button"); + this.deleteButton = page.getByTestId("jira-delete-button"); + this.statusBanner = page.getByTestId("integration-auth-status-banner"); + } + + async goto(workspaceId: string) { + await this.page.goto(`/settings/workspace/${workspaceId}/jira`); + await this.siteInput.waitFor({ state: "visible" }); + } + + async fillForm(args: { siteUrl: string; email?: string; secret: string; projectKey?: string }) { + await this.siteInput.fill(args.siteUrl); + if (args.email !== undefined) await this.emailInput.fill(args.email); + await this.secretInput.fill(args.secret); + if (args.projectKey) await this.projectInput.fill(args.projectKey); + } +} diff --git a/apps/web/e2e/pages/linear-settings-page.ts b/apps/web/e2e/pages/linear-settings-page.ts new file mode 100644 index 0000000000..a4dcf0323d --- /dev/null +++ b/apps/web/e2e/pages/linear-settings-page.ts @@ -0,0 +1,22 @@ +import { type Locator, type Page } from "@playwright/test"; + +export class LinearSettingsPage { + readonly secretInput: Locator; + readonly testButton: Locator; + readonly saveButton: Locator; + readonly deleteButton: Locator; + readonly statusBanner: Locator; + + constructor(private page: Page) { + this.secretInput = page.getByTestId("linear-secret-input"); + this.testButton = page.getByTestId("linear-test-button"); + this.saveButton = page.getByTestId("linear-save-button"); + this.deleteButton = page.getByTestId("linear-delete-button"); + this.statusBanner = page.getByTestId("integration-auth-status-banner"); + } + + async goto(workspaceId: string) { + await this.page.goto(`/settings/workspace/${workspaceId}/linear`); + await this.secretInput.waitFor({ state: "visible" }); + } +} diff --git a/apps/web/e2e/tests/integrations/jira-import.spec.ts b/apps/web/e2e/tests/integrations/jira-import.spec.ts new file mode 100644 index 0000000000..21727dfb5e --- /dev/null +++ b/apps/web/e2e/tests/integrations/jira-import.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; + +test.describe("Jira import bar", () => { + test.beforeEach(async ({ apiClient, seedData }) => { + await apiClient.setJiraConfig({ + workspaceId: seedData.workspaceId, + siteUrl: "https://acme.atlassian.net", + email: "alice@example.com", + secret: "api-token-value", + }); + await apiClient.waitForIntegrationAuthHealthy("jira", seedData.workspaceId); + }); + + test("pasting a known ticket key fills the title and description", async ({ + testPage, + apiClient, + }) => { + await apiClient.mockJiraAddTickets([ + { + key: "PROJ-12", + summary: "Fix login redirect", + description: "Body text from Jira", + url: "https://acme.atlassian.net/browse/PROJ-12", + }, + ]); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + await testPage.getByTestId("jira-import-trigger").click(); + await testPage.getByTestId("jira-import-input").fill("PROJ-12"); + await testPage.getByTestId("jira-import-submit").click(); + + await expect(testPage.getByTestId("task-title-input")).toHaveValue( + "[PROJ-12] Fix login redirect", + ); + await expect(testPage.getByTestId("task-description-input")).toContainText( + "Body text from Jira", + ); + }); + + test("invalid input keeps submit disabled and shows the validation hint", async ({ + testPage, + }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await testPage.getByTestId("jira-import-trigger").click(); + + const submit = testPage.getByTestId("jira-import-submit"); + await expect(submit).toBeDisabled(); + + // Garbage that doesn't match the JIRA_KEY_RE → hint shows on submit. + await testPage.getByTestId("jira-import-input").fill("not-a-key"); + await expect(submit).toBeEnabled(); + await submit.click(); + await expect(testPage.getByTestId("jira-import-error")).toContainText( + /Paste a Jira ticket URL or key/i, + ); + }); + + test("unknown key surfaces the backend error inline", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await testPage.getByTestId("jira-import-trigger").click(); + + await testPage.getByTestId("jira-import-input").fill("MISSING-99"); + await testPage.getByTestId("jira-import-submit").click(); + await expect(testPage.getByTestId("jira-import-error")).toContainText(/MISSING-99/); + }); +}); diff --git a/apps/web/e2e/tests/integrations/jira-settings.spec.ts b/apps/web/e2e/tests/integrations/jira-settings.spec.ts new file mode 100644 index 0000000000..96ca030e8b --- /dev/null +++ b/apps/web/e2e/tests/integrations/jira-settings.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from "../../fixtures/test-base"; +import { JiraSettingsPage } from "../../pages/jira-settings-page"; + +test.describe("Jira settings", () => { + test("empty workspace shows form with disabled save/test until secret is filled", async ({ + testPage, + seedData, + }) => { + const settings = new JiraSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + + await expect(settings.siteInput).toHaveValue(""); + await expect(settings.secretInput).toHaveValue(""); + await expect(settings.statusBanner).toHaveCount(0); + await expect(settings.saveButton).toBeDisabled(); + await expect(settings.testButton).toBeDisabled(); + + await settings.siteInput.fill("https://acme.atlassian.net"); + await settings.emailInput.fill("alice@example.com"); + await expect(settings.saveButton).toBeDisabled(); + + await settings.secretInput.fill("api-token-value"); + await expect(settings.saveButton).toBeEnabled(); + await expect(settings.testButton).toBeEnabled(); + }); + + test("saving the config persists across reload and shows the auth banner", async ({ + testPage, + seedData, + apiClient, + }) => { + const settings = new JiraSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + + await settings.fillForm({ + siteUrl: "https://acme.atlassian.net", + email: "alice@example.com", + secret: "api-token-value", + projectKey: "PROJ", + }); + await settings.saveButton.click(); + + // After save the button label flips from "Save" to "Update" (config exists). + await expect(settings.saveButton).toHaveText(/Update/i); + // The post-save probe runs async; await it before reloading so the new + // banner state is in the DB by the time the page re-fetches the config. + await apiClient.waitForIntegrationAuthHealthy("jira", seedData.workspaceId); + + await testPage.reload(); + await settings.siteInput.waitFor(); + await expect(settings.siteInput).toHaveValue("https://acme.atlassian.net"); + await expect(settings.emailInput).toHaveValue("alice@example.com"); + await expect(settings.projectInput).toHaveValue("PROJ"); + await expect(settings.statusBanner).toHaveAttribute("data-state", "ok"); + }); + + test("test connection surfaces inline success and failure", async ({ + testPage, + seedData, + apiClient, + }) => { + const settings = new JiraSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + + await apiClient.mockJiraSetAuthResult({ + ok: true, + displayName: "Alice from Jira", + email: "alice@example.com", + }); + await settings.fillForm({ + siteUrl: "https://acme.atlassian.net", + email: "alice@example.com", + secret: "api-token-value", + }); + await settings.testButton.click(); + await expect(testPage.getByText(/Connected as Alice from Jira/i)).toBeVisible(); + + await apiClient.mockJiraSetAuthResult({ ok: false, error: "401 Unauthorized" }); + await settings.testButton.click(); + await expect(testPage.getByText(/Failed: 401 Unauthorized/)).toBeVisible(); + }); + + test("seeded auth-health failure renders the failed banner on load", async ({ + testPage, + seedData, + apiClient, + }) => { + const settings = new JiraSettingsPage(testPage); + // Save first so a config row exists, then simulate the poller writing + // a failure status onto it. + await settings.goto(seedData.workspaceId); + await settings.fillForm({ + siteUrl: "https://acme.atlassian.net", + email: "alice@example.com", + secret: "api-token-value", + }); + await settings.saveButton.click(); + // Wait for the post-save probe to land BEFORE forcing the failure: the + // probe goroutine could otherwise overwrite our forced lastOk=false back + // to true a few ms after the mockJiraSetAuthHealth call, flipping the + // banner to "ok" right when the assertion expects "failed". + await apiClient.waitForIntegrationAuthHealthy("jira", seedData.workspaceId); + + await apiClient.mockJiraSetAuthHealth({ + workspaceId: seedData.workspaceId, + ok: false, + error: "session expired", + }); + await testPage.reload(); + await settings.statusBanner.waitFor(); + await expect(settings.statusBanner).toHaveAttribute("data-state", "failed"); + await expect(settings.statusBanner).toContainText(/session expired/i); + }); + + test("delete clears the saved configuration", async ({ testPage, seedData }) => { + const settings = new JiraSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + await settings.fillForm({ + siteUrl: "https://acme.atlassian.net", + email: "alice@example.com", + secret: "api-token-value", + }); + await settings.saveButton.click(); + await expect(settings.deleteButton).toBeVisible(); + + // confirm() is a native dialog — auto-accept so the click proceeds. + testPage.once("dialog", (d) => void d.accept()); + await settings.deleteButton.click(); + await expect(settings.deleteButton).toHaveCount(0); + await expect(settings.siteInput).toHaveValue(""); + await expect(settings.secretInput).toHaveValue(""); + await expect(settings.statusBanner).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/tests/integrations/linear-import.spec.ts b/apps/web/e2e/tests/integrations/linear-import.spec.ts new file mode 100644 index 0000000000..581d39cc98 --- /dev/null +++ b/apps/web/e2e/tests/integrations/linear-import.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; + +test.describe("Linear import bar", () => { + test.beforeEach(async ({ apiClient, seedData }) => { + await apiClient.setLinearConfig({ + workspaceId: seedData.workspaceId, + secret: "lin_api_xxx", + }); + await apiClient.waitForIntegrationAuthHealthy("linear", seedData.workspaceId); + }); + + test("pasting a known identifier fills the title and description", async ({ + testPage, + apiClient, + }) => { + await apiClient.mockLinearAddIssues([ + { + id: "issue-1", + identifier: "ENG-12", + title: "Add billing endpoint", + description: "Spec lives at notion://...", + url: "https://linear.app/mock-org/issue/ENG-12", + }, + ]); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + await testPage.getByTestId("linear-import-trigger").click(); + await testPage.getByTestId("linear-import-input").fill("ENG-12"); + await testPage.getByTestId("linear-import-submit").click(); + + await expect(testPage.getByTestId("task-title-input")).toHaveValue( + "[ENG-12] Add billing endpoint", + ); + await expect(testPage.getByTestId("task-description-input")).toContainText( + "Spec lives at notion://", + ); + }); + + test("invalid input shows the validation hint", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await testPage.getByTestId("linear-import-trigger").click(); + + const submit = testPage.getByTestId("linear-import-submit"); + await expect(submit).toBeDisabled(); + + await testPage.getByTestId("linear-import-input").fill("garbage"); + await submit.click(); + await expect(testPage.getByTestId("linear-import-error")).toContainText( + /Paste a Linear issue URL or identifier/i, + ); + }); + + test("unknown identifier surfaces the backend error inline", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await testPage.getByTestId("linear-import-trigger").click(); + + await testPage.getByTestId("linear-import-input").fill("NOPE-99"); + await testPage.getByTestId("linear-import-submit").click(); + await expect(testPage.getByTestId("linear-import-error")).toContainText(/NOPE-99/); + }); +}); diff --git a/apps/web/e2e/tests/integrations/linear-settings.spec.ts b/apps/web/e2e/tests/integrations/linear-settings.spec.ts new file mode 100644 index 0000000000..f13b29dcf2 --- /dev/null +++ b/apps/web/e2e/tests/integrations/linear-settings.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from "../../fixtures/test-base"; +import { LinearSettingsPage } from "../../pages/linear-settings-page"; + +test.describe("Linear settings", () => { + test("empty workspace shows form with disabled save/test until secret is filled", async ({ + testPage, + seedData, + }) => { + const settings = new LinearSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + + await expect(settings.secretInput).toHaveValue(""); + await expect(settings.statusBanner).toHaveCount(0); + await expect(settings.saveButton).toBeDisabled(); + await expect(settings.testButton).toBeDisabled(); + + await settings.secretInput.fill("lin_api_xxx"); + await expect(settings.saveButton).toBeEnabled(); + await expect(settings.testButton).toBeEnabled(); + }); + + test("saving the config persists across reload and shows the auth banner", async ({ + testPage, + seedData, + apiClient, + }) => { + // Seed a single team so the dropdown post-save can populate without an + // empty-list flash. The default-team field is optional, but the team + // fetch fires when hasSecret flips true and we want it to succeed. + await apiClient.mockLinearSetTeams([{ id: "team-1", key: "ENG", name: "Engineering" }]); + + const settings = new LinearSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + + await settings.secretInput.fill("lin_api_xxx"); + await settings.saveButton.click(); + await expect(settings.saveButton).toHaveText(/Update/i); + // Wait for the async post-save probe to write lastOk=true before reloading. + await apiClient.waitForIntegrationAuthHealthy("linear", seedData.workspaceId); + + await testPage.reload(); + await settings.secretInput.waitFor(); + await expect(settings.statusBanner).toHaveAttribute("data-state", "ok"); + // Saved-secret hint indicates the row was loaded, not started fresh. + await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible(); + }); + + test("test connection surfaces inline success and failure", async ({ + testPage, + seedData, + apiClient, + }) => { + const settings = new LinearSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + + await apiClient.mockLinearSetAuthResult({ + ok: true, + displayName: "Alice from Linear", + email: "alice@example.com", + orgName: "Acme", + }); + await settings.secretInput.fill("lin_api_xxx"); + await settings.testButton.click(); + await expect(testPage.getByText(/Connected as Alice from Linear/i)).toBeVisible(); + + await apiClient.mockLinearSetAuthResult({ ok: false, error: "Bad token" }); + await settings.testButton.click(); + await expect(testPage.getByText(/Failed: Bad token/)).toBeVisible(); + }); + + test("seeded auth-health failure renders the failed banner on load", async ({ + testPage, + seedData, + apiClient, + }) => { + const settings = new LinearSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + await settings.secretInput.fill("lin_api_xxx"); + await settings.saveButton.click(); + // Wait for the post-save probe to land BEFORE forcing the failure: the + // probe goroutine could otherwise overwrite our forced lastOk=false back + // to true a few ms after the mockLinearSetAuthHealth call. + await apiClient.waitForIntegrationAuthHealthy("linear", seedData.workspaceId); + + await apiClient.mockLinearSetAuthHealth({ + workspaceId: seedData.workspaceId, + ok: false, + error: "rate limited", + }); + await testPage.reload(); + await settings.statusBanner.waitFor(); + await expect(settings.statusBanner).toHaveAttribute("data-state", "failed"); + await expect(settings.statusBanner).toContainText(/rate limited/i); + }); + + test("delete clears the saved configuration", async ({ testPage, seedData }) => { + const settings = new LinearSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + await settings.secretInput.fill("lin_api_xxx"); + await settings.saveButton.click(); + await expect(settings.deleteButton).toBeVisible(); + + testPage.once("dialog", (d) => void d.accept()); + await settings.deleteButton.click(); + await expect(settings.deleteButton).toHaveCount(0); + await expect(settings.secretInput).toHaveValue(""); + await expect(settings.statusBanner).toHaveCount(0); + }); +});