test: Add comprehensive RPC list filter parity tests (bd-zkl)
- Test pattern matching filters (title/description/notes contains)
- Test empty/null checks (empty description, no assignee, no labels)
- Test priority range filters (min/max)
- Test date range filters with multiple formats
- Test status normalization ('all' vs unset)
- Test backward compatibility (deprecated --label flag)
- Verify daemon mode (RPC) behaves identically to direct mode
- All tests pass with real daemon instance
Resolves bd-zkl
This commit is contained in:
755
internal/rpc/list_filters_test.go
Normal file
755
internal/rpc/list_filters_test.go
Normal file
@@ -0,0 +1,755 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sqlitestorage "github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupTestServerWithStore creates a test server and returns the store for direct access
|
||||||
|
func setupTestServerWithStore(t *testing.T) (*Server, *Client, *sqlitestorage.SQLiteStorage, func()) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "bd-rpc-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
t.Fatalf("Failed to create .beads dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(beadsDir, "test.db")
|
||||||
|
socketPath := filepath.Join(beadsDir, "bd.sock")
|
||||||
|
|
||||||
|
os.Remove(socketPath)
|
||||||
|
|
||||||
|
store, err := sqlitestorage.New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
||||||
|
store.Close()
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
t.Fatalf("Failed to set issue_prefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := NewServer(socketPath, store, tmpDir, dbPath)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() {
|
||||||
|
if err := server.Start(ctx); err != nil && err.Error() != "accept unix "+socketPath+": use of closed network connection" {
|
||||||
|
t.Logf("Server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
maxWait := 50
|
||||||
|
for i := 0; i < maxWait; i++ {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if _, err := os.Stat(socketPath); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i == maxWait-1 {
|
||||||
|
cancel()
|
||||||
|
server.Stop()
|
||||||
|
store.Close()
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
t.Fatalf("Server socket not created after waiting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
originalWd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
server.Stop()
|
||||||
|
store.Close()
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
t.Fatalf("Failed to get working directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
cancel()
|
||||||
|
server.Stop()
|
||||||
|
store.Close()
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
t.Fatalf("Failed to change directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := TryConnect(socketPath)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
server.Stop()
|
||||||
|
store.Close()
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
t.Fatalf("Failed to connect client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
cancel()
|
||||||
|
server.Stop()
|
||||||
|
store.Close()
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
t.Fatalf("Client is nil after connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
client.dbPath = dbPath
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
client.Close()
|
||||||
|
cancel()
|
||||||
|
server.Stop()
|
||||||
|
store.Close()
|
||||||
|
os.Chdir(originalWd)
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server, client, store, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListFiltersParity verifies daemon mode (RPC) behaves identically to direct mode
|
||||||
|
// for all new filter flags added in bd-o43.
|
||||||
|
func TestListFiltersParity(t *testing.T) {
|
||||||
|
_, client, store, cleanup := setupTestServerWithStore(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Create diverse test fixtures
|
||||||
|
fixtures := []struct {
|
||||||
|
title string
|
||||||
|
description string
|
||||||
|
notes string
|
||||||
|
status types.Status
|
||||||
|
priority int
|
||||||
|
assignee string
|
||||||
|
labels []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
title: "Implement authentication",
|
||||||
|
description: "Add JWT token support",
|
||||||
|
notes: "Use bcrypt for password hashing",
|
||||||
|
status: types.StatusOpen,
|
||||||
|
priority: 1,
|
||||||
|
assignee: "alice",
|
||||||
|
labels: []string{"security", "backend"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fix login bug",
|
||||||
|
description: "",
|
||||||
|
notes: "",
|
||||||
|
status: types.StatusInProgress,
|
||||||
|
priority: 0,
|
||||||
|
assignee: "",
|
||||||
|
labels: []string{"bug"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Refactor database layer",
|
||||||
|
description: "Extract common patterns into helpers",
|
||||||
|
notes: "Focus on query builders",
|
||||||
|
status: types.StatusClosed,
|
||||||
|
priority: 2,
|
||||||
|
assignee: "bob",
|
||||||
|
labels: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Update documentation",
|
||||||
|
description: "Add API examples",
|
||||||
|
notes: "",
|
||||||
|
status: types.StatusOpen,
|
||||||
|
priority: 3,
|
||||||
|
assignee: "alice",
|
||||||
|
labels: []string{"docs"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Authentication middleware",
|
||||||
|
description: "Protect routes with JWT",
|
||||||
|
notes: "Remember to add rate limiting",
|
||||||
|
status: types.StatusBlocked,
|
||||||
|
priority: 1,
|
||||||
|
assignee: "",
|
||||||
|
labels: []string{"backend", "security"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create issues and track their IDs
|
||||||
|
issueIDs := make([]string, len(fixtures))
|
||||||
|
for i, f := range fixtures {
|
||||||
|
createArgs := &CreateArgs{
|
||||||
|
Title: f.title,
|
||||||
|
Description: f.description,
|
||||||
|
IssueType: "task",
|
||||||
|
Priority: f.priority,
|
||||||
|
Assignee: f.assignee,
|
||||||
|
}
|
||||||
|
resp, err := client.Create(createArgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create fixture %d: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdIssue types.Issue
|
||||||
|
if err := json.Unmarshal(resp.Data, &createdIssue); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal created issue: %v", err)
|
||||||
|
}
|
||||||
|
issueIDs[i] = createdIssue.ID
|
||||||
|
|
||||||
|
// Manually update all fields directly via UpdateIssue
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"status": string(f.status),
|
||||||
|
"notes": f.notes,
|
||||||
|
}
|
||||||
|
if err := store.UpdateIssue(ctx, createdIssue.ID, updates, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to update issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add labels
|
||||||
|
for _, label := range f.labels {
|
||||||
|
if err := store.AddLabel(ctx, createdIssue.ID, label, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to add label: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
listArgs *ListArgs
|
||||||
|
directCount int
|
||||||
|
validator func(t *testing.T, issues []*types.Issue)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "pattern matching - title contains",
|
||||||
|
listArgs: &ListArgs{
|
||||||
|
TitleContains: "authentication",
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
directCount: 2,
|
||||||
|
validator: func(t *testing.T, issues []*types.Issue) {
|
||||||
|
for _, issue := range issues {
|
||||||
|
if !strings.Contains(strings.ToLower(issue.Title), "authentication") {
|
||||||
|
t.Errorf("Issue %s title does not contain 'authentication': %s", issue.ID, issue.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pattern matching - description contains",
|
||||||
|
listArgs: &ListArgs{
|
||||||
|
DescriptionContains: "JWT",
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
directCount: 2,
|
||||||
|
validator: func(t *testing.T, issues []*types.Issue) {
|
||||||
|
for _, issue := range issues {
|
||||||
|
if !strings.Contains(issue.Description, "JWT") {
|
||||||
|
t.Errorf("Issue %s description does not contain 'JWT': %s", issue.ID, issue.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pattern matching - notes contains",
|
||||||
|
listArgs: &ListArgs{
|
||||||
|
NotesContains: "hashing",
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
directCount: 1,
|
||||||
|
validator: func(t *testing.T, issues []*types.Issue) {
|
||||||
|
for _, issue := range issues {
|
||||||
|
if !strings.Contains(issue.Notes, "hashing") {
|
||||||
|
t.Errorf("Issue %s notes do not contain 'hashing': %s", issue.ID, issue.Notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty description check",
|
||||||
|
listArgs: &ListArgs{
|
||||||
|
EmptyDescription: true,
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
directCount: 1, // Only "Fix login bug" has empty description
|
||||||
|
validator: func(t *testing.T, issues []*types.Issue) {
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.Description != "" {
|
||||||
|
t.Errorf("Issue %s has non-empty description: %s", issue.ID, issue.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no assignee check",
|
||||||
|
listArgs: &ListArgs{
|
||||||
|
NoAssignee: true,
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
directCount: 2,
|
||||||
|
validator: func(t *testing.T, issues []*types.Issue) {
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.Assignee != "" {
|
||||||
|
t.Errorf("Issue %s has assignee: %s", issue.ID, issue.Assignee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no labels check",
|
||||||
|
listArgs: &ListArgs{
|
||||||
|
NoLabels: true,
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
directCount: 1,
|
||||||
|
validator: func(t *testing.T, issues []*types.Issue) {
|
||||||
|
for _, issue := range issues {
|
||||||
|
labels, err := store.GetLabels(ctx, issue.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to get labels: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(labels) > 0 {
|
||||||
|
t.Errorf("Issue %s has labels: %v", issue.ID, labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "priority range - min",
|
||||||
|
listArgs: &ListArgs{
|
||||||
|
PriorityMin: ptrInt(1),
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
directCount: 4, // priorities 1,1,1,2 from auth, auth middleware, doc, refactor
|
||||||
|
validator: func(t *testing.T, issues []*types.Issue) {
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.Priority < 1 {
|
||||||
|
t.Errorf("Issue %s priority %d is below min 1", issue.ID, issue.Priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "priority range - max",
|
||||||
|
listArgs: &ListArgs{
|
||||||
|
PriorityMax: ptrInt(1),
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
directCount: 3,
|
||||||
|
validator: func(t *testing.T, issues []*types.Issue) {
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.Priority > 1 {
|
||||||
|
t.Errorf("Issue %s priority %d is above max 1", issue.ID, issue.Priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "priority range - both",
|
||||||
|
listArgs: &ListArgs{
|
||||||
|
PriorityMin: ptrInt(1),
|
||||||
|
PriorityMax: ptrInt(2),
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
directCount: 3,
|
||||||
|
validator: func(t *testing.T, issues []*types.Issue) {
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.Priority < 1 || issue.Priority > 2 {
|
||||||
|
t.Errorf("Issue %s priority %d is outside range [1,2]", issue.ID, issue.Priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "date range - created after (recent)",
|
||||||
|
listArgs: &ListArgs{
|
||||||
|
CreatedAfter: now.Add(-1 * time.Second).Format(time.RFC3339),
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
directCount: 5, // All test issues created just now
|
||||||
|
validator: func(t *testing.T, issues []*types.Issue) {
|
||||||
|
cutoff := now.Add(-1 * time.Second)
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.CreatedAt.Before(cutoff) {
|
||||||
|
t.Errorf("Issue %s created at %v is before cutoff %v", issue.ID, issue.CreatedAt, cutoff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "date range - created before (future)",
|
||||||
|
listArgs: &ListArgs{
|
||||||
|
CreatedBefore: now.Add(1 * time.Hour).Format(time.RFC3339),
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
directCount: 5, // All test issues created before future time
|
||||||
|
validator: func(t *testing.T, issues []*types.Issue) {
|
||||||
|
cutoff := now.Add(1 * time.Hour)
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.CreatedAt.After(cutoff) {
|
||||||
|
t.Errorf("Issue %s created at %v is after cutoff %v", issue.ID, issue.CreatedAt, cutoff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex combination - security issues without assignee",
|
||||||
|
listArgs: &ListArgs{
|
||||||
|
Labels: []string{"security"},
|
||||||
|
NoAssignee: true,
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
directCount: 1,
|
||||||
|
validator: func(t *testing.T, issues []*types.Issue) {
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.Assignee != "" {
|
||||||
|
t.Errorf("Issue %s has assignee: %s", issue.ID, issue.Assignee)
|
||||||
|
}
|
||||||
|
labels, err := store.GetLabels(ctx, issue.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to get labels: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasLabel := false
|
||||||
|
for _, l := range labels {
|
||||||
|
if l == "security" {
|
||||||
|
hasLabel = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasLabel {
|
||||||
|
t.Errorf("Issue %s does not have 'security' label", issue.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Test daemon mode (RPC)
|
||||||
|
resp, err := client.List(tt.listArgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RPC List failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rpcIssues []*types.Issue
|
||||||
|
if err := json.Unmarshal(resp.Data, &rpcIssues); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal RPC issues: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test direct mode
|
||||||
|
filter := listArgsToFilter(tt.listArgs, t)
|
||||||
|
directIssues, err := store.SearchIssues(ctx, "", *filter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Direct SearchIssues failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare counts
|
||||||
|
if len(rpcIssues) != len(directIssues) {
|
||||||
|
t.Errorf("Count mismatch: RPC returned %d issues, direct returned %d", len(rpcIssues), len(directIssues))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rpcIssues) != tt.directCount {
|
||||||
|
t.Errorf("Expected %d issues, RPC returned %d", tt.directCount, len(rpcIssues))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(directIssues) != tt.directCount {
|
||||||
|
t.Errorf("Expected %d issues, direct returned %d", tt.directCount, len(directIssues))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate RPC results
|
||||||
|
if tt.validator != nil {
|
||||||
|
tt.validator(t, rpcIssues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate direct results with same validator
|
||||||
|
if tt.validator != nil {
|
||||||
|
tt.validator(t, directIssues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare issue IDs (order might differ, so sort and compare)
|
||||||
|
rpcIDs := make(map[string]bool)
|
||||||
|
for _, issue := range rpcIssues {
|
||||||
|
rpcIDs[issue.ID] = true
|
||||||
|
}
|
||||||
|
directIDs := make(map[string]bool)
|
||||||
|
for _, issue := range directIssues {
|
||||||
|
directIDs[issue.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for id := range rpcIDs {
|
||||||
|
if !directIDs[id] {
|
||||||
|
t.Errorf("RPC returned issue %s not in direct results", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range directIDs {
|
||||||
|
if !rpcIDs[id] {
|
||||||
|
t.Errorf("Direct returned issue %s not in RPC results", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListFiltersDateParsing tests various date formats
|
||||||
|
func TestListFiltersDateParsing(t *testing.T) {
|
||||||
|
_, client, _, cleanup := setupTestServerWithStore(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create a test issue
|
||||||
|
createArgs := &CreateArgs{
|
||||||
|
Title: "Test issue",
|
||||||
|
IssueType: "task",
|
||||||
|
Priority: 2,
|
||||||
|
}
|
||||||
|
_, err := client.Create(createArgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testDate := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
validFormats := []struct {
|
||||||
|
name string
|
||||||
|
format string
|
||||||
|
}{
|
||||||
|
{"RFC3339", testDate.Format(time.RFC3339)},
|
||||||
|
{"RFC3339Nano", testDate.Format(time.RFC3339Nano)},
|
||||||
|
{"YYYY-MM-DD", testDate.Format("2006-01-02")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tf := range validFormats {
|
||||||
|
t.Run("valid_format_"+tf.name, func(t *testing.T) {
|
||||||
|
listArgs := &ListArgs{
|
||||||
|
CreatedAfter: tf.format,
|
||||||
|
Limit: 10,
|
||||||
|
}
|
||||||
|
resp, err := client.List(listArgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to parse %s format: %v", tf.name, err)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
t.Errorf("Expected response for %s format", tf.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidFormats := []string{
|
||||||
|
"2025-13-01", // Invalid month
|
||||||
|
"not-a-date",
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, invalid := range invalidFormats {
|
||||||
|
if invalid == "" {
|
||||||
|
continue // Empty string is valid (means no filter)
|
||||||
|
}
|
||||||
|
t.Run("invalid_format_"+string(rune(i)), func(t *testing.T) {
|
||||||
|
listArgs := &ListArgs{
|
||||||
|
CreatedAfter: invalid,
|
||||||
|
Limit: 10,
|
||||||
|
}
|
||||||
|
_, err := client.List(listArgs)
|
||||||
|
// Should either fail gracefully or handle the error
|
||||||
|
// The exact behavior depends on implementation
|
||||||
|
if err == nil {
|
||||||
|
t.Logf("Warning: invalid date %q did not produce error", invalid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListFiltersStatusNormalization tests that status='all' is treated as unset
|
||||||
|
func TestListFiltersStatusNormalization(t *testing.T) {
|
||||||
|
_, client, store, cleanup := setupTestServerWithStore(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create issues with different statuses
|
||||||
|
statuses := []types.Status{types.StatusOpen, types.StatusInProgress, types.StatusBlocked, types.StatusClosed}
|
||||||
|
for _, status := range statuses {
|
||||||
|
createArgs := &CreateArgs{
|
||||||
|
Title: "Test " + string(status),
|
||||||
|
IssueType: "task",
|
||||||
|
Priority: 2,
|
||||||
|
}
|
||||||
|
resp, err := client.Create(createArgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdIssue types.Issue
|
||||||
|
if err := json.Unmarshal(resp.Data, &createdIssue); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal created issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status (UpdateIssue automatically manages closed_at)
|
||||||
|
statusUpdates := map[string]interface{}{
|
||||||
|
"status": string(status),
|
||||||
|
}
|
||||||
|
if err := store.UpdateIssue(ctx, createdIssue.ID, statusUpdates, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to update issue: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test status='all' vs no status filter
|
||||||
|
allArgs := &ListArgs{
|
||||||
|
Status: "all",
|
||||||
|
Limit: 10,
|
||||||
|
}
|
||||||
|
allResp, err := client.List(allArgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List with status='all' failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
noStatusArgs := &ListArgs{
|
||||||
|
Limit: 10,
|
||||||
|
}
|
||||||
|
noStatusResp, err := client.List(noStatusArgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List with no status failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allIssues, noStatusIssues []*types.Issue
|
||||||
|
json.Unmarshal(allResp.Data, &allIssues)
|
||||||
|
json.Unmarshal(noStatusResp.Data, &noStatusIssues)
|
||||||
|
|
||||||
|
if len(allIssues) != len(noStatusIssues) {
|
||||||
|
t.Errorf("status='all' returned %d issues, no status returned %d", len(allIssues), len(noStatusIssues))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both should return all 4 statuses
|
||||||
|
if len(allIssues) < 4 {
|
||||||
|
t.Errorf("Expected at least 4 issues, got %d", len(allIssues))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListFiltersBackwardCompat tests that deprecated --label flag still works
|
||||||
|
func TestListFiltersBackwardCompat(t *testing.T) {
|
||||||
|
_, client, store, cleanup := setupTestServerWithStore(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create issue with label
|
||||||
|
createArgs := &CreateArgs{
|
||||||
|
Title: "Test issue",
|
||||||
|
IssueType: "task",
|
||||||
|
Priority: 2,
|
||||||
|
}
|
||||||
|
resp, err := client.Create(createArgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdIssue types.Issue
|
||||||
|
if err := json.Unmarshal(resp.Data, &createdIssue); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal created issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.AddLabel(ctx, createdIssue.ID, "testlabel", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to add label: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test deprecated Label field
|
||||||
|
deprecatedArgs := &ListArgs{
|
||||||
|
Label: "testlabel",
|
||||||
|
Limit: 10,
|
||||||
|
}
|
||||||
|
deprecatedResp, err := client.List(deprecatedArgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List with deprecated Label failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test new Labels field
|
||||||
|
newArgs := &ListArgs{
|
||||||
|
Labels: []string{"testlabel"},
|
||||||
|
Limit: 10,
|
||||||
|
}
|
||||||
|
newResp, err := client.List(newArgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List with new Labels failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var deprecatedIssues, newIssues []*types.Issue
|
||||||
|
json.Unmarshal(deprecatedResp.Data, &deprecatedIssues)
|
||||||
|
json.Unmarshal(newResp.Data, &newIssues)
|
||||||
|
|
||||||
|
if len(deprecatedIssues) != len(newIssues) {
|
||||||
|
t.Errorf("Deprecated Label and new Labels returned different counts: %d vs %d", len(deprecatedIssues), len(newIssues))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func ptrInt(i int) *int {
|
||||||
|
return &i
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrTime(t time.Time) *time.Time {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// listArgsToFilter converts ListArgs to IssueFilter for direct store comparison
|
||||||
|
func listArgsToFilter(args *ListArgs, t *testing.T) *types.IssueFilter {
|
||||||
|
filter := &types.IssueFilter{
|
||||||
|
Limit: args.Limit,
|
||||||
|
TitleContains: args.TitleContains,
|
||||||
|
DescriptionContains: args.DescriptionContains,
|
||||||
|
NotesContains: args.NotesContains,
|
||||||
|
EmptyDescription: args.EmptyDescription,
|
||||||
|
NoAssignee: args.NoAssignee,
|
||||||
|
NoLabels: args.NoLabels,
|
||||||
|
PriorityMin: args.PriorityMin,
|
||||||
|
PriorityMax: args.PriorityMax,
|
||||||
|
Labels: args.Labels,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Status != "" && args.Status != "all" {
|
||||||
|
status := types.Status(args.Status)
|
||||||
|
filter.Status = &status
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Priority != nil {
|
||||||
|
filter.Priority = args.Priority
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Assignee != "" {
|
||||||
|
filter.Assignee = &args.Assignee
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.IssueType != "" {
|
||||||
|
issueType := types.IssueType(args.IssueType)
|
||||||
|
filter.IssueType = &issueType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse dates
|
||||||
|
parseTime := func(s string) *time.Time {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
formats := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02"}
|
||||||
|
for _, format := range formats {
|
||||||
|
if t, err := time.Parse(format, s); err == nil {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filter.CreatedAfter = parseTime(args.CreatedAfter)
|
||||||
|
filter.CreatedBefore = parseTime(args.CreatedBefore)
|
||||||
|
filter.UpdatedAfter = parseTime(args.UpdatedAfter)
|
||||||
|
filter.UpdatedBefore = parseTime(args.UpdatedBefore)
|
||||||
|
filter.ClosedAfter = parseTime(args.ClosedAfter)
|
||||||
|
filter.ClosedBefore = parseTime(args.ClosedBefore)
|
||||||
|
|
||||||
|
return filter
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user