Files
agent/backend/internal/logic/projects/service_test.go

366 lines
15 KiB
Go

package projects
import (
"errors"
"fmt"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"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.SenlinAgentProject{OwnerID: 1, Name: "Alpha", Identifier: "SHARED"}
require.NoError(t, database.Create(&first).Error)
duplicate := models.SenlinAgentProject{OwnerID: 1, Name: "Duplicate", Identifier: "SHARED"}
require.ErrorIs(t, database.Create(&duplicate).Error, gorm.ErrDuplicatedKey)
otherOwner := models.SenlinAgentProject{OwnerID: 2, Name: "Allowed", Identifier: "SHARED"}
require.NoError(t, database.Create(&otherOwner).Error)
}
func TestProjectIdentifierDatabaseErrorMapsToConflict(t *testing.T) {
database := newTestDB(t)
first := models.SenlinAgentProject{OwnerID: 1, Name: "Alpha", Identifier: "SHARED"}
require.NoError(t, database.Create(&first).Error)
duplicate := models.SenlinAgentProject{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 TestDashboardCountsOnlyRequestedProject(t *testing.T) {
database := newTestDB(t)
service := NewService()
first, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
second, err := service.CreateProject(1, "Beta", "")
require.NoError(t, err)
require.NoError(t, database.Create(&models.SenlinAgentInboxItem{ProjectID: first.ID, CreatedBy: 1, SourceType: "text", Status: "open"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentInboxItem{ProjectID: second.ID, CreatedBy: 1, SourceType: "text", Status: "open"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: first.ID, CreatedBy: 1, Title: "A", Status: "open"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: second.ID, CreatedBy: 1, Title: "B", Status: "open"}).Error)
dashboard, err := service.Dashboard(1, first.ID)
require.NoError(t, err)
require.Equal(t, int64(1), dashboard.PendingInboxCount)
require.Equal(t, int64(1), dashboard.OpenTaskCount)
}
func TestDashboardRejectsProjectOwnedByAnotherUser(t *testing.T) {
newTestDB(t)
service := NewService()
project, err := service.CreateProject(2, "Beta", "")
require.NoError(t, err)
_, err = service.Dashboard(1, project.ID)
require.Error(t, err)
}
func TestWorkspaceMatchesFrontendContract(t *testing.T) {
database := newTestDB(t)
service := NewService()
require.NoError(t, database.Create(&models.SenlinAgentUser{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.SenlinAgentTag{ProjectID: project.ID, Name: "UI"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentTag{ProjectID: project.ID, Name: "knowledge"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentInboxItem{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.SenlinAgentInboxItem{ProjectID: other.ID, CreatedBy: 1, SourceType: "Manual", Title: "Other notes", Body: "Hidden", Status: "open"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: 1, Title: "Confirm IA", Description: "Review channel layout.", Status: "open", DueAt: &nextRun}).Error)
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: 1, Title: "Archive notes", Description: "Move outdated notes.", Status: "done"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentAISession{ProjectID: project.ID, CreatedBy: 1, Title: "UI prototype critique", Context: "Discuss channel behavior."}).Error)
require.NoError(t, database.Create(&models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: 1, Title: "Workbench principles", Markdown: "# Notes"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentSource{ProjectID: project.ID, CreatedBy: 1, Kind: "link", Title: "Reference board", URL: "https://example.com"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentProjectChannel{ProjectID: project.ID, Title: "Roadmap Board", Icon: "link", URL: "https://example.com/roadmap-a1", SortOrder: 7}).Error)
require.NoError(t, database.Create(&models.SenlinAgentProjectChannel{ProjectID: other.ID, Title: "Other Board", Icon: "link", URL: "https://example.com/other", SortOrder: 7}).Error)
require.NoError(t, database.Create(&models.SenlinAgentCronPlan{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.SenlinAgentCronPlan{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 TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
database := newTestDB(t)
service := NewService()
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
dueAt := time.Date(2026, 7, 21, 9, 30, 0, 0, time.UTC)
task, err := service.CreateTask(1, project.ID, CreateTaskInput{
Title: "Follow up with client",
Description: "整理客户反馈并形成行动项",
Status: "",
DueAt: &dueAt,
Tag: "客户反馈",
})
require.NoError(t, err)
require.Equal(t, project.ID, task.ProjectID)
require.Equal(t, uint(1), task.CreatedBy)
require.Equal(t, "Follow up with client", task.Title)
require.Equal(t, "open", task.Status)
require.NotNil(t, task.DueAt)
require.NotNil(t, task.TagID)
workspace, err := service.Workspace(1, project.Identity)
require.NoError(t, err)
require.Len(t, workspace.Tasks, 1)
require.Equal(t, "Follow up with client", workspace.Tasks[0].Title)
require.Equal(t, "客户反馈", workspace.Tasks[0].Tag)
require.Equal(t, []string{"客户反馈"}, workspaceTagNames(workspace.Tags))
}
func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
newTestDB(t)
service := NewService()
project, err := service.CreateProject(2, "Beta", "")
require.NoError(t, err)
_, err = service.CreateTask(1, project.ID, CreateTaskInput{Title: "Nope"})
require.Error(t, err)
}
func TestUpdateTaskPersistsTagAndCompletion(t *testing.T) {
newTestDB(t)
service := NewService()
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
task, err := service.CreateTask(1, project.ID, CreateTaskInput{Title: "Draft proposal", Tag: "客户"})
require.NoError(t, err)
updated, err := service.UpdateTask(1, project.ID, task.ID, UpdateTaskInput{
Title: "Draft proposal v2",
Description: "Updated scope",
Completed: true,
Tag: "重要",
})
require.NoError(t, err)
require.Equal(t, "Draft proposal v2", updated.Title)
require.Equal(t, "done", updated.Status)
workspace, err := service.Workspace(1, project.Identity)
require.NoError(t, err)
require.Equal(t, "重要", workspace.Tasks[0].Tag)
require.Equal(t, []string{"客户", "重要"}, workspaceTagNames(workspace.Tags))
}
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 TestCreateFileSourceAddsSourceToOwnedProject(t *testing.T) {
database := newTestDB(t)
service := NewService()
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
source, err := service.CreateFileSource(1, project.ID, CreateFileSourceInput{
Title: "客户访谈.pdf",
FilePath: "projects/1/客户访谈.pdf",
})
require.NoError(t, err)
require.Equal(t, project.ID, source.ProjectID)
require.Equal(t, uint(1), source.CreatedBy)
require.Equal(t, "file", source.Kind)
require.Equal(t, "客户访谈.pdf", source.Title)
require.Equal(t, "projects/1/客户访谈.pdf", source.FilePath)
var count int64
require.NoError(t, database.Model(&models.SenlinAgentSource{}).Where("project_id = ? AND kind = ?", project.ID, "file").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.SenlinAgentUser{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)
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
}