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"
2026-07-22 01:24:41 +08:00
"path/filepath"
"sync"
"sync/atomic"
2026-07-18 15:57:44 +08:00
"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-22 01:24:41 +08:00
"gorm.io/gorm/logger"
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 )
2026-07-23 14:30:17 +08:00
first := models . SaProject { OwnerID : 1 , Name : "Alpha" , Identifier : "SHARED" }
2026-07-21 14:41:30 +08:00
require . NoError ( t , database . Create ( & first ) . Error )
2026-07-23 14:30:17 +08:00
duplicate := models . SaProject { OwnerID : 1 , Name : "Duplicate" , Identifier : "SHARED" }
2026-07-21 14:41:30 +08:00
require . ErrorIs ( t , database . Create ( & duplicate ) . Error , gorm . ErrDuplicatedKey )
2026-07-23 14:30:17 +08:00
otherOwner := models . SaProject { OwnerID : 2 , Name : "Allowed" , Identifier : "SHARED" }
2026-07-21 14:41:30 +08:00
require . NoError ( t , database . Create ( & otherOwner ) . Error )
}
func TestProjectIdentifierDatabaseErrorMapsToConflict ( t * testing . T ) {
database := newTestDB ( t )
2026-07-23 14:30:17 +08:00
first := models . SaProject { OwnerID : 1 , Name : "Alpha" , Identifier : "SHARED" }
2026-07-21 14:41:30 +08:00
require . NoError ( t , database . Create ( & first ) . Error )
2026-07-23 14:30:17 +08:00
duplicate := models . SaProject { OwnerID : 1 , Name : "Duplicate" , Identifier : "SHARED" }
2026-07-21 14:41:30 +08:00
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 )
2026-07-21 14:58:25 +08:00
workspace , err := service . Workspace ( 1 , project . Identity )
2026-07-21 00:46:09 +08:00
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-19 21:46:43 +08:00
func TestWorkspaceMatchesFrontendContract ( t * testing . T ) {
database := newTestDB ( t )
service := NewService ( )
2026-07-23 14:30:17 +08:00
require . NoError ( t , database . Create ( & models . SaUser { Email : "david@example.com" , DisplayName : "David" , PasswordHash : "hash" } ) . Error )
2026-07-19 21:46:43 +08:00
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 )
2026-07-23 14:30:17 +08:00
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 )
2026-07-19 21:46:43 +08:00
2026-07-21 14:58:25 +08:00
workspace , err := service . Workspace ( 1 , project . Identity )
2026-07-19 21:46:43 +08:00
require . NoError ( t , err )
2026-07-21 14:58:25 +08:00
require . Equal ( t , project . Identity , workspace . Project . ID )
2026-07-19 21:46:43 +08:00
require . Equal ( t , "Project A1" , workspace . Project . Name )
require . Equal ( t , "A1" , workspace . Project . Initials )
require . Equal ( t , int64 ( 1 ) , workspace . Project . UnreadCount )
2026-07-21 14:58:25 +08:00
require . Equal ( t , [ ] string { "UI" , "knowledge" } , workspaceTagNames ( workspace . Tags ) )
2026-07-19 21:46:43 +08:00
require . Len ( t , workspace . Channels , 7 )
require . Equal ( t , "overview" , workspace . Channels [ 0 ] . Type )
require . Equal ( t , "inbox" , workspace . Channels [ 1 ] . Type )
2026-07-21 16:44:16 +08:00
require . Equal ( t , int64 ( 1 ) , workspace . Channels [ 1 ] . Count )
2026-07-19 21:46:43 +08:00
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 )
2026-07-21 14:58:25 +08:00
_ , err = service . Workspace ( 1 , project . Identity )
2026-07-19 21:46:43 +08:00
require . Error ( t , err )
}
2026-07-21 00:46:09 +08:00
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 )
2026-07-21 14:58:25 +08:00
workspace , err := service . Workspace ( 1 , project . Identity )
2026-07-21 00:46:09 +08:00
require . NoError ( t , err )
2026-07-21 14:58:25 +08:00
require . Equal ( t , [ ] string { "Design" } , workspaceTagNames ( workspace . Tags ) )
2026-07-21 00:46:09 +08:00
}
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-22 01:24:41 +08:00
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 ) {
2026-07-23 14:30:17 +08:00
if tx . Statement . Schema == nil || tx . Statement . Schema . Table != ( models . SaTag { } ) . TableName ( ) {
2026-07-22 01:24:41 +08:00
return
}
if synchronizedQueries . Add ( 1 ) <= 2 {
arrived <- struct { } { }
<- release
}
} ) )
type result struct {
2026-07-23 14:30:17 +08:00
tag * models . SaTag
2026-07-22 01:24:41 +08:00
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
2026-07-23 14:30:17 +08:00
require . NoError ( t , database . Model ( & models . SaTag { } ) . Where ( "project_id = ? AND name = ?" , project . ID , "UI" ) . Count ( & count ) . Error )
2026-07-22 01:24:41 +08:00
require . Equal ( t , int64 ( 1 ) , count )
}
2026-07-20 21:45:36 +08:00
func TestCreateCronPlanAddsPlanToOwnedProject ( t * testing . T ) {
database := newTestDB ( t )
service := NewService ( )
2026-07-23 14:30:17 +08:00
require . NoError ( t , database . Create ( & models . SaUser { Email : "david@example.com" , DisplayName : "David" , PasswordHash : "hash" } ) . Error )
2026-07-20 21:45:36 +08:00
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 )
2026-07-22 01:24:41 +08:00
require . Empty ( t , plan . LastResult , "创建计划只保存元数据,不能伪造执行结果" )
2026-07-21 14:58:25 +08:00
workspace , err := service . Workspace ( 1 , project . Identity )
2026-07-20 21:45:36 +08:00
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
}
2026-07-21 14:58:25 +08:00
func workspaceTagNames ( tags [ ] WorkspaceTagDTO ) [ ] string {
names := make ( [ ] string , 0 , len ( tags ) )
for _ , tag := range tags {
names = append ( names , tag . Name )
}
return names
}