329 lines
13 KiB
Go
329 lines
13 KiB
Go
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 TestCreateProjectChannelRequiresOwnedProjectAndSafeURL(t *testing.T) {
|
|
newTestDB(t)
|
|
service := NewService()
|
|
project, err := service.CreateProject(1, "Alpha", "")
|
|
require.NoError(t, err)
|
|
|
|
channel, err := service.CreateProjectChannel(1, project.ID, CreateProjectChannelInput{
|
|
Title: "Research", URL: "https://example.com/research",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "link", channel.Icon)
|
|
require.Equal(t, 1, channel.SortOrder)
|
|
|
|
_, err = service.CreateProjectChannel(1, project.ID, CreateProjectChannelInput{Title: "Unsafe", URL: "javascript:alert(1)"})
|
|
require.ErrorIs(t, err, ErrChannelURLInvalid)
|
|
_, err = service.CreateProjectChannel(2, project.ID, CreateProjectChannelInput{Title: "Foreign", URL: "https://example.com"})
|
|
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
|
|
}
|
|
|
|
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.SaDocumentTree{ProjectID: project.ID, OwnerID: 1, Kind: models.DocumentKindFile, Name: "Workbench principles.md", NormalizedName: "workbench principles.md", Extension: ".md", Revision: 1}).Error)
|
|
require.NoError(t, database.Create(&models.SaDocumentTree{ProjectID: project.ID, OwnerID: 1, Kind: models.DocumentKindFile, Name: "Reference board.pdf", NormalizedName: "reference board.pdf", Extension: ".pdf", Revision: 1}).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.Documents, 2)
|
|
require.Equal(t, models.DocumentKindFile, workspace.Documents[0].Kind)
|
|
require.Equal(t, models.DocumentKindFile, workspace.Documents[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
|
|
}
|