Files
kandev/apps/backend/internal/linear/store_test.go
T
João Salavisa 7b5b3f329d feat: add Linear integration (#736)
* feat: add Linear integration

Mirrors the existing Jira integration so a workspace can connect to Linear
with a personal API key, browse and search issues, and start a Kandev task
from any issue. Backend uses Linear's GraphQL API with a workspace-scoped
config + secret store and a 90s auth-health poller; the frontend adds a
settings page, an issue dialog with state transitions, link/import buttons
in the task UI, and a paginated /linear browse page with a Start task flow.

* fix(linear): address PR review feedback

- Route httpListStates through Service.ListStates instead of clientFor
- Extract repeated workspace_id required string into a constant
- Log GetConfig errors during credential resolution instead of dropping them
- Clear popover error state on close in import + link buttons
- Pick the first workflow with steps in the quick task launcher (don't hard-pin to workflows[0])
- Reset useLinearEnabled state on workspace change via tag-based pattern
- Add cursor-pointer to anchor + Link elements per project convention
- Add tests: handlers_test.go, linear-api.test.ts, use-linear-enabled.test.ts, linear-issue-common.test.tsx
- Document internal/jira and internal/linear in CLAUDE.md/AGENTS.md

* fix(linear): address second-round PR review feedback

- Round Linear's fractional state position instead of truncating it via
  int(math.Round(s.Position)) so adjacent fractional positions don't collapse
- Force LinearSettings to remount on workspace change with key={id} so the
  previous workspace's config / form / testResult / teams cannot bleed into
  the next workspace before the new fetches settle
- Broaden the legacy-value useLinearEnabled test to cover "true", "1", "yes",
  and "legacy" so a regression to strict value === "true" would fail

* fix(linear): address third-round PR review feedback

- Propagate context.Canceled / context.DeadlineExceeded from ListStates in
  GetIssue instead of swallowing them — a caller cancellation must not return
  a partial-success response
- Drop the one-off contains() test helper in handlers_test.go and use
  strings.Contains directly so the assertion is self-documenting
- Add getLinearIssue URL-shape test that locks identifier-as-path-segment
  vs workspaceId-as-query-param so accidentally swapping them would fail

* test(linear): extract repeated literals in linear-api.test.ts

Pulls "ws-1", the API base URL, and the auth-method literal into module-
level constants so the assertions read as the route shape they care about
and the duplicate-strings ESLint rule (currently warn, threshold 4)
stays quiet as the file grows.

---------

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

162 lines
4.2 KiB
Go

package linear
import (
"context"
"database/sql"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
raw, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
raw.SetMaxOpenConns(1)
raw.SetMaxIdleConns(1)
db := sqlx.NewDb(raw, "sqlite3")
t.Cleanup(func() { _ = db.Close() })
store, err := NewStore(db, db)
if err != nil {
t.Fatalf("new store: %v", err)
}
return store
}
func TestStore_UpsertGetDelete(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
cfg := &LinearConfig{
WorkspaceID: "ws-1",
AuthMethod: AuthMethodAPIKey,
DefaultTeamKey: "ENG",
}
if err := store.UpsertConfig(ctx, cfg); err != nil {
t.Fatalf("upsert: %v", err)
}
got, err := store.GetConfig(ctx, "ws-1")
if err != nil {
t.Fatalf("get: %v", err)
}
if got == nil {
t.Fatal("expected config, got nil")
}
if got.AuthMethod != cfg.AuthMethod || got.DefaultTeamKey != cfg.DefaultTeamKey {
t.Errorf("round-trip mismatch: %+v vs %+v", got, cfg)
}
if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() {
t.Error("timestamps not set")
}
cfg.DefaultTeamKey = "MOB"
if err := store.UpsertConfig(ctx, cfg); err != nil {
t.Fatalf("update upsert: %v", err)
}
got2, _ := store.GetConfig(ctx, "ws-1")
if got2.DefaultTeamKey != "MOB" {
t.Errorf("expected team update, got %q", got2.DefaultTeamKey)
}
if err := store.DeleteConfig(ctx, "ws-1"); err != nil {
t.Fatalf("delete: %v", err)
}
gone, err := store.GetConfig(ctx, "ws-1")
if err != nil {
t.Fatalf("get after delete: %v", err)
}
if gone != nil {
t.Errorf("expected nil after delete, got %+v", gone)
}
}
func TestStore_GetConfig_Missing(t *testing.T) {
store := newTestStore(t)
cfg, err := store.GetConfig(context.Background(), "does-not-exist")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg != nil {
t.Errorf("expected nil for missing config, got %+v", cfg)
}
}
func TestStore_ListConfiguredWorkspaces(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
mustUpsert := func(id string) {
if err := store.UpsertConfig(ctx, &LinearConfig{
WorkspaceID: id,
AuthMethod: AuthMethodAPIKey,
}); err != nil {
t.Fatalf("upsert %s: %v", id, err)
}
}
mustUpsert("ws-b")
mustUpsert("ws-a")
ids, err := store.ListConfiguredWorkspaces(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(ids) != 2 || ids[0] != "ws-a" || ids[1] != "ws-b" {
t.Errorf("expected sorted [ws-a ws-b], got %v", ids)
}
}
func TestStore_UpdateAuthHealth(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
if err := store.UpsertConfig(ctx, &LinearConfig{
WorkspaceID: "ws-1",
AuthMethod: AuthMethodAPIKey,
}); err != nil {
t.Fatalf("upsert: %v", err)
}
cfg, _ := store.GetConfig(ctx, "ws-1")
if cfg.LastCheckedAt != nil {
t.Errorf("expected nil last_checked_at on fresh row, got %v", cfg.LastCheckedAt)
}
now := time.Now().UTC().Truncate(time.Second)
if err := store.UpdateAuthHealth(ctx, "ws-1", true, "", "acme", now); err != nil {
t.Fatalf("update ok: %v", err)
}
cfg, _ = store.GetConfig(ctx, "ws-1")
if !cfg.LastOk {
t.Error("expected last_ok=true after successful probe")
}
if cfg.OrgSlug != "acme" {
t.Errorf("expected org_slug=acme, got %q", cfg.OrgSlug)
}
if cfg.LastCheckedAt == nil || !cfg.LastCheckedAt.Equal(now) {
t.Errorf("expected last_checked_at=%v, got %v", now, cfg.LastCheckedAt)
}
// Empty orgSlug should leave the existing slug intact.
failAt := now.Add(time.Minute)
if err := store.UpdateAuthHealth(ctx, "ws-1", false, "401 unauthorized", "", failAt); err != nil {
t.Fatalf("update fail: %v", err)
}
cfg, _ = store.GetConfig(ctx, "ws-1")
if cfg.LastOk {
t.Error("expected last_ok=false after failure")
}
if cfg.LastError != "401 unauthorized" {
t.Errorf("expected last_error preserved, got %q", cfg.LastError)
}
if cfg.OrgSlug != "acme" {
t.Errorf("orgSlug should be preserved across failed probe, got %q", cfg.OrgSlug)
}
// Update for missing workspace must not error.
if err := store.UpdateAuthHealth(ctx, "missing", true, "", "x", now); err != nil {
t.Errorf("expected no-op for missing row, got %v", err)
}
}