package projects import ( "errors" "fmt" "path/filepath" "sync" "sync/atomic" "testing" "time" "github.com/glebarez/sqlite" "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/require" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" "senlinai-agent/backend/internal/models" ) func TestFindOwnedProjectScopesIdentityByOwner(t *testing.T) { newTestDB(t) service := NewService() owned, err := service.CreateProject(1, "Alpha", "") require.NoError(t, err) other, err := service.CreateProject(2, "Beta", "") require.NoError(t, err) found, err := FindOwnedProject(1, owned.Identity) require.NoError(t, err) require.Equal(t, owned.ID, found.ID) _, err = FindOwnedProject(1, other.Identity) require.True(t, errors.Is(err, gorm.ErrRecordNotFound)) } func TestProjectIdentifierDatabaseConstraintIsScopedByOwner(t *testing.T) { database := newTestDB(t) first := models.SaProject{OwnerID: 1, Name: "Alpha", Identifier: "SHARED"} require.NoError(t, database.Create(&first).Error) duplicate := models.SaProject{OwnerID: 1, Name: "Duplicate", Identifier: "SHARED"} require.ErrorIs(t, database.Create(&duplicate).Error, gorm.ErrDuplicatedKey) otherOwner := models.SaProject{OwnerID: 2, Name: "Allowed", Identifier: "SHARED"} require.NoError(t, database.Create(&otherOwner).Error) } func TestProjectIdentifierDatabaseErrorMapsToConflict(t *testing.T) { database := newTestDB(t) first := models.SaProject{OwnerID: 1, Name: "Alpha", Identifier: "SHARED"} require.NoError(t, database.Create(&first).Error) duplicate := models.SaProject{OwnerID: 1, Name: "Duplicate", Identifier: "SHARED"} databaseErr := database.Create(&duplicate).Error require.ErrorIs(t, databaseErr, gorm.ErrDuplicatedKey) require.ErrorIs(t, projectWriteError(databaseErr), ErrProjectIdentifierConflict) } func TestPostgresDuplicateKeyErrorMapsToProjectConflict(t *testing.T) { databaseErr := postgres.Dialector{}.Translate(&pgconn.PgError{Code: "23505"}) require.ErrorIs(t, databaseErr, gorm.ErrDuplicatedKey) require.ErrorIs(t, projectWriteError(databaseErr), ErrProjectIdentifierConflict) } func TestProjectTagsAreScopedToProject(t *testing.T) { newTestDB(t) service := NewService() first, err := service.CreateProject(1, "Alpha", "") require.NoError(t, err) second, err := service.CreateProject(1, "Beta", "") require.NoError(t, err) _, err = service.CreateTag(first.ID, "重要") require.NoError(t, err) _, err = service.CreateTag(second.ID, "重要") require.NoError(t, err) firstTags, err := service.ListTags(first.ID) require.NoError(t, err) require.Len(t, firstTags, 1) require.Equal(t, first.ID, firstTags[0].ProjectID) } func TestCreateProjectPersistsWorkspaceMetadata(t *testing.T) { newTestDB(t) service := NewService() project, err := service.CreateProjectWithInput(1, CreateProjectRequest{ Name: "探索项目", Identifier: "EXP", Icon: "compass", Background: "#165DFF", Description: "RSS 采集和线索沉淀", }) require.NoError(t, err) require.Equal(t, "探索项目", project.Name) require.Equal(t, "EXP", project.Identifier) require.Equal(t, "compass", project.Icon) require.Equal(t, "#165DFF", project.Background) require.Equal(t, "RSS 采集和线索沉淀", project.Description) workspace, err := service.Workspace(1, project.Identity) require.NoError(t, err) require.Equal(t, "EXP", workspace.Project.Identifier) require.Equal(t, "compass", workspace.Project.Icon) require.Equal(t, "#165DFF", workspace.Project.Background) require.Equal(t, "RSS 采集和线索沉淀", workspace.Project.Description) } func TestWorkspaceMatchesFrontendContract(t *testing.T) { database := newTestDB(t) service := NewService() require.NoError(t, database.Create(&models.SaUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error) project, err := service.CreateProject(1, "Project A1", "Client strategy workspace") require.NoError(t, err) other, err := service.CreateProject(1, "Project A2", "") require.NoError(t, err) nextRun := time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC) require.NoError(t, database.Create(&models.SaTag{ProjectID: project.ID, Name: "UI"}).Error) require.NoError(t, database.Create(&models.SaTag{ProjectID: project.ID, Name: "knowledge"}).Error) require.NoError(t, database.Create(&models.SaInboxItem{ProjectID: project.ID, CreatedBy: 1, SourceType: "Manual", Title: "Collect notes", Body: "Turn pasted research into tasks.", Status: "open"}).Error) require.NoError(t, database.Create(&models.SaInboxItem{ProjectID: other.ID, CreatedBy: 1, SourceType: "Manual", Title: "Other notes", Body: "Hidden", Status: "open"}).Error) require.NoError(t, database.Create(&models.SaTask{ProjectID: project.ID, CreatedBy: 1, Title: "Confirm IA", Description: "Review channel layout.", Status: "open", DueAt: &nextRun}).Error) require.NoError(t, database.Create(&models.SaTask{ProjectID: project.ID, CreatedBy: 1, Title: "Archive notes", Description: "Move outdated notes.", Status: "done"}).Error) require.NoError(t, database.Create(&models.SaAISession{ProjectID: project.ID, CreatedBy: 1, Title: "UI prototype critique", Context: "Discuss channel behavior."}).Error) require.NoError(t, database.Create(&models.SaNote{ProjectID: project.ID, CreatedBy: 1, Title: "Workbench principles", Markdown: "# Notes"}).Error) require.NoError(t, database.Create(&models.SaSource{ProjectID: project.ID, CreatedBy: 1, Kind: "link", Title: "Reference board", URL: "https://example.com"}).Error) require.NoError(t, database.Create(&models.SaProjectChannel{ProjectID: project.ID, Title: "Roadmap Board", Icon: "link", URL: "https://example.com/roadmap-a1", SortOrder: 7}).Error) require.NoError(t, database.Create(&models.SaProjectChannel{ProjectID: other.ID, Title: "Other Board", Icon: "link", URL: "https://example.com/other", SortOrder: 7}).Error) require.NoError(t, database.Create(&models.SaCronPlan{ProjectID: project.ID, CreatedBy: 1, Title: "Weekly inbox review reminder", Schedule: "0 9 * * 1", NextRunAt: &nextRun, Enabled: true, LastResult: "Not run yet"}).Error) require.NoError(t, database.Create(&models.SaCronPlan{ProjectID: other.ID, CreatedBy: 1, Title: "Other cron", Schedule: "0 9 * * 2", Enabled: true}).Error) workspace, err := service.Workspace(1, project.Identity) require.NoError(t, err) require.Equal(t, project.Identity, workspace.Project.ID) require.Equal(t, "Project A1", workspace.Project.Name) require.Equal(t, "A1", workspace.Project.Initials) require.Equal(t, int64(1), workspace.Project.UnreadCount) require.Equal(t, []string{"UI", "knowledge"}, workspaceTagNames(workspace.Tags)) require.Len(t, workspace.Channels, 7) require.Equal(t, "overview", workspace.Channels[0].Type) require.Equal(t, "inbox", workspace.Channels[1].Type) require.Equal(t, int64(1), workspace.Channels[1].Count) require.Equal(t, "custom_link", workspace.Channels[6].Type) require.Equal(t, "Roadmap Board", workspace.Channels[6].Title) require.Equal(t, "https://example.com/roadmap-a1", workspace.Channels[6].URL) require.Len(t, workspace.Inbox, 1) require.Equal(t, "Manual", workspace.Inbox[0].Source) require.Equal(t, "Turn pasted research into tasks.", workspace.Inbox[0].Summary) require.Len(t, workspace.Tasks, 2) require.False(t, workspace.Tasks[0].Completed) require.Equal(t, "David", workspace.Tasks[0].Owner) require.NotEmpty(t, workspace.Tasks[0].CreatedAt) require.Len(t, workspace.AISessions, 1) require.Len(t, workspace.RecentSessions, 1) require.Len(t, workspace.NotesSources, 2) require.Equal(t, "note", workspace.NotesSources[0].Kind) require.Equal(t, "link", workspace.NotesSources[1].Kind) require.Len(t, workspace.CronPlans, 1) require.True(t, workspace.CronPlans[0].Enabled) require.Equal(t, "David", workspace.CronPlans[0].Owner) } func TestWorkspaceRejectsProjectOwnedByAnotherUser(t *testing.T) { newTestDB(t) service := NewService() project, err := service.CreateProject(2, "Beta", "") require.NoError(t, err) _, err = service.Workspace(1, project.Identity) require.Error(t, err) } func TestCreateProjectTagAddsTagToOwnedProject(t *testing.T) { newTestDB(t) service := NewService() project, err := service.CreateProject(1, "Alpha", "") require.NoError(t, err) tag, err := service.CreateProjectTag(1, project.ID, "Design") require.NoError(t, err) require.Equal(t, project.ID, tag.ProjectID) require.Equal(t, "Design", tag.Name) workspace, err := service.Workspace(1, project.Identity) require.NoError(t, err) require.Equal(t, []string{"Design"}, workspaceTagNames(workspace.Tags)) } func TestCreateProjectTagRejectsProjectOwnedByAnotherUser(t *testing.T) { newTestDB(t) service := NewService() project, err := service.CreateProject(2, "Beta", "") require.NoError(t, err) _, err = service.CreateProjectTag(1, project.ID, "Hidden") require.Error(t, err) } func TestCreateProjectTagConcurrentlyReturnsOneProjectScopedTag(t *testing.T) { databasePath := filepath.ToSlash(filepath.Join(t.TempDir(), "tags.db")) database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)", databasePath)), &gorm.Config{TranslateError: true, Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) sqlDB, err := database.DB() require.NoError(t, err) t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) require.NoError(t, models.AutoMigrate(database)) models.DBService = database service := NewService() project, err := service.CreateProjectWithInput(1, CreateProjectRequest{Name: "Concurrent", Identifier: "CONCURRENT"}) require.NoError(t, err) arrived := make(chan struct{}, 2) release := make(chan struct{}) var synchronizedQueries atomic.Int32 require.NoError(t, database.Callback().Create().Before("gorm:create").Register("test:synchronize_tag_inserts", func(tx *gorm.DB) { if tx.Statement.Schema == nil || tx.Statement.Schema.Table != (models.SaTag{}).TableName() { return } if synchronizedQueries.Add(1) <= 2 { arrived <- struct{}{} <-release } })) type result struct { tag *models.SaTag err error } results := make(chan result, 2) var workers sync.WaitGroup workers.Add(2) for range 2 { go func() { defer workers.Done() tag, err := service.CreateProjectTag(1, project.ID, "UI") results <- result{tag: tag, err: err} }() } <-arrived <-arrived close(release) workers.Wait() close(results) identities := map[string]struct{}{} for value := range results { require.NoError(t, value.err) require.NotNil(t, value.tag) identities[value.tag.Identity] = struct{}{} } require.Len(t, identities, 1) var count int64 require.NoError(t, database.Model(&models.SaTag{}).Where("project_id = ? AND name = ?", project.ID, "UI").Count(&count).Error) require.Equal(t, int64(1), count) } func TestCreateCronPlanAddsPlanToOwnedProject(t *testing.T) { database := newTestDB(t) service := NewService() require.NoError(t, database.Create(&models.SaUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error) project, err := service.CreateProject(1, "Alpha", "") require.NoError(t, err) nextRun := time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC) plan, err := service.CreateCronPlan(1, project.ID, CreateCronPlanInput{ Title: "Daily inbox sweep", Schedule: "0 9 * * *", Enabled: true, NextRunAt: &nextRun, }) require.NoError(t, err) require.Equal(t, project.ID, plan.ProjectID) require.Equal(t, uint(1), plan.CreatedBy) require.Equal(t, "Daily inbox sweep", plan.Title) require.Equal(t, "0 9 * * *", plan.Schedule) require.True(t, plan.Enabled) require.NotNil(t, plan.NextRunAt) require.Empty(t, plan.LastResult, "创建计划只保存元数据,不能伪造执行结果") workspace, err := service.Workspace(1, project.Identity) require.NoError(t, err) require.Len(t, workspace.CronPlans, 1) require.Equal(t, "Daily inbox sweep", workspace.CronPlans[0].Title) } func newTestDB(t *testing.T) *gorm.DB { t.Helper() database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{TranslateError: true}) require.NoError(t, err) require.NoError(t, models.AutoMigrate(database)) models.DBService = database return database } func workspaceTagNames(tags []WorkspaceTagDTO) []string { names := make([]string, 0, len(tags)) for _, tag := range tags { names = append(names, tag.Name) } return names }