Files
pad/internal/store/items_empty_assignment_test.go
T
xarmian 7943234dd7 fix(store): treat empty assignment IDs as clear-to-NULL (BUG-2566) (#1098)
* fix(store): treat empty assignment IDs as clear-to-NULL (BUG-2566)

PATCH with {"assigned_user_id": ""} 500'd with a raw FK constraint
error and left the item assigned. An explicit empty string is what a
JSON client sends when a user blanks the field (JSON null on a *string
decodes to nil = "don't change", so it can't express clearing), and
validateAssignmentScope already skips "" as nothing-to-validate — the
SQL builder just never learned the same convention and bound it
verbatim into the FK column.

Store-level fix so every surface (HTTP, bulk, MCP, CLI) inherits it:
"" now clears assigned_user_id / agent_role_id exactly like
ClearAssignedUser / ClearAgentRole on update, and binds NULL on
create. The mutation signal keeps the ClearAssignedUser shape (tested)
so watch notifications are unaffected. parent_id deliberately NOT
given the same coercion: parent relations also live in item_links, and
clearing the column alone would desync them.

Web UI is unaffected either way — it already sends
clear_assigned_user: true; only API clients hit this.

Tests reproduce the exact FK failure pre-fix (verified by stash-run)
and pass on both SQLite and PostgreSQL post-fix.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt

* fix(store): relocate nullIfEmptyID out of createItemTx's doc comment

Codex round 2 P3: the helper was inserted between createItemTx's doc
block and the function, orphaning the doc comment.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-14 22:39:39 -04:00

93 lines
3.0 KiB
Go

package store
import (
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-2566: an explicit empty string for assigned_user_id / agent_role_id
// is what a JSON client sends when a user blanks the field. It must clear
// the assignment (write NULL) exactly like ClearAssignedUser /
// ClearAgentRole — before the fix it was bound verbatim and failed the FK
// with a driver-specific 500.
func TestUpdateItem_EmptyAssignedUserIDClearsAssignment(t *testing.T) {
t.Parallel()
s := testStore(t)
wsID, colID := newTransitionTestWorkspace(t, s)
item := createTestItem(t, s, wsID, colID, "Assign then blank", "")
assignee := createTestUser(t, s, "blankme@example.com", "BlankMe", "pw")
if err := s.AddWorkspaceMember(wsID, assignee.ID, "editor"); err != nil {
t.Fatalf("add member: %v", err)
}
if _, err := s.UpdateItem(item.ID, models.ItemUpdate{AssignedUserID: &assignee.ID}); err != nil {
t.Fatalf("assign item: %v", err)
}
empty := ""
cleared, err := s.UpdateItem(item.ID, models.ItemUpdate{AssignedUserID: &empty})
if err != nil {
t.Fatalf("update with empty assigned_user_id: %v", err)
}
if cleared.AssignedUserID != nil {
t.Fatalf("expected assignment cleared, got %q", *cleared.AssignedUserID)
}
// The mutation signal must match the ClearAssignedUser shape — the
// notification pipeline keys off it.
if cleared.LastMutation == nil || !cleared.LastMutation.AssignmentChanged {
t.Fatalf("expected AssignmentChanged=true, got %+v", cleared.LastMutation)
}
if cleared.LastMutation.FromAssignedUserID != assignee.ID || cleared.LastMutation.ToAssignedUserID != "" {
t.Fatalf("expected %q -> '', got %q -> %q", assignee.ID,
cleared.LastMutation.FromAssignedUserID, cleared.LastMutation.ToAssignedUserID)
}
}
func TestUpdateItem_EmptyAgentRoleIDClearsRole(t *testing.T) {
t.Parallel()
s := testStore(t)
wsID, colID := newTransitionTestWorkspace(t, s)
item := createTestItem(t, s, wsID, colID, "Role then blank", "")
role, err := s.CreateAgentRole(wsID, models.AgentRoleCreate{Name: "Blanker"})
if err != nil {
t.Fatalf("CreateAgentRole: %v", err)
}
if _, err := s.UpdateItem(item.ID, models.ItemUpdate{AgentRoleID: &role.ID}); err != nil {
t.Fatalf("set role: %v", err)
}
empty := ""
cleared, err := s.UpdateItem(item.ID, models.ItemUpdate{AgentRoleID: &empty})
if err != nil {
t.Fatalf("update with empty agent_role_id: %v", err)
}
if cleared.AgentRoleID != nil {
t.Fatalf("expected role cleared, got %q", *cleared.AgentRoleID)
}
}
func TestCreateItem_EmptyAssignmentIDsAreNull(t *testing.T) {
t.Parallel()
s := testStore(t)
wsID, colID := newTransitionTestWorkspace(t, s)
empty := ""
item, err := s.CreateItem(wsID, colID, models.ItemCreate{
Title: "Born unassigned",
AssignedUserID: &empty,
AgentRoleID: &empty,
})
if err != nil {
t.Fatalf("create with empty assignment ids: %v", err)
}
if item.AssignedUserID != nil {
t.Fatalf("expected nil assigned_user_id, got %q", *item.AssignedUserID)
}
if item.AgentRoleID != nil {
t.Fatalf("expected nil agent_role_id, got %q", *item.AgentRoleID)
}
}