From 86cabee5c63e279d99cfb6041c2dd2b336c41c0a Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 26 Mar 2026 16:14:42 +0100 Subject: [PATCH] test: add test fixtures and tests for project soft-delete - Add soft-deleted project fixtures (IDs 41, 42, 43) - Update existing delete tests to verify soft-delete behavior - Add tests for restore, list deleted, and permanent delete - Verify soft-deleted projects are excluded from ReadAll and permissions --- pkg/db/fixtures/projects.yml | 36 +++++++ pkg/models/project_test.go | 201 +++++++++++++++++++++++++++++++---- pkg/webtests/project_test.go | 3 + 3 files changed, 222 insertions(+), 18 deletions(-) diff --git a/pkg/db/fixtures/projects.yml b/pkg/db/fixtures/projects.yml index 65809edff..9ade23fff 100644 --- a/pkg/db/fixtures/projects.yml +++ b/pkg/db/fixtures/projects.yml @@ -361,3 +361,39 @@ position: 40 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +# Soft-deleted project owned by user 1 +- + id: 41 + title: Test41 soft-deleted + description: A soft-deleted project + identifier: test41 + owner_id: 1 + parent_project_id: 0 + position: 41 + deleted_at: 2026-03-20 10:00:00 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 +# Soft-deleted parent project owned by user 1 (parent of 43) +- + id: 42 + title: Test42 soft-deleted parent + description: A soft-deleted parent project + identifier: test42 + owner_id: 1 + parent_project_id: 0 + position: 42 + deleted_at: 2026-03-20 10:00:00 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 +# Soft-deleted child project owned by user 1 (child of 42) +- + id: 43 + title: Test43 soft-deleted child + description: A soft-deleted child project + identifier: test43 + owner_id: 1 + parent_project_id: 42 + position: 43 + deleted_at: 2026-03-20 10:00:00 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 diff --git a/pkg/models/project_test.go b/pkg/models/project_test.go index 88286cdd7..8e38b8372 100644 --- a/pkg/models/project_test.go +++ b/pkg/models/project_test.go @@ -347,6 +347,31 @@ func TestProject_CreateOrUpdate(t *testing.T) { }) } +func assertSoftDeleted(t *testing.T, projectID int64) { + t.Helper() + s := db.NewSession() + defer s.Close() + + // Use Unscoped to bypass soft-delete filter + p := &Project{} + exists, err := s.Unscoped().Where("id = ?", projectID).Get(p) + require.NoError(t, err) + require.True(t, exists, "Project %d should still exist in db after soft-delete", projectID) + assert.NotNil(t, p.DeletedAt, "Project %d should have deleted_at set", projectID) +} + +func assertNotSoftDeleted(t *testing.T, projectID int64) { + t.Helper() + s := db.NewSession() + defer s.Close() + + p := &Project{} + exists, err := s.Unscoped().Where("id = ?", projectID).Get(p) + require.NoError(t, err) + require.True(t, exists, "Project %d should exist in db", projectID) + assert.Nil(t, p.DeletedAt, "Project %d should not have deleted_at set", projectID) +} + func TestProject_Delete(t *testing.T) { t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) @@ -359,12 +384,12 @@ func TestProject_Delete(t *testing.T) { require.NoError(t, err) err = s.Commit() require.NoError(t, err) - db.AssertMissing(t, "projects", map[string]interface{}{ + // With soft-delete, project row still exists but has deleted_at set + assertSoftDeleted(t, 1) + // Tasks should still exist (not permanently deleted) + db.AssertExists(t, "tasks", map[string]interface{}{ "id": 1, - }) - db.AssertMissing(t, "tasks", map[string]interface{}{ - "id": 1, - }) + }, false) }) t.Run("with background", func(t *testing.T) { db.LoadAndAssertFixtures(t) @@ -378,12 +403,11 @@ func TestProject_Delete(t *testing.T) { require.NoError(t, err) err = s.Commit() require.NoError(t, err) - db.AssertMissing(t, "projects", map[string]interface{}{ - "id": 35, - }) - db.AssertMissing(t, "files", map[string]interface{}{ + // Project is soft-deleted, background file still exists + assertSoftDeleted(t, 35) + db.AssertExists(t, "files", map[string]interface{}{ "id": 1, - }) + }, false) }) t.Run("default project of the same user", func(t *testing.T) { db.LoadAndAssertFixtures(t) @@ -407,7 +431,7 @@ func TestProject_Delete(t *testing.T) { require.Error(t, err) assert.True(t, IsErrCannotDeleteDefaultProject(err)) }) - t.Run("deletes archived parent and its child atomically", func(t *testing.T) { + t.Run("soft-deletes archived parent and its child", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() @@ -420,10 +444,10 @@ func TestProject_Delete(t *testing.T) { err = s.Commit() require.NoError(t, err) - db.AssertMissing(t, "projects", map[string]interface{}{"id": 22}) - db.AssertMissing(t, "projects", map[string]interface{}{"id": 21}) + assertSoftDeleted(t, 22) + assertSoftDeleted(t, 21) }) - t.Run("deletes deeply nested child projects recursively", func(t *testing.T) { + t.Run("soft-deletes deeply nested child projects recursively", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() @@ -435,10 +459,151 @@ func TestProject_Delete(t *testing.T) { err = s.Commit() require.NoError(t, err) - db.AssertMissing(t, "projects", map[string]interface{}{"id": 27}) - db.AssertMissing(t, "projects", map[string]interface{}{"id": 12}) - db.AssertMissing(t, "projects", map[string]interface{}{"id": 25}) - db.AssertMissing(t, "projects", map[string]interface{}{"id": 26}) + assertSoftDeleted(t, 27) + assertSoftDeleted(t, 12) + assertSoftDeleted(t, 25) + assertSoftDeleted(t, 26) + }) + t.Run("soft-deleted projects are excluded from ReadAll", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Project 41 is soft-deleted and owned by user 1 + p := &Project{} + projects, _, _, err := p.ReadAll(s, &user.User{ID: 1}, "", 1, 50) + require.NoError(t, err) + + projectList := projects.([]*Project) + for _, proj := range projectList { + assert.NotEqual(t, int64(41), proj.ID, "Soft-deleted project 41 should not appear in ReadAll") + assert.NotEqual(t, int64(42), proj.ID, "Soft-deleted project 42 should not appear in ReadAll") + assert.NotEqual(t, int64(43), proj.ID, "Soft-deleted project 43 should not appear in ReadAll") + } + }) + t.Run("soft-deleted projects return no permission", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Project 41 is soft-deleted and owned by user 1 + // XORM auto-filters soft-deleted projects, so CanRead will error with "Project does not exist" + p := &Project{ID: 41} + canRead, _, err := p.CanRead(s, &user.User{ID: 1}) + if err != nil { + assert.True(t, IsErrProjectDoesNotExist(err), "Expected project not found error for soft-deleted project") + } else { + assert.False(t, canRead, "Should not be able to read soft-deleted project") + } + }) +} + +func TestProject_Restore(t *testing.T) { + t.Run("restore soft-deleted project", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Project 41 is soft-deleted and owned by user 1 + project, err := RestoreProject(s, 41, &user.User{ID: 1}) + require.NoError(t, err) + require.NotNil(t, project) + assert.Nil(t, project.DeletedAt) + err = s.Commit() + require.NoError(t, err) + + assertNotSoftDeleted(t, 41) + }) + t.Run("restore soft-deleted parent restores children", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Project 42 is soft-deleted parent, 43 is soft-deleted child + _, err := RestoreProject(s, 42, &user.User{ID: 1}) + require.NoError(t, err) + err = s.Commit() + require.NoError(t, err) + + assertNotSoftDeleted(t, 42) + assertNotSoftDeleted(t, 43) + }) + t.Run("restore non-existent project", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + _, err := RestoreProject(s, 999, &user.User{ID: 1}) + require.Error(t, err) + assert.True(t, IsErrProjectDoesNotExist(err)) + }) + t.Run("restore project without admin access", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Project 41 is owned by user 1, user 2 has no access + _, err := RestoreProject(s, 41, &user.User{ID: 2}) + require.Error(t, err) + }) +} + +func TestProject_GetDeletedProjects(t *testing.T) { + t.Run("returns deleted projects for user", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // User 1 owns soft-deleted projects 41, 42, 43 + projects, err := GetDeletedProjects(s, &user.User{ID: 1}) + require.NoError(t, err) + assert.Len(t, projects, 3) + + ids := make(map[int64]bool) + for _, p := range projects { + ids[p.ID] = true + } + assert.True(t, ids[41]) + assert.True(t, ids[42]) + assert.True(t, ids[43]) + }) + t.Run("returns empty for user with no deleted projects", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // User 2 owns no soft-deleted projects + projects, err := GetDeletedProjects(s, &user.User{ID: 2}) + require.NoError(t, err) + assert.Empty(t, projects) + }) +} + +func TestProject_PermanentDelete(t *testing.T) { + t.Run("permanently deletes project and all related entities", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // First soft-delete the project + project := Project{ID: 1} + err := project.Delete(s, &user.User{ID: 1}) + require.NoError(t, err) + err = s.Commit() + require.NoError(t, err) + + // Now permanently delete it + s2 := db.NewSession() + defer s2.Close() + + p := &Project{ID: 1} + err = p.PermanentDelete(s2, &user.User{ID: 1}) + require.NoError(t, err) + err = s2.Commit() + require.NoError(t, err) + + db.AssertMissing(t, "projects", map[string]interface{}{"id": 1}) + db.AssertMissing(t, "tasks", map[string]interface{}{"id": 1}) }) } diff --git a/pkg/webtests/project_test.go b/pkg/webtests/project_test.go index 4413ac13b..069fa4470 100644 --- a/pkg/webtests/project_test.go +++ b/pkg/webtests/project_test.go @@ -48,6 +48,9 @@ func TestProject(t *testing.T) { assert.NotContains(t, rec.Body.String(), `Test5`) assert.NotContains(t, rec.Body.String(), `Test21`) // Archived through parent project assert.NotContains(t, rec.Body.String(), `Test22`) // Archived directly + assert.NotContains(t, rec.Body.String(), `Test41`) // Soft-deleted + assert.NotContains(t, rec.Body.String(), `Test42`) // Soft-deleted parent + assert.NotContains(t, rec.Body.String(), `Test43`) // Soft-deleted child }) t.Run("Search", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"s": []string{"Test1"}}, nil)