1018 lines
36 KiB
Go
1018 lines
36 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
orchmodels "github.com/kandev/kandev/internal/office/models"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
// fakeCascadeRepo extends phase4TaskRepo with archive/unarchive support
|
|
// keyed by id. This is a minimal fake — a single map-of-tasks is enough
|
|
// to exercise the cascade walk.
|
|
type fakeCascadeRepo struct {
|
|
*phase4TaskRepo
|
|
}
|
|
|
|
func newCascadeRepo(base *fakeTaskRepo) *fakeCascadeRepo {
|
|
return &fakeCascadeRepo{phase4TaskRepo: &phase4TaskRepo{base: base}}
|
|
}
|
|
|
|
func (r *fakeCascadeRepo) ArchiveTaskIfActive(_ context.Context, id, cascadeID string) (bool, error) {
|
|
r.base.mu.Lock()
|
|
defer r.base.mu.Unlock()
|
|
t := r.base.tasks[id]
|
|
if t == nil || t.ArchivedAt != nil {
|
|
return false, nil
|
|
}
|
|
now := time.Now().UTC()
|
|
t.ArchivedAt = &now
|
|
t.ArchivedByCascadeID = cascadeID
|
|
return true, nil
|
|
}
|
|
|
|
func (r *fakeCascadeRepo) UnarchiveTaskByCascade(_ context.Context, id, cascadeID string) (bool, error) {
|
|
r.base.mu.Lock()
|
|
defer r.base.mu.Unlock()
|
|
t := r.base.tasks[id]
|
|
if t == nil || t.ArchivedByCascadeID != cascadeID {
|
|
return false, nil
|
|
}
|
|
t.ArchivedAt = nil
|
|
t.ArchivedByCascadeID = ""
|
|
return true, nil
|
|
}
|
|
|
|
// fakeWSGroupRepoCascade extends fakeWSGroupRepo with the phase 6
|
|
// release/restore/cleanup-status methods.
|
|
type fakeWSGroupRepoCascade struct {
|
|
*fakeWSGroupRepo
|
|
releaseCalls []struct {
|
|
groupID, taskID, reason, cascadeID string
|
|
}
|
|
cleanupStatuses map[string]string
|
|
// allMembers records every member that ever joined the group
|
|
// (including released ones). Tests opt-in by populating it
|
|
// directly — when empty, ListWorkspaceGroupMembers falls back to
|
|
// the active members map.
|
|
allMembers map[string]map[string]string
|
|
}
|
|
|
|
func newCascadeWSGroupRepo() *fakeWSGroupRepoCascade {
|
|
return &fakeWSGroupRepoCascade{
|
|
fakeWSGroupRepo: newFakeWSGroupRepo(),
|
|
cleanupStatuses: map[string]string{},
|
|
allMembers: map[string]map[string]string{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeWSGroupRepoCascade) ListWorkspaceGroupMembers(ctx context.Context, groupID string) ([]orchmodels.WorkspaceGroupMember, error) {
|
|
f.mu.Lock()
|
|
if hist, ok := f.allMembers[groupID]; ok && len(hist) > 0 {
|
|
out := make([]orchmodels.WorkspaceGroupMember, 0, len(hist))
|
|
for tid, role := range hist {
|
|
out = append(out, orchmodels.WorkspaceGroupMember{
|
|
WorkspaceGroupID: groupID,
|
|
TaskID: tid,
|
|
Role: role,
|
|
})
|
|
}
|
|
f.mu.Unlock()
|
|
return out, nil
|
|
}
|
|
f.mu.Unlock()
|
|
return f.fakeWSGroupRepo.ListWorkspaceGroupMembers(ctx, groupID)
|
|
}
|
|
|
|
func (f *fakeWSGroupRepoCascade) ReleaseWorkspaceGroupMember(_ context.Context, groupID, taskID, reason, cascadeID string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.releaseCalls = append(f.releaseCalls, struct {
|
|
groupID, taskID, reason, cascadeID string
|
|
}{groupID, taskID, reason, cascadeID})
|
|
// Mark the member released by removing it from the live members map.
|
|
delete(f.members[groupID], taskID)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeWSGroupRepoCascade) RestoreWorkspaceGroupMemberByCascade(_ context.Context, taskID, cascadeID string) error {
|
|
// no-op for these tests
|
|
_ = taskID
|
|
_ = cascadeID
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeWSGroupRepoCascade) ListActiveWorkspaceGroupMembers(_ context.Context, groupID string) ([]orchmodels.WorkspaceGroupMember, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := []orchmodels.WorkspaceGroupMember{}
|
|
for tid, role := range f.members[groupID] {
|
|
out = append(out, orchmodels.WorkspaceGroupMember{
|
|
WorkspaceGroupID: groupID,
|
|
TaskID: tid,
|
|
Role: role,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeWSGroupRepoCascade) UpdateWorkspaceGroupCleanupStatus(_ context.Context, id, status, _ string, _ *time.Time) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.cleanupStatuses[id] = status
|
|
return nil
|
|
}
|
|
|
|
// addArchivedTask seeds an already-archived task on the fake — used by
|
|
// the regression test for the resurrection bug.
|
|
func (f *fakeTaskRepo) addArchivedTask(id, parentID, ws, cascadeID string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
now := time.Now().UTC().Add(-time.Hour) // archived earlier
|
|
f.tasks[id] = &models.Task{
|
|
ID: id,
|
|
ParentID: parentID,
|
|
WorkspaceID: ws,
|
|
ArchivedAt: &now,
|
|
ArchivedByCascadeID: cascadeID,
|
|
}
|
|
if parentID != "" {
|
|
f.children[parentID] = append(f.children[parentID], id)
|
|
}
|
|
}
|
|
|
|
func newCascadeService(t *testing.T, tasks *fakeTaskRepo, ws *fakeWSGroupRepoCascade) *HandoffService {
|
|
t.Helper()
|
|
tr := newCascadeRepo(tasks)
|
|
return NewHandoffService(tr, nil, nil, nil, ws, nil)
|
|
}
|
|
|
|
func TestArchiveTaskTree_StampsCascadeAcrossDescendants(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
tasks.addTask("c2", "root", "ws-1")
|
|
tasks.addTask("g1", "c1", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
svc := newCascadeService(t, tasks, groups)
|
|
|
|
out, err := svc.ArchiveTaskTree(context.Background(), "root", true)
|
|
if err != nil {
|
|
t.Fatalf("archive: %v", err)
|
|
}
|
|
if out.CascadeID == "" {
|
|
t.Fatal("cascade ID should be set")
|
|
}
|
|
if len(out.ArchivedTaskIDs) != 4 {
|
|
t.Errorf("archived = %d, want 4", len(out.ArchivedTaskIDs))
|
|
}
|
|
for _, id := range []string{"root", "c1", "c2", "g1"} {
|
|
got, _ := tasks.GetTask(context.Background(), id)
|
|
if got.ArchivedAt == nil {
|
|
t.Errorf("%s should be archived", id)
|
|
}
|
|
if got.ArchivedByCascadeID != out.CascadeID {
|
|
t.Errorf("%s cascade id = %q, want %q", id, got.ArchivedByCascadeID, out.CascadeID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// REGRESSION: an unarchive cascade must NOT resurrect descendants that
|
|
// the user manually archived before the cascade ran. Phase 6 scopes
|
|
// restoration to tasks tagged with the same cascade ID.
|
|
func TestUnarchiveTaskTree_LeavesPriorlyArchivedDescendantsAlone(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
// c2 was manually archived BEFORE the cascade — different cascade id.
|
|
tasks.addArchivedTask("c2", "root", "ws-1", "")
|
|
groups := newCascadeWSGroupRepo()
|
|
svc := newCascadeService(t, tasks, groups)
|
|
|
|
archive, err := svc.ArchiveTaskTree(context.Background(), "root", true)
|
|
if err != nil {
|
|
t.Fatalf("archive: %v", err)
|
|
}
|
|
// c2 should not be in the cascade's archive set.
|
|
for _, id := range archive.ArchivedTaskIDs {
|
|
if id == "c2" {
|
|
t.Error("c2 should not be re-archived by the cascade")
|
|
}
|
|
}
|
|
|
|
if _, err := svc.UnarchiveTaskTree(context.Background(), "root"); err != nil {
|
|
t.Fatalf("unarchive: %v", err)
|
|
}
|
|
// root + c1 should be active; c2 should still be archived because
|
|
// its cascade id ("") does not match.
|
|
for _, id := range []string{"root", "c1"} {
|
|
got, _ := tasks.GetTask(context.Background(), id)
|
|
if got.ArchivedAt != nil {
|
|
t.Errorf("%s should be unarchived", id)
|
|
}
|
|
}
|
|
c2, _ := tasks.GetTask(context.Background(), "c2")
|
|
if c2.ArchivedAt == nil {
|
|
t.Error("c2 should remain archived (different cascade id)")
|
|
}
|
|
}
|
|
|
|
func TestArchiveTaskTree_ReleasesGroupMemberships(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
|
|
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "root",
|
|
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
|
|
CleanupStatus: orchmodels.WorkspaceCleanupStatusActive,
|
|
}
|
|
groups.members["g1"] = map[string]string{
|
|
"root": orchmodels.WorkspaceMemberRoleOwner,
|
|
"c1": orchmodels.WorkspaceMemberRoleMember,
|
|
}
|
|
svc := newCascadeService(t, tasks, groups)
|
|
|
|
out, err := svc.ArchiveTaskTree(context.Background(), "root", true)
|
|
if err != nil {
|
|
t.Fatalf("archive: %v", err)
|
|
}
|
|
if len(out.ReleasedGroupIDs) != 1 || out.ReleasedGroupIDs[0] != "g1" {
|
|
t.Errorf("ReleasedGroupIDs = %v", out.ReleasedGroupIDs)
|
|
}
|
|
if len(groups.releaseCalls) != 2 {
|
|
t.Errorf("expected 2 release calls (root + c1), got %d", len(groups.releaseCalls))
|
|
}
|
|
for _, call := range groups.releaseCalls {
|
|
if call.cascadeID != out.CascadeID {
|
|
t.Errorf("release cascade id = %q, want %q", call.cascadeID, out.CascadeID)
|
|
}
|
|
if call.reason != orchmodels.WorkspaceReleaseReasonArchived {
|
|
t.Errorf("release reason = %q", call.reason)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEvaluateWorkspaceGroupCleanup_NoOpForUserOwned(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
groups := newCascadeWSGroupRepo()
|
|
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
|
|
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "x",
|
|
MaterializedKind: orchmodels.WorkspaceGroupKindPlainFolder,
|
|
OwnedByKandev: false, // user-owned
|
|
CleanupPolicy: orchmodels.WorkspaceCleanupPolicyNeverDelete,
|
|
CleanupStatus: orchmodels.WorkspaceCleanupStatusActive,
|
|
}
|
|
groups.members["g1"] = map[string]string{}
|
|
svc := newCascadeService(t, tasks, groups)
|
|
|
|
if err := svc.evaluateWorkspaceGroupCleanup(context.Background(), "g1"); err != nil {
|
|
t.Fatalf("evaluate: %v", err)
|
|
}
|
|
if status := groups.cleanupStatuses["g1"]; status != "" {
|
|
t.Errorf("user-owned group cleanup_status was mutated to %q", status)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateWorkspaceGroupCleanup_PendingWhenLastMemberLeavesKandevGroup(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
groups := newCascadeWSGroupRepo()
|
|
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
|
|
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "x",
|
|
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
|
|
OwnedByKandev: true,
|
|
CleanupPolicy: orchmodels.WorkspaceCleanupPolicyDeleteWhenLastMemberArchivedOrDel,
|
|
CleanupStatus: orchmodels.WorkspaceCleanupStatusActive,
|
|
}
|
|
groups.members["g1"] = map[string]string{}
|
|
svc := newCascadeService(t, tasks, groups)
|
|
|
|
if err := svc.evaluateWorkspaceGroupCleanup(context.Background(), "g1"); err != nil {
|
|
t.Fatalf("evaluate: %v", err)
|
|
}
|
|
if status := groups.cleanupStatuses["g1"]; status != orchmodels.WorkspaceCleanupStatusPending {
|
|
t.Errorf("kandev-owned group cleanup_status = %q, want cleanup_pending", status)
|
|
}
|
|
}
|
|
|
|
// fakeRunCanceller records every CancelTaskExecution call so cascade
|
|
// tests can verify run cancellation runs before archive / delete.
|
|
type fakeRunCanceller struct {
|
|
mu sync.Mutex
|
|
calls []string
|
|
failOn map[string]error
|
|
}
|
|
|
|
func (f *fakeRunCanceller) CancelTaskExecution(_ context.Context, taskID, _ string, _ bool) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls = append(f.calls, taskID)
|
|
return f.failOn[taskID]
|
|
}
|
|
|
|
// fakeDeleteRepo extends fakeCascadeRepo with DeleteTask support so the
|
|
// delete cascade test can verify rows are actually removed.
|
|
type fakeDeleteRepo struct {
|
|
*fakeCascadeRepo
|
|
}
|
|
|
|
func (r *fakeDeleteRepo) DeleteTask(_ context.Context, id string) error {
|
|
r.base.mu.Lock()
|
|
defer r.base.mu.Unlock()
|
|
delete(r.base.tasks, id)
|
|
return nil
|
|
}
|
|
|
|
// REGRESSION (post-review #4): a parent with already-archived children
|
|
// must still have those children deleted by the cascade. The original
|
|
// collectTaskTree used ListChildren which filtered archived rows; the
|
|
// fix is to use ListChildrenIncludingArchived for delete.
|
|
func TestDeleteTaskTree_IncludesArchivedDescendants(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("active-child", "root", "ws-1")
|
|
// archived-child was manually archived BEFORE the delete cascade.
|
|
tasks.addArchivedTask("archived-child", "root", "ws-1", "")
|
|
groups := newCascadeWSGroupRepo()
|
|
tr := &fakeDeleteRepo{fakeCascadeRepo: newCascadeRepo(tasks)}
|
|
svc := NewHandoffService(tr, nil, nil, nil, groups, nil)
|
|
|
|
out, err := svc.DeleteTaskTree(context.Background(), "root", true)
|
|
if err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
if len(out.ArchivedTaskIDs) != 3 {
|
|
t.Errorf("expected 3 tasks deleted (root + active + archived); got %d (ids=%v)",
|
|
len(out.ArchivedTaskIDs), out.ArchivedTaskIDs)
|
|
}
|
|
if _, exists := tasks.tasks["archived-child"]; exists {
|
|
t.Error("archived-child must be removed by the delete cascade — leaving it behind is a data-loss bug")
|
|
}
|
|
}
|
|
|
|
func TestDeleteTaskTree_RemovesAllAndCancelsRuns(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
tasks.addTask("g1", "c1", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
groups.groups["grp"] = &orchmodels.WorkspaceGroup{
|
|
ID: "grp", WorkspaceID: "ws-1", OwnerTaskID: "root",
|
|
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
|
|
CleanupStatus: orchmodels.WorkspaceCleanupStatusActive,
|
|
}
|
|
groups.members["grp"] = map[string]string{"root": orchmodels.WorkspaceMemberRoleOwner}
|
|
tr := &fakeDeleteRepo{fakeCascadeRepo: newCascadeRepo(tasks)}
|
|
svc := NewHandoffService(tr, nil, nil, nil, groups, nil)
|
|
canceller := &fakeRunCanceller{}
|
|
svc.SetRunCanceller(canceller)
|
|
|
|
out, err := svc.DeleteTaskTree(context.Background(), "root", true)
|
|
if err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
if len(out.ArchivedTaskIDs) != 3 {
|
|
t.Errorf("deleted count = %d, want 3", len(out.ArchivedTaskIDs))
|
|
}
|
|
for _, id := range []string{"root", "c1", "g1"} {
|
|
if _, ok := tasks.tasks[id]; ok {
|
|
t.Errorf("%s should be removed", id)
|
|
}
|
|
}
|
|
// Run cancellation must have been called for every task in the cascade.
|
|
if len(canceller.calls) != 3 {
|
|
t.Errorf("expected 3 cancel calls, got %d", len(canceller.calls))
|
|
}
|
|
// Group membership released with reason=deleted.
|
|
for _, c := range groups.releaseCalls {
|
|
if c.taskID == "root" && c.reason != orchmodels.WorkspaceReleaseReasonDeleted {
|
|
t.Errorf("root release reason = %q, want deleted", c.reason)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestArchiveTaskTree_CancelsRunsBeforeArchive(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
svc := newCascadeService(t, tasks, groups)
|
|
canceller := &fakeRunCanceller{}
|
|
svc.SetRunCanceller(canceller)
|
|
|
|
if _, err := svc.ArchiveTaskTree(context.Background(), "root", true); err != nil {
|
|
t.Fatalf("archive: %v", err)
|
|
}
|
|
if len(canceller.calls) != 2 {
|
|
t.Errorf("expected 2 cancel calls (root + c1), got %d", len(canceller.calls))
|
|
}
|
|
}
|
|
|
|
// REGRESSION (post-review #5): cleanup must NOT proceed while a
|
|
// member session still has an executors_running row. Even when no
|
|
// active group members remain (all released by cascade), a lingering
|
|
// executor would be writing to the workspace we're about to delete.
|
|
// The safety gate parks the group in cleanup_pending with a clear
|
|
// reason instead.
|
|
func TestEvaluateWorkspaceGroupCleanup_BlockedByActiveExecutor(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
|
|
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "root",
|
|
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
|
|
OwnedByKandev: true,
|
|
CleanupPolicy: orchmodels.WorkspaceCleanupPolicyDeleteWhenLastMemberArchivedOrDel,
|
|
CleanupStatus: orchmodels.WorkspaceCleanupStatusActive,
|
|
}
|
|
// Active members list is empty (all released), but the
|
|
// historical-members list still carries "root" so the gate can
|
|
// inspect its sessions for lingering executors.
|
|
groups.members["g1"] = map[string]string{}
|
|
groups.allMembers = map[string]map[string]string{
|
|
"g1": {"root": orchmodels.WorkspaceMemberRoleOwner},
|
|
}
|
|
|
|
sr := newFakeSessionReader()
|
|
sr.sessions["root"] = []*models.TaskSession{{ID: "s1", IsPrimary: true, TaskID: "root"}}
|
|
sr.sessionsWithRunningExecutor["s1"] = true
|
|
|
|
tr := newCascadeRepo(tasks)
|
|
svc := NewHandoffService(tr, nil, nil, nil, groups, nil)
|
|
svc.SetSessionReader(sr)
|
|
|
|
if err := svc.evaluateWorkspaceGroupCleanup(context.Background(), "g1"); err != nil {
|
|
t.Fatalf("evaluate: %v", err)
|
|
}
|
|
if status := groups.cleanupStatuses["g1"]; status != orchmodels.WorkspaceCleanupStatusPending {
|
|
t.Errorf("cleanup_status = %q, want cleanup_pending (active executor blocks)", status)
|
|
}
|
|
}
|
|
|
|
// Verify the cascade is race-free under concurrent invocations.
|
|
func TestArchiveTaskTree_RaceFree(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
tasks.addTask("c2", "root", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
svc := newCascadeService(t, tasks, groups)
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 4; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, _ = svc.ArchiveTaskTree(context.Background(), "root", true)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
// Whatever order the goroutines ran in, every task should be
|
|
// archived exactly once (CAS guard) and the cascade IDs must be
|
|
// consistent within each task — though different tasks may carry
|
|
// different cascade IDs depending on which goroutine won the race.
|
|
for _, id := range []string{"root", "c1", "c2"} {
|
|
got, _ := tasks.GetTask(context.Background(), id)
|
|
if got.ArchivedAt == nil {
|
|
t.Errorf("%s should be archived", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fakeEventPublisher captures the task IDs delivered to each cascade
|
|
// event-publish callback. It exists so the tests below can verify that
|
|
// ArchiveTaskTree / DeleteTaskTree actually invoke the publisher hook —
|
|
// without the hook firing, the gateway never broadcasts task.updated /
|
|
// task.deleted and the kanban board's All-Workflows view shows stale
|
|
// rows after a cascade.
|
|
type fakeEventPublisher struct {
|
|
mu sync.Mutex
|
|
updated []string
|
|
deleted []string
|
|
archived []bool // archivedAt nil/non-nil per updated entry
|
|
}
|
|
|
|
func (f *fakeEventPublisher) PublishTaskUpdated(_ context.Context, task *models.Task) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.updated = append(f.updated, task.ID)
|
|
f.archived = append(f.archived, task.ArchivedAt != nil)
|
|
}
|
|
|
|
func (f *fakeEventPublisher) PublishTaskDeleted(_ context.Context, task *models.Task) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.deleted = append(f.deleted, task.ID)
|
|
}
|
|
|
|
// TestArchiveTaskTree_PublishesTaskUpdatedPerTask pins the regression
|
|
// the office HandoffService introduced: cascade-archive walks the repo
|
|
// directly and bypasses Service.ArchiveTask, which is the only caller
|
|
// of publishTaskEvent. Before the event-publisher wiring, the WS
|
|
// gateway never saw the cascade and the kanban board stayed populated
|
|
// with archived cards until a full reload.
|
|
func TestArchiveTaskTree_PublishesTaskUpdatedPerTask(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
tasks.addTask("c2", "root", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
svc := newCascadeService(t, tasks, groups)
|
|
pub := &fakeEventPublisher{}
|
|
svc.SetTaskEventPublisher(pub)
|
|
|
|
if _, err := svc.ArchiveTaskTree(context.Background(), "root", true); err != nil {
|
|
t.Fatalf("archive: %v", err)
|
|
}
|
|
|
|
pub.mu.Lock()
|
|
defer pub.mu.Unlock()
|
|
if got, want := len(pub.updated), 3; got != want {
|
|
t.Fatalf("PublishTaskUpdated calls = %d, want %d (one per archived task)", got, want)
|
|
}
|
|
for i, taskID := range pub.updated {
|
|
if !pub.archived[i] {
|
|
t.Errorf("task %s published BEFORE archived_at was stamped; the WS handler keys off archived_at to remove the card", taskID)
|
|
}
|
|
}
|
|
// Every task in the cascade must be in the published set; map keys
|
|
// guarantee order-independent membership.
|
|
want := map[string]bool{"root": true, "c1": true, "c2": true}
|
|
for _, id := range pub.updated {
|
|
delete(want, id)
|
|
}
|
|
if len(want) > 0 {
|
|
t.Errorf("missing PublishTaskUpdated for: %v", want)
|
|
}
|
|
}
|
|
|
|
// TestDeleteTaskTree_PublishesTaskDeletedPerTask is the delete-side
|
|
// equivalent of the archive test above. The cascade snapshots each
|
|
// task before the repository row is gone so the published event carries
|
|
// the workflow_id that the WS handler keys off to pick the right
|
|
// swimlane in All-Workflows view.
|
|
func TestDeleteTaskTree_PublishesTaskDeletedPerTask(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
tr := &fakeDeleteRepo{fakeCascadeRepo: newCascadeRepo(tasks)}
|
|
svc := NewHandoffService(tr, nil, nil, nil, groups, nil)
|
|
pub := &fakeEventPublisher{}
|
|
svc.SetTaskEventPublisher(pub)
|
|
|
|
if _, err := svc.DeleteTaskTree(context.Background(), "root", true); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
pub.mu.Lock()
|
|
defer pub.mu.Unlock()
|
|
if got, want := len(pub.deleted), 2; got != want {
|
|
t.Fatalf("PublishTaskDeleted calls = %d, want %d (one per deleted task)", got, want)
|
|
}
|
|
want := map[string]bool{"root": true, "c1": true}
|
|
for _, id := range pub.deleted {
|
|
delete(want, id)
|
|
}
|
|
if len(want) > 0 {
|
|
t.Errorf("missing PublishTaskDeleted for: %v", want)
|
|
}
|
|
}
|
|
|
|
// TestCascade_NilEventPublisher_NoCrash defends the optional-wiring
|
|
// contract: legacy / test setups that don't call SetTaskEventPublisher
|
|
// must still complete the cascade. Without this branch a future
|
|
// refactor could turn the publisher into a required dependency and
|
|
// break every test that doesn't wire it.
|
|
func TestCascade_NilEventPublisher_NoCrash(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
tr := &fakeDeleteRepo{fakeCascadeRepo: newCascadeRepo(tasks)}
|
|
svc := NewHandoffService(tr, nil, nil, nil, groups, nil)
|
|
// Intentionally NOT calling SetTaskEventPublisher.
|
|
|
|
if _, err := svc.ArchiveTaskTree(context.Background(), "root", true); err != nil {
|
|
t.Fatalf("archive with nil publisher: %v", err)
|
|
}
|
|
if _, err := svc.DeleteTaskTree(context.Background(), "root", true); err != nil {
|
|
t.Fatalf("delete with nil publisher: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCleanupWorkspaceGroupsUsesStoredMaterializedHandles(t *testing.T) {
|
|
groups := newCascadeWSGroupRepo()
|
|
ctx := context.Background()
|
|
if err := groups.CreateWorkspaceGroup(ctx, &orchmodels.WorkspaceGroup{
|
|
ID: "group-owned",
|
|
WorkspaceID: "ws-delete",
|
|
OwnerTaskID: "task-1",
|
|
MaterializedPath: "/tmp/kandev-owned-group",
|
|
MaterializedKind: orchmodels.WorkspaceGroupKindPlainFolder,
|
|
OwnedByKandev: true,
|
|
CleanupPolicy: orchmodels.WorkspaceCleanupPolicyDeleteWhenLastMemberArchivedOrDel,
|
|
CleanupStatus: orchmodels.WorkspaceCleanupStatusActive,
|
|
}); err != nil {
|
|
t.Fatalf("create owned group: %v", err)
|
|
}
|
|
if err := groups.CreateWorkspaceGroup(ctx, &orchmodels.WorkspaceGroup{
|
|
ID: "group-user",
|
|
WorkspaceID: "ws-delete",
|
|
OwnerTaskID: "task-2",
|
|
MaterializedPath: "/tmp/user-owned-group",
|
|
MaterializedKind: orchmodels.WorkspaceGroupKindPlainFolder,
|
|
OwnedByKandev: false,
|
|
CleanupPolicy: orchmodels.WorkspaceCleanupPolicyNeverDelete,
|
|
CleanupStatus: orchmodels.WorkspaceCleanupStatusActive,
|
|
}); err != nil {
|
|
t.Fatalf("create user group: %v", err)
|
|
}
|
|
if err := groups.CreateWorkspaceGroup(ctx, &orchmodels.WorkspaceGroup{
|
|
ID: "group-cleaned",
|
|
WorkspaceID: "ws-delete",
|
|
OwnerTaskID: "task-3",
|
|
MaterializedPath: "/tmp/already-cleaned-group",
|
|
MaterializedKind: orchmodels.WorkspaceGroupKindPlainFolder,
|
|
OwnedByKandev: true,
|
|
CleanupPolicy: orchmodels.WorkspaceCleanupPolicyDeleteWhenLastMemberArchivedOrDel,
|
|
CleanupStatus: orchmodels.WorkspaceCleanupStatusCleaned,
|
|
}); err != nil {
|
|
t.Fatalf("create cleaned group: %v", err)
|
|
}
|
|
cleaner := &fakeWorkspaceCleaner{}
|
|
svc := NewHandoffService(nil, nil, nil, nil, groups, nil)
|
|
svc.SetWorkspaceCleaner(cleaner)
|
|
|
|
if err := svc.CleanupWorkspaceGroups(ctx, "ws-delete"); err != nil {
|
|
t.Fatalf("cleanup workspace groups: %v", err)
|
|
}
|
|
if len(cleaner.plainFolders) != 1 || cleaner.plainFolders[0] != "/tmp/kandev-owned-group" {
|
|
t.Fatalf("plain folder cleanups = %#v, want owned group path", cleaner.plainFolders)
|
|
}
|
|
if got := groups.cleanupStatuses["group-owned"]; got != orchmodels.WorkspaceCleanupStatusCleaned {
|
|
t.Fatalf("owned group cleanup status = %q, want cleaned", got)
|
|
}
|
|
if _, ok := groups.cleanupStatuses["group-user"]; ok {
|
|
t.Fatal("user-owned group should not be cleaned")
|
|
}
|
|
}
|
|
|
|
func TestCleanupWorkspaceGroupsWaitsForActiveExecutions(t *testing.T) {
|
|
groups := newCascadeWSGroupRepo()
|
|
ctx := context.Background()
|
|
if err := groups.CreateWorkspaceGroup(ctx, &orchmodels.WorkspaceGroup{
|
|
ID: "group-owned",
|
|
WorkspaceID: "ws-delete",
|
|
OwnerTaskID: "task-1",
|
|
MaterializedPath: "/tmp/kandev-owned-group",
|
|
MaterializedKind: orchmodels.WorkspaceGroupKindPlainFolder,
|
|
OwnedByKandev: true,
|
|
CleanupPolicy: orchmodels.WorkspaceCleanupPolicyDeleteWhenLastMemberArchivedOrDel,
|
|
CleanupStatus: orchmodels.WorkspaceCleanupStatusActive,
|
|
}); err != nil {
|
|
t.Fatalf("create owned group: %v", err)
|
|
}
|
|
if err := groups.AddWorkspaceGroupMember(ctx, "group-owned", "task-1", "owner"); err != nil {
|
|
t.Fatalf("add group member: %v", err)
|
|
}
|
|
sessions := &flippingActiveSessionReader{taskID: "task-1", sessionID: "session-1"}
|
|
cleaner := &fakeWorkspaceCleaner{}
|
|
svc := NewHandoffService(nil, nil, nil, nil, groups, nil)
|
|
svc.SetSessionReader(sessions)
|
|
svc.SetWorkspaceCleaner(cleaner)
|
|
|
|
cleanupCtx, cancel := context.WithTimeout(ctx, time.Second)
|
|
defer cancel()
|
|
if err := svc.CleanupWorkspaceGroups(cleanupCtx, "ws-delete"); err != nil {
|
|
t.Fatalf("cleanup workspace groups: %v", err)
|
|
}
|
|
if len(cleaner.plainFolders) != 1 || cleaner.plainFolders[0] != "/tmp/kandev-owned-group" {
|
|
t.Fatalf("plain folder cleanups = %#v, want owned group path", cleaner.plainFolders)
|
|
}
|
|
sessions.mu.Lock()
|
|
if sessions.checks < 2 {
|
|
t.Errorf("HasExecutorRunningRow called %d times, want >= 2", sessions.checks)
|
|
}
|
|
sessions.mu.Unlock()
|
|
if got := groups.cleanupStatuses["group-owned"]; got != orchmodels.WorkspaceCleanupStatusCleaned {
|
|
t.Fatalf("owned group cleanup status = %q, want cleaned", got)
|
|
}
|
|
}
|
|
|
|
// fakeResourceCleaner captures the (taskID, deleteEnvRow) pair delivered
|
|
// to CleanupTaskResources by each cascade invocation. Mirrors
|
|
// fakeEventPublisher above — the same regression class (cascade silently
|
|
// dropping a per-task callback) caused both the WS-event leak and the
|
|
// Docker-container leak.
|
|
type fakeResourceCleaner struct {
|
|
mu sync.Mutex
|
|
calls []resourceCleanerCall
|
|
}
|
|
|
|
type resourceCleanerCall struct {
|
|
taskID string
|
|
deleteEnvRow bool
|
|
}
|
|
|
|
func (f *fakeResourceCleaner) CleanupTaskResources(_ context.Context, taskID string, deleteEnvRow bool) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls = append(f.calls, resourceCleanerCall{taskID: taskID, deleteEnvRow: deleteEnvRow})
|
|
}
|
|
|
|
type fakeWorkspaceCleaner struct {
|
|
plainFolders []string
|
|
}
|
|
|
|
func (f *fakeWorkspaceCleaner) CleanupPlainFolder(_ context.Context, path string) error {
|
|
f.plainFolders = append(f.plainFolders, path)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeWorkspaceCleaner) CleanupSingleRepoWorktree(context.Context, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeWorkspaceCleaner) CleanupMultiRepoRoot(context.Context, string, []string) error {
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeWorkspaceCleaner) CleanupRemoteEnvironment(context.Context, string, string) error {
|
|
return nil
|
|
}
|
|
|
|
type flippingActiveSessionReader struct {
|
|
mu sync.Mutex
|
|
taskID string
|
|
sessionID string
|
|
checks int
|
|
}
|
|
|
|
func (f *flippingActiveSessionReader) ListTaskSessions(_ context.Context, taskID string) ([]*models.TaskSession, error) {
|
|
if taskID != f.taskID {
|
|
return nil, nil
|
|
}
|
|
return []*models.TaskSession{{ID: f.sessionID, TaskID: f.taskID}}, nil
|
|
}
|
|
|
|
func (f *flippingActiveSessionReader) ListTaskSessionWorktrees(context.Context, string) ([]*models.TaskSessionWorktree, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *flippingActiveSessionReader) GetTask(context.Context, string) (*models.Task, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *flippingActiveSessionReader) HasExecutorRunningRow(_ context.Context, sessionID string) (bool, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if sessionID != f.sessionID {
|
|
return false, nil
|
|
}
|
|
f.checks++
|
|
return f.checks == 1, nil
|
|
}
|
|
|
|
// TestArchiveTaskTree_InvokesResourceCleanerPerTask pins the regression
|
|
// where cascade-archive stopped active runs (which stops the agent /
|
|
// container) but never invoked the env-teardown branch — so containers
|
|
// kept existing on disk forever. Archive preserves the env row, so
|
|
// deleteEnvRow must be false on every call.
|
|
func TestArchiveTaskTree_InvokesResourceCleanerPerTask(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
tasks.addTask("c2", "root", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
svc := newCascadeService(t, tasks, groups)
|
|
cleaner := &fakeResourceCleaner{}
|
|
svc.SetTaskResourceCleaner(cleaner)
|
|
|
|
if _, err := svc.ArchiveTaskTree(context.Background(), "root", true); err != nil {
|
|
t.Fatalf("archive: %v", err)
|
|
}
|
|
|
|
cleaner.mu.Lock()
|
|
defer cleaner.mu.Unlock()
|
|
if got, want := len(cleaner.calls), 3; got != want {
|
|
t.Fatalf("CleanupTaskResources calls = %d, want %d (one per archived task)", got, want)
|
|
}
|
|
want := map[string]bool{"root": true, "c1": true, "c2": true}
|
|
for _, call := range cleaner.calls {
|
|
if call.deleteEnvRow {
|
|
t.Errorf("task %s: archive cascade passed deleteEnvRow=true, want false (archive preserves the env row)", call.taskID)
|
|
}
|
|
delete(want, call.taskID)
|
|
}
|
|
if len(want) > 0 {
|
|
t.Errorf("missing CleanupTaskResources for: %v", want)
|
|
}
|
|
}
|
|
|
|
// TestDeleteTaskTree_InvokesResourceCleanerPerTask is the delete-side
|
|
// equivalent: every deleted task gets its runtime resources torn down,
|
|
// and the env row is removed (deleteEnvRow=true) since the task itself
|
|
// is gone.
|
|
func TestDeleteTaskTree_InvokesResourceCleanerPerTask(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
tr := &fakeDeleteRepo{fakeCascadeRepo: newCascadeRepo(tasks)}
|
|
svc := NewHandoffService(tr, nil, nil, nil, groups, nil)
|
|
cleaner := &fakeResourceCleaner{}
|
|
svc.SetTaskResourceCleaner(cleaner)
|
|
|
|
if _, err := svc.DeleteTaskTree(context.Background(), "root", true); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
cleaner.mu.Lock()
|
|
defer cleaner.mu.Unlock()
|
|
if got, want := len(cleaner.calls), 2; got != want {
|
|
t.Fatalf("CleanupTaskResources calls = %d, want %d (one per deleted task)", got, want)
|
|
}
|
|
want := map[string]bool{"root": true, "c1": true}
|
|
for _, call := range cleaner.calls {
|
|
if !call.deleteEnvRow {
|
|
t.Errorf("task %s: delete cascade passed deleteEnvRow=false, want true (the task is gone, the env row must follow)", call.taskID)
|
|
}
|
|
delete(want, call.taskID)
|
|
}
|
|
if len(want) > 0 {
|
|
t.Errorf("missing CleanupTaskResources for: %v", want)
|
|
}
|
|
}
|
|
|
|
// TestCascade_NilResourceCleaner_NoCrash defends the optional-wiring
|
|
// contract for the resource cleaner. Legacy / test setups that don't
|
|
// wire one must still complete the cascade — the cleaner is a runtime
|
|
// hook, not a correctness requirement of the cascade itself.
|
|
func TestCascade_NilResourceCleaner_NoCrash(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
tr := &fakeDeleteRepo{fakeCascadeRepo: newCascadeRepo(tasks)}
|
|
svc := NewHandoffService(tr, nil, nil, nil, groups, nil)
|
|
// Intentionally NOT calling SetTaskResourceCleaner.
|
|
|
|
if _, err := svc.ArchiveTaskTree(context.Background(), "root", true); err != nil {
|
|
t.Fatalf("archive with nil cleaner: %v", err)
|
|
}
|
|
if _, err := svc.DeleteTaskTree(context.Background(), "root", true); err != nil {
|
|
t.Fatalf("delete with nil cleaner: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestArchiveTaskTree_NoCascade_LeavesChildrenActive pins the new
|
|
// default: archiving a parent must NOT touch its subtasks unless the
|
|
// caller explicitly opts in. The subtasks might still be in progress
|
|
// and the user just wanted the parent off the board.
|
|
func TestArchiveTaskTree_NoCascade_LeavesChildrenActive(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
tasks.addTask("c2", "root", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
svc := newCascadeService(t, tasks, groups)
|
|
|
|
out, err := svc.ArchiveTaskTree(context.Background(), "root", false)
|
|
if err != nil {
|
|
t.Fatalf("archive: %v", err)
|
|
}
|
|
if len(out.ArchivedTaskIDs) != 1 || out.ArchivedTaskIDs[0] != "root" {
|
|
t.Errorf("ArchivedTaskIDs = %v, want [root]", out.ArchivedTaskIDs)
|
|
}
|
|
rootRow, _ := tasks.GetTask(context.Background(), "root")
|
|
if rootRow.ArchivedAt == nil {
|
|
t.Error("root should be archived")
|
|
}
|
|
for _, id := range []string{"c1", "c2"} {
|
|
child, _ := tasks.GetTask(context.Background(), id)
|
|
if child.ArchivedAt != nil {
|
|
t.Errorf("%s should remain active, got archived_at=%v", id, child.ArchivedAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDeleteTaskTree_NoCascade_PublishesUpdatedForReparentedChildren
|
|
// pins the WS-event contract for the reparent step: after children are
|
|
// reparented to root, the bus must carry one task.updated per child so
|
|
// WS-driven clients refresh their cached parent_id pointers. Without
|
|
// the publish, the kanban / sidebar would keep displaying the children
|
|
// nested under the (now-deleted) parent until a manual reload.
|
|
func TestDeleteTaskTree_NoCascade_PublishesUpdatedForReparentedChildren(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
tasks.addTask("c2", "root", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
tr := &fakeDeleteRepo{fakeCascadeRepo: newCascadeRepo(tasks)}
|
|
svc := NewHandoffService(tr, nil, nil, nil, groups, nil)
|
|
pub := &fakeEventPublisher{}
|
|
svc.SetTaskEventPublisher(pub)
|
|
|
|
if _, err := svc.DeleteTaskTree(context.Background(), "root", false); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
pub.mu.Lock()
|
|
defer pub.mu.Unlock()
|
|
// We expect one update per direct child (c1, c2). The deleted root
|
|
// goes via PublishTaskDeleted, not PublishTaskUpdated.
|
|
want := map[string]bool{"c1": true, "c2": true}
|
|
for _, id := range pub.updated {
|
|
delete(want, id)
|
|
}
|
|
if len(want) > 0 {
|
|
t.Errorf("missing PublishTaskUpdated for reparented children: %v", want)
|
|
}
|
|
if len(pub.deleted) != 1 || pub.deleted[0] != "root" {
|
|
t.Errorf("expected exactly one task.deleted for root, got %v", pub.deleted)
|
|
}
|
|
}
|
|
|
|
// TestDeleteTaskTree_NoCascade_ReparentFailureAborts pins the safety
|
|
// invariant: when the no-cascade reparent step fails we MUST refuse to
|
|
// delete the parent. Continuing past a reparent error would leave
|
|
// children pointing at a row we're about to remove — exactly the
|
|
// dangling pointer the no-cascade path is designed to prevent.
|
|
func TestDeleteTaskTree_NoCascade_ReparentFailureAborts(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
groups := newCascadeWSGroupRepo()
|
|
tr := &fakeReparentErrRepo{fakeDeleteRepo: &fakeDeleteRepo{fakeCascadeRepo: newCascadeRepo(tasks)}}
|
|
svc := NewHandoffService(tr, nil, nil, nil, groups, nil)
|
|
|
|
_, err := svc.DeleteTaskTree(context.Background(), "root", false)
|
|
if err == nil {
|
|
t.Fatal("expected error from failed reparent, got nil")
|
|
}
|
|
if _, exists := tasks.tasks["root"]; !exists {
|
|
t.Error("root should NOT be deleted when reparent fails")
|
|
}
|
|
if _, exists := tasks.tasks["c1"]; !exists {
|
|
t.Error("c1 should still exist")
|
|
}
|
|
}
|
|
|
|
// fakeReparentErrRepo overrides ReparentDirectChildren to simulate a DB
|
|
// failure so the no-cascade abort path can be exercised in tests.
|
|
type fakeReparentErrRepo struct {
|
|
*fakeDeleteRepo
|
|
}
|
|
|
|
func (r *fakeReparentErrRepo) ReparentDirectChildren(_ context.Context, _, _ string) error {
|
|
return errors.New("simulated DB failure")
|
|
}
|
|
|
|
// TestDeleteTaskTree_NoCascade_ReparentsDirectChildren verifies the
|
|
// orphaning step: when the user deletes a parent without cascade, the
|
|
// direct subtasks have their parent_id cleared so the deleted-row
|
|
// pointer doesn't dangle. The subtask rows themselves survive.
|
|
func TestDeleteTaskTree_NoCascade_ReparentsDirectChildren(t *testing.T) {
|
|
tasks := newFakeTaskRepo()
|
|
tasks.addTask("root", "", "ws-1")
|
|
tasks.addTask("c1", "root", "ws-1")
|
|
tasks.addTask("c2", "root", "ws-1")
|
|
tasks.addTask("g1", "c1", "ws-1") // grandchild — should NOT be reparented
|
|
groups := newCascadeWSGroupRepo()
|
|
tr := &fakeDeleteRepo{fakeCascadeRepo: newCascadeRepo(tasks)}
|
|
svc := NewHandoffService(tr, nil, nil, nil, groups, nil)
|
|
|
|
out, err := svc.DeleteTaskTree(context.Background(), "root", false)
|
|
if err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
if len(out.ArchivedTaskIDs) != 1 || out.ArchivedTaskIDs[0] != "root" {
|
|
t.Errorf("ArchivedTaskIDs = %v, want [root]", out.ArchivedTaskIDs)
|
|
}
|
|
if _, exists := tasks.tasks["root"]; exists {
|
|
t.Error("root should be removed")
|
|
}
|
|
for _, id := range []string{"c1", "c2"} {
|
|
child, ok := tasks.tasks[id]
|
|
if !ok {
|
|
t.Errorf("%s should NOT be deleted (cascade=false)", id)
|
|
continue
|
|
}
|
|
if child.ParentID != "" {
|
|
t.Errorf("%s.parent_id = %q, want empty (reparented to root)", id, child.ParentID)
|
|
}
|
|
}
|
|
g1, ok := tasks.tasks["g1"]
|
|
if !ok {
|
|
t.Fatal("g1 should not be deleted")
|
|
}
|
|
if g1.ParentID != "c1" {
|
|
t.Errorf("g1.parent_id = %q, want c1 (only direct children of root are reparented)", g1.ParentID)
|
|
}
|
|
}
|