diff --git a/internal/application/repository/datasource_repo.go b/internal/application/repository/datasource_repo.go index 87faf8c2d..4771aa153 100644 --- a/internal/application/repository/datasource_repo.go +++ b/internal/application/repository/datasource_repo.go @@ -113,9 +113,8 @@ func (r *DataSourceRepository) Delete(ctx context.Context, id string) error { return errors.New("id is empty") } if err := r.db.WithContext(ctx). - Model(&types.DataSource{}). Where("id = ?", id). - Update("deleted_at", gorm.Expr("NOW()")).Error; err != nil { + Delete(&types.DataSource{}).Error; err != nil { return err } return nil diff --git a/internal/application/repository/datasource_repo_test.go b/internal/application/repository/datasource_repo_test.go index 03cb0e03a..e3ab5c260 100644 --- a/internal/application/repository/datasource_repo_test.go +++ b/internal/application/repository/datasource_repo_test.go @@ -51,6 +51,43 @@ func TestDataSourceRepositoryUpdateSyncStateClearsErrorMessage(t *testing.T) { require.NotNil(t, stored.LastSyncAt) } +func TestDataSourceRepositoryDeleteSoftDeletesOnSQLite(t *testing.T) { + db := setupDataSourceRepoTestDB(t) + repo := NewDataSourceRepository(db) + ctx := context.Background() + + target := &types.DataSource{ + ID: "ds-delete-target", + TenantID: 1, + KnowledgeBaseID: "kb-1", + Name: "Delete target", + Type: types.ConnectorTypeFeishu, + } + other := &types.DataSource{ + ID: "ds-delete-other", + TenantID: 1, + KnowledgeBaseID: "kb-1", + Name: "Other data source", + Type: types.ConnectorTypeFeishu, + } + require.NoError(t, repo.Create(ctx, target)) + require.NoError(t, repo.Create(ctx, other)) + + require.NoError(t, repo.Delete(ctx, target.ID)) + + var deleted types.DataSource + require.NoError(t, db.Unscoped().First(&deleted, "id = ?", target.ID).Error) + assert.True(t, deleted.DeletedAt.Valid) + + found, err := repo.FindByID(ctx, target.ID) + assert.Error(t, err) + assert.Nil(t, found) + + untouched, err := repo.FindByID(ctx, other.ID) + require.NoError(t, err) + assert.Equal(t, other.ID, untouched.ID) +} + func TestSyncLogRepositoryUpdateResultClearsErrorMessage(t *testing.T) { db := setupDataSourceRepoTestDB(t) repo := NewSyncLogRepository(db) diff --git a/internal/application/service/datasource_delete_sqlite_test.go b/internal/application/service/datasource_delete_sqlite_test.go new file mode 100644 index 000000000..b4087b96a --- /dev/null +++ b/internal/application/service/datasource_delete_sqlite_test.go @@ -0,0 +1,166 @@ +package service + +import ( + "context" + "path/filepath" + "testing" + + "github.com/Tencent/WeKnora/internal/application/repository" + "github.com/Tencent/WeKnora/internal/datasource" + "github.com/Tencent/WeKnora/internal/types" + "github.com/Tencent/WeKnora/internal/types/interfaces" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type sqliteDataSourceDeleteFixture struct { + db *gorm.DB + dsRepo interfaces.DataSourceRepository + syncLogRepo interfaces.SyncLogRepository + scheduler *datasource.Scheduler + ds *types.DataSource + pendingLog *types.SyncLog + runningLog *types.SyncLog +} + +func newSQLiteDataSourceDeleteFixture(t *testing.T) *sqliteDataSourceDeleteFixture { + t.Helper() + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "weknora.db")), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&types.DataSource{}, &types.SyncLog{})) + + dsRepo := repository.NewDataSourceRepository(db) + syncLogRepo := repository.NewSyncLogRepository(db) + ds := &types.DataSource{ + ID: "ds-sqlite-delete", + TenantID: 1, + KnowledgeBaseID: "kb-sqlite-delete", + Name: "SQLite delete", + Type: types.ConnectorTypeFeishu, + Status: types.DataSourceStatusActive, + SyncSchedule: "0 0 * * * *", + } + pendingLog := &types.SyncLog{ + ID: "log-pending", + DataSourceID: ds.ID, + TenantID: ds.TenantID, + Status: "pending", + } + runningLog := &types.SyncLog{ + ID: "log-running", + DataSourceID: ds.ID, + TenantID: ds.TenantID, + Status: types.SyncLogStatusRunning, + } + require.NoError(t, dsRepo.Create(context.Background(), ds)) + require.NoError(t, syncLogRepo.Create(context.Background(), pendingLog)) + require.NoError(t, syncLogRepo.Create(context.Background(), runningLog)) + + scheduler := datasource.NewScheduler(dsRepo, syncLogRepo, kbDeleteTaskEnqueuer{}) + require.NoError(t, scheduler.AddOrUpdate(ds)) + require.Equal(t, 1, scheduler.EntryCount()) + + return &sqliteDataSourceDeleteFixture{ + db: db, + dsRepo: dsRepo, + syncLogRepo: syncLogRepo, + scheduler: scheduler, + ds: ds, + pendingLog: pendingLog, + runningLog: runningLog, + } +} + +func TestDataSourceServiceDeleteSQLiteCleansUpAfterSoftDelete(t *testing.T) { + fixture := newSQLiteDataSourceDeleteFixture(t) + svc := &DataSourceService{ + dsRepo: fixture.dsRepo, + syncLogRepo: fixture.syncLogRepo, + scheduler: fixture.scheduler, + } + + require.NoError(t, svc.DeleteDataSource(context.Background(), fixture.ds.ID)) + + _, err := fixture.dsRepo.FindByID(context.Background(), fixture.ds.ID) + require.EqualError(t, err, "data source not found") + assert.Equal(t, 0, fixture.scheduler.EntryCount()) + + for _, logID := range []string{fixture.pendingLog.ID, fixture.runningLog.ID} { + log, err := fixture.syncLogRepo.FindByID(context.Background(), logID) + require.NoError(t, err) + assert.Equal(t, types.SyncLogStatusCanceled, log.Status) + require.NotNil(t, log.FinishedAt) + assert.Equal(t, "data source deleted", log.ErrorMessage) + } +} + +func TestDataSourceServiceDeleteKeepsCleanupStateWhenSoftDeleteFails(t *testing.T) { + fixture := newSQLiteDataSourceDeleteFixture(t) + require.NoError(t, fixture.db.Exec(` + CREATE TRIGGER fail_datasource_soft_delete + BEFORE UPDATE OF deleted_at ON data_sources + WHEN NEW.id = 'ds-sqlite-delete' + BEGIN + SELECT RAISE(FAIL, 'forced soft delete failure'); + END; + `).Error) + svc := &DataSourceService{ + dsRepo: fixture.dsRepo, + syncLogRepo: fixture.syncLogRepo, + scheduler: fixture.scheduler, + } + + err := svc.DeleteDataSource(context.Background(), fixture.ds.ID) + require.ErrorContains(t, err, "forced soft delete failure") + + found, err := fixture.dsRepo.FindByID(context.Background(), fixture.ds.ID) + require.NoError(t, err) + assert.Equal(t, fixture.ds.ID, found.ID) + assert.Equal(t, 1, fixture.scheduler.EntryCount()) + + pending, err := fixture.syncLogRepo.FindByID(context.Background(), fixture.pendingLog.ID) + require.NoError(t, err) + assert.Equal(t, "pending", pending.Status) + running, err := fixture.syncLogRepo.FindByID(context.Background(), fixture.runningLog.ID) + require.NoError(t, err) + assert.Equal(t, types.SyncLogStatusRunning, running.Status) +} + +func TestDeleteKnowledgeBaseCleansUpSQLiteDataSources(t *testing.T) { + fixture := newSQLiteDataSourceDeleteFixture(t) + kbRepo := &kbDeleteKBRepo{fakeKBRepo: *newFakeKBRepo()} + kbRepo.rows[fixture.ds.KnowledgeBaseID] = &types.KnowledgeBase{ + ID: fixture.ds.KnowledgeBaseID, + TenantID: fixture.ds.TenantID, + Name: "SQLite delete", + } + svc := &knowledgeBaseService{ + repo: kbRepo, + asynqClient: kbDeleteTaskEnqueuer{}, + dsRepo: fixture.dsRepo, + syncLogRepo: fixture.syncLogRepo, + dsScheduler: fixture.scheduler, + } + + err := svc.DeleteKnowledgeBase( + ctxWithTenantStorage(fixture.ds.TenantID, "local"), + fixture.ds.KnowledgeBaseID, + ) + require.NoError(t, err) + assert.Equal(t, fixture.ds.KnowledgeBaseID, kbRepo.deletedID) + + _, err = fixture.dsRepo.FindByID(context.Background(), fixture.ds.ID) + require.EqualError(t, err, "data source not found") + var deleted types.DataSource + require.NoError(t, fixture.db.Unscoped().First(&deleted, "id = ?", fixture.ds.ID).Error) + assert.True(t, deleted.DeletedAt.Valid) + assert.Equal(t, 0, fixture.scheduler.EntryCount()) + + for _, logID := range []string{fixture.pendingLog.ID, fixture.runningLog.ID} { + log, err := fixture.syncLogRepo.FindByID(context.Background(), logID) + require.NoError(t, err) + assert.Equal(t, types.SyncLogStatusCanceled, log.Status) + } +}