chore: align forest AI development setup
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -9,4 +9,11 @@ coverage/
|
|||||||
*.log
|
*.log
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
.superpowers/
|
||||||
|
backend/tmp/
|
||||||
|
backend/data/
|
||||||
|
apps/web_v1/dist/
|
||||||
|
apps/web_v1/test-results/
|
||||||
|
apps/web_v1/playwright-report/
|
||||||
|
apps/desktop/src-tauri/target/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
# 仅供本地开发使用;以下默认凭据与密钥不得用于生产环境。
|
||||||
env: development
|
env: development
|
||||||
port: "9150"
|
port: "9150"
|
||||||
dsn: "postgres://postgres:postgres@localhost:5432/agent_dev?sslmode=disable"
|
dsn: "postgres://agent:agent@localhost:5432/agent?sslmode=disable"
|
||||||
storage_dir: "./data/files"
|
storage_dir: "./data/files"
|
||||||
max_upload_bytes: 33554432
|
max_upload_bytes: 33554432
|
||||||
auth_secret: "development-auth-secret-change-me"
|
auth_secret: "development-auth-secret-change-me"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -45,5 +46,18 @@ func LoadFromDir(configDir string) (Config, error) {
|
|||||||
if cfg.MaxUploadBytes <= 0 {
|
if cfg.MaxUploadBytes <= 0 {
|
||||||
cfg.MaxUploadBytes = 32 << 20
|
cfg.MaxUploadBytes = 32 << 20
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(cfg.StorageDir) == "" {
|
||||||
|
return Config{}, fmt.Errorf("storage_dir must not be empty")
|
||||||
|
}
|
||||||
|
hasAllowedOrigin := false
|
||||||
|
for _, origin := range cfg.AllowedOrigins {
|
||||||
|
if strings.TrimSpace(origin) != "" {
|
||||||
|
hasAllowedOrigin = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasAllowedOrigin {
|
||||||
|
return Config{}, fmt.Errorf("allowed_origins must include at least one origin")
|
||||||
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,38 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestDevelopmentConfigMatchesLocalWorkspaceContract(t *testing.T) {
|
||||||
|
t.Setenv("SENLIN_APP_MODE", "dev")
|
||||||
|
|
||||||
|
cfg, err := LoadFromDir(filepath.Join("..", "..", "etc"))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "development", cfg.Env)
|
||||||
|
require.Equal(t, "9150", cfg.Port)
|
||||||
|
require.Equal(t, "postgres://agent:agent@localhost:5432/agent?sslmode=disable", cfg.DSN)
|
||||||
|
require.NotEmpty(t, strings.TrimSpace(cfg.StorageDir))
|
||||||
|
require.Equal(t, int64(32<<20), cfg.MaxUploadBytes)
|
||||||
|
require.ElementsMatch(t, []string{
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
"http://127.0.0.1:4173",
|
||||||
|
"http://localhost:4174",
|
||||||
|
"http://127.0.0.1:4174",
|
||||||
|
"http://localhost:4175",
|
||||||
|
"http://127.0.0.1:4175",
|
||||||
|
"http://localhost:4176",
|
||||||
|
"http://127.0.0.1:4176",
|
||||||
|
"http://tauri.localhost",
|
||||||
|
}, cfg.AllowedOrigins)
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadFromDirDefaultsToDevYAML(t *testing.T) {
|
func TestLoadFromDirDefaultsToDevYAML(t *testing.T) {
|
||||||
configDir := t.TempDir()
|
configDir := t.TempDir()
|
||||||
writeConfig(t, configDir, "agent.dev.yaml", "development", "18080", "postgres://dev", "./dev-files", "dev-auth", "dev-system", "dev-ai")
|
writeConfig(t, configDir, "agent.dev.yaml", "development", "18080", "postgres://dev", "./dev-files", "dev-auth", "dev-system", "dev-ai")
|
||||||
@@ -47,6 +74,32 @@ func TestLoadFromDirUsesSENLINAppMode(t *testing.T) {
|
|||||||
require.Equal(t, []string{"https://workbench.example.com"}, cfg.AllowedOrigins)
|
require.Equal(t, []string{"https://workbench.example.com"}, cfg.AllowedOrigins)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadFromDirRejectsMissingStorageDir(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
writeConfig(t, configDir, "agent.dev.yaml", "development", "9150", "postgres://agent", "", "dev-auth", "", "dev-ai")
|
||||||
|
t.Setenv("SENLIN_APP_MODE", "dev")
|
||||||
|
|
||||||
|
_, err := LoadFromDir(configDir)
|
||||||
|
|
||||||
|
require.ErrorContains(t, err, "storage_dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFromDirRejectsMissingAllowedOrigins(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
content := []byte("env: development\n" +
|
||||||
|
"port: \"9150\"\n" +
|
||||||
|
"dsn: \"postgres://agent\"\n" +
|
||||||
|
"storage_dir: \"./data/files\"\n" +
|
||||||
|
"auth_secret: \"dev-auth\"\n" +
|
||||||
|
"ai_key_encryption_secret: \"dev-ai\"\n")
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(configDir, "agent.dev.yaml"), content, 0o600))
|
||||||
|
t.Setenv("SENLIN_APP_MODE", "dev")
|
||||||
|
|
||||||
|
_, err := LoadFromDir(configDir)
|
||||||
|
|
||||||
|
require.ErrorContains(t, err, "allowed_origins")
|
||||||
|
}
|
||||||
|
|
||||||
func writeConfig(t *testing.T, dir string, name string, env string, port string, databaseURL string, storageDir string, authSecret string, systemAIKey string, aiKeySecret string) {
|
func writeConfig(t *testing.T, dir string, name string, env string, port string, databaseURL string, storageDir string, authSecret string, systemAIKey string, aiKeySecret string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
allowedOrigins := " - http://localhost:5173\n - http://tauri.localhost\n"
|
allowedOrigins := " - http://localhost:5173\n - http://tauri.localhost\n"
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ func seedProjects(tx *gorm.DB, ownerID uint) ([]models.SenlinAgentProject, error
|
|||||||
|
|
||||||
func firstOrCreateProject(tx *gorm.DB, ownerID uint, name string, identifier string, description string) (models.SenlinAgentProject, error) {
|
func firstOrCreateProject(tx *gorm.DB, ownerID uint, name string, identifier string, description string) (models.SenlinAgentProject, error) {
|
||||||
var project models.SenlinAgentProject
|
var project models.SenlinAgentProject
|
||||||
err := tx.Where("owner_id = ? AND name = ?", ownerID, name).First(&project).Error
|
// identifier 是用户范围内的稳定唯一键;展示名称被本地修改后,重复 seed 仍复用原项目。
|
||||||
|
err := tx.Where("owner_id = ? AND identifier = ?", ownerID, identifier).First(&project).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return project, nil
|
return project, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
"senlinai-agent/backend/internal/logic/auth"
|
"senlinai-agent/backend/internal/logic/auth"
|
||||||
"senlinai-agent/backend/internal/logic/projects"
|
"senlinai-agent/backend/internal/logic/projects"
|
||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
@@ -53,11 +54,80 @@ func TestDemoSeedIsIdempotent(t *testing.T) {
|
|||||||
var userCount int64
|
var userCount int64
|
||||||
require.NoError(t, database.Model(&models.SenlinAgentUser{}).Where("email = ?", "demo@senlin.ai").Count(&userCount).Error)
|
require.NoError(t, database.Model(&models.SenlinAgentUser{}).Where("email = ?", "demo@senlin.ai").Count(&userCount).Error)
|
||||||
require.Equal(t, int64(1), userCount)
|
require.Equal(t, int64(1), userCount)
|
||||||
|
|
||||||
|
var projectCount int64
|
||||||
|
require.NoError(t, database.Model(&models.SenlinAgentProject{}).Where("owner_id = ?", first.User.ID).Count(&projectCount).Error)
|
||||||
|
require.Equal(t, int64(4), projectCount)
|
||||||
|
var distinctIdentifiers int64
|
||||||
|
require.NoError(t, database.Model(&models.SenlinAgentProject{}).
|
||||||
|
Where("owner_id = ?", first.User.ID).
|
||||||
|
Distinct("identifier").
|
||||||
|
Count(&distinctIdentifiers).Error)
|
||||||
|
require.Equal(t, projectCount, distinctIdentifiers)
|
||||||
|
|
||||||
|
var tags []models.SenlinAgentTag
|
||||||
|
require.NoError(t, database.Where("project_id = ?", first.Projects[0].ID).Find(&tags).Error)
|
||||||
|
require.Len(t, tags, 4)
|
||||||
|
for _, tag := range tags {
|
||||||
|
require.Equal(t, first.Projects[0].ID, tag.ProjectID)
|
||||||
|
require.NotEmpty(t, tag.Identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDemoSeedUsesIdentifierAsStableProjectKey(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
|
||||||
|
first, err := Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
original := first.Projects[0]
|
||||||
|
require.NoError(t, database.Model(&original).Update("name", "本地重命名项目").Error)
|
||||||
|
|
||||||
|
second, err := Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, original.ID, second.Projects[0].ID)
|
||||||
|
require.Equal(t, "A1", second.Projects[0].Identifier)
|
||||||
|
var projectCount int64
|
||||||
|
require.NoError(t, database.Model(&models.SenlinAgentProject{}).
|
||||||
|
Where("owner_id = ? AND identifier = ?", first.User.ID, "A1").
|
||||||
|
Count(&projectCount).Error)
|
||||||
|
require.Equal(t, int64(1), projectCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDemoSeedKeepsSameNamedTagsScopedToTheirProjects(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
|
||||||
|
first, err := Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
otherTag := models.SenlinAgentTag{ProjectID: first.Projects[1].ID, Name: "UI"}
|
||||||
|
require.NoError(t, database.Create(&otherTag).Error)
|
||||||
|
|
||||||
|
_, err = Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, projectID := range []uint{first.Projects[0].ID, first.Projects[1].ID} {
|
||||||
|
var count int64
|
||||||
|
require.NoError(t, database.Model(&models.SenlinAgentTag{}).
|
||||||
|
Where("project_id = ? AND name = ?", projectID, "UI").
|
||||||
|
Count(&count).Error)
|
||||||
|
require.Equal(t, int64(1), count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var taggedTasks []models.SenlinAgentTask
|
||||||
|
require.NoError(t, database.Where("project_id = ? AND tag_id IS NOT NULL", first.Projects[0].ID).Find(&taggedTasks).Error)
|
||||||
|
require.NotEmpty(t, taggedTasks)
|
||||||
|
for _, task := range taggedTasks {
|
||||||
|
var tag models.SenlinAgentTag
|
||||||
|
require.NoError(t, database.First(&tag, *task.TagID).Error)
|
||||||
|
require.Equal(t, task.ProjectID, tag.ProjectID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestDB(t *testing.T) *gorm.DB {
|
func newTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, models.AutoMigrate(database))
|
require.NoError(t, models.AutoMigrate(database))
|
||||||
models.DBService = database
|
models.DBService = database
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# 仅供本地开发使用;agent/agent 是非生产默认凭据。
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
|
|||||||
Reference in New Issue
Block a user