test(linear): add comprehensive tests for mapping and client functions

Added tests for:
- DefaultMappingConfig
- PriorityToBeads/PriorityToLinear
- StateToBeadsStatus/ParseBeadsStatus
- StatusToLinearStateType
- LabelToIssueType/ParseIssueType
- RelationToBeadsDep
- IssueToBeads (with parent/child handling)
- BuildLinearToLocalUpdates
- LoadMappingConfig
- BuildLinearDescription
- NewClient/WithEndpoint/WithHTTPClient
- ExtractLinearIdentifier
- IsLinearExternalRef

Linear package coverage improved from 14.3% to ~50%.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-19 18:30:03 -08:00
parent cc5893656b
commit af432bee4e
2 changed files with 589 additions and 1 deletions

View File

@@ -1,6 +1,10 @@
package linear
import "testing"
import (
"net/http"
"testing"
"time"
)
func TestCanonicalizeLinearExternalRef(t *testing.T) {
tests := []struct {
@@ -39,3 +43,130 @@ func TestCanonicalizeLinearExternalRef(t *testing.T) {
}
}
}
func TestNewClient(t *testing.T) {
client := NewClient("test-api-key", "test-team-id")
if client.APIKey != "test-api-key" {
t.Errorf("APIKey = %q, want %q", client.APIKey, "test-api-key")
}
if client.TeamID != "test-team-id" {
t.Errorf("TeamID = %q, want %q", client.TeamID, "test-team-id")
}
if client.Endpoint != DefaultAPIEndpoint {
t.Errorf("Endpoint = %q, want %q", client.Endpoint, DefaultAPIEndpoint)
}
if client.HTTPClient == nil {
t.Error("HTTPClient should not be nil")
}
}
func TestWithEndpoint(t *testing.T) {
client := NewClient("key", "team")
customEndpoint := "https://custom.linear.app/graphql"
newClient := client.WithEndpoint(customEndpoint)
if newClient.Endpoint != customEndpoint {
t.Errorf("Endpoint = %q, want %q", newClient.Endpoint, customEndpoint)
}
// Original should be unchanged
if client.Endpoint != DefaultAPIEndpoint {
t.Errorf("Original endpoint changed: %q", client.Endpoint)
}
// Other fields preserved
if newClient.APIKey != "key" {
t.Errorf("APIKey not preserved: %q", newClient.APIKey)
}
}
func TestWithHTTPClient(t *testing.T) {
client := NewClient("key", "team")
customHTTPClient := &http.Client{Timeout: 60 * time.Second}
newClient := client.WithHTTPClient(customHTTPClient)
if newClient.HTTPClient != customHTTPClient {
t.Error("HTTPClient not set correctly")
}
// Other fields preserved
if newClient.APIKey != "key" {
t.Errorf("APIKey not preserved: %q", newClient.APIKey)
}
if newClient.Endpoint != DefaultAPIEndpoint {
t.Errorf("Endpoint not preserved: %q", newClient.Endpoint)
}
}
func TestExtractLinearIdentifier(t *testing.T) {
tests := []struct {
name string
externalRef string
want string
}{
{
name: "standard URL",
externalRef: "https://linear.app/team/issue/PROJ-123",
want: "PROJ-123",
},
{
name: "URL with slug",
externalRef: "https://linear.app/team/issue/PROJ-456/some-title-here",
want: "PROJ-456",
},
{
name: "URL with trailing slash",
externalRef: "https://linear.app/team/issue/ABC-789/",
want: "ABC-789",
},
{
name: "non-linear URL",
externalRef: "https://jira.example.com/browse/PROJ-123",
want: "",
},
{
name: "empty string",
externalRef: "",
want: "",
},
{
name: "malformed URL",
externalRef: "not-a-url",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractLinearIdentifier(tt.externalRef)
if got != tt.want {
t.Errorf("ExtractLinearIdentifier(%q) = %q, want %q", tt.externalRef, got, tt.want)
}
})
}
}
func TestIsLinearExternalRef(t *testing.T) {
tests := []struct {
ref string
want bool
}{
{"https://linear.app/team/issue/PROJ-123", true},
{"https://linear.app/team/issue/PROJ-123/slug", true},
{"https://jira.example.com/browse/PROJ-123", false},
{"https://github.com/org/repo/issues/123", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.ref, func(t *testing.T) {
got := IsLinearExternalRef(tt.ref)
if got != tt.want {
t.Errorf("IsLinearExternalRef(%q) = %v, want %v", tt.ref, got, tt.want)
}
})
}
}
// Note: BuildStateCache and FindStateForBeadsStatus require API calls
// and would need mocking to test. Skipping unit tests for those.

