package repository import ( "context" "strings" "testing" "time" "github.com/Tencent/WeKnora/internal/accesspolicy" "github.com/Tencent/WeKnora/internal/types" "github.com/google/uuid" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupDigitalHumanTest(t *testing.T) (*DigitalHumanRepository, *gorm.DB, types.DigitalHumanScope, *types.DigitalHuman) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) sqlDB.SetMaxOpenConns(1) t.Cleanup(func() { sqlDB.Close() }) require.NoError(t, db.AutoMigrate(&types.CustomAgent{}, &types.DigitalHuman{}, &types.DigitalHumanVersion{}, &types.DigitalHumanBinding{}, &types.DigitalHumanJob{})) require.NoError(t, db.Exec("CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT, deleted_at DATETIME)").Error) require.NoError(t, db.Exec("CREATE TABLE im_channels (id TEXT PRIMARY KEY, agent_id TEXT, tenant_id INTEGER, enabled BOOLEAN, updated_at DATETIME, deleted_at DATETIME)").Error) require.NoError(t, db.Exec("INSERT INTO users (id, username) VALUES (?, ?)", "owner", "Teacher Owner").Error) scope := types.DigitalHumanScope{TenantID: 7, OwnerID: "owner"} human := &types.DigitalHuman{ID: uuid.NewString(), TenantID: scope.TenantID, OwnerID: scope.OwnerID, Name: "Teacher", PrimaryAgentID: uuid.NewString(), LatestVersion: 1, Status: "draft", Revision: 1} repo := NewDigitalHumanRepository(db) require.NoError(t, repo.Create(context.Background(), human, map[string]string{"tone": "measured"})) return repo, db, scope, human } func TestDigitalHumanProgressRenewsLeaseAndFencesExpiredOrForeignJobs(t *testing.T) { repo, db, scope, human := setupDigitalHumanTest(t) ctx := context.Background() job := &types.DigitalHumanJob{ID: "job", TenantID: scope.TenantID, OwnerID: scope.OwnerID, DigitalHumanID: human.ID, Status: "running", ExpiresAt: time.Now().Add(time.Minute)} require.NoError(t, repo.StartJob(ctx, scope, human.ID, human.Revision, job)) require.NoError(t, repo.JobProgress(ctx, scope, job.ID, 40, "batch 4/10")) stored, err := repo.Job(ctx, scope, human.ID, job.ID) require.NoError(t, err) require.Greater(t, stored.ExpiresAt.Sub(time.Now()), 9*time.Minute) foreign := scope foreign.OwnerID = "other-owner" require.ErrorIs(t, repo.JobProgress(ctx, foreign, job.ID, 50, "foreign"), types.ErrDigitalHumanConflict) require.NoError(t, db.Model(&types.DigitalHumanJob{}).Where("id = ?", job.ID).Update("expires_at", time.Now().Add(-time.Minute)).Error) require.ErrorIs(t, repo.JobProgress(ctx, scope, job.ID, 60, "late"), types.ErrDigitalHumanConflict) } func TestDigitalHumanCheckpointIsPrivateOwnerScopedAndFenced(t *testing.T) { repo, db, scope, human := setupDigitalHumanTest(t) ctx := context.Background() key := strings.Repeat("a", 64) checkpoint := &types.DigitalHumanDistillCheckpoint{CompletedBatches: 1, TotalBatches: 3, Persona: map[string]string{"tone": "private draft"}} job := &types.DigitalHumanJob{ID: "checkpoint-job", TenantID: scope.TenantID, OwnerID: scope.OwnerID, DigitalHumanID: human.ID, Status: "running", ExpiresAt: time.Now().Add(time.Minute)} require.NoError(t, repo.StartJob(ctx, scope, human.ID, human.Revision, job)) require.NoError(t, repo.SaveDistillCheckpoint(ctx, scope, human.ID, job.ID, key, checkpoint)) foreign := scope foreign.OwnerID = "other" require.Error(t, repo.SaveDistillCheckpoint(ctx, foreign, human.ID, job.ID, key, checkpoint)) require.ErrorIs(t, repo.SaveDistillCheckpoint(ctx, scope, human.ID, job.ID, strings.Repeat("b", 64), checkpoint), types.ErrDigitalHumanConflict) require.ErrorIs(t, repo.SaveDistillCheckpoint(ctx, scope, human.ID, job.ID, key, &types.DigitalHumanDistillCheckpoint{CompletedBatches: 0, TotalBatches: 3}), types.ErrDigitalHumanConflict) require.NoError(t, db.Model(&types.DigitalHumanJob{}).Where("id = ?", job.ID).Update("expires_at", time.Now().Add(-time.Minute)).Error) require.ErrorIs(t, repo.SaveDistillCheckpoint(ctx, scope, human.ID, job.ID, key, checkpoint), types.ErrDigitalHumanConflict) require.NoError(t, repo.RecoverExpiredJobs(ctx, scope)) found, err := repo.DistillCheckpoint(ctx, scope, human.ID, key) require.NoError(t, err) require.Equal(t, checkpoint, found) for _, lookup := range []struct { scope types.DigitalHumanScope human, fingerprint string }{ {foreign, human.ID, key}, {types.DigitalHumanScope{TenantID: 8, OwnerID: scope.OwnerID}, human.ID, key}, {scope, "another-human", key}, {scope, human.ID, strings.Repeat("c", 64)}, } { found, err := repo.DistillCheckpoint(ctx, lookup.scope, lookup.human, lookup.fingerprint) require.NoError(t, err) require.Nil(t, found) } detail, err := repo.Get(ctx, scope, human.ID) require.NoError(t, err) newJob := &types.DigitalHumanJob{ID: "new-job", TenantID: scope.TenantID, OwnerID: scope.OwnerID, DigitalHumanID: human.ID, Status: "running", ExpiresAt: time.Now().Add(time.Minute)} require.NoError(t, repo.StartJob(ctx, scope, human.ID, detail.Revision, newJob)) // Even a stale worker with a stale local lease may not change old retry state. require.ErrorIs(t, repo.SaveDistillCheckpoint(ctx, scope, human.ID, job.ID, key, checkpoint), types.ErrDigitalHumanConflict) require.NoError(t, repo.SaveDistillCheckpoint(ctx, scope, human.ID, newJob.ID, key, checkpoint)) } func TestDigitalHumanDeletionCommitsChannelsTogether(t *testing.T) { for _, rollback := range []bool{false, true} { t.Run(map[bool]string{false: "commit", true: "rollback"}[rollback], func(t *testing.T) { repo, db, scope, human := setupDigitalHumanTest(t) for _, row := range []struct { id, agent string tenant uint64 }{ {"owned", human.PrimaryAgentID, scope.TenantID}, {"other-agent", "other-agent", scope.TenantID}, {"other-tenant", human.PrimaryAgentID, scope.TenantID + 1}, } { require.NoError(t, db.Exec("INSERT INTO im_channels (id, agent_id, tenant_id, enabled) VALUES (?, ?, ?, true)", row.id, row.agent, row.tenant).Error) } if rollback { require.NoError(t, db.Exec("CREATE TRIGGER reject_digital_human_delete BEFORE UPDATE OF deleted_at ON digital_humans BEGIN SELECT RAISE(ABORT, 'simulated write failure'); END").Error) } ids, err := repo.DeleteWithIMChannels(context.Background(), scope, human.ID) var channels int64 require.NoError(t, db.Table("im_channels").Where("deleted_at IS NULL AND enabled = true").Count(&channels).Error) var agents int64 require.NoError(t, db.Model(&types.CustomAgent{}).Where("id = ?", human.PrimaryAgentID).Count(&agents).Error) if rollback { require.Error(t, err) require.Empty(t, ids) require.Equal(t, int64(3), channels) require.Equal(t, int64(1), agents) } else { require.NoError(t, err) require.Equal(t, []string{"owned"}, ids) require.Equal(t, int64(2), channels) require.Zero(t, agents) } }) } } func TestDigitalHumanOwnerIsolationAndAdminRead(t *testing.T) { repo, db, scope, human := setupDigitalHumanTest(t) ctx := context.Background() var agent types.CustomAgent require.NoError(t, db.First(&agent, "id = ?", human.PrimaryAgentID).Error) require.Equal(t, human.Name, agent.Name) require.Equal(t, scope.OwnerID, agent.CreatedBy) other := types.DigitalHumanScope{TenantID: scope.TenantID, OwnerID: "other"} _, err := repo.Get(ctx, other, human.ID) require.ErrorIs(t, err, types.ErrDigitalHumanNotFound) other.ReadAll = true detail, err := repo.Get(ctx, other, human.ID) require.NoError(t, err) require.Len(t, detail.Versions, 1) require.Len(t, detail.Bindings, 1) require.Equal(t, "Teacher Owner", detail.CreatorName) listed, err := repo.List(ctx, other, 0, 100) require.NoError(t, err) require.Len(t, listed, 1) require.Equal(t, "Teacher Owner", listed[0].CreatorName) require.ErrorIs(t, repo.Delete(ctx, other, human.ID), types.ErrDigitalHumanNotFound) require.ErrorIs(t, repo.Review(ctx, other, human.ID, 1, true, ""), types.ErrDigitalHumanNotFound) other.TenantID++ _, err = repo.Get(ctx, other, human.ID) require.ErrorIs(t, err, types.ErrDigitalHumanNotFound) } func TestDigitalHumanReviewBindingsAndDeletionPreserveAgentEdits(t *testing.T) { repo, db, scope, human := setupDigitalHumanTest(t) ctx := context.Background() extra := types.CustomAgent{ID: uuid.NewString(), TenantID: scope.TenantID, CreatedBy: scope.OwnerID, Name: "Content", Config: types.CustomAgentConfig{SystemPrompt: "Original task rules"}} require.NoError(t, db.Create(&extra).Error) require.NoError(t, repo.Bind(ctx, scope, human.ID, extra.ID)) require.NoError(t, repo.Review(ctx, scope, human.ID, 1, true, "approved")) require.NoError(t, db.First(&extra, "id = ?", extra.ID).Error) require.Contains(t, extra.Config.SystemPrompt, "measured") extra.Config.SystemPrompt = "Revised task rules\n" + extra.Config.SystemPrompt + "\nKeep continuity" require.NoError(t, db.Save(&extra).Error) h, err := repo.Get(ctx, scope, human.ID) require.NoError(t, err) require.NoError(t, repo.Update(ctx, scope, human.ID, h.Revision, h.Name, h.Description, h.Avatar, h.ModelID, map[string]string{"tone": "clear"})) require.NoError(t, repo.Review(ctx, scope, human.ID, 2, true, "")) require.NoError(t, db.First(&extra, "id = ?", extra.ID).Error) require.Contains(t, extra.Config.SystemPrompt, "Revised task rules") require.Contains(t, extra.Config.SystemPrompt, "Keep continuity") require.NotContains(t, extra.Config.SystemPrompt, "measured") require.Equal(t, 1, strings.Count(extra.Config.SystemPrompt, ":start -->")) require.NoError(t, repo.Delete(ctx, scope, human.ID)) require.NoError(t, db.First(&extra, "id = ?", extra.ID).Error) require.NotContains(t, extra.Config.SystemPrompt, "weknora:digital-human:") require.Contains(t, extra.Config.SystemPrompt, "Keep continuity") var count int64 require.NoError(t, db.Model(&types.CustomAgent{}).Where("id = ?", human.PrimaryAgentID).Count(&count).Error) require.Zero(t, count) require.NoError(t, db.Model(&types.DigitalHumanBinding{}).Where("digital_human_id = ?", human.ID).Count(&count).Error) require.Zero(t, count) } func TestDigitalHumanAgentCopyKeepsLiveBinding(t *testing.T) { repo, db, scope, human := setupDigitalHumanTest(t) ctx := context.WithValue(context.Background(), types.TenantIDContextKey, scope.TenantID) ctx = context.WithValue(ctx, types.UserIDContextKey, scope.OwnerID) require.NoError(t, repo.Review(ctx, scope, human.ID, 1, true, "approved")) var source types.CustomAgent require.NoError(t, db.First(&source, "id = ?", human.PrimaryAgentID).Error) copy := source copy.ID, copy.Name = uuid.NewString(), "Copied persona" agents := &customAgentRepository{db: db} require.NoError(t, agents.CreateAgentCopy(ctx, source.ID, ©)) require.NoError(t, agents.CheckDigitalHumanAgentExecution(ctx, ©)) detail, err := repo.Get(ctx, scope, human.ID) require.NoError(t, err) require.Len(t, detail.Bindings, 2) require.NoError(t, repo.Update(ctx, scope, human.ID, detail.Revision, detail.Name, detail.Description, detail.Avatar, detail.ModelID, map[string]string{"tone": "new voice"})) require.NoError(t, repo.Review(ctx, scope, human.ID, 2, true, "approved")) require.NoError(t, db.First(©, "id = ?", copy.ID).Error) require.Contains(t, copy.Config.SystemPrompt, "new voice") require.NoError(t, agents.CheckDigitalHumanAgentExecution(ctx, ©)) staleCopy := source staleCopy.ID = uuid.NewString() require.ErrorIs(t, agents.CreateAgentCopy(ctx, source.ID, &staleCopy), types.ErrDigitalHumanConflict) var count int64 require.NoError(t, db.Model(&types.CustomAgent{}).Where("id = ?", staleCopy.ID).Count(&count).Error) require.Zero(t, count) require.NoError(t, repo.Unbind(ctx, scope, human.ID, copy.ID)) require.Error(t, agents.CheckDigitalHumanAgentExecution(ctx, ©), "saved profile must stop executing after unbind") } func TestSharedDigitalHumanCanBeMountedWithoutChangingSourceOrOtherUsers(t *testing.T) { repo, db, owner, human := setupDigitalHumanTest(t) ctx := accesspolicy.WithSnapshot(context.Background(), &accesspolicy.Snapshot{Publications: map[string]bool{"digital-humans:" + human.ID: true}}) consumer := types.DigitalHumanScope{TenantID: owner.TenantID, OwnerID: "consumer"} for _, agent := range []types.CustomAgent{ {ID: "consumer-agent", TenantID: owner.TenantID, CreatedBy: consumer.OwnerID, Name: "Consumer", Config: types.CustomAgentConfig{SystemPrompt: "Consumer task"}}, {ID: "other-agent", TenantID: owner.TenantID, CreatedBy: "other", Name: "Other"}, {ID: "foreign-agent", TenantID: owner.TenantID + 1, CreatedBy: consumer.OwnerID, Name: "Foreign"}, } { require.NoError(t, db.Create(&agent).Error) } require.NoError(t, repo.Review(ctx, owner, human.ID, 1, true, "")) require.NoError(t, repo.Bind(ctx, consumer, human.ID, "consumer-agent")) require.ErrorIs(t, repo.Bind(ctx, consumer, human.ID, "other-agent"), types.ErrDigitalHumanNotFound) require.ErrorIs(t, repo.Bind(ctx, consumer, human.ID, "foreign-agent"), types.ErrDigitalHumanNotFound) require.ErrorIs(t, repo.Delete(ctx, consumer, human.ID), types.ErrDigitalHumanNotFound) detail, err := repo.Get(ctx, consumer, human.ID) require.NoError(t, err) require.Len(t, detail.Bindings, 1, "recipients only see bindings for their own Agents") require.Equal(t, "consumer-agent", detail.Bindings[0].AgentID) var agent types.CustomAgent require.NoError(t, db.First(&agent, "id = ?", "consumer-agent").Error) require.Contains(t, agent.Config.SystemPrompt, "measured") agentRepo := &customAgentRepository{db: db} consumerContext := context.WithValue(ctx, types.TenantIDContextKey, owner.TenantID) consumerContext = context.WithValue(consumerContext, types.UserIDContextKey, consumer.OwnerID) visible, err := agentRepo.FilterDigitalHumanAgents(consumerContext, []*types.CustomAgent{&agent}) require.NoError(t, err) require.Len(t, visible, 1) require.NotNil(t, visible[0].DigitalHuman) require.Equal(t, human.ID, visible[0].DigitalHuman.ID) require.Equal(t, human.Name, visible[0].DigitalHuman.Name) require.Equal(t, 1, visible[0].DigitalHuman.AppliedVersion) require.ErrorContains(t, agentRepo.CheckDigitalHumanAgentCopy(consumerContext, agent.ID, owner.TenantID), "共享数字人") require.NoError(t, agentRepo.CheckDigitalHumanAgentExecution(consumerContext, &agent)) savedSnapshot := agent revokedExecution := accesspolicy.WithSnapshot(consumerContext, &accesspolicy.Snapshot{Publications: map[string]bool{"digital-humans:" + human.ID: false}}) require.ErrorContains(t, agentRepo.CheckDigitalHumanAgentExecution(revokedExecution, &savedSnapshot), "授权已撤销") foreignExecution := context.WithValue(consumerContext, types.TenantIDContextKey, owner.TenantID+1) require.Error(t, agentRepo.CheckDigitalHumanAgentExecution(foreignExecution, &savedSnapshot)) adminContext := context.WithValue(consumerContext, types.TenantRoleContextKey, types.TenantRoleAdmin) require.NoError(t, agentRepo.CheckDigitalHumanAgentCopy(adminContext, agent.ID, owner.TenantID)) // Owner publication updates every installed binding, preserving its owner. require.NoError(t, repo.Update(ctx, owner, human.ID, detail.Revision, human.Name, "", "", "", map[string]string{"tone": "revised"})) require.NoError(t, repo.Review(ctx, owner, human.ID, 2, true, "")) require.NoError(t, db.First(&agent, "id = ?", "consumer-agent").Error) require.Contains(t, agent.Config.SystemPrompt, "revised") require.Equal(t, consumer.OwnerID, agent.CreatedBy) // Revocation must still permit removing a persona from one's own Agent. revoked := accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{}) require.NoError(t, repo.Unbind(revoked, consumer, human.ID, agent.ID)) require.ErrorContains(t, agentRepo.CheckDigitalHumanAgentExecution(consumerContext, &savedSnapshot), "解绑") require.NoError(t, db.First(&agent, "id = ?", "consumer-agent").Error) require.Equal(t, "Consumer task", agent.Config.SystemPrompt) require.ErrorIs(t, repo.Bind(revoked, consumer, human.ID, agent.ID), types.ErrDigitalHumanNotFound) require.NoError(t, repo.Bind(ctx, consumer, human.ID, agent.ID)) require.NoError(t, repo.Delete(ctx, owner, human.ID)) require.NoError(t, db.First(&agent, "id = ?", "consumer-agent").Error) require.Equal(t, "Consumer task", agent.Config.SystemPrompt) var count int64 require.NoError(t, db.Model(&types.DigitalHumanBinding{}).Where("digital_human_id = ?", human.ID).Count(&count).Error) require.Zero(t, count) } func TestDigitalHumanJobRecoveryFencesOldWorker(t *testing.T) { repo, _, scope, human := setupDigitalHumanTest(t) ctx := context.Background() job := &types.DigitalHumanJob{ID: uuid.NewString(), TenantID: scope.TenantID, OwnerID: scope.OwnerID, DigitalHumanID: human.ID, Status: "running", ExpiresAt: time.Now().Add(-time.Minute)} require.NoError(t, repo.StartJob(ctx, scope, human.ID, human.Revision, job)) require.ErrorIs(t, repo.StartJob(ctx, scope, human.ID, human.Revision, job), types.ErrDigitalHumanConflict) require.NoError(t, repo.RecoverExpiredJobs(ctx, scope)) h, err := repo.Get(ctx, scope, human.ID) require.NoError(t, err) require.Empty(t, h.ProcessingJobID) stored, err := repo.Job(ctx, scope, human.ID, job.ID) require.NoError(t, err) require.Equal(t, "failed", stored.Status) require.ErrorIs(t, repo.FinishJob(ctx, scope, human.ID, job.ID, &types.DigitalHumanVersion{Persona: map[string]string{"tone": "stale"}}, ""), types.ErrDigitalHumanConflict) job2 := &types.DigitalHumanJob{ID: uuid.NewString(), TenantID: scope.TenantID, OwnerID: scope.OwnerID, DigitalHumanID: human.ID, Status: "running", ExpiresAt: time.Now().Add(time.Minute)} require.NoError(t, repo.StartJob(ctx, scope, human.ID, h.Revision, job2)) require.NoError(t, repo.FinishJob(ctx, scope, human.ID, job2.ID, &types.DigitalHumanVersion{Persona: map[string]string{"tone": "fresh"}}, "")) h, err = repo.Get(ctx, scope, human.ID) require.NoError(t, err) require.Equal(t, 2, h.LatestVersion) require.Equal(t, "fresh", h.Versions[0].Persona["tone"]) } func TestImportedDigitalHumanKeepsStyleUntilNewVersionReviewed(t *testing.T) { repo, db, scope, human := setupDigitalHumanTest(t) ctx := context.Background() persona := map[string]string{"persona_name": "Legacy teacher", "tone": "direct", "taboo": "no jargon", "vocab": "hello", "identity": "teacher", "interaction": "ask questions"} require.NoError(t, db.Model(&types.DigitalHumanVersion{}).Where("digital_human_id = ?", human.ID). Updates(&types.DigitalHumanVersion{Status: "imported", Persona: persona}).Error) require.NoError(t, db.Model(human).Updates(map[string]any{"status": "active", "active_version": 1}).Error) extra := types.CustomAgent{ID: uuid.NewString(), TenantID: scope.TenantID, CreatedBy: scope.OwnerID, Name: "Imported style consumer", Config: types.CustomAgentConfig{SystemPrompt: "Keep current task rules"}} require.NoError(t, db.Create(&extra).Error) require.NoError(t, repo.Bind(ctx, scope, human.ID, extra.ID)) require.NoError(t, db.First(&extra, "id = ?", extra.ID).Error) require.Contains(t, extra.Config.SystemPrompt, "旧配置迁入数字人(未重新蒸馏)") require.NotContains(t, extra.Config.SystemPrompt, "已审核数字人") for _, value := range persona { require.Contains(t, extra.Config.SystemPrompt, value) } require.ErrorIs(t, repo.Review(ctx, scope, human.ID, 1, true, ""), types.ErrDigitalHumanConflict) detail, err := repo.Get(ctx, scope, human.ID) require.NoError(t, err) require.Nil(t, detail.Versions[0].ReviewedAt) require.NoError(t, repo.Update(ctx, scope, human.ID, detail.Revision, human.Name, "", "", "", map[string]string{"tone": "new tone"})) require.NoError(t, db.First(&extra, "id = ?", extra.ID).Error) require.Contains(t, extra.Config.SystemPrompt, "tone:direct") require.NotContains(t, extra.Config.SystemPrompt, "new tone") require.NoError(t, repo.Review(ctx, scope, human.ID, 2, false, "revise")) detail, err = repo.Get(ctx, scope, human.ID) require.NoError(t, err) require.Equal(t, 1, detail.ActiveVersion) require.Equal(t, "active", detail.Status) require.NoError(t, repo.Update(ctx, scope, human.ID, detail.Revision, human.Name, "", "", "", map[string]string{"tone": "reviewed replacement"})) require.NoError(t, repo.Review(ctx, scope, human.ID, 3, true, "")) require.NoError(t, db.First(&extra, "id = ?", extra.ID).Error) require.Contains(t, extra.Config.SystemPrompt, "已审核数字人") require.Contains(t, extra.Config.SystemPrompt, "Keep current task rules") require.Contains(t, extra.Config.SystemPrompt, "reviewed replacement") require.NotContains(t, extra.Config.SystemPrompt, "tone:direct") require.Equal(t, 1, strings.Count(extra.Config.SystemPrompt, ":start -->")) }