Files
kandev/apps/backend/internal/linear/mock_client.go
T
João Salavisa 5936bb6808 test: add e2e coverage and mock backends for jira and linear integrations (#771)
* test: add e2e coverage and mock backends for jira and linear integrations

Adds in-memory MockClient + MockController for both integrations gated by
KANDEV_MOCK_{JIRA,LINEAR}=true, plus apiClient mock helpers and Playwright
specs covering settings and import flows.

Also fixes ValidatedPopover's TriggerButton silently dropping radix Slot's
onClick handler — the popover wouldn't open under any synthetic-click path
(including Playwright). Spreads `...rest` onto the inner Button.

Reworks the integration cleanup hook in test-base as an auto fixture so it
runs for every test using the imported `test` object, not just tests in the
fixture file itself.

* fix: address PR review feedback on jira/linear mocks and e2e specs

- Fix SetAuthResult docstrings: nil simulates an unconfigured state, not the default success.
- Drop unused JSON tags from doneTransitionCall and setStateCall (Go-only test fixtures).
- Track Linear issue insertion order so SearchIssues returns deterministic results instead of map-iteration shuffle.
- Document filterByJQL precedence at the SearchTickets call site so the all-hits-on-no-match fallback is visible.
- Wait for the post-save auth-health probe before forcing a failure in the seeded-failure tests, eliminating a goroutine race.
- Reorder authMethod precedence in setJiraConfig/setLinearConfig to put the spread first so 'caller wins' reads naturally.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-02 14:41:36 +01:00

200 lines
5.5 KiB
Go

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