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:
Steve Yegge
2025-11-05 00:56:06 -08:00
parent c10a5bc839
commit 510ca17195

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