2026-07-18 15:57:44 +08:00
package projects
import (
2026-07-21 14:22:13 +08:00
"errors"
2026-07-18 15:57:44 +08:00
"fmt"
"testing"
2026-07-19 21:46:43 +08:00
"time"
2026-07-18 15:57:44 +08:00
"github.com/glebarez/sqlite"
2026-07-21 14:41:30 +08:00
"github.com/jackc/pgx/v5/pgconn"
2026-07-18 15:57:44 +08:00
"github.com/stretchr/testify/require"
2026-07-21 14:41:30 +08:00
"gorm.io/driver/postgres"
2026-07-18 15:57:44 +08:00
"gorm.io/gorm"
2026-07-18 21:58:15 +08:00
"senlinai-agent/backend/internal/models"
2026-07-18 15:57:44 +08:00
)
2026-07-21 14:22:13 +08:00
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 ) )
}
2026-07-21 14:41:30 +08:00
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 )
}
2026-07-18 15:57:44 +08:00
func TestProjectTagsAreScopedToProject ( t * testing . T ) {
2026-07-18 21:58:15 +08:00
newTestDB ( t )
service := NewService ( )
2026-07-18 15:57:44 +08:00
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 )
}
2026-07-21 00:46:09 +08:00
func TestCreateProjectPersistsWorkspaceMetadata ( t * testing . T ) {
newTestDB ( t )
service := NewService ( )
2026-07-21 14:22:13 +08:00
project , err := service . CreateProjectWithInput ( 1 , CreateProjectRequest {
2026-07-21 00:46:09 +08:00
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 . ID )
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 )
}
2026-07-18 15:57:44 +08:00
func TestDashboardCountsOnlyRequestedProject ( t * testing . T ) {
database := newTestDB ( t )
2026-07-18 21:58:15 +08:00
service := NewService ( )
2026-07-18 15:57:44 +08:00
first , err := service . CreateProject ( 1 , "Alpha" , "" )
require . NoError ( t , err )
second , err := service . CreateProject ( 1 , "Beta" , "" )
require . NoError ( t , err )
2026-07-18 21:58:15 +08:00
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 )
2026-07-18 15:57:44 +08:00
2026-07-18 18:02:29 +08:00
dashboard , err := service . Dashboard ( 1 , first . ID )
2026-07-18 15:57:44 +08:00
require . NoError ( t , err )
require . Equal ( t , int64 ( 1 ) , dashboard . PendingInboxCount )
require . Equal ( t , int64 ( 1 ) , dashboard . OpenTaskCount )
}
2026-07-18 18:02:29 +08:00
func TestDashboardRejectsProjectOwnedByAnotherUser ( t * testing . T ) {
2026-07-18 21:58:15 +08:00
newTestDB ( t )
service := NewService ( )
2026-07-18 18:02:29 +08:00
project , err := service . CreateProject ( 2 , "Beta" , "" )
require . NoError ( t , err )
_ , err = service . Dashboard ( 1 , project . ID )
require . Error ( t , err )
}
2026-07-19 21:46:43 +08:00
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 . ID )
require . NoError ( t , err )
require . Equal ( t , project . ID , 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 { "all" , "UI" , "knowledge" } , 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 )
2026-07-20 18:25:04 +08:00
require . NotEmpty ( t , workspace . Tasks [ 0 ] . CreatedAt )
2026-07-19 21:46:43 +08:00
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 . ID )
require . Error ( t , err )
}
2026-07-20 21:45:36 +08:00
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 ,
2026-07-21 00:46:09 +08:00
Tag : "客户反馈" ,
2026-07-20 21:45:36 +08:00
} )
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 )
2026-07-21 00:46:09 +08:00
require . NotNil ( t , task . TagID )
2026-07-20 21:45:36 +08:00
workspace , err := service . Workspace ( 1 , project . ID )
require . NoError ( t , err )
require . Len ( t , workspace . Tasks , 1 )
require . Equal ( t , "Follow up with client" , workspace . Tasks [ 0 ] . Title )
2026-07-21 00:46:09 +08:00
require . Equal ( t , "客户反馈" , workspace . Tasks [ 0 ] . Tag )
require . Equal ( t , [ ] string { "all" , "客户反馈" } , workspace . Tags )
2026-07-20 21:45:36 +08:00
}
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 )
}
2026-07-21 00:46:09 +08:00
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 . ID )
require . NoError ( t , err )
require . Equal ( t , "重要" , workspace . Tasks [ 0 ] . Tag )
require . Equal ( t , [ ] string { "all" , "客户" , "重要" } , 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 . ID )
require . NoError ( t , err )
require . Equal ( t , [ ] string { "all" , "Design" } , 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 )
}
2026-07-20 21:45:36 +08:00
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 . ID )
require . NoError ( t , err )
require . Len ( t , workspace . CronPlans , 1 )
require . Equal ( t , "Daily inbox sweep" , workspace . CronPlans [ 0 ] . Title )
}
2026-07-18 15:57:44 +08:00
func newTestDB ( t * testing . T ) * gorm . DB {
t . Helper ( )
2026-07-21 14:41:30 +08:00
database , err := gorm . Open ( sqlite . Open ( fmt . Sprintf ( "file:%s?mode=memory&cache=shared" , t . Name ( ) ) ) , & gorm . Config { TranslateError : true } )
2026-07-18 15:57:44 +08:00
require . NoError ( t , err )
2026-07-18 21:58:15 +08:00
require . NoError ( t , models . AutoMigrate ( database ) )
models . DBService = database
2026-07-18 15:57:44 +08:00
return database
}