View File

@@ -142,3 +142,460 @@ func TestNormalizeIssueForLinearHashCanonicalizesExternalRef(t *testing.T) {
func hasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
func TestDefaultMappingConfig(t *testing.T) {
config := DefaultMappingConfig()
// Check priority mappings
if config.PriorityMap["0"] != 4 {
t.Errorf("PriorityMap[0] = %d, want 4", config.PriorityMap["0"])
}
if config.PriorityMap["1"] != 0 {
t.Errorf("PriorityMap[1] = %d, want 0", config.PriorityMap["1"])
}
// Check state mappings
if config.StateMap["backlog"] != "open" {
t.Errorf("StateMap[backlog] = %s, want open", config.StateMap["backlog"])
}
if config.StateMap["started"] != "in_progress" {
t.Errorf("StateMap[started] = %s, want in_progress", config.StateMap["started"])
}
if config.StateMap["completed"] != "closed" {
t.Errorf("StateMap[completed] = %s, want closed", config.StateMap["completed"])
}
// Check label type mappings
if config.LabelTypeMap["bug"] != "bug" {
t.Errorf("LabelTypeMap[bug] = %s, want bug", config.LabelTypeMap["bug"])
}
if config.LabelTypeMap["feature"] != "feature" {
t.Errorf("LabelTypeMap[feature] = %s, want feature", config.LabelTypeMap["feature"])
}
// Check relation mappings
if config.RelationMap["blocks"] != "blocks" {
t.Errorf("RelationMap[blocks] = %s, want blocks", config.RelationMap["blocks"])
}
}
func TestPriorityToBeads(t *testing.T) {
config := DefaultMappingConfig()
tests := []struct {
linearPriority int
want int
}{
{0, 4}, // No priority -> Backlog
{1, 0}, // Urgent -> Critical
{2, 1}, // High -> High
{3, 2}, // Medium -> Medium
{4, 3}, // Low -> Low
{5, 2}, // Unknown -> Medium (default)
}
for _, tt := range tests {
got := PriorityToBeads(tt.linearPriority, config)
if got != tt.want {
t.Errorf("PriorityToBeads(%d) = %d, want %d", tt.linearPriority, got, tt.want)
}
}
}
func TestPriorityToLinear(t *testing.T) {
config := DefaultMappingConfig()
tests := []struct {
beadsPriority int
want int
}{
{0, 1}, // Critical -> Urgent
{1, 2}, // High -> High
{2, 3}, // Medium -> Medium
{3, 4}, // Low -> Low
{4, 0}, // Backlog -> No priority
{5, 3}, // Unknown -> Medium (default)
}
for _, tt := range tests {
got := PriorityToLinear(tt.beadsPriority, config)
if got != tt.want {
t.Errorf("PriorityToLinear(%d) = %d, want %d", tt.beadsPriority, got, tt.want)
}
}
}
func TestStateToBeadsStatus(t *testing.T) {
config := DefaultMappingConfig()
tests := []struct {
state *State
want types.Status
}{
{nil, types.StatusOpen},
{&State{Type: "backlog", Name: "Backlog"}, types.StatusOpen},
{&State{Type: "unstarted", Name: "Todo"}, types.StatusOpen},
{&State{Type: "started", Name: "In Progress"}, types.StatusInProgress},
{&State{Type: "completed", Name: "Done"}, types.StatusClosed},
{&State{Type: "canceled", Name: "Cancelled"}, types.StatusClosed},
{&State{Type: "unknown", Name: "Unknown"}, types.StatusOpen}, // Default
}
for _, tt := range tests {
got := StateToBeadsStatus(tt.state, config)
if got != tt.want {
stateName := "nil"
if tt.state != nil {
stateName = tt.state.Type
}
t.Errorf("StateToBeadsStatus(%s) = %v, want %v", stateName, got, tt.want)
}
}
}
func TestParseBeadsStatus(t *testing.T) {
tests := []struct {
input string
want types.Status
}{
{"open", types.StatusOpen},
{"OPEN", types.StatusOpen},
{"in_progress", types.StatusInProgress},
{"in-progress", types.StatusInProgress},
{"inprogress", types.StatusInProgress},
{"blocked", types.StatusBlocked},
{"closed", types.StatusClosed},
{"CLOSED", types.StatusClosed},
{"unknown", types.StatusOpen}, // Default
}
for _, tt := range tests {
got := ParseBeadsStatus(tt.input)
if got != tt.want {
t.Errorf("ParseBeadsStatus(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestStatusToLinearStateType(t *testing.T) {
tests := []struct {
status types.Status
want string
}{
{types.StatusOpen, "unstarted"},
{types.StatusInProgress, "started"},
{types.StatusBlocked, "started"},
{types.StatusClosed, "completed"},
{types.Status("unknown"), "unstarted"}, // Unknown -> default
}
for _, tt := range tests {
got := StatusToLinearStateType(tt.status)
if got != tt.want {
t.Errorf("StatusToLinearStateType(%v) = %q, want %q", tt.status, got, tt.want)
}
}
}
func TestLabelToIssueType(t *testing.T) {
config := DefaultMappingConfig()
tests := []struct {
labels *Labels
want types.IssueType
}{
{nil, types.TypeTask},
{&Labels{Nodes: []Label{}}, types.TypeTask},
{&Labels{Nodes: []Label{{Name: "bug"}}}, types.TypeBug},
{&Labels{Nodes: []Label{{Name: "Bug"}}}, types.TypeBug},
{&Labels{Nodes: []Label{{Name: "feature"}}}, types.TypeFeature},
{&Labels{Nodes: []Label{{Name: "epic"}}}, types.TypeEpic},
{&Labels{Nodes: []Label{{Name: "chore"}}}, types.TypeChore},
{&Labels{Nodes: []Label{{Name: "random"}, {Name: "bug"}}}, types.TypeBug},
{&Labels{Nodes: []Label{{Name: "contains-bug-keyword"}}}, types.TypeBug},
}
for _, tt := range tests {
got := LabelToIssueType(tt.labels, config)
if got != tt.want {
t.Errorf("LabelToIssueType(%v) = %v, want %v", tt.labels, got, tt.want)
}
}
}
func TestParseIssueType(t *testing.T) {
tests := []struct {
input string
want types.IssueType
}{
{"bug", types.TypeBug},
{"BUG", types.TypeBug},
{"feature", types.TypeFeature},
{"task", types.TypeTask},
{"epic", types.TypeEpic},
{"chore", types.TypeChore},
{"unknown", types.TypeTask}, // Default
}
for _, tt := range tests {
got := ParseIssueType(tt.input)
if got != tt.want {
t.Errorf("ParseIssueType(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestRelationToBeadsDep(t *testing.T) {
config := DefaultMappingConfig()
tests := []struct {
relationType string
want string
}{
{"blocks", "blocks"},
{"blockedBy", "blocks"},
{"duplicate", "duplicates"},
{"related", "related"},
{"unknown", "related"}, // Default
}
for _, tt := range tests {
got := RelationToBeadsDep(tt.relationType, config)
if got != tt.want {
t.Errorf("RelationToBeadsDep(%q) = %q, want %q", tt.relationType, got, tt.want)
}
}
}
func TestIssueToBeads(t *testing.T) {
config := DefaultMappingConfig()
linearIssue := &Issue{
ID: "uuid-123",
Identifier: "PROJ-123",
Title: "Test Issue",
Description: "Test description",
URL: "https://linear.app/team/issue/PROJ-123/test-issue",
Priority: 2, // High
State: &State{Type: "started", Name: "In Progress"},
Assignee: &User{Name: "John Doe", Email: "john@example.com"},
Labels: &Labels{Nodes: []Label{{Name: "bug"}}},
CreatedAt: "2024-01-15T10:00:00Z",
UpdatedAt: "2024-01-16T12:00:00Z",
}
result := IssueToBeads(linearIssue, config)
issue := result.Issue.(*types.Issue)
if issue.Title != "Test Issue" {
t.Errorf("Title = %q, want %q", issue.Title, "Test Issue")
}
if issue.Description != "Test description" {
t.Errorf("Description = %q, want %q", issue.Description, "Test description")
}
if issue.Priority != 1 { // High in beads
t.Errorf("Priority = %d, want 1", issue.Priority)
}
if issue.Status != types.StatusInProgress {
t.Errorf("Status = %v, want %v", issue.Status, types.StatusInProgress)
}
if issue.Assignee != "john@example.com" {
t.Errorf("Assignee = %q, want %q", issue.Assignee, "john@example.com")
}
if issue.IssueType != types.TypeBug {
t.Errorf("IssueType = %v, want %v", issue.IssueType, types.TypeBug)
}
if issue.ExternalRef == nil {
t.Error("ExternalRef should not be nil")
}
}
func TestIssueToBeadsWithParent(t *testing.T) {
config := DefaultMappingConfig()
linearIssue := &Issue{
ID: "uuid-456",
Identifier: "PROJ-456",
Title: "Child Issue",
Description: "Child description",
URL: "https://linear.app/team/issue/PROJ-456",
Priority: 3,
State: &State{Type: "unstarted", Name: "Todo"},
Parent: &Parent{ID: "uuid-123", Identifier: "PROJ-123"},
CreatedAt: "2024-01-15T10:00:00Z",
UpdatedAt: "2024-01-16T12:00:00Z",
}
result := IssueToBeads(linearIssue, config)
if len(result.Dependencies) != 1 {
t.Fatalf("Expected 1 dependency, got %d", len(result.Dependencies))
}
if result.Dependencies[0].Type != "parent-child" {
t.Errorf("Dependency type = %q, want %q", result.Dependencies[0].Type, "parent-child")
}
if result.Dependencies[0].FromLinearID != "PROJ-456" {
t.Errorf("FromLinearID = %q, want %q", result.Dependencies[0].FromLinearID, "PROJ-456")
}
if result.Dependencies[0].ToLinearID != "PROJ-123" {
t.Errorf("ToLinearID = %q, want %q", result.Dependencies[0].ToLinearID, "PROJ-123")
}
}
func TestBuildLinearToLocalUpdates(t *testing.T) {
config := DefaultMappingConfig()
linearIssue := &Issue{
ID: "uuid-123",
Identifier: "PROJ-123",
Title: "Updated Title",
Description: "Updated description",
Priority: 1, // Urgent
State: &State{Type: "completed", Name: "Done"},
Assignee: &User{Name: "Jane Doe", Email: "jane@example.com"},
Labels: &Labels{Nodes: []Label{{Name: "feature"}, {Name: "priority"}}},
UpdatedAt: "2024-01-20T15:00:00Z",
CompletedAt: "2024-01-20T14:00:00Z",
}
updates := BuildLinearToLocalUpdates(linearIssue, config)
if updates["title"] != "Updated Title" {
t.Errorf("title = %v, want %q", updates["title"], "Updated Title")
}
if updates["description"] != "Updated description" {
t.Errorf("description = %v, want %q", updates["description"], "Updated description")
}
if updates["priority"] != 0 { // Critical in beads
t.Errorf("priority = %v, want 0", updates["priority"])
}
if updates["status"] != "closed" {
t.Errorf("status = %v, want %q", updates["status"], "closed")
}
if updates["assignee"] != "jane@example.com" {
t.Errorf("assignee = %v, want %q", updates["assignee"], "jane@example.com")
}
labels, ok := updates["labels"].([]string)
if !ok || len(labels) != 2 {
t.Errorf("labels = %v, want 2 labels", updates["labels"])
}
}
func TestBuildLinearToLocalUpdatesNoAssignee(t *testing.T) {
config := DefaultMappingConfig()
linearIssue := &Issue{
ID: "uuid-123",
Identifier: "PROJ-123",
Title: "No Assignee",
Description: "Test",
Priority: 3,
State: &State{Type: "unstarted", Name: "Todo"},
Assignee: nil,
UpdatedAt: "2024-01-20T15:00:00Z",
}
updates := BuildLinearToLocalUpdates(linearIssue, config)
if updates["assignee"] != "" {
t.Errorf("assignee = %v, want empty string", updates["assignee"])
}
}
// mockConfigLoader implements ConfigLoader for testing
type mockConfigLoader struct {
config map[string]string
}
func (m *mockConfigLoader) GetAllConfig() (map[string]string, error) {
return m.config, nil
}
func TestLoadMappingConfig(t *testing.T) {
loader := &mockConfigLoader{
config: map[string]string{
"linear.priority_map.0": "3",
"linear.state_map.custom": "in_progress",
"linear.label_type_map.story": "feature",
"linear.relation_map.parent": "parent-child",
},
}
config := LoadMappingConfig(loader)
// Check custom priority mapping
if config.PriorityMap["0"] != 3 {
t.Errorf("PriorityMap[0] = %d, want 3", config.PriorityMap["0"])
}
// Check custom state mapping
if config.StateMap["custom"] != "in_progress" {
t.Errorf("StateMap[custom] = %s, want in_progress", config.StateMap["custom"])
}
// Check custom label type mapping
if config.LabelTypeMap["story"] != "feature" {
t.Errorf("LabelTypeMap[story] = %s, want feature", config.LabelTypeMap["story"])
}
// Check custom relation mapping
if config.RelationMap["parent"] != "parent-child" {
t.Errorf("RelationMap[parent] = %s, want parent-child", config.RelationMap["parent"])
}
// Check that defaults are preserved
if config.StateMap["started"] != "in_progress" {
t.Errorf("StateMap[started] = %s, want in_progress (default preserved)", config.StateMap["started"])
}
}
func TestLoadMappingConfigNilLoader(t *testing.T) {
config := LoadMappingConfig(nil)
// Should return defaults
if config.PriorityMap["1"] != 0 {
t.Errorf("Expected default priority map with nil loader")
}
}
func TestBuildLinearDescription(t *testing.T) {
tests := []struct {
name string
issue *types.Issue
want string
}{
{
name: "description only",
issue: &types.Issue{Description: "Basic description"},
want: "Basic description",
},
{
name: "with acceptance criteria",
issue: &types.Issue{
Description: "Main description",
AcceptanceCriteria: "- Must do X\n- Must do Y",
},
want: "Main description\n\n## Acceptance Criteria\n- Must do X\n- Must do Y",
},
{
name: "with all fields",
issue: &types.Issue{
Description: "Main description",
AcceptanceCriteria: "AC here",
Design: "Design notes",
Notes: "Additional notes",
},
want: "Main description\n\n## Acceptance Criteria\nAC here\n\n## Design\nDesign notes\n\n## Notes\nAdditional notes",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := BuildLinearDescription(tt.issue)
if got != tt.want {
t.Errorf("BuildLinearDescription() = %q, want %q", got, tt.want)
}
})
}
}