Reviewed by beads/crew/wolf. Fixes daemon mode silently ignoring --due and --defer flags. Adds comprehensive tests including TestDualPathParity for regression prevention.
700 lines
20 KiB
Go
700 lines
20 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// TestUpdatesFromArgs_DueAt verifies that DueAt is extracted from UpdateArgs
|
|
// and included in the updates map for the storage layer.
|
|
//
|
|
// This test is a TRACER BULLET for GH#952 Issue 1: Daemon ignoring --due flag.
|
|
// Gap 1: updatesFromArgs() handles 19 fields but DueAt/DeferUntil are MISSING.
|
|
//
|
|
// Expected behavior: When UpdateArgs.DueAt contains an RFC3339 date string,
|
|
// it should be parsed and added to the updates map as a time.Time value.
|
|
func TestUpdatesFromArgs_DueAt(t *testing.T) {
|
|
tests := map[string]struct {
|
|
input string // ISO date or RFC3339 format
|
|
wantKey string
|
|
wantTime bool // if true, expect time.Time value; if false, expect nil
|
|
}{
|
|
"RFC3339 with timezone": {
|
|
input: "2026-01-15T10:00:00Z",
|
|
wantKey: "due_at",
|
|
wantTime: true,
|
|
},
|
|
"ISO date only": {
|
|
input: "2026-01-15",
|
|
wantKey: "due_at",
|
|
wantTime: true,
|
|
},
|
|
}
|
|
|
|
for name, tt := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
args := UpdateArgs{
|
|
ID: "test-issue",
|
|
DueAt: &tt.input,
|
|
}
|
|
|
|
updates, err := updatesFromArgs(args)
|
|
if err != nil {
|
|
t.Fatalf("updatesFromArgs returned error: %v", err)
|
|
}
|
|
|
|
val, exists := updates[tt.wantKey]
|
|
if !exists {
|
|
t.Fatalf("updatesFromArgs did not include %q key; got keys: %v", tt.wantKey, mapKeys(updates))
|
|
}
|
|
|
|
if tt.wantTime {
|
|
if _, ok := val.(time.Time); !ok {
|
|
t.Errorf("expected time.Time value for %q, got %T: %v", tt.wantKey, val, val)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUpdatesFromArgs_DeferUntil verifies that DeferUntil is extracted from UpdateArgs
|
|
// and included in the updates map for the storage layer.
|
|
//
|
|
// This test is a TRACER BULLET for GH#952 Issue 1: Daemon ignoring --defer flag.
|
|
// Gap 1: updatesFromArgs() handles 19 fields but DueAt/DeferUntil are MISSING.
|
|
//
|
|
// Expected behavior: When UpdateArgs.DeferUntil contains an RFC3339 date string,
|
|
// it should be parsed and added to the updates map as a time.Time value.
|
|
func TestUpdatesFromArgs_DeferUntil(t *testing.T) {
|
|
tests := map[string]struct {
|
|
input string // ISO date or RFC3339 format
|
|
wantKey string
|
|
wantTime bool
|
|
}{
|
|
"RFC3339 with timezone": {
|
|
input: "2026-01-20T14:30:00Z",
|
|
wantKey: "defer_until",
|
|
wantTime: true,
|
|
},
|
|
"ISO date only": {
|
|
input: "2026-01-20",
|
|
wantKey: "defer_until",
|
|
wantTime: true,
|
|
},
|
|
}
|
|
|
|
for name, tt := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
args := UpdateArgs{
|
|
ID: "test-issue",
|
|
DeferUntil: &tt.input,
|
|
}
|
|
|
|
updates, err := updatesFromArgs(args)
|
|
if err != nil {
|
|
t.Fatalf("updatesFromArgs returned error: %v", err)
|
|
}
|
|
|
|
val, exists := updates[tt.wantKey]
|
|
if !exists {
|
|
t.Fatalf("updatesFromArgs did not include %q key; got keys: %v", tt.wantKey, mapKeys(updates))
|
|
}
|
|
|
|
if tt.wantTime {
|
|
if _, ok := val.(time.Time); !ok {
|
|
t.Errorf("expected time.Time value for %q, got %T: %v", tt.wantKey, val, val)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUpdatesFromArgs_ClearFields verifies that empty strings clear date fields.
|
|
//
|
|
// This test is a TRACER BULLET for GH#952: verifying that undefer works.
|
|
// When an empty string is passed for DueAt or DeferUntil, it should result in
|
|
// a nil value in the updates map, which will clear the field in the database.
|
|
//
|
|
// Expected behavior: Empty string input should set the field to nil in updates map.
|
|
func TestUpdatesFromArgs_ClearFields(t *testing.T) {
|
|
tests := map[string]struct {
|
|
setupArgs func() UpdateArgs
|
|
wantKey string
|
|
}{
|
|
"clear due_at with empty string": {
|
|
setupArgs: func() UpdateArgs {
|
|
empty := ""
|
|
return UpdateArgs{
|
|
ID: "test-issue",
|
|
DueAt: &empty,
|
|
}
|
|
},
|
|
wantKey: "due_at",
|
|
},
|
|
"clear defer_until with empty string": {
|
|
setupArgs: func() UpdateArgs {
|
|
empty := ""
|
|
return UpdateArgs{
|
|
ID: "test-issue",
|
|
DeferUntil: &empty,
|
|
}
|
|
},
|
|
wantKey: "defer_until",
|
|
},
|
|
}
|
|
|
|
for name, tt := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
args := tt.setupArgs()
|
|
|
|
updates, err := updatesFromArgs(args)
|
|
if err != nil {
|
|
t.Fatalf("updatesFromArgs returned error: %v", err)
|
|
}
|
|
|
|
val, exists := updates[tt.wantKey]
|
|
if !exists {
|
|
t.Fatalf("updatesFromArgs did not include %q key for clearing; got keys: %v", tt.wantKey, mapKeys(updates))
|
|
}
|
|
|
|
// When clearing, value should be nil (not an empty string)
|
|
if val != nil {
|
|
t.Errorf("expected nil value for clearing %q, got %T: %v", tt.wantKey, val, val)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleCreate_DeferUntil verifies that DeferUntil is parsed and set in handleCreate.
|
|
//
|
|
// This test is a TRACER BULLET for GH#952 Issue 1: Daemon ignoring --defer in create.
|
|
// Gap 2: handleCreate() parses DueAt (lines 224-239) but NOT DeferUntil.
|
|
//
|
|
// Expected behavior: When CreateArgs.DeferUntil contains an ISO date or RFC3339 string,
|
|
// it should be parsed and set on the created issue's DeferUntil field.
|
|
func TestHandleCreate_DeferUntil(t *testing.T) {
|
|
_, client, cleanup := setupTestServer(t)
|
|
defer cleanup()
|
|
|
|
tests := map[string]struct {
|
|
deferUntil string
|
|
wantSet bool // true if DeferUntil should be set on the issue
|
|
}{
|
|
"RFC3339 format": {
|
|
deferUntil: "2026-01-20T14:30:00Z",
|
|
wantSet: true,
|
|
},
|
|
"ISO date format": {
|
|
deferUntil: "2026-01-20",
|
|
wantSet: true,
|
|
},
|
|
}
|
|
|
|
for name, tt := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
createArgs := &CreateArgs{
|
|
Title: "Test issue with defer - " + name,
|
|
IssueType: "task",
|
|
Priority: 1,
|
|
DeferUntil: tt.deferUntil,
|
|
}
|
|
|
|
resp, err := client.Create(createArgs)
|
|
if err != nil {
|
|
t.Fatalf("Create failed: %v", err)
|
|
}
|
|
if !resp.Success {
|
|
t.Fatalf("Create returned error: %s", resp.Error)
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
|
t.Fatalf("Failed to unmarshal issue: %v", err)
|
|
}
|
|
|
|
if tt.wantSet {
|
|
if issue.DeferUntil == nil {
|
|
t.Error("expected DeferUntil to be set, got nil")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUpdateViaDaemon_DueAt tests end-to-end update of DueAt through the daemon RPC.
|
|
//
|
|
// This test verifies that `bd update --due` works via daemon mode.
|
|
// It creates an issue, updates it with a due date via RPC, and verifies
|
|
// the due date was actually persisted.
|
|
func TestUpdateViaDaemon_DueAt(t *testing.T) {
|
|
_, client, store, cleanup := setupTestServerWithStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create an issue without due date
|
|
createArgs := &CreateArgs{
|
|
Title: "Issue for due date update test",
|
|
IssueType: "task",
|
|
Priority: 1,
|
|
}
|
|
|
|
createResp, err := client.Create(createArgs)
|
|
if err != nil {
|
|
t.Fatalf("Create failed: %v", err)
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(createResp.Data, &issue); err != nil {
|
|
t.Fatalf("Failed to unmarshal issue: %v", err)
|
|
}
|
|
|
|
// Update with due date via daemon RPC
|
|
dueDate := "2026-01-25"
|
|
updateArgs := &UpdateArgs{
|
|
ID: issue.ID,
|
|
DueAt: &dueDate,
|
|
}
|
|
|
|
updateResp, err := client.Update(updateArgs)
|
|
if err != nil {
|
|
t.Fatalf("Update failed: %v", err)
|
|
}
|
|
if !updateResp.Success {
|
|
t.Fatalf("Update returned error: %s", updateResp.Error)
|
|
}
|
|
|
|
// Verify directly from storage
|
|
retrieved, err := store.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get issue: %v", err)
|
|
}
|
|
|
|
if retrieved.DueAt == nil {
|
|
t.Fatal("expected DueAt to be set after update, got nil")
|
|
}
|
|
|
|
// Verify the date is correct (just check the date part)
|
|
expectedDate := time.Date(2026, 1, 25, 0, 0, 0, 0, time.Local)
|
|
if retrieved.DueAt.Year() != expectedDate.Year() ||
|
|
retrieved.DueAt.Month() != expectedDate.Month() ||
|
|
retrieved.DueAt.Day() != expectedDate.Day() {
|
|
t.Errorf("DueAt date mismatch: got %v, want date 2026-01-25", retrieved.DueAt)
|
|
}
|
|
}
|
|
|
|
// TestUpdateViaDaemon_DeferUntil tests end-to-end update of DeferUntil through the daemon RPC.
|
|
//
|
|
// This test verifies that `bd update --defer` and `bd defer --until` work via daemon mode.
|
|
func TestUpdateViaDaemon_DeferUntil(t *testing.T) {
|
|
_, client, store, cleanup := setupTestServerWithStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create an issue without defer_until
|
|
createArgs := &CreateArgs{
|
|
Title: "Issue for defer update test",
|
|
IssueType: "task",
|
|
Priority: 1,
|
|
}
|
|
|
|
createResp, err := client.Create(createArgs)
|
|
if err != nil {
|
|
t.Fatalf("Create failed: %v", err)
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(createResp.Data, &issue); err != nil {
|
|
t.Fatalf("Failed to unmarshal issue: %v", err)
|
|
}
|
|
|
|
// Update with defer_until via daemon RPC
|
|
deferDate := "2026-01-30"
|
|
updateArgs := &UpdateArgs{
|
|
ID: issue.ID,
|
|
DeferUntil: &deferDate,
|
|
}
|
|
|
|
updateResp, err := client.Update(updateArgs)
|
|
if err != nil {
|
|
t.Fatalf("Update failed: %v", err)
|
|
}
|
|
if !updateResp.Success {
|
|
t.Fatalf("Update returned error: %s", updateResp.Error)
|
|
}
|
|
|
|
// Verify directly from storage
|
|
retrieved, err := store.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get issue: %v", err)
|
|
}
|
|
|
|
if retrieved.DeferUntil == nil {
|
|
t.Fatal("expected DeferUntil to be set after update, got nil")
|
|
}
|
|
|
|
// Verify the date is correct
|
|
expectedDate := time.Date(2026, 1, 30, 0, 0, 0, 0, time.Local)
|
|
if retrieved.DeferUntil.Year() != expectedDate.Year() ||
|
|
retrieved.DeferUntil.Month() != expectedDate.Month() ||
|
|
retrieved.DeferUntil.Day() != expectedDate.Day() {
|
|
t.Errorf("DeferUntil date mismatch: got %v, want date 2026-01-30", retrieved.DeferUntil)
|
|
}
|
|
}
|
|
|
|
// TestUndefer_ClearsDeferUntil tests that undefer clears the defer_until field via daemon.
|
|
//
|
|
// This verifies SC-005: `bd undefer` clears defer_until via daemon.
|
|
func TestUndefer_ClearsDeferUntil(t *testing.T) {
|
|
_, client, store, cleanup := setupTestServerWithStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create an issue with defer_until set
|
|
createArgs := &CreateArgs{
|
|
Title: "Issue to undefer",
|
|
IssueType: "task",
|
|
Priority: 1,
|
|
DeferUntil: "2026-02-15",
|
|
}
|
|
|
|
createResp, err := client.Create(createArgs)
|
|
if err != nil {
|
|
t.Fatalf("Create failed: %v", err)
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(createResp.Data, &issue); err != nil {
|
|
t.Fatalf("Failed to unmarshal issue: %v", err)
|
|
}
|
|
|
|
// Verify defer_until was set on create
|
|
retrieved, err := store.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get issue: %v", err)
|
|
}
|
|
if retrieved.DeferUntil == nil {
|
|
t.Log("WARNING: DeferUntil not set on create - Gap 2 not yet fixed")
|
|
// Set it directly for this test
|
|
deferTime := time.Date(2026, 2, 15, 0, 0, 0, 0, time.Local)
|
|
updates := map[string]interface{}{"defer_until": deferTime}
|
|
if err := store.UpdateIssue(ctx, issue.ID, updates, "test"); err != nil {
|
|
t.Fatalf("Failed to set defer_until directly: %v", err)
|
|
}
|
|
}
|
|
|
|
// Now clear defer_until via RPC update with empty string
|
|
empty := ""
|
|
updateArgs := &UpdateArgs{
|
|
ID: issue.ID,
|
|
DeferUntil: &empty,
|
|
}
|
|
|
|
updateResp, err := client.Update(updateArgs)
|
|
if err != nil {
|
|
t.Fatalf("Update (undefer) failed: %v", err)
|
|
}
|
|
if !updateResp.Success {
|
|
t.Fatalf("Update (undefer) returned error: %s", updateResp.Error)
|
|
}
|
|
|
|
// Verify defer_until was cleared
|
|
retrieved, err = store.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get issue after undefer: %v", err)
|
|
}
|
|
|
|
if retrieved.DeferUntil != nil {
|
|
t.Errorf("expected DeferUntil to be nil after undefer, got %v", retrieved.DeferUntil)
|
|
}
|
|
}
|
|
|
|
// TestCreateWithRelativeDate tests that relative date formats like "+1d" work via daemon create.
|
|
//
|
|
// This test validates GH#952 Issue 3 fix: CLI formats relative dates as RFC3339.
|
|
// Gap 3 fix: create.go now converts "+1d", "tomorrow" etc. to RFC3339 before sending.
|
|
//
|
|
// This test simulates the fixed CLI behavior by pre-formatting relative dates.
|
|
// The daemon receives RFC3339 strings and parses them correctly.
|
|
func TestCreateWithRelativeDate(t *testing.T) {
|
|
_, client, store, cleanup := setupTestServerWithStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
|
|
tests := map[string]struct {
|
|
dueOffset time.Duration // Duration from now for DueAt
|
|
deferOffset time.Duration // Duration from now for DeferUntil
|
|
wantDue bool
|
|
wantDefer bool
|
|
}{
|
|
"relative +1d for due": {
|
|
dueOffset: 24 * time.Hour,
|
|
wantDue: true,
|
|
},
|
|
"relative tomorrow for defer": {
|
|
deferOffset: 24 * time.Hour,
|
|
wantDefer: true,
|
|
},
|
|
"both relative dates": {
|
|
dueOffset: 48 * time.Hour,
|
|
deferOffset: 24 * time.Hour,
|
|
wantDue: true,
|
|
wantDefer: true,
|
|
},
|
|
}
|
|
|
|
for name, tt := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
// Simulate what create.go now does: format times as RFC3339
|
|
var dueStr, deferStr string
|
|
if tt.dueOffset > 0 {
|
|
dueStr = now.Add(tt.dueOffset).Format(time.RFC3339)
|
|
}
|
|
if tt.deferOffset > 0 {
|
|
deferStr = now.Add(tt.deferOffset).Format(time.RFC3339)
|
|
}
|
|
|
|
createArgs := &CreateArgs{
|
|
Title: "Issue with relative date - " + name,
|
|
IssueType: "task",
|
|
Priority: 1,
|
|
DueAt: dueStr,
|
|
DeferUntil: deferStr,
|
|
}
|
|
|
|
resp, err := client.Create(createArgs)
|
|
if err != nil {
|
|
t.Fatalf("Create failed: %v", err)
|
|
}
|
|
if !resp.Success {
|
|
// This is expected to fail currently because the daemon doesn't parse relative dates
|
|
t.Logf("Create returned error (expected with current bug): %s", resp.Error)
|
|
t.Fatalf("Create failed with relative date: %s", resp.Error)
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
|
t.Fatalf("Failed to unmarshal issue: %v", err)
|
|
}
|
|
|
|
// Verify from storage to ensure persistence
|
|
retrieved, err := store.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get issue: %v", err)
|
|
}
|
|
|
|
if tt.wantDue {
|
|
if retrieved.DueAt == nil {
|
|
t.Error("expected DueAt to be set from relative date, got nil")
|
|
} else {
|
|
// Verify it's in the future
|
|
if retrieved.DueAt.Before(time.Now()) {
|
|
t.Errorf("expected DueAt to be in the future, got %v", retrieved.DueAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
if tt.wantDefer {
|
|
if retrieved.DeferUntil == nil {
|
|
t.Error("expected DeferUntil to be set from relative date, got nil")
|
|
} else {
|
|
// Verify it's in the future
|
|
if retrieved.DeferUntil.Before(time.Now()) {
|
|
t.Errorf("expected DeferUntil to be in the future, got %v", retrieved.DeferUntil)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// mapKeys returns the keys of a map for debugging
|
|
func mapKeys(m map[string]interface{}) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// TestDualPathParity validates that daemon mode produces identical results to direct mode.
|
|
// This test prevents regressions like GH#952 where new fields work in direct mode but
|
|
// fail in daemon mode due to missing extraction in updatesFromArgs() or handleCreate().
|
|
//
|
|
// ADD NEW FIELDS HERE when extending the Issue type to prevent future gaps.
|
|
func TestDualPathParity(t *testing.T) {
|
|
_, client, store, cleanup := setupTestServerWithStore(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
now := time.Now()
|
|
dueAt := now.Add(24 * time.Hour)
|
|
deferUntil := now.Add(48 * time.Hour)
|
|
|
|
t.Run("Create_DueAt", func(t *testing.T) {
|
|
// Direct mode: set field directly on Issue struct
|
|
directIssue := &types.Issue{
|
|
Title: "Direct DueAt",
|
|
IssueType: "task",
|
|
Priority: 1,
|
|
Status: types.StatusOpen,
|
|
DueAt: &dueAt,
|
|
CreatedAt: now,
|
|
}
|
|
if err := store.CreateIssue(ctx, directIssue, "bd"); err != nil {
|
|
t.Fatalf("Direct create failed: %v", err)
|
|
}
|
|
|
|
// Daemon mode: send via RPC
|
|
resp, err := client.Create(&CreateArgs{
|
|
Title: "Daemon DueAt",
|
|
IssueType: "task",
|
|
Priority: 1,
|
|
DueAt: dueAt.Format(time.RFC3339),
|
|
})
|
|
if err != nil || !resp.Success {
|
|
t.Fatalf("Daemon create failed: %v / %s", err, resp.Error)
|
|
}
|
|
var daemonIssue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &daemonIssue); err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
|
|
// Compare persisted values
|
|
directRetrieved, _ := store.GetIssue(ctx, directIssue.ID)
|
|
daemonRetrieved, _ := store.GetIssue(ctx, daemonIssue.ID)
|
|
|
|
if !compareTimePtr(t, "DueAt", directRetrieved.DueAt, daemonRetrieved.DueAt) {
|
|
t.Error("PARITY FAILURE: DueAt differs between direct and daemon mode")
|
|
}
|
|
})
|
|
|
|
t.Run("Create_DeferUntil", func(t *testing.T) {
|
|
// Direct mode
|
|
directIssue := &types.Issue{
|
|
Title: "Direct DeferUntil",
|
|
IssueType: "task",
|
|
Priority: 1,
|
|
Status: types.StatusOpen,
|
|
DeferUntil: &deferUntil,
|
|
CreatedAt: now,
|
|
}
|
|
if err := store.CreateIssue(ctx, directIssue, "bd"); err != nil {
|
|
t.Fatalf("Direct create failed: %v", err)
|
|
}
|
|
|
|
// Daemon mode
|
|
resp, err := client.Create(&CreateArgs{
|
|
Title: "Daemon DeferUntil",
|
|
IssueType: "task",
|
|
Priority: 1,
|
|
DeferUntil: deferUntil.Format(time.RFC3339),
|
|
})
|
|
if err != nil || !resp.Success {
|
|
t.Fatalf("Daemon create failed: %v / %s", err, resp.Error)
|
|
}
|
|
var daemonIssue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &daemonIssue); err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
|
|
// Compare persisted values
|
|
directRetrieved, _ := store.GetIssue(ctx, directIssue.ID)
|
|
daemonRetrieved, _ := store.GetIssue(ctx, daemonIssue.ID)
|
|
|
|
if !compareTimePtr(t, "DeferUntil", directRetrieved.DeferUntil, daemonRetrieved.DeferUntil) {
|
|
t.Error("PARITY FAILURE: DeferUntil differs between direct and daemon mode")
|
|
}
|
|
})
|
|
|
|
t.Run("Update_DueAt", func(t *testing.T) {
|
|
// Create base issue
|
|
issue := &types.Issue{
|
|
Title: "Update DueAt Test",
|
|
IssueType: "task",
|
|
Priority: 1,
|
|
Status: types.StatusOpen,
|
|
CreatedAt: now,
|
|
}
|
|
if err := store.CreateIssue(ctx, issue, "bd"); err != nil {
|
|
t.Fatalf("Create failed: %v", err)
|
|
}
|
|
|
|
// Update via daemon
|
|
dueStr := dueAt.Format(time.RFC3339)
|
|
resp, err := client.Update(&UpdateArgs{
|
|
ID: issue.ID,
|
|
DueAt: &dueStr,
|
|
})
|
|
if err != nil || !resp.Success {
|
|
t.Fatalf("Daemon update failed: %v / %s", err, resp.Error)
|
|
}
|
|
|
|
// Verify persisted
|
|
retrieved, _ := store.GetIssue(ctx, issue.ID)
|
|
if retrieved.DueAt == nil {
|
|
t.Error("PARITY FAILURE: DueAt not set after daemon update")
|
|
} else if retrieved.DueAt.Sub(dueAt).Abs() > time.Second {
|
|
t.Errorf("PARITY FAILURE: DueAt mismatch: got %v, want %v", *retrieved.DueAt, dueAt)
|
|
}
|
|
})
|
|
|
|
t.Run("Update_DeferUntil", func(t *testing.T) {
|
|
// Create base issue
|
|
issue := &types.Issue{
|
|
Title: "Update DeferUntil Test",
|
|
IssueType: "task",
|
|
Priority: 1,
|
|
Status: types.StatusOpen,
|
|
CreatedAt: now,
|
|
}
|
|
if err := store.CreateIssue(ctx, issue, "bd"); err != nil {
|
|
t.Fatalf("Create failed: %v", err)
|
|
}
|
|
|
|
// Update via daemon
|
|
deferStr := deferUntil.Format(time.RFC3339)
|
|
resp, err := client.Update(&UpdateArgs{
|
|
ID: issue.ID,
|
|
DeferUntil: &deferStr,
|
|
})
|
|
if err != nil || !resp.Success {
|
|
t.Fatalf("Daemon update failed: %v / %s", err, resp.Error)
|
|
}
|
|
|
|
// Verify persisted
|
|
retrieved, _ := store.GetIssue(ctx, issue.ID)
|
|
if retrieved.DeferUntil == nil {
|
|
t.Error("PARITY FAILURE: DeferUntil not set after daemon update")
|
|
} else if retrieved.DeferUntil.Sub(deferUntil).Abs() > time.Second {
|
|
t.Errorf("PARITY FAILURE: DeferUntil mismatch: got %v, want %v", *retrieved.DeferUntil, deferUntil)
|
|
}
|
|
})
|
|
|
|
// ADD NEW FIELD PARITY TESTS HERE when extending Issue type
|
|
}
|
|
|
|
// compareTimePtr compares two time pointers with 1-second tolerance
|
|
func compareTimePtr(t *testing.T, name string, direct, daemon *time.Time) bool {
|
|
if (direct == nil) != (daemon == nil) {
|
|
t.Errorf("%s nil mismatch: direct=%v, daemon=%v", name, direct, daemon)
|
|
return false
|
|
}
|
|
if direct != nil && daemon != nil {
|
|
// Allow 1-second tolerance for parsing/timezone differences
|
|
if direct.Sub(*daemon).Abs() > time.Second {
|
|
t.Errorf("%s value mismatch: direct=%v, daemon=%v", name, *direct, *daemon)
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|