test(office): cover config import, sync, and HTTP surface (#2582)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-12 22:27:44 +01:00
committed by GitHub
parent bd24f7f07b
commit f3a7d43ebc
8 changed files with 2989 additions and 0 deletions
@@ -0,0 +1,360 @@
package config
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
)
// newTestRouter mounts the config routes over a fresh service.
func newTestRouter(t *testing.T) (*gin.Engine, *testEnv) {
t.Helper()
gin.SetMode(gin.TestMode)
env := newTestEnv(t)
router := gin.New()
RegisterRoutes(router.Group("/api/v1"), NewHandler(env.svc, logger.Default()))
return router, env
}
// do issues a request and returns the recorder.
func do(t *testing.T, router *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
// decodeBody unmarshals a JSON response body, failing on malformed output.
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, out interface{}) {
t.Helper()
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
t.Fatalf("decode body %q: %v", rec.Body.String(), err)
}
}
func assertStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
t.Helper()
if rec.Code != want {
t.Fatalf("status: got %d, want %d (body: %s)", rec.Code, want, rec.Body.String())
}
}
// assertErrorBody checks a failure response carries a non-empty error field.
func assertErrorBody(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
var body struct {
Error string `json:"error"`
}
decodeBody(t, rec, &body)
if body.Error == "" {
t.Errorf("expected a non-empty error field, got %s", rec.Body.String())
}
}
func TestNewHandler_WiresDependencies(t *testing.T) {
env := newTestEnv(t)
h := NewHandler(env.svc, logger.Default())
if h.svc != env.svc {
t.Error("service not wired")
}
if h.logger == nil {
t.Error("logger not wired")
}
}
func TestRegisterRoutes_RegistersEveryEndpoint(t *testing.T) {
router, _ := newTestRouter(t)
want := map[string]string{
"GET /api/v1/workspaces/:wsId/config/export": "",
"GET /api/v1/workspaces/:wsId/config/export/zip": "",
"POST /api/v1/workspaces/:wsId/config/preview": "",
"POST /api/v1/workspaces/:wsId/config/import": "",
"GET /api/v1/workspaces/:wsId/config/sync/incoming": "",
"GET /api/v1/workspaces/:wsId/config/sync/outgoing": "",
"POST /api/v1/workspaces/:wsId/config/sync/import-fs": "",
"POST /api/v1/workspaces/:wsId/config/sync/export-fs": "",
}
for _, r := range router.Routes() {
delete(want, r.Method+" "+r.Path)
}
for missing := range want {
t.Errorf("route not registered: %s", missing)
}
}
func TestHandler_ExportConfig(t *testing.T) {
router, env := newTestRouter(t)
seedFullDBState(t, env, testWorkspaceID)
rec := do(t, router, http.MethodGet, "/api/v1/workspaces/"+testWorkspaceID+"/config/export", "")
assertStatus(t, rec, http.StatusOK)
var body struct {
Bundle ConfigBundle `json:"bundle"`
}
decodeBody(t, rec, &body)
// The path parameter reaches the service: it labels the settings block.
assertEqual(t, "settings name", body.Bundle.Settings.Name, testWorkspaceID)
assertEqual(t, "agents", len(body.Bundle.Agents), 2)
assertEqual(t, "skills", len(body.Bundle.Skills), 1)
assertEqual(t, "routines", len(body.Bundle.Routines), 1)
assertEqual(t, "projects", len(body.Bundle.Projects), 1)
}
func TestHandler_ExportConfig_ServiceError(t *testing.T) {
router, env := newTestRouter(t)
env.closeDB(t)
rec := do(t, router, http.MethodGet, "/api/v1/workspaces/ws/config/export", "")
assertStatus(t, rec, http.StatusInternalServerError)
assertErrorBody(t, rec)
}
func TestHandler_ExportConfigZip(t *testing.T) {
router, env := newTestRouter(t)
seedFullDBState(t, env, testWorkspaceID)
rec := do(t, router, http.MethodGet,
"/api/v1/workspaces/"+testWorkspaceID+"/config/export/zip", "")
assertStatus(t, rec, http.StatusOK)
assertEqual(t, "content type", rec.Header().Get("Content-Type"), "application/zip")
assertEqual(t, "content disposition", rec.Header().Get("Content-Disposition"),
"attachment; filename=kandev-config.zip")
entries := readZip(t, rec.Body)
if _, ok := entries[".kandev/agents/ada.yml"]; !ok {
t.Errorf("agent entry missing from the archive, got %v", entries)
}
}
func TestHandler_ExportConfigZip_ServiceError(t *testing.T) {
router, env := newTestRouter(t)
env.closeDB(t)
rec := do(t, router, http.MethodGet, "/api/v1/workspaces/ws/config/export/zip", "")
assertStatus(t, rec, http.StatusInternalServerError)
assertErrorBody(t, rec)
if got := rec.Header().Get("Content-Type"); strings.HasPrefix(got, "application/zip") {
t.Errorf("error response should not claim to be a zip, got %q", got)
}
}
func TestHandler_PreviewImport(t *testing.T) {
router, env := newTestRouter(t)
seedAgent(t, env, testWorkspaceID, "ada")
body := `{"agents":[{"name":"ada"},{"name":"grace"}],"skills":[{"slug":"triage"}]}`
rec := do(t, router, http.MethodPost,
"/api/v1/workspaces/"+testWorkspaceID+"/config/preview", body)
assertStatus(t, rec, http.StatusOK)
var out struct {
Preview ImportPreview `json:"preview"`
}
decodeBody(t, rec, &out)
assertStrings(t, "agents updated", out.Preview.Agents.Updated, []string{"ada"})
assertStrings(t, "agents created", out.Preview.Agents.Created, []string{"grace"})
assertStrings(t, "skills created", out.Preview.Skills.Created, []string{"triage"})
// Preview must not have written anything.
agents, err := env.repo.ListAgentInstances(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("list agents: %v", err)
}
assertEqual(t, "row count unchanged", len(agents), 1)
}
func TestHandler_PreviewImport_MalformedBody(t *testing.T) {
router, _ := newTestRouter(t)
for _, body := range []string{"{not json", "", `{"agents":"not-an-array"}`} {
rec := do(t, router, http.MethodPost, "/api/v1/workspaces/ws/config/preview", body)
assertStatus(t, rec, http.StatusBadRequest)
assertErrorBody(t, rec)
}
}
func TestHandler_PreviewImport_ServiceError(t *testing.T) {
router, env := newTestRouter(t)
env.closeDB(t)
rec := do(t, router, http.MethodPost, "/api/v1/workspaces/ws/config/preview", `{}`)
assertStatus(t, rec, http.StatusInternalServerError)
assertErrorBody(t, rec)
}
func TestHandler_ApplyImport(t *testing.T) {
router, env := newTestRouter(t)
body := `{"agents":[{"name":"ada","role":"cto","budget_monthly_cents":4200,
"max_concurrent_sessions":3,"executor_preference":"local_docker"}],
"projects":[{"name":"apollo","budget_cents":99000}]}`
rec := do(t, router, http.MethodPost,
"/api/v1/workspaces/"+testWorkspaceID+"/config/import", body)
assertStatus(t, rec, http.StatusOK)
var out struct {
Result ImportResult `json:"result"`
}
decodeBody(t, rec, &out)
assertEqual(t, "created count", out.Result.CreatedCount, 2)
assertEqual(t, "updated count", out.Result.UpdatedCount, 0)
// The workspace path parameter scopes the write.
agent := agentByName(t, env, testWorkspaceID, "ada")
assertEqual(t, "agent role", string(agent.Role), "cto")
assertEqual(t, "agent budget", agent.BudgetMonthlyCents, 4200)
assertEqual(t, "agent max sessions", agent.MaxConcurrentSessions, 3)
assertEqual(t, "agent executor preference", agent.ExecutorPreference, "local_docker")
assertEqual(t, "project budget",
projectByName(t, env, testWorkspaceID, "apollo").BudgetCents, 99000)
}
func TestHandler_ApplyImport_MalformedBody(t *testing.T) {
router, env := newTestRouter(t)
rec := do(t, router, http.MethodPost,
"/api/v1/workspaces/"+testWorkspaceID+"/config/import", "{not json")
assertStatus(t, rec, http.StatusBadRequest)
assertErrorBody(t, rec)
// Scoped to the workspace the request actually targeted: listing a
// workspace nothing writes to would pass no matter what the handler did.
agents, err := env.repo.ListAgentInstances(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("list agents: %v", err)
}
assertEqual(t, "a rejected body writes nothing", len(agents), 0)
}
func TestHandler_ApplyImport_ServiceError(t *testing.T) {
router, env := newTestRouter(t)
env.closeDB(t)
rec := do(t, router, http.MethodPost, "/api/v1/workspaces/ws/config/import",
`{"agents":[{"name":"ada"}]}`)
assertStatus(t, rec, http.StatusInternalServerError)
assertErrorBody(t, rec)
}
func TestHandler_SyncIncomingDiff(t *testing.T) {
router, env := newTestRouter(t)
seedFullFSState(t, env)
seedAgent(t, env, testWorkspaceID, "gone-agent")
rec := do(t, router, http.MethodGet,
"/api/v1/workspaces/"+testWorkspaceID+"/config/sync/incoming", "")
assertStatus(t, rec, http.StatusOK)
var out struct {
Diff SyncDiff `json:"diff"`
}
decodeBody(t, rec, &out)
assertEqual(t, "direction", out.Diff.Direction, "incoming")
assertStrings(t, "agents created", out.Diff.Preview.Agents.Created, []string{"ada"})
assertStrings(t, "agents deleted", out.Diff.Preview.Agents.Deleted, []string{"gone-agent"})
assertEqual(t, "errors omitted when empty", len(out.Diff.Errors), 0)
}
func TestHandler_SyncIncomingDiff_ServiceError(t *testing.T) {
router, env := newTestRouter(t)
env.closeDB(t)
rec := do(t, router, http.MethodGet, "/api/v1/workspaces/ws/config/sync/incoming", "")
assertStatus(t, rec, http.StatusInternalServerError)
assertErrorBody(t, rec)
}
func TestHandler_SyncOutgoingDiff(t *testing.T) {
router, env := newTestRouter(t)
seedAgent(t, env, testWorkspaceID, "ada")
rec := do(t, router, http.MethodGet,
"/api/v1/workspaces/"+testWorkspaceID+"/config/sync/outgoing", "")
assertStatus(t, rec, http.StatusOK)
var out struct {
Diff SyncDiff `json:"diff"`
}
decodeBody(t, rec, &out)
assertEqual(t, "direction", out.Diff.Direction, "outgoing")
assertStrings(t, "agents created", out.Diff.Preview.Agents.Created, []string{"ada"})
}
func TestHandler_SyncOutgoingDiff_ServiceError(t *testing.T) {
router, env := newTestRouter(t)
env.closeDB(t)
rec := do(t, router, http.MethodGet, "/api/v1/workspaces/ws/config/sync/outgoing", "")
assertStatus(t, rec, http.StatusInternalServerError)
assertErrorBody(t, rec)
}
func TestHandler_SyncApplyIncoming(t *testing.T) {
router, env := newTestRouter(t)
seedFullFSState(t, env)
seedAgent(t, env, testWorkspaceID, "gone-agent")
rec := do(t, router, http.MethodPost,
"/api/v1/workspaces/"+testWorkspaceID+"/config/sync/import-fs", "")
assertStatus(t, rec, http.StatusOK)
var out struct {
Result ImportResult `json:"result"`
}
decodeBody(t, rec, &out)
assertEqual(t, "created count", out.Result.CreatedCount, 4)
assertRemainingNames(t, env, testWorkspaceID, []string{"ada"}, []string{"code-review"},
[]string{"standup"}, []string{"apollo"})
}
func TestHandler_SyncApplyIncoming_ServiceError(t *testing.T) {
router, env := newTestRouter(t)
env.closeDB(t)
rec := do(t, router, http.MethodPost, "/api/v1/workspaces/ws/config/sync/import-fs", "")
assertStatus(t, rec, http.StatusInternalServerError)
assertErrorBody(t, rec)
}
func TestHandler_SyncApplyOutgoing(t *testing.T) {
router, env := newTestRouter(t)
seedFullDBState(t, env, testWorkspaceID)
rec := do(t, router, http.MethodPost,
"/api/v1/workspaces/"+testWorkspaceID+"/config/sync/export-fs", "")
assertStatus(t, rec, http.StatusOK)
var out struct {
Status string `json:"status"`
}
decodeBody(t, rec, &out)
assertEqual(t, "status", out.Status, "ok")
bundle, _, err := env.svc.ScanFilesystem(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ScanFilesystem: %v", err)
}
assertStrings(t, "agents on disk",
sortedCopy(agentBundleNames(bundle.Agents)), []string{"ada", "grace"})
}
func TestHandler_SyncApplyOutgoing_ServiceError(t *testing.T) {
router, env := newTestRouter(t)
env.closeDB(t)
rec := do(t, router, http.MethodPost, "/api/v1/workspaces/ws/config/sync/export-fs", "")
assertStatus(t, rec, http.StatusInternalServerError)
assertErrorBody(t, rec)
}
@@ -0,0 +1,438 @@
package config
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/office/configloader"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/repository/sqlite"
)
// testWorkspaceID is the DB workspace every fixture is written under. It is
// deliberately different from defaultWorkspaceName ("default"), which is the
// *filesystem* workspace directory the ConfigLoader reads — the two are
// separate namespaces and several tests depend on telling them apart.
const testWorkspaceID = "ws-test"
// activityEntry records one shared.ActivityLogger.LogActivity call.
type activityEntry struct {
wsID string
actorType string
actorID string
action string
targetType string
targetID string
details string
}
// recordingActivityLogger captures activity calls made during an import.
type recordingActivityLogger struct {
entries []activityEntry
}
func (r *recordingActivityLogger) LogActivity(
_ context.Context, wsID, actorType, actorID, action, targetType, targetID, details string,
) {
r.entries = append(r.entries, activityEntry{
wsID: wsID, actorType: actorType, actorID: actorID,
action: action, targetType: targetType, targetID: targetID, details: details,
})
}
func (r *recordingActivityLogger) LogActivityWithRun(
ctx context.Context, wsID, actorType, actorID, action, targetType, targetID, details, _, _ string,
) {
r.LogActivity(ctx, wsID, actorType, actorID, action, targetType, targetID, details)
}
// testEnv bundles a ConfigService with everything a test needs to reach
// behind it: the DB it writes to, the on-disk config root the loader and
// writer share, and the activity log it emits to.
type testEnv struct {
svc *ConfigService
repo *sqlite.Repository
// db is the handle repository reads go through; wdb is the handle writes
// go through. newTestEnv points both at one in-memory database;
// newSplitTestEnv gives them separate handles so writes can be broken
// while reads keep working.
db *sqlx.DB
wdb *sqlx.DB
base string
loader *configloader.ConfigLoader
writer *configloader.FileWriter
activity *recordingActivityLogger
}
// newTestEnv builds a ConfigService over an in-memory SQLite repo and a
// temp-dir config root containing an empty "default" FS workspace.
//
// Office agent CRUD reads/writes the unified agent_profiles table (ADR 0005
// Wave C), so the settings-store schema is brought up on the same handles
// before the office repo initialises.
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
db := newTestDB(t)
repo, err := sqlite.NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("new repo: %v", err)
}
return newEnvAround(t, repo, db, db)
}
// newSplitTestEnv builds the same service over a file-backed database opened
// twice: once for reads and once for writes. Closing the write handle with
// breakWrites leaves every List working while every Create/Update/Delete
// fails, which is the only way to reach the write-error branches of the
// import and prune paths.
func newSplitTestEnv(t *testing.T) *testEnv {
t.Helper()
dsn := filepath.Join(t.TempDir(), "office.db")
writerDB := openTestDB(t, dsn)
if _, _, err := settingsstore.Provide(writerDB, writerDB, nil); err != nil {
t.Fatalf("settings store init: %v", err)
}
readerDB := openTestDB(t, dsn)
repo, err := sqlite.NewWithDB(writerDB, readerDB, nil)
if err != nil {
t.Fatalf("new repo: %v", err)
}
return newEnvAround(t, repo, readerDB, writerDB)
}
func openTestDB(t *testing.T, dsn string) *sqlx.DB {
t.Helper()
db, err := sqlx.Open("sqlite3", dsn)
if err != nil {
t.Fatalf("open sqlite %s: %v", dsn, err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
return db
}
// newEnvAround wires a ConfigService around an already-built repository plus a
// fresh temp-dir config root holding an empty "default" FS workspace.
func newEnvAround(t *testing.T, repo *sqlite.Repository, readDB, writeDB *sqlx.DB) *testEnv {
t.Helper()
base := t.TempDir()
wsDir := filepath.Join(base, "workspaces", defaultWorkspaceName)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatalf("mkdir fs workspace: %v", err)
}
writeFile(t, filepath.Join(wsDir, "kandev.yml"), "name: default\n")
loader := configloader.NewConfigLoader(base)
if err := loader.Load(); err != nil {
t.Fatalf("load config: %v", err)
}
writer := configloader.NewFileWriter(base, loader)
activity := &recordingActivityLogger{}
return &testEnv{
svc: NewConfigService(repo, loader, writer, logger.Default(), activity),
repo: repo,
db: readDB,
wdb: writeDB,
base: base,
loader: loader,
writer: writer,
activity: activity,
}
}
// breakWrites closes the write handle of a split environment.
func (e *testEnv) breakWrites(t *testing.T) {
t.Helper()
if e.wdb == e.db {
t.Fatal("breakWrites requires newSplitTestEnv")
}
if err := e.wdb.Close(); err != nil {
t.Fatalf("close write handle: %v", err)
}
}
// newTestDB opens an in-memory SQLite database with the settings-store
// schema installed.
func newTestDB(t *testing.T) *sqlx.DB {
t.Helper()
db, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if _, _, err := settingsstore.Provide(db, db, nil); err != nil {
t.Fatalf("settings store init: %v", err)
}
return db
}
// closeDB closes the underlying database so every repository call fails.
// Used to exercise error propagation without a mock repository (the service
// holds a concrete *sqlite.Repository).
func (e *testEnv) closeDB(t *testing.T) {
t.Helper()
if err := e.db.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
}
// dropTable removes one table so calls touching that entity fail while the
// rest of the repository keeps working. This isolates a single stage of a
// multi-stage import, which closing the whole database cannot do.
func (e *testEnv) dropTable(t *testing.T, table string) {
t.Helper()
if _, err := e.db.Exec("DROP TABLE " + table); err != nil {
t.Fatalf("drop table %s: %v", table, err)
}
}
// wsPath returns a path inside the on-disk "default" workspace.
func (e *testEnv) wsPath(parts ...string) string {
return filepath.Join(append([]string{e.base, "workspaces", defaultWorkspaceName}, parts...)...)
}
// writeFSAgent writes an agent YAML file into the on-disk workspace.
func (e *testEnv) writeFSAgent(t *testing.T, name, body string) {
t.Helper()
writeFileIn(t, e.wsPath("agents"), name+".yml", body)
}
// writeFSSkill writes a skills/<slug>/SKILL.md file into the on-disk workspace.
func (e *testEnv) writeFSSkill(t *testing.T, slug, content string) {
t.Helper()
writeFileIn(t, e.wsPath("skills", slug), "SKILL.md", content)
}
// writeFSRoutine writes a routine YAML file into the on-disk workspace.
func (e *testEnv) writeFSRoutine(t *testing.T, name, body string) {
t.Helper()
writeFileIn(t, e.wsPath("routines"), name+".yml", body)
}
// writeFSProject writes a project YAML file into the on-disk workspace.
func (e *testEnv) writeFSProject(t *testing.T, name, body string) {
t.Helper()
writeFileIn(t, e.wsPath("projects"), name+".yml", body)
}
func writeFileIn(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
writeFile(t, filepath.Join(dir, name), body)
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// fullBundle returns a ConfigBundle with every field of every entity set to a
// distinct non-zero value. Tests that import or export it assert field-by-field,
// so a field dropped anywhere in the pipeline fails loudly instead of silently
// arriving empty.
func fullBundle() *ConfigBundle {
return &ConfigBundle{
Settings: SettingsConfig{Name: "Acme Workspace", Description: "the whole office"},
Agents: []AgentConfig{{
Name: "ada",
Role: "cto",
Icon: "🤖",
ReportsTo: "grace",
BudgetMonthlyCents: 4200,
MaxConcurrentSessions: 3,
DesiredSkills: `["go","sql"]`,
ExecutorPreference: "local_docker",
}},
Skills: []SkillConfig{{
Name: "Code Review",
Slug: "code-review",
Description: "reviews diffs",
SourceType: "inline",
Content: "# Code Review\n\nread the diff\n",
}},
Routines: []RoutineConfig{{
Name: "standup",
Description: "daily standup",
TaskTemplate: "summarise yesterday",
AssigneeName: "ada",
ConcurrencyPolicy: "skip",
}},
Projects: []ProjectConfig{{
Name: "apollo",
Description: "moon shot",
Status: "paused",
Color: "#ff00ff",
BudgetCents: 99000,
Repositories: `["kdlbs/kandev"]`,
ExecutorConfig: `{"type":"local_docker"}`,
LeadAgentName: "ada",
}},
}
}
// seedAgent inserts an office agent row and returns it.
func seedAgent(t *testing.T, e *testEnv, wsID, name string) *models.AgentInstance {
t.Helper()
agent := &models.AgentInstance{
WorkspaceID: wsID,
Name: name,
Role: models.AgentRole("engineer"),
Icon: "🛠",
Status: models.AgentStatusIdle,
BudgetMonthlyCents: 100,
MaxConcurrentSessions: 1,
}
if err := e.repo.CreateAgentInstance(context.Background(), agent); err != nil {
t.Fatalf("seed agent %s: %v", name, err)
}
return agent
}
// seedSkill inserts an office skill row and returns it.
func seedSkill(t *testing.T, e *testEnv, wsID, slug string) *models.Skill {
t.Helper()
skill := &models.Skill{
WorkspaceID: wsID,
Name: slug,
Slug: slug,
SourceType: models.SkillSourceType("inline"),
Content: "seeded",
}
if err := e.repo.CreateSkill(context.Background(), skill); err != nil {
t.Fatalf("seed skill %s: %v", slug, err)
}
return skill
}
// seedRoutine inserts an office routine row and returns it.
func seedRoutine(t *testing.T, e *testEnv, wsID, name string) *models.Routine {
t.Helper()
routine := &models.Routine{
WorkspaceID: wsID,
Name: name,
Status: "active",
}
if err := e.repo.CreateRoutine(context.Background(), routine); err != nil {
t.Fatalf("seed routine %s: %v", name, err)
}
return routine
}
// seedProject inserts an office project row and returns it.
func seedProject(t *testing.T, e *testEnv, wsID, name string) *models.Project {
t.Helper()
project := &models.Project{
WorkspaceID: wsID,
Name: name,
Status: models.ProjectStatusActive,
}
if err := e.repo.CreateProject(context.Background(), project); err != nil {
t.Fatalf("seed project %s: %v", name, err)
}
return project
}
// agentByName finds a seeded/imported agent by name, failing if absent.
func agentByName(t *testing.T, e *testEnv, wsID, name string) *models.AgentInstance {
t.Helper()
rows, err := e.repo.ListAgentInstances(context.Background(), wsID)
if err != nil {
t.Fatalf("list agents: %v", err)
}
for _, a := range rows {
if a.Name == name {
return a
}
}
t.Fatalf("agent %q not found in workspace %q", name, wsID)
return nil
}
// skillBySlug finds a seeded/imported skill by slug, failing if absent.
func skillBySlug(t *testing.T, e *testEnv, wsID, slug string) *models.Skill {
t.Helper()
rows, err := e.repo.ListSkills(context.Background(), wsID)
if err != nil {
t.Fatalf("list skills: %v", err)
}
for _, sk := range rows {
if sk.Slug == slug {
return sk
}
}
t.Fatalf("skill %q not found in workspace %q", slug, wsID)
return nil
}
// routineByName finds a seeded/imported routine by name, failing if absent.
func routineByName(t *testing.T, e *testEnv, wsID, name string) *models.Routine {
t.Helper()
rows, err := e.repo.ListRoutines(context.Background(), wsID)
if err != nil {
t.Fatalf("list routines: %v", err)
}
for _, r := range rows {
if r.Name == name {
return r
}
}
t.Fatalf("routine %q not found in workspace %q", name, wsID)
return nil
}
// projectByName finds a seeded/imported project by name, failing if absent.
func projectByName(t *testing.T, e *testEnv, wsID, name string) *models.Project {
t.Helper()
rows, err := e.repo.ListProjects(context.Background(), wsID)
if err != nil {
t.Fatalf("list projects: %v", err)
}
for _, p := range rows {
if p.Name == name {
return p
}
}
t.Fatalf("project %q not found in workspace %q", name, wsID)
return nil
}
// assertStrings compares a []string against want by length and elements. A nil
// slice and an empty slice are equivalent here; pass want=nil to mean "no
// entries" regardless of which the production code returns.
func assertStrings(t *testing.T, label string, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s: got %v (len %d), want %v (len %d)", label, got, len(got), want, len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("%s[%d]: got %q, want %q", label, i, got[i], want[i])
}
}
}
// assertEqual fails with a labelled message when got != want.
func assertEqual[T comparable](t *testing.T, label string, got, want T) {
t.Helper()
if got != want {
t.Errorf("%s: got %v, want %v", label, got, want)
}
}
@@ -0,0 +1,453 @@
package config
import (
"context"
"strings"
"testing"
"github.com/kandev/kandev/internal/office/models"
)
// TestApplyImport_CreatesEveryFieldFromBundle imports a bundle with every
// field populated into an empty workspace and asserts each one arrives in the
// DB. A field dropped from applyAgents/applySkills/applyRoutines/applyProjects
// is silent in production — no error, no panic, just missing configuration —
// so this is the test that has to catch it.
func TestApplyImport_CreatesEveryFieldFromBundle(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
result, err := env.svc.ApplyImport(ctx, testWorkspaceID, fullBundle())
if err != nil {
t.Fatalf("ApplyImport: %v", err)
}
assertEqual(t, "created count", result.CreatedCount, 4)
assertEqual(t, "updated count", result.UpdatedCount, 0)
agent := agentByName(t, env, testWorkspaceID, "ada")
assertEqual(t, "agent workspace", agent.WorkspaceID, testWorkspaceID)
assertEqual(t, "agent role", string(agent.Role), "cto")
assertEqual(t, "agent icon", agent.Icon, "🤖")
assertEqual(t, "agent status", agent.Status, models.AgentStatusIdle)
assertEqual(t, "agent budget", agent.BudgetMonthlyCents, 4200)
assertEqual(t, "agent max sessions", agent.MaxConcurrentSessions, 3)
assertEqual(t, "agent desired skills", agent.DesiredSkills, `["go","sql"]`)
assertEqual(t, "agent executor preference", agent.ExecutorPreference, "local_docker")
skill := skillBySlug(t, env, testWorkspaceID, "code-review")
assertEqual(t, "skill workspace", skill.WorkspaceID, testWorkspaceID)
assertEqual(t, "skill name", skill.Name, "Code Review")
assertEqual(t, "skill description", skill.Description, "reviews diffs")
assertEqual(t, "skill source type", string(skill.SourceType), "inline")
assertEqual(t, "skill content", skill.Content, "# Code Review\n\nread the diff\n")
routine := routineByName(t, env, testWorkspaceID, "standup")
assertEqual(t, "routine workspace", routine.WorkspaceID, testWorkspaceID)
assertEqual(t, "routine description", routine.Description, "daily standup")
assertEqual(t, "routine task template", routine.TaskTemplate, "summarise yesterday")
assertEqual(t, "routine concurrency", string(routine.ConcurrencyPolicy), "skip")
assertEqual(t, "routine status", routine.Status, "active")
project := projectByName(t, env, testWorkspaceID, "apollo")
assertEqual(t, "project workspace", project.WorkspaceID, testWorkspaceID)
assertEqual(t, "project description", project.Description, "moon shot")
assertEqual(t, "project color", project.Color, "#ff00ff")
assertEqual(t, "project budget", project.BudgetCents, 99000)
assertEqual(t, "project repositories", project.Repositories, `["kdlbs/kandev"]`)
assertEqual(t, "project executor config", project.ExecutorConfig, `{"type":"local_docker"}`)
}
// TestApplyImport_NameReferencesAreNotResolved pins the deliberate gap in the
// importer: the three cross-entity references in the DTO (agent reports_to,
// routine assignee_name, project lead_agent_name) name an entity but the
// import never resolves them to an ID. They are export-only fields today.
// Wiring resolution in must update this test.
func TestApplyImport_NameReferencesAreNotResolved(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
// Seed the referenced agent so a resolver would have something to find.
lead := seedAgent(t, env, testWorkspaceID, "grace")
if _, err := env.svc.ApplyImport(ctx, testWorkspaceID, fullBundle()); err != nil {
t.Fatalf("ApplyImport: %v", err)
}
agent := agentByName(t, env, testWorkspaceID, "ada")
assertEqual(t, "agent reports_to", agent.ReportsTo, "")
if agent.ReportsTo == lead.ID {
t.Error("reports_to resolution appears implemented; update this test")
}
assertEqual(t, "routine assignee",
routineByName(t, env, testWorkspaceID, "standup").AssigneeAgentProfileID, "")
assertEqual(t, "project lead",
projectByName(t, env, testWorkspaceID, "apollo").LeadAgentProfileID, "")
}
// TestApplyImport_ProjectStatusIsForcedActive documents that a created project
// ignores the bundle's status and starts active, and that updating an existing
// project leaves its status untouched.
func TestApplyImport_ProjectStatusIsForcedActive(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
// fullBundle carries status "paused".
if _, err := env.svc.ApplyImport(ctx, testWorkspaceID, fullBundle()); err != nil {
t.Fatalf("ApplyImport create: %v", err)
}
assertEqual(t, "created project status",
projectByName(t, env, testWorkspaceID, "apollo").Status, models.ProjectStatusActive)
bundle := fullBundle()
bundle.Projects[0].Status = "archived"
bundle.Projects[0].Description = "changed"
if _, err := env.svc.ApplyImport(ctx, testWorkspaceID, bundle); err != nil {
t.Fatalf("ApplyImport update: %v", err)
}
updated := projectByName(t, env, testWorkspaceID, "apollo")
assertEqual(t, "updated project status", updated.Status, models.ProjectStatusActive)
assertEqual(t, "updated project description", updated.Description, "changed")
}
// TestApplyImport_UpdatesExistingRowsInPlace changes every mutable field and
// asserts the update path carries each one through without creating a second
// row.
func TestApplyImport_UpdatesExistingRowsInPlace(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
if _, err := env.svc.ApplyImport(ctx, testWorkspaceID, fullBundle()); err != nil {
t.Fatalf("seed import: %v", err)
}
agentID := agentByName(t, env, testWorkspaceID, "ada").ID
skillID := skillBySlug(t, env, testWorkspaceID, "code-review").ID
routineID := routineByName(t, env, testWorkspaceID, "standup").ID
projectID := projectByName(t, env, testWorkspaceID, "apollo").ID
changed := fullBundle()
changed.Agents[0].Role = "engineer"
changed.Agents[0].Icon = "🦾"
changed.Agents[0].BudgetMonthlyCents = 1
changed.Agents[0].MaxConcurrentSessions = 9
changed.Agents[0].DesiredSkills = `["rust"]`
changed.Agents[0].ExecutorPreference = "local_pc"
changed.Skills[0].Name = "Deep Review"
changed.Skills[0].Description = "reads everything"
changed.Skills[0].SourceType = "git"
changed.Skills[0].Content = "# Deep Review\n"
changed.Routines[0].Description = "weekly"
changed.Routines[0].TaskTemplate = "summarise the week"
changed.Routines[0].ConcurrencyPolicy = "queue"
changed.Projects[0].Description = "mars shot"
changed.Projects[0].Color = "#00ff00"
changed.Projects[0].BudgetCents = 1234
changed.Projects[0].Repositories = `["kdlbs/other"]`
changed.Projects[0].ExecutorConfig = `{"type":"local_pc"}`
result, err := env.svc.ApplyImport(ctx, testWorkspaceID, changed)
if err != nil {
t.Fatalf("ApplyImport update: %v", err)
}
assertEqual(t, "created count", result.CreatedCount, 0)
assertEqual(t, "updated count", result.UpdatedCount, 4)
agent := agentByName(t, env, testWorkspaceID, "ada")
assertEqual(t, "agent id stable", agent.ID, agentID)
assertEqual(t, "agent role", string(agent.Role), "engineer")
assertEqual(t, "agent icon", agent.Icon, "🦾")
assertEqual(t, "agent budget", agent.BudgetMonthlyCents, 1)
assertEqual(t, "agent max sessions", agent.MaxConcurrentSessions, 9)
assertEqual(t, "agent desired skills", agent.DesiredSkills, `["rust"]`)
assertEqual(t, "agent executor preference", agent.ExecutorPreference, "local_pc")
skill := skillBySlug(t, env, testWorkspaceID, "code-review")
assertEqual(t, "skill id stable", skill.ID, skillID)
assertEqual(t, "skill name", skill.Name, "Deep Review")
assertEqual(t, "skill description", skill.Description, "reads everything")
assertEqual(t, "skill source type", string(skill.SourceType), "git")
assertEqual(t, "skill content", skill.Content, "# Deep Review\n")
routine := routineByName(t, env, testWorkspaceID, "standup")
assertEqual(t, "routine id stable", routine.ID, routineID)
assertEqual(t, "routine description", routine.Description, "weekly")
assertEqual(t, "routine task template", routine.TaskTemplate, "summarise the week")
assertEqual(t, "routine concurrency", string(routine.ConcurrencyPolicy), "queue")
project := projectByName(t, env, testWorkspaceID, "apollo")
assertEqual(t, "project id stable", project.ID, projectID)
assertEqual(t, "project description", project.Description, "mars shot")
assertEqual(t, "project color", project.Color, "#00ff00")
assertEqual(t, "project budget", project.BudgetCents, 1234)
assertEqual(t, "project repositories", project.Repositories, `["kdlbs/other"]`)
assertEqual(t, "project executor config", project.ExecutorConfig, `{"type":"local_pc"}`)
}
// TestApplyImport_IsIdempotent imports the same bundle twice and asserts the
// second pass updates rather than duplicates, leaving exactly one row per
// entity with unchanged identity.
func TestApplyImport_IsIdempotent(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
bundle := fullBundle()
first, err := env.svc.ApplyImport(ctx, testWorkspaceID, bundle)
if err != nil {
t.Fatalf("first import: %v", err)
}
agentID := agentByName(t, env, testWorkspaceID, "ada").ID
second, err := env.svc.ApplyImport(ctx, testWorkspaceID, bundle)
if err != nil {
t.Fatalf("second import: %v", err)
}
assertEqual(t, "first created", first.CreatedCount, 4)
assertEqual(t, "second created", second.CreatedCount, 0)
assertEqual(t, "second updated", second.UpdatedCount, 4)
agents, err := env.repo.ListAgentInstances(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("list agents: %v", err)
}
assertEqual(t, "agent row count", len(agents), 1)
assertEqual(t, "agent id unchanged", agents[0].ID, agentID)
skills, err := env.repo.ListSkills(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("list skills: %v", err)
}
assertEqual(t, "skill row count", len(skills), 1)
routines, err := env.repo.ListRoutines(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("list routines: %v", err)
}
assertEqual(t, "routine row count", len(routines), 1)
projects, err := env.repo.ListProjects(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("list projects: %v", err)
}
assertEqual(t, "project row count", len(projects), 1)
}
// TestApplyImport_MatchesExistingByNameNotIdentity proves the dedup key: an
// agent already present under the same name is updated (not duplicated) even
// though the bundle carries no ID, and skills key on slug rather than name.
func TestApplyImport_MatchesExistingByNameNotIdentity(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
existingAgent := seedAgent(t, env, testWorkspaceID, "ada")
existingSkill := seedSkill(t, env, testWorkspaceID, "code-review")
bundle := fullBundle()
// Same slug, different display name: still an update.
bundle.Skills[0].Name = "Renamed Review"
result, err := env.svc.ApplyImport(ctx, testWorkspaceID, bundle)
if err != nil {
t.Fatalf("ApplyImport: %v", err)
}
assertEqual(t, "created count", result.CreatedCount, 2) // routine + project
assertEqual(t, "updated count", result.UpdatedCount, 2) // agent + skill
assertEqual(t, "agent id reused", agentByName(t, env, testWorkspaceID, "ada").ID, existingAgent.ID)
skill := skillBySlug(t, env, testWorkspaceID, "code-review")
assertEqual(t, "skill id reused", skill.ID, existingSkill.ID)
assertEqual(t, "skill renamed", skill.Name, "Renamed Review")
}
// TestApplyImport_IgnoresOtherWorkspaces confirms an import scoped to one
// workspace neither reads nor rewrites a same-named row in another.
func TestApplyImport_IgnoresOtherWorkspaces(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
foreign := seedAgent(t, env, "ws-other", "ada")
result, err := env.svc.ApplyImport(ctx, testWorkspaceID, fullBundle())
if err != nil {
t.Fatalf("ApplyImport: %v", err)
}
assertEqual(t, "created count", result.CreatedCount, 4)
mine := agentByName(t, env, testWorkspaceID, "ada")
if mine.ID == foreign.ID {
t.Fatal("import adopted another workspace's agent row")
}
other := agentByName(t, env, "ws-other", "ada")
assertEqual(t, "foreign agent icon untouched", other.Icon, "🛠")
assertEqual(t, "foreign agent budget untouched", other.BudgetMonthlyCents, 100)
}
func TestApplyImport_EmptyBundleIsANoOp(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
seedAgent(t, env, testWorkspaceID, "ada")
result, err := env.svc.ApplyImport(ctx, testWorkspaceID, &ConfigBundle{})
if err != nil {
t.Fatalf("ApplyImport: %v", err)
}
assertEqual(t, "created count", result.CreatedCount, 0)
assertEqual(t, "updated count", result.UpdatedCount, 0)
agents, err := env.repo.ListAgentInstances(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("list agents: %v", err)
}
assertEqual(t, "existing rows survive", len(agents), 1)
}
// TestApplyImport_LogsActivityWithCounts asserts the audit entry the import
// emits, including the counts embedded in its details string.
func TestApplyImport_LogsActivityWithCounts(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
seedAgent(t, env, testWorkspaceID, "ada")
if _, err := env.svc.ApplyImport(ctx, testWorkspaceID, fullBundle()); err != nil {
t.Fatalf("ApplyImport: %v", err)
}
if len(env.activity.entries) != 1 {
t.Fatalf("activity entries: got %d, want 1", len(env.activity.entries))
}
got := env.activity.entries[0]
assertEqual(t, "activity workspace", got.wsID, testWorkspaceID)
assertEqual(t, "activity actor type", got.actorType, "user")
assertEqual(t, "activity action", got.action, "config_imported")
assertEqual(t, "activity target type", got.targetType, "workspace")
assertEqual(t, "activity target id", got.targetID, testWorkspaceID)
assertEqual(t, "activity details", got.details, "created=3 updated=1")
}
// TestApplyImport_WrapsRepositoryErrors breaks one entity's table at a time so
// each apply stage fails on its own, and asserts the stage names itself in the
// wrapped error rather than surfacing a bare SQL message.
//
// wantAgentSurvives records the other half of the contract: the import is not
// wrapped in a transaction, so rows written by stages that ran before the
// failure stay in the DB. Adding a transaction here should flip these.
func TestApplyImport_WrapsRepositoryErrors(t *testing.T) {
tests := []struct {
name string
dropTable string
wantPrefix string
wantAgentSurvives bool
}{
{"agents", "agent_profiles", "apply agents:", false},
{"skills", "office_skills", "apply skills:", true},
{"routines", "office_routines", "apply routines:", true},
{"projects", "office_projects", "apply projects:", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
env := newTestEnv(t)
env.dropTable(t, tc.dropTable)
result, err := env.svc.ApplyImport(context.Background(), testWorkspaceID, fullBundle())
if err == nil {
t.Fatalf("expected an error after dropping %s, got nil", tc.dropTable)
}
if result != nil {
t.Errorf("result should be nil on error, got %+v", result)
}
if !strings.HasPrefix(err.Error(), tc.wantPrefix) {
t.Errorf("error %q does not start with %q", err.Error(), tc.wantPrefix)
}
if len(env.activity.entries) != 0 {
t.Errorf("failed import should not log activity, got %d entries",
len(env.activity.entries))
}
if !tc.wantAgentSurvives {
return
}
// The agent stage ran to completion before the failure. Its row
// is committed and is not rolled back.
agent := agentByName(t, env, testWorkspaceID, "ada")
assertEqual(t, "partially imported agent survives", agent.Name, "ada")
})
}
}
// TestPreviewImport_ClassifiesCreatesAndUpdates checks every entity type is
// split against existing DB state on the right key, and that preview writes
// nothing.
func TestPreviewImport_ClassifiesCreatesAndUpdates(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
seedAgent(t, env, testWorkspaceID, "ada")
seedSkill(t, env, testWorkspaceID, "code-review")
seedRoutine(t, env, testWorkspaceID, "standup")
seedProject(t, env, testWorkspaceID, "apollo")
bundle := fullBundle()
bundle.Agents = append(bundle.Agents, AgentConfig{Name: "grace"})
bundle.Skills = append(bundle.Skills, SkillConfig{Slug: "triage"})
bundle.Routines = append(bundle.Routines, RoutineConfig{Name: "retro"})
bundle.Projects = append(bundle.Projects, ProjectConfig{Name: "gemini"})
preview, err := env.svc.PreviewImport(ctx, testWorkspaceID, bundle)
if err != nil {
t.Fatalf("PreviewImport: %v", err)
}
assertStrings(t, "agents updated", preview.Agents.Updated, []string{"ada"})
assertStrings(t, "agents created", preview.Agents.Created, []string{"grace"})
assertStrings(t, "skills updated", preview.Skills.Updated, []string{"code-review"})
assertStrings(t, "skills created", preview.Skills.Created, []string{"triage"})
assertStrings(t, "routines updated", preview.Routines.Updated, []string{"standup"})
assertStrings(t, "routines created", preview.Routines.Created, []string{"retro"})
assertStrings(t, "projects updated", preview.Projects.Updated, []string{"apollo"})
assertStrings(t, "projects created", preview.Projects.Created, []string{"gemini"})
// PreviewImport never populates deletions — that is IncomingDiff's job.
assertStrings(t, "agents deleted", preview.Agents.Deleted, nil)
assertStrings(t, "routines deleted", preview.Routines.Deleted, nil)
agents, err := env.repo.ListAgentInstances(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("list agents: %v", err)
}
assertEqual(t, "preview wrote no rows", len(agents), 1)
}
func TestPreviewImport_EmptyBundleYieldsEmptyDiffs(t *testing.T) {
env := newTestEnv(t)
seedAgent(t, env, testWorkspaceID, "ada")
preview, err := env.svc.PreviewImport(context.Background(), testWorkspaceID, &ConfigBundle{})
if err != nil {
t.Fatalf("PreviewImport: %v", err)
}
assertStrings(t, "agents created", preview.Agents.Created, nil)
assertStrings(t, "agents updated", preview.Agents.Updated, nil)
assertStrings(t, "skills created", preview.Skills.Created, nil)
assertStrings(t, "projects created", preview.Projects.Created, nil)
}
// TestPreviewImport_ScopesToWorkspace confirms an existing row in another
// workspace does not turn a create into an update.
func TestPreviewImport_ScopesToWorkspace(t *testing.T) {
env := newTestEnv(t)
seedAgent(t, env, "ws-other", "ada")
preview, err := env.svc.PreviewImport(context.Background(), testWorkspaceID, fullBundle())
if err != nil {
t.Fatalf("PreviewImport: %v", err)
}
assertStrings(t, "agents created", preview.Agents.Created, []string{"ada"})
assertStrings(t, "agents updated", preview.Agents.Updated, nil)
}
func TestPreviewImport_PropagatesRepositoryErrors(t *testing.T) {
env := newTestEnv(t)
env.closeDB(t)
preview, err := env.svc.PreviewImport(context.Background(), testWorkspaceID, fullBundle())
if err == nil {
t.Fatal("expected an error from a closed database, got nil")
}
if preview != nil {
t.Errorf("preview should be nil on error, got %+v", preview)
}
}
@@ -0,0 +1,411 @@
package config
import (
"archive/zip"
"bytes"
"context"
"io"
"sort"
"testing"
"gopkg.in/yaml.v3"
"github.com/kandev/kandev/internal/office/models"
)
func TestNewConfigService_WiresDependencies(t *testing.T) {
env := newTestEnv(t)
if env.svc.repo != env.repo {
t.Error("repo not wired")
}
if env.svc.cfgLoader != env.loader {
t.Error("config loader not wired")
}
if env.svc.cfgWriter != env.writer {
t.Error("config writer not wired")
}
if env.svc.logger == nil {
t.Error("logger not wired")
}
if env.svc.activity == nil {
t.Error("activity logger not wired")
}
}
// seedFullDBState writes one of each entity with every exportable column set,
// plus a second agent used as the target of the three name references.
func seedFullDBState(t *testing.T, env *testEnv, wsID string) *models.AgentInstance {
t.Helper()
ctx := context.Background()
lead := seedAgent(t, env, wsID, "grace")
agent := &models.AgentInstance{
WorkspaceID: wsID,
Name: "ada",
Role: models.AgentRole("cto"),
Icon: "🤖",
Status: models.AgentStatusIdle,
ReportsTo: lead.ID,
BudgetMonthlyCents: 4200,
MaxConcurrentSessions: 3,
DesiredSkills: `["go","sql"]`,
ExecutorPreference: "local_docker",
}
if err := env.repo.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
skill := &models.Skill{
WorkspaceID: wsID,
Name: "Code Review",
Slug: "code-review",
Description: "reviews diffs",
SourceType: models.SkillSourceType("inline"),
Content: "# Code Review\n",
}
if err := env.repo.CreateSkill(ctx, skill); err != nil {
t.Fatalf("create skill: %v", err)
}
routine := &models.Routine{
WorkspaceID: wsID,
Name: "standup",
Description: "daily standup",
TaskTemplate: "summarise yesterday",
AssigneeAgentProfileID: lead.ID,
Status: "active",
ConcurrencyPolicy: models.RoutineConcurrencyPolicy("skip"),
}
if err := env.repo.CreateRoutine(ctx, routine); err != nil {
t.Fatalf("create routine: %v", err)
}
project := &models.Project{
WorkspaceID: wsID,
Name: "apollo",
Description: "moon shot",
Status: models.ProjectStatus("paused"),
LeadAgentProfileID: lead.ID,
Color: "#ff00ff",
BudgetCents: 99000,
Repositories: `["kdlbs/kandev"]`,
ExecutorConfig: `{"type":"local_docker"}`,
}
if err := env.repo.CreateProject(ctx, project); err != nil {
t.Fatalf("create project: %v", err)
}
return lead
}
// TestExportBundle_CarriesEveryField exports fully-populated DB rows and
// asserts each column reaches the bundle, including the three ID→name
// references the exporter resolves.
func TestExportBundle_CarriesEveryField(t *testing.T) {
env := newTestEnv(t)
seedFullDBState(t, env, testWorkspaceID)
bundle, err := env.svc.ExportBundle(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ExportBundle: %v", err)
}
assertEqual(t, "settings name", bundle.Settings.Name, testWorkspaceID)
var ada *AgentConfig
for i := range bundle.Agents {
if bundle.Agents[i].Name == "ada" {
ada = &bundle.Agents[i]
}
}
if ada == nil {
t.Fatalf("agent ada missing from export (%d agents)", len(bundle.Agents))
}
assertEqual(t, "agent role", ada.Role, "cto")
assertEqual(t, "agent icon", ada.Icon, "🤖")
assertEqual(t, "agent reports_to resolved to name", ada.ReportsTo, "grace")
assertEqual(t, "agent budget", ada.BudgetMonthlyCents, 4200)
assertEqual(t, "agent max sessions", ada.MaxConcurrentSessions, 3)
assertEqual(t, "agent desired skills", ada.DesiredSkills, `["go","sql"]`)
assertEqual(t, "agent executor preference", ada.ExecutorPreference, "local_docker")
if len(bundle.Skills) != 1 {
t.Fatalf("skills: got %d, want 1", len(bundle.Skills))
}
assertEqual(t, "skill name", bundle.Skills[0].Name, "Code Review")
assertEqual(t, "skill slug", bundle.Skills[0].Slug, "code-review")
assertEqual(t, "skill description", bundle.Skills[0].Description, "reviews diffs")
assertEqual(t, "skill source type", bundle.Skills[0].SourceType, "inline")
assertEqual(t, "skill content", bundle.Skills[0].Content, "# Code Review\n")
if len(bundle.Routines) != 1 {
t.Fatalf("routines: got %d, want 1", len(bundle.Routines))
}
assertEqual(t, "routine name", bundle.Routines[0].Name, "standup")
assertEqual(t, "routine description", bundle.Routines[0].Description, "daily standup")
assertEqual(t, "routine task template", bundle.Routines[0].TaskTemplate, "summarise yesterday")
assertEqual(t, "routine assignee resolved to name", bundle.Routines[0].AssigneeName, "grace")
assertEqual(t, "routine concurrency", bundle.Routines[0].ConcurrencyPolicy, "skip")
if len(bundle.Projects) != 1 {
t.Fatalf("projects: got %d, want 1", len(bundle.Projects))
}
assertEqual(t, "project name", bundle.Projects[0].Name, "apollo")
assertEqual(t, "project description", bundle.Projects[0].Description, "moon shot")
assertEqual(t, "project status", bundle.Projects[0].Status, "paused")
assertEqual(t, "project color", bundle.Projects[0].Color, "#ff00ff")
assertEqual(t, "project budget", bundle.Projects[0].BudgetCents, 99000)
assertEqual(t, "project repositories", bundle.Projects[0].Repositories, `["kdlbs/kandev"]`)
assertEqual(t, "project executor config", bundle.Projects[0].ExecutorConfig,
`{"type":"local_docker"}`)
assertEqual(t, "project lead resolved to name", bundle.Projects[0].LeadAgentName, "grace")
}
// TestExportBundle_UnresolvableReferencesBecomeEmpty covers the other branch of
// the ID→name lookup: an ID with no matching agent exports as an empty string
// rather than leaking the raw ID.
func TestExportBundle_UnresolvableReferencesBecomeEmpty(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
agent := &models.AgentInstance{
WorkspaceID: testWorkspaceID, Name: "ada", ReportsTo: "no-such-agent",
}
if err := env.repo.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
routine := &models.Routine{
WorkspaceID: testWorkspaceID, Name: "standup", AssigneeAgentProfileID: "no-such-agent",
}
if err := env.repo.CreateRoutine(ctx, routine); err != nil {
t.Fatalf("create routine: %v", err)
}
project := &models.Project{
WorkspaceID: testWorkspaceID, Name: "apollo", LeadAgentProfileID: "no-such-agent",
}
if err := env.repo.CreateProject(ctx, project); err != nil {
t.Fatalf("create project: %v", err)
}
bundle, err := env.svc.ExportBundle(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("ExportBundle: %v", err)
}
assertEqual(t, "agent reports_to", bundle.Agents[0].ReportsTo, "")
assertEqual(t, "routine assignee", bundle.Routines[0].AssigneeName, "")
assertEqual(t, "project lead", bundle.Projects[0].LeadAgentName, "")
}
// TestExportBundle_LeaksOtherWorkspaces_KnownDefect records a suspected bug,
// not an endorsed contract. Every export helper calls ListX(ctx, "") and so
// lists across all workspaces; the workspace ID argument only labels the
// settings block. PreviewImport on the same service *is* scoped (see
// TestPreviewImport_ScopesToWorkspace), so the two disagree, and under
// enabled auth a config export or zip for one workspace can carry another
// user's agents, routines, projects and skill content.
//
// This is reported separately rather than fixed here: this package's tests
// were added under a test-only mandate. Scoping the export is the fix, and it
// must delete or invert this test — that failure is the intended signal.
func TestExportBundle_LeaksOtherWorkspaces_KnownDefect(t *testing.T) {
env := newTestEnv(t)
seedAgent(t, env, testWorkspaceID, "ada")
seedAgent(t, env, "ws-other", "grace")
seedSkill(t, env, "ws-other", "triage")
seedRoutine(t, env, "ws-other", "retro")
seedProject(t, env, "ws-other", "gemini")
bundle, err := env.svc.ExportBundle(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ExportBundle: %v", err)
}
names := agentBundleNames(bundle.Agents)
sort.Strings(names)
assertStrings(t, "agents across workspaces", names, []string{"ada", "grace"})
assertStrings(t, "skills across workspaces", skillBundleSlugs(bundle.Skills), []string{"triage"})
assertStrings(t, "routines across workspaces",
routineBundleNames(bundle.Routines), []string{"retro"})
assertStrings(t, "projects across workspaces",
projectBundleNames(bundle.Projects), []string{"gemini"})
}
func TestExportBundle_EmptyDatabase(t *testing.T) {
env := newTestEnv(t)
bundle, err := env.svc.ExportBundle(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ExportBundle: %v", err)
}
assertEqual(t, "settings name", bundle.Settings.Name, testWorkspaceID)
assertEqual(t, "agents", len(bundle.Agents), 0)
assertEqual(t, "skills", len(bundle.Skills), 0)
assertEqual(t, "routines", len(bundle.Routines), 0)
assertEqual(t, "projects", len(bundle.Projects), 0)
}
// TestExportBundle_WrapsRepositoryErrors drops one table at a time and asserts
// each export stage names its own failure.
func TestExportBundle_WrapsRepositoryErrors(t *testing.T) {
tests := []struct {
name string
dropTable string
wantIn string
}{
{"agents", "agent_profiles", "list agents:"},
{"skills", "office_skills", "list skills:"},
{"routines", "office_routines", "list routines:"},
{"projects", "office_projects", "list projects:"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
env := newTestEnv(t)
env.dropTable(t, tc.dropTable)
bundle, err := env.svc.ExportBundle(context.Background(), testWorkspaceID)
if err == nil {
t.Fatalf("expected an error after dropping %s, got nil", tc.dropTable)
}
if bundle != nil {
t.Errorf("bundle should be nil on error, got %+v", bundle)
}
if !bytes.Contains([]byte(err.Error()), []byte(tc.wantIn)) {
t.Errorf("error %q does not contain %q", err.Error(), tc.wantIn)
}
})
}
}
// readZip returns every entry of a zip archive keyed by name.
func readZip(t *testing.T, r io.Reader) map[string][]byte {
t.Helper()
data, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read zip bytes: %v", err)
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("open zip: %v", err)
}
out := make(map[string][]byte, len(zr.File))
for _, f := range zr.File {
rc, openErr := f.Open()
if openErr != nil {
t.Fatalf("open zip entry %s: %v", f.Name, openErr)
}
body, readErr := io.ReadAll(rc)
_ = rc.Close()
if readErr != nil {
t.Fatalf("read zip entry %s: %v", f.Name, readErr)
}
out[f.Name] = body
}
return out
}
// TestBundleToZip_EveryFieldSurvivesTheArchive marshals a fully-populated
// bundle and unmarshals each entry back into its DTO, so a field lost in the
// YAML layer fails here.
func TestBundleToZip_EveryFieldSurvivesTheArchive(t *testing.T) {
want := fullBundle()
r, err := bundleToZip(want)
if err != nil {
t.Fatalf("bundleToZip: %v", err)
}
entries := readZip(t, r)
if len(entries) != 5 {
t.Fatalf("zip entries: got %d (%v), want 5", len(entries), entries)
}
var settings SettingsConfig
unmarshalEntry(t, entries, ".kandev/kandev.yml", &settings)
assertEqual(t, "settings name", settings.Name, want.Settings.Name)
assertEqual(t, "settings description", settings.Description, want.Settings.Description)
var agent AgentConfig
unmarshalEntry(t, entries, ".kandev/agents/ada.yml", &agent)
assertEqual(t, "agent", agent, want.Agents[0])
var skill SkillConfig
unmarshalEntry(t, entries, ".kandev/skills/code-review.yml", &skill)
assertEqual(t, "skill", skill, want.Skills[0])
var routine RoutineConfig
unmarshalEntry(t, entries, ".kandev/routines/standup.yml", &routine)
assertEqual(t, "routine", routine, want.Routines[0])
var project ProjectConfig
unmarshalEntry(t, entries, ".kandev/projects/apollo.yml", &project)
assertEqual(t, "project", project, want.Projects[0])
}
func unmarshalEntry(t *testing.T, entries map[string][]byte, name string, out interface{}) {
t.Helper()
body, ok := entries[name]
if !ok {
t.Fatalf("zip entry %q missing", name)
}
if err := yaml.Unmarshal(body, out); err != nil {
t.Fatalf("unmarshal %s: %v", name, err)
}
}
func TestBundleToZip_EmptyBundleStillCarriesSettings(t *testing.T) {
r, err := bundleToZip(&ConfigBundle{})
if err != nil {
t.Fatalf("bundleToZip: %v", err)
}
entries := readZip(t, r)
assertEqual(t, "zip entries", len(entries), 1)
if _, ok := entries[".kandev/kandev.yml"]; !ok {
t.Errorf("settings entry missing, got %v", entries)
}
}
// TestExportZip_ReflectsDatabaseState wires ExportBundle and bundleToZip
// together over real rows.
func TestExportZip_ReflectsDatabaseState(t *testing.T) {
env := newTestEnv(t)
seedFullDBState(t, env, testWorkspaceID)
r, err := env.svc.ExportZip(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ExportZip: %v", err)
}
entries := readZip(t, r)
for _, name := range []string{
".kandev/kandev.yml",
".kandev/agents/ada.yml",
".kandev/agents/grace.yml",
".kandev/skills/code-review.yml",
".kandev/routines/standup.yml",
".kandev/projects/apollo.yml",
} {
if _, ok := entries[name]; !ok {
t.Errorf("zip entry %q missing", name)
}
}
var agent AgentConfig
unmarshalEntry(t, entries, ".kandev/agents/ada.yml", &agent)
assertEqual(t, "agent role", agent.Role, "cto")
assertEqual(t, "agent reports_to", agent.ReportsTo, "grace")
assertEqual(t, "agent budget", agent.BudgetMonthlyCents, 4200)
}
func TestExportZip_PropagatesExportError(t *testing.T) {
env := newTestEnv(t)
env.closeDB(t)
r, err := env.svc.ExportZip(context.Background(), testWorkspaceID)
if err == nil {
t.Fatal("expected an error from a closed database, got nil")
}
if r != nil {
t.Error("reader should be nil on error")
}
}
@@ -0,0 +1,257 @@
package config
import (
"context"
"sort"
"testing"
)
// TestDiffBundles_ClassifiesEveryEntityType feeds an incoming bundle that
// creates, updates and deletes one of each entity type, and asserts each
// bucket. Inverting any of the four membership checks flips a create into an
// update here.
func TestDiffBundles_ClassifiesEveryEntityType(t *testing.T) {
existing := &ConfigBundle{
Agents: []AgentConfig{{Name: "ada"}, {Name: "gone-agent"}},
Skills: []SkillConfig{{Slug: "code-review"}, {Slug: "gone-skill"}},
Routines: []RoutineConfig{{Name: "standup"}, {Name: "gone-routine"}},
Projects: []ProjectConfig{{Name: "apollo"}, {Name: "gone-project"}},
}
incoming := &ConfigBundle{
Agents: []AgentConfig{{Name: "ada"}, {Name: "new-agent"}},
Skills: []SkillConfig{{Slug: "code-review"}, {Slug: "new-skill"}},
Routines: []RoutineConfig{{Name: "standup"}, {Name: "new-routine"}},
Projects: []ProjectConfig{{Name: "apollo"}, {Name: "new-project"}},
}
preview := diffBundles(incoming, existing)
assertStrings(t, "agents updated", preview.Agents.Updated, []string{"ada"})
assertStrings(t, "agents created", preview.Agents.Created, []string{"new-agent"})
assertStrings(t, "agents deleted", preview.Agents.Deleted, []string{"gone-agent"})
assertStrings(t, "skills updated", preview.Skills.Updated, []string{"code-review"})
assertStrings(t, "skills created", preview.Skills.Created, []string{"new-skill"})
assertStrings(t, "skills deleted", preview.Skills.Deleted, []string{"gone-skill"})
assertStrings(t, "routines updated", preview.Routines.Updated, []string{"standup"})
assertStrings(t, "routines created", preview.Routines.Created, []string{"new-routine"})
assertStrings(t, "routines deleted", preview.Routines.Deleted, []string{"gone-routine"})
assertStrings(t, "projects updated", preview.Projects.Updated, []string{"apollo"})
assertStrings(t, "projects created", preview.Projects.Created, []string{"new-project"})
assertStrings(t, "projects deleted", preview.Projects.Deleted, []string{"gone-project"})
}
// TestDiffBundles_SkillsKeyOnSlug guards the one entity that does not key on
// Name: a skill whose slug matches but whose name changed is an update, and a
// skill whose name matches but whose slug changed is a create plus a delete.
func TestDiffBundles_SkillsKeyOnSlug(t *testing.T) {
existing := &ConfigBundle{Skills: []SkillConfig{{Name: "Code Review", Slug: "code-review"}}}
renamed := diffBundles(
&ConfigBundle{Skills: []SkillConfig{{Name: "Deep Review", Slug: "code-review"}}}, existing)
assertStrings(t, "renamed updated", renamed.Skills.Updated, []string{"code-review"})
assertStrings(t, "renamed deleted", renamed.Skills.Deleted, nil)
reslugged := diffBundles(
&ConfigBundle{Skills: []SkillConfig{{Name: "Code Review", Slug: "deep-review"}}}, existing)
assertStrings(t, "reslugged created", reslugged.Skills.Created, []string{"deep-review"})
assertStrings(t, "reslugged deleted", reslugged.Skills.Deleted, []string{"code-review"})
}
func TestDiffBundles_EmptySides(t *testing.T) {
full := fullBundle()
fromEmpty := diffBundles(full, &ConfigBundle{})
assertStrings(t, "everything created", fromEmpty.Agents.Created, []string{"ada"})
assertStrings(t, "nothing deleted", fromEmpty.Agents.Deleted, nil)
toEmpty := diffBundles(&ConfigBundle{}, full)
assertStrings(t, "nothing created", toEmpty.Agents.Created, nil)
assertStrings(t, "everything deleted", toEmpty.Agents.Deleted, []string{"ada"})
assertStrings(t, "skills deleted", toEmpty.Skills.Deleted, []string{"code-review"})
assertStrings(t, "routines deleted", toEmpty.Routines.Deleted, []string{"standup"})
assertStrings(t, "projects deleted", toEmpty.Projects.Deleted, []string{"apollo"})
both := diffBundles(&ConfigBundle{}, &ConfigBundle{})
assertStrings(t, "empty created", both.Agents.Created, nil)
assertStrings(t, "empty updated", both.Agents.Updated, nil)
assertStrings(t, "empty deleted", both.Agents.Deleted, nil)
}
// TestAppendDeletions_ReportsRowsMissingFromBundle covers all four entity
// types and confirms the created/updated buckets are left alone.
func TestAppendDeletions_ReportsRowsMissingFromBundle(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
seedAgent(t, env, testWorkspaceID, "ada")
seedAgent(t, env, testWorkspaceID, "gone-agent")
seedSkill(t, env, testWorkspaceID, "code-review")
seedSkill(t, env, testWorkspaceID, "gone-skill")
seedRoutine(t, env, testWorkspaceID, "standup")
seedRoutine(t, env, testWorkspaceID, "gone-routine")
seedProject(t, env, testWorkspaceID, "apollo")
seedProject(t, env, testWorkspaceID, "gone-project")
preview := &ImportPreview{}
preview.Agents.Created = []string{"pre-existing"}
if err := env.svc.appendDeletions(ctx, testWorkspaceID, fullBundle(), preview); err != nil {
t.Fatalf("appendDeletions: %v", err)
}
assertStrings(t, "agents deleted", preview.Agents.Deleted, []string{"gone-agent"})
assertStrings(t, "skills deleted", preview.Skills.Deleted, []string{"gone-skill"})
assertStrings(t, "routines deleted", preview.Routines.Deleted, []string{"gone-routine"})
assertStrings(t, "projects deleted", preview.Projects.Deleted, []string{"gone-project"})
assertStrings(t, "created bucket untouched", preview.Agents.Created, []string{"pre-existing"})
}
// TestAppendDeletions_IgnoresOtherWorkspaces makes sure a row that only exists
// in another workspace is never proposed for deletion.
func TestAppendDeletions_IgnoresOtherWorkspaces(t *testing.T) {
env := newTestEnv(t)
seedAgent(t, env, "ws-other", "foreign")
preview := &ImportPreview{}
if err := env.svc.appendDeletions(
context.Background(), testWorkspaceID, fullBundle(), preview); err != nil {
t.Fatalf("appendDeletions: %v", err)
}
assertStrings(t, "agents deleted", preview.Agents.Deleted, nil)
}
func TestAppendDeletions_PropagatesRepositoryErrors(t *testing.T) {
for _, table := range []string{
"agent_profiles", "office_skills", "office_routines", "office_projects",
} {
t.Run(table, func(t *testing.T) {
env := newTestEnv(t)
env.dropTable(t, table)
err := env.svc.appendDeletions(
context.Background(), testWorkspaceID, fullBundle(), &ImportPreview{})
if err == nil {
t.Fatalf("expected an error after dropping %s, got nil", table)
}
})
}
}
// TestDeleteRowsMissingFromBundle_RemovesOnlyAbsentRows seeds two rows per
// entity type, keeps one of each in the bundle, and asserts exactly the other
// is gone.
func TestDeleteRowsMissingFromBundle_RemovesOnlyAbsentRows(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
seedAgent(t, env, testWorkspaceID, "ada")
seedAgent(t, env, testWorkspaceID, "gone-agent")
seedSkill(t, env, testWorkspaceID, "code-review")
seedSkill(t, env, testWorkspaceID, "gone-skill")
seedRoutine(t, env, testWorkspaceID, "standup")
seedRoutine(t, env, testWorkspaceID, "gone-routine")
seedProject(t, env, testWorkspaceID, "apollo")
seedProject(t, env, testWorkspaceID, "gone-project")
if err := env.svc.deleteRowsMissingFromBundle(ctx, testWorkspaceID, fullBundle()); err != nil {
t.Fatalf("deleteRowsMissingFromBundle: %v", err)
}
assertRemainingNames(t, env, testWorkspaceID, []string{"ada"}, []string{"code-review"},
[]string{"standup"}, []string{"apollo"})
}
// TestDeleteRowsMissingFromBundle_EmptyBundleClearsWorkspace is the
// destructive end of the range: nothing on disk means nothing in the DB.
func TestDeleteRowsMissingFromBundle_EmptyBundleClearsWorkspace(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
seedAgent(t, env, testWorkspaceID, "ada")
seedSkill(t, env, testWorkspaceID, "code-review")
seedRoutine(t, env, testWorkspaceID, "standup")
seedProject(t, env, testWorkspaceID, "apollo")
if err := env.svc.deleteRowsMissingFromBundle(ctx, testWorkspaceID, &ConfigBundle{}); err != nil {
t.Fatalf("deleteRowsMissingFromBundle: %v", err)
}
assertRemainingNames(t, env, testWorkspaceID, nil, nil, nil, nil)
}
// TestDeleteRowsMissingFromBundle_LeavesOtherWorkspacesAlone is the guard that
// keeps an FS sync of one workspace from wiping another.
func TestDeleteRowsMissingFromBundle_LeavesOtherWorkspacesAlone(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
seedAgent(t, env, "ws-other", "foreign-agent")
seedSkill(t, env, "ws-other", "foreign-skill")
seedRoutine(t, env, "ws-other", "foreign-routine")
seedProject(t, env, "ws-other", "foreign-project")
if err := env.svc.deleteRowsMissingFromBundle(ctx, testWorkspaceID, &ConfigBundle{}); err != nil {
t.Fatalf("deleteRowsMissingFromBundle: %v", err)
}
assertRemainingNames(t, env, "ws-other", []string{"foreign-agent"}, []string{"foreign-skill"},
[]string{"foreign-routine"}, []string{"foreign-project"})
}
func TestDeleteRowsMissingFromBundle_PropagatesRepositoryErrors(t *testing.T) {
for _, table := range []string{
"agent_profiles", "office_skills", "office_routines", "office_projects",
} {
t.Run(table, func(t *testing.T) {
env := newTestEnv(t)
env.dropTable(t, table)
err := env.svc.deleteRowsMissingFromBundle(
context.Background(), testWorkspaceID, fullBundle())
if err == nil {
t.Fatalf("expected an error after dropping %s, got nil", table)
}
})
}
}
// assertRemainingNames compares the workspace's surviving rows, sorted, with
// the expected names for each entity type.
func assertRemainingNames(
t *testing.T, env *testEnv, wsID string, agents, skills, routines, projects []string,
) {
t.Helper()
ctx := context.Background()
gotAgents, err := env.repo.ListAgentInstances(ctx, wsID)
if err != nil {
t.Fatalf("list agents: %v", err)
}
assertSorted(t, "agents", agentNames(gotAgents), agents)
gotSkills, err := env.repo.ListSkills(ctx, wsID)
if err != nil {
t.Fatalf("list skills: %v", err)
}
assertSorted(t, "skills", skillSlugs(gotSkills), skills)
gotRoutines, err := env.repo.ListRoutines(ctx, wsID)
if err != nil {
t.Fatalf("list routines: %v", err)
}
assertSorted(t, "routines", routineNames(gotRoutines), routines)
gotProjects, err := env.repo.ListProjects(ctx, wsID)
if err != nil {
t.Fatalf("list projects: %v", err)
}
assertSorted(t, "projects", projectNames(gotProjects), projects)
}
func assertSorted(t *testing.T, label string, got, want []string) {
t.Helper()
sort.Strings(got)
if want == nil {
want = []string{}
}
assertStrings(t, label, got, want)
}
@@ -0,0 +1,573 @@
package config
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/kandev/kandev/internal/common/logger"
)
const fullAgentYAML = `name: ada
role: cto
icon: "🤖"
reports_to: grace
permissions: '{"hire_agents":true}'
budget_monthly_cents: 4200
max_concurrent_sessions: 3
desired_skills: '["go","sql"]'
executor_preference: local_docker
`
const fullRoutineYAML = `name: standup
description: daily standup
task_template: summarise yesterday
assignee_name: grace
status: paused
concurrency_policy: skip
`
const fullProjectYAML = `name: apollo
description: moon shot
status: paused
color: "#ff00ff"
budget_cents: 99000
repositories: '["kdlbs/kandev"]'
executor_config: '{"type":"local_docker"}'
lead_agent_name: grace
`
// seedFullFSState writes one fully-populated file per entity type into the
// on-disk "default" workspace.
func seedFullFSState(t *testing.T, env *testEnv) {
t.Helper()
env.writeFSAgent(t, "ada", fullAgentYAML)
env.writeFSSkill(t, "code-review", "# Code Review\n\nread the diff\n")
env.writeFSRoutine(t, "standup", fullRoutineYAML)
env.writeFSProject(t, "apollo", fullProjectYAML)
}
func sortedCopy(in []string) []string {
out := append([]string(nil), in...)
sort.Strings(out)
return out
}
// TestScanFilesystem_CarriesEveryFieldFromDisk is the FS→bundle half of the
// field-drop guard: every value written to disk must arrive in the bundle.
func TestScanFilesystem_CarriesEveryFieldFromDisk(t *testing.T) {
env := newTestEnv(t)
seedFullFSState(t, env)
bundle, parseErrs, err := env.svc.ScanFilesystem(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ScanFilesystem: %v", err)
}
if len(parseErrs) != 0 {
t.Fatalf("unexpected parse errors: %+v", parseErrs)
}
// The scan always reads the "default" FS workspace, regardless of the
// DB workspace ID passed in.
assertEqual(t, "settings name", bundle.Settings.Name, defaultWorkspaceName)
if len(bundle.Agents) != 1 {
t.Fatalf("agents: got %d, want 1", len(bundle.Agents))
}
agent := bundle.Agents[0]
assertEqual(t, "agent name", agent.Name, "ada")
assertEqual(t, "agent role", agent.Role, "cto")
assertEqual(t, "agent icon", agent.Icon, "🤖")
assertEqual(t, "agent budget", agent.BudgetMonthlyCents, 4200)
assertEqual(t, "agent max sessions", agent.MaxConcurrentSessions, 3)
assertEqual(t, "agent desired skills", agent.DesiredSkills, `["go","sql"]`)
assertEqual(t, "agent executor preference", agent.ExecutorPreference, "local_docker")
if len(bundle.Skills) != 1 {
t.Fatalf("skills: got %d, want 1", len(bundle.Skills))
}
// The loader derives name and slug from the directory and stamps
// source_type "filesystem"; only the body comes from SKILL.md.
assertEqual(t, "skill name", bundle.Skills[0].Name, "code-review")
assertEqual(t, "skill slug", bundle.Skills[0].Slug, "code-review")
assertEqual(t, "skill source type", bundle.Skills[0].SourceType, "filesystem")
assertEqual(t, "skill content", bundle.Skills[0].Content, "# Code Review\n\nread the diff\n")
if len(bundle.Routines) != 1 {
t.Fatalf("routines: got %d, want 1", len(bundle.Routines))
}
assertEqual(t, "routine name", bundle.Routines[0].Name, "standup")
assertEqual(t, "routine description", bundle.Routines[0].Description, "daily standup")
assertEqual(t, "routine task template", bundle.Routines[0].TaskTemplate, "summarise yesterday")
assertEqual(t, "routine concurrency", bundle.Routines[0].ConcurrencyPolicy, "skip")
if len(bundle.Projects) != 1 {
t.Fatalf("projects: got %d, want 1", len(bundle.Projects))
}
assertEqual(t, "project name", bundle.Projects[0].Name, "apollo")
assertEqual(t, "project description", bundle.Projects[0].Description, "moon shot")
assertEqual(t, "project status", bundle.Projects[0].Status, "paused")
assertEqual(t, "project color", bundle.Projects[0].Color, "#ff00ff")
assertEqual(t, "project budget", bundle.Projects[0].BudgetCents, 99000)
assertEqual(t, "project repositories", bundle.Projects[0].Repositories, `["kdlbs/kandev"]`)
assertEqual(t, "project executor config", bundle.Projects[0].ExecutorConfig,
`{"type":"local_docker"}`)
}
// TestScanFilesystem_DropsNameReferences records the same gap the importer
// has: the scan does not carry reports_to, assignee_name or lead_agent_name
// off disk even though all three are present in the fixture files. The result
// is a lossy sync — applying a checked-in workspace config erases the org
// chart, routine assignees and project leads rather than resolving the
// portable names to IDs.
//
// Recorded rather than fixed (test-only change); carrying the names through
// the scan and resolving them on import must update this test.
func TestScanFilesystem_DropsNameReferences(t *testing.T) {
env := newTestEnv(t)
seedFullFSState(t, env)
bundle, _, err := env.svc.ScanFilesystem(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ScanFilesystem: %v", err)
}
assertEqual(t, "agent reports_to", bundle.Agents[0].ReportsTo, "")
assertEqual(t, "routine assignee", bundle.Routines[0].AssigneeName, "")
assertEqual(t, "project lead", bundle.Projects[0].LeadAgentName, "")
}
// TestScanFilesystem_NoConfigOnDisk documents that a missing workspaces
// directory is not an error and yields an empty (non-nil) bundle.
func TestScanFilesystem_NoConfigOnDisk(t *testing.T) {
env := newTestEnv(t)
if err := os.RemoveAll(filepath.Join(env.base, "workspaces")); err != nil {
t.Fatalf("remove workspaces dir: %v", err)
}
bundle, parseErrs, err := env.svc.ScanFilesystem(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ScanFilesystem: %v", err)
}
if bundle == nil {
t.Fatal("bundle should be non-nil even with no config on disk")
}
assertEqual(t, "settings name", bundle.Settings.Name, defaultWorkspaceName)
assertEqual(t, "agents", len(bundle.Agents), 0)
assertEqual(t, "parse errors", len(parseErrs), 0)
}
// TestScanFilesystem_ReportsParseErrors covers the malformed-input path: a
// broken file is reported rather than swallowed, and the good files still load.
func TestScanFilesystem_ReportsParseErrors(t *testing.T) {
env := newTestEnv(t)
env.writeFSAgent(t, "ada", fullAgentYAML)
env.writeFSAgent(t, "broken", "name: [unclosed\n")
bundle, parseErrs, err := env.svc.ScanFilesystem(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ScanFilesystem: %v", err)
}
if len(parseErrs) != 1 {
t.Fatalf("parse errors: got %d (%+v), want 1", len(parseErrs), parseErrs)
}
assertEqual(t, "parse error workspace", parseErrs[0].WorkspaceID, defaultWorkspaceName)
if !strings.HasSuffix(parseErrs[0].FilePath, "broken.yml") {
t.Errorf("parse error file path %q does not name broken.yml", parseErrs[0].FilePath)
}
if parseErrs[0].Error == "" {
t.Error("parse error message is empty")
}
assertStrings(t, "good agent still loaded", agentBundleNames(bundle.Agents), []string{"ada"})
}
// TestScanFilesystem_UnreadableWorkspacesDirIsAnError separates "no config on
// disk" (fine) from "config root is unusable" (an error the caller must see).
func TestScanFilesystem_UnreadableWorkspacesDirIsAnError(t *testing.T) {
env := newTestEnv(t)
wsRoot := filepath.Join(env.base, "workspaces")
if err := os.RemoveAll(wsRoot); err != nil {
t.Fatalf("remove workspaces dir: %v", err)
}
// A regular file where the directory should be: ReadDir fails with
// something other than "not exist".
writeFile(t, wsRoot, "not a directory\n")
bundle, _, err := env.svc.ScanFilesystem(context.Background(), testWorkspaceID)
if err == nil {
t.Fatal("expected an error for an unreadable workspaces dir, got nil")
}
if !strings.HasPrefix(err.Error(), "scan workspaces:") {
t.Errorf("error %q does not start with %q", err.Error(), "scan workspaces:")
}
if bundle != nil {
t.Errorf("bundle should be nil on error, got %+v", bundle)
}
}
// TestScanFilesystem_NilLoaderIsRejected covers the guard in front of every
// filesystem read.
func TestScanFilesystem_NilLoaderIsRejected(t *testing.T) {
env := newTestEnv(t)
svc := NewConfigService(env.repo, nil, env.writer, logger.Default(), env.activity)
bundle, parseErrs, err := svc.ScanFilesystem(context.Background(), testWorkspaceID)
if err == nil {
t.Fatal("expected an error for a nil config loader, got nil")
}
assertEqual(t, "error message", err.Error(), "config loader not initialized")
if bundle != nil || parseErrs != nil {
t.Errorf("bundle and errors should be nil, got %+v / %+v", bundle, parseErrs)
}
}
func TestParseErrorsFromLoader_EmptyIsNil(t *testing.T) {
env := newTestEnv(t)
if got := parseErrorsFromLoader(env.loader); got != nil {
t.Errorf("expected nil for a clean loader, got %+v", got)
}
}
// TestIncomingDiff_CombinesPreviewAndDeletions is the FS→DB direction: files
// not yet in the DB are creates, matching names are updates, and DB rows with
// no file are deletions.
func TestIncomingDiff_CombinesPreviewAndDeletions(t *testing.T) {
env := newTestEnv(t)
seedFullFSState(t, env)
seedAgent(t, env, testWorkspaceID, "ada")
seedAgent(t, env, testWorkspaceID, "gone-agent")
seedSkill(t, env, testWorkspaceID, "gone-skill")
seedRoutine(t, env, testWorkspaceID, "gone-routine")
seedProject(t, env, testWorkspaceID, "gone-project")
diff, err := env.svc.IncomingDiff(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("IncomingDiff: %v", err)
}
assertEqual(t, "direction", diff.Direction, "incoming")
if diff.Preview == nil {
t.Fatal("preview is nil")
}
assertEqual(t, "errors", len(diff.Errors), 0)
assertStrings(t, "agents updated", diff.Preview.Agents.Updated, []string{"ada"})
assertStrings(t, "agents deleted", diff.Preview.Agents.Deleted, []string{"gone-agent"})
assertStrings(t, "skills created", diff.Preview.Skills.Created, []string{"code-review"})
assertStrings(t, "skills deleted", diff.Preview.Skills.Deleted, []string{"gone-skill"})
assertStrings(t, "routines created", diff.Preview.Routines.Created, []string{"standup"})
assertStrings(t, "routines deleted", diff.Preview.Routines.Deleted, []string{"gone-routine"})
assertStrings(t, "projects created", diff.Preview.Projects.Created, []string{"apollo"})
assertStrings(t, "projects deleted", diff.Preview.Projects.Deleted, []string{"gone-project"})
}
func TestIncomingDiff_SurfacesParseErrors(t *testing.T) {
env := newTestEnv(t)
env.writeFSAgent(t, "broken", "name: [unclosed\n")
diff, err := env.svc.IncomingDiff(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("IncomingDiff: %v", err)
}
if len(diff.Errors) != 1 {
t.Fatalf("errors: got %d (%+v), want 1", len(diff.Errors), diff.Errors)
}
}
func TestIncomingDiff_PropagatesErrors(t *testing.T) {
t.Run("scan failure", func(t *testing.T) {
env := newTestEnv(t)
svc := NewConfigService(env.repo, nil, env.writer, logger.Default(), env.activity)
if _, err := svc.IncomingDiff(context.Background(), testWorkspaceID); err == nil {
t.Fatal("expected an error for a nil config loader, got nil")
}
})
t.Run("preview failure", func(t *testing.T) {
env := newTestEnv(t)
env.closeDB(t)
diff, err := env.svc.IncomingDiff(context.Background(), testWorkspaceID)
if err == nil {
t.Fatal("expected an error from a closed database, got nil")
}
if diff != nil {
t.Errorf("diff should be nil on error, got %+v", diff)
}
})
// PreviewImport and appendDeletions read the same four tables, so a
// broken table always surfaces from the preview first. Each entity is
// still worth covering: none of them may be swallowed.
for _, table := range []string{
"agent_profiles", "office_skills", "office_routines", "office_projects",
} {
t.Run("broken "+table, func(t *testing.T) {
env := newTestEnv(t)
env.dropTable(t, table)
if _, err := env.svc.IncomingDiff(context.Background(), testWorkspaceID); err == nil {
t.Fatalf("expected an error after dropping %s, got nil", table)
}
})
}
}
// TestOutgoingDiff_ComparesDatabaseAgainstDisk is the DB→FS direction: DB rows
// with no file are creates, and files with no DB row are deletions.
func TestOutgoingDiff_ComparesDatabaseAgainstDisk(t *testing.T) {
env := newTestEnv(t)
env.writeFSAgent(t, "ada", fullAgentYAML)
env.writeFSAgent(t, "stale", "name: stale\nrole: engineer\n")
seedAgent(t, env, testWorkspaceID, "ada")
seedAgent(t, env, testWorkspaceID, "fresh")
diff, err := env.svc.OutgoingDiff(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("OutgoingDiff: %v", err)
}
assertEqual(t, "direction", diff.Direction, "outgoing")
assertEqual(t, "errors", len(diff.Errors), 0)
assertStrings(t, "agents updated", diff.Preview.Agents.Updated, []string{"ada"})
assertStrings(t, "agents created", diff.Preview.Agents.Created, []string{"fresh"})
assertStrings(t, "agents deleted", diff.Preview.Agents.Deleted, []string{"stale"})
}
func TestOutgoingDiff_PropagatesErrors(t *testing.T) {
t.Run("export failure", func(t *testing.T) {
env := newTestEnv(t)
env.closeDB(t)
diff, err := env.svc.OutgoingDiff(context.Background(), testWorkspaceID)
if err == nil {
t.Fatal("expected an error from a closed database, got nil")
}
if diff != nil {
t.Errorf("diff should be nil on error, got %+v", diff)
}
})
t.Run("scan failure", func(t *testing.T) {
env := newTestEnv(t)
svc := NewConfigService(env.repo, nil, env.writer, logger.Default(), env.activity)
if _, err := svc.OutgoingDiff(context.Background(), testWorkspaceID); err == nil {
t.Fatal("expected an error for a nil config loader, got nil")
}
})
}
// TestApplyIncoming_ImportsAndPrunes asserts the full FS→DB apply: files
// create or update rows, and rows with no file are removed.
func TestApplyIncoming_ImportsAndPrunes(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
seedFullFSState(t, env)
seedAgent(t, env, testWorkspaceID, "ada")
seedAgent(t, env, testWorkspaceID, "gone-agent")
seedSkill(t, env, testWorkspaceID, "gone-skill")
result, err := env.svc.ApplyIncoming(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("ApplyIncoming: %v", err)
}
assertEqual(t, "created count", result.CreatedCount, 3) // skill + routine + project
assertEqual(t, "updated count", result.UpdatedCount, 1) // ada
assertRemainingNames(t, env, testWorkspaceID, []string{"ada"}, []string{"code-review"},
[]string{"standup"}, []string{"apollo"})
// The updated agent carries the on-disk values.
agent := agentByName(t, env, testWorkspaceID, "ada")
assertEqual(t, "agent role", string(agent.Role), "cto")
assertEqual(t, "agent budget", agent.BudgetMonthlyCents, 4200)
assertEqual(t, "agent executor preference", agent.ExecutorPreference, "local_docker")
}
// TestApplyIncoming_IsIdempotent runs the same FS state through twice: the
// second pass updates in place and prunes nothing further.
func TestApplyIncoming_IsIdempotent(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
seedFullFSState(t, env)
first, err := env.svc.ApplyIncoming(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("first ApplyIncoming: %v", err)
}
agentID := agentByName(t, env, testWorkspaceID, "ada").ID
second, err := env.svc.ApplyIncoming(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("second ApplyIncoming: %v", err)
}
assertEqual(t, "first created", first.CreatedCount, 4)
assertEqual(t, "second created", second.CreatedCount, 0)
assertEqual(t, "second updated", second.UpdatedCount, 4)
assertEqual(t, "agent id stable", agentByName(t, env, testWorkspaceID, "ada").ID, agentID)
assertRemainingNames(t, env, testWorkspaceID, []string{"ada"}, []string{"code-review"},
[]string{"standup"}, []string{"apollo"})
}
// TestApplyIncoming_EmptyFilesystemClearsWorkspace is the destructive case:
// with no config on disk, the sync prunes every row.
func TestApplyIncoming_EmptyFilesystemClearsWorkspace(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
seedAgent(t, env, testWorkspaceID, "ada")
seedSkill(t, env, testWorkspaceID, "code-review")
seedRoutine(t, env, testWorkspaceID, "standup")
seedProject(t, env, testWorkspaceID, "apollo")
result, err := env.svc.ApplyIncoming(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("ApplyIncoming: %v", err)
}
assertEqual(t, "created count", result.CreatedCount, 0)
assertEqual(t, "updated count", result.UpdatedCount, 0)
assertRemainingNames(t, env, testWorkspaceID, nil, nil, nil, nil)
}
func TestApplyIncoming_PropagatesErrors(t *testing.T) {
t.Run("scan failure", func(t *testing.T) {
env := newTestEnv(t)
svc := NewConfigService(env.repo, nil, env.writer, logger.Default(), env.activity)
result, err := svc.ApplyIncoming(context.Background(), testWorkspaceID)
if err == nil {
t.Fatal("expected an error for a nil config loader, got nil")
}
if result != nil {
t.Errorf("result should be nil on error, got %+v", result)
}
})
t.Run("import failure", func(t *testing.T) {
env := newTestEnv(t)
env.closeDB(t)
if _, err := env.svc.ApplyIncoming(context.Background(), testWorkspaceID); err == nil {
t.Fatal("expected an error from a closed database, got nil")
}
})
}
// TestApplyOutgoing_WritesDatabaseStateToDisk is the DB→FS apply: every row
// becomes a file the loader can read back.
func TestApplyOutgoing_WritesDatabaseStateToDisk(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
seedFullDBState(t, env, testWorkspaceID)
if err := env.svc.ApplyOutgoing(ctx, testWorkspaceID); err != nil {
t.Fatalf("ApplyOutgoing: %v", err)
}
bundle, _, err := env.svc.ScanFilesystem(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("ScanFilesystem: %v", err)
}
assertStrings(t, "agents on disk",
sortedCopy(agentBundleNames(bundle.Agents)), []string{"ada", "grace"})
assertStrings(t, "skills on disk", skillBundleSlugs(bundle.Skills), []string{"code-review"})
assertStrings(t, "routines on disk", routineBundleNames(bundle.Routines), []string{"standup"})
assertStrings(t, "projects on disk", projectBundleNames(bundle.Projects), []string{"apollo"})
var ada AgentConfig
for _, a := range bundle.Agents {
if a.Name == "ada" {
ada = a
}
}
assertEqual(t, "agent role", ada.Role, "cto")
assertEqual(t, "agent budget", ada.BudgetMonthlyCents, 4200)
assertEqual(t, "agent desired skills", ada.DesiredSkills, `["go","sql"]`)
assertEqual(t, "project status", bundle.Projects[0].Status, "paused")
assertEqual(t, "project budget", bundle.Projects[0].BudgetCents, 99000)
}
// TestApplyOutgoing_DoesNotDeleteStaleFiles records the asymmetry with
// ApplyIncoming: the incoming apply prunes DB rows missing from disk, but the
// outgoing apply only writes, so a file with no DB row survives even though
// OutgoingDiff just listed it as a deletion. A user who applies the outgoing
// diff in the Sync UI can still commit or later re-import the stale file.
//
// Recorded rather than fixed: making export-fs prune is a production change,
// and it must invert this test.
func TestApplyOutgoing_DoesNotDeleteStaleFiles(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
env.writeFSAgent(t, "stale", "name: stale\nrole: engineer\n")
seedAgent(t, env, testWorkspaceID, "ada")
diff, err := env.svc.OutgoingDiff(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("OutgoingDiff: %v", err)
}
assertStrings(t, "diff proposes a deletion", diff.Preview.Agents.Deleted, []string{"stale"})
if err := env.svc.ApplyOutgoing(ctx, testWorkspaceID); err != nil {
t.Fatalf("ApplyOutgoing: %v", err)
}
if _, err := os.Stat(env.wsPath("agents", "stale.yml")); err != nil {
t.Errorf("stale file should survive an outgoing apply: %v", err)
}
}
func TestApplyOutgoing_NilWriterIsRejected(t *testing.T) {
env := newTestEnv(t)
svc := NewConfigService(env.repo, env.loader, nil, logger.Default(), env.activity)
err := svc.ApplyOutgoing(context.Background(), testWorkspaceID)
if err == nil {
t.Fatal("expected an error for a nil config writer, got nil")
}
assertEqual(t, "error message", err.Error(), "config writer not initialized")
}
func TestApplyOutgoing_PropagatesExportError(t *testing.T) {
env := newTestEnv(t)
env.closeDB(t)
if err := env.svc.ApplyOutgoing(context.Background(), testWorkspaceID); err == nil {
t.Fatal("expected an error from a closed database, got nil")
}
}
// TestSyncRoundTrip_DiskToDatabaseToDisk applies incoming then outgoing and
// asserts the values survive both crossings unchanged.
func TestSyncRoundTrip_DiskToDatabaseToDisk(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
seedFullFSState(t, env)
if _, err := env.svc.ApplyIncoming(ctx, testWorkspaceID); err != nil {
t.Fatalf("ApplyIncoming: %v", err)
}
if err := env.svc.ApplyOutgoing(ctx, testWorkspaceID); err != nil {
t.Fatalf("ApplyOutgoing: %v", err)
}
bundle, _, err := env.svc.ScanFilesystem(ctx, testWorkspaceID)
if err != nil {
t.Fatalf("ScanFilesystem: %v", err)
}
if len(bundle.Agents) != 1 {
t.Fatalf("agents after round trip: got %d, want 1", len(bundle.Agents))
}
assertEqual(t, "agent name", bundle.Agents[0].Name, "ada")
assertEqual(t, "agent role", bundle.Agents[0].Role, "cto")
assertEqual(t, "agent icon", bundle.Agents[0].Icon, "🤖")
assertEqual(t, "agent budget", bundle.Agents[0].BudgetMonthlyCents, 4200)
assertEqual(t, "agent max sessions", bundle.Agents[0].MaxConcurrentSessions, 3)
assertEqual(t, "agent desired skills", bundle.Agents[0].DesiredSkills, `["go","sql"]`)
assertEqual(t, "agent executor preference", bundle.Agents[0].ExecutorPreference, "local_docker")
assertEqual(t, "routine description", bundle.Routines[0].Description, "daily standup")
assertEqual(t, "routine task template", bundle.Routines[0].TaskTemplate, "summarise yesterday")
assertEqual(t, "routine concurrency", bundle.Routines[0].ConcurrencyPolicy, "skip")
assertEqual(t, "project description", bundle.Projects[0].Description, "moon shot")
assertEqual(t, "project color", bundle.Projects[0].Color, "#ff00ff")
assertEqual(t, "project budget", bundle.Projects[0].BudgetCents, 99000)
assertEqual(t, "project repositories", bundle.Projects[0].Repositories, `["kdlbs/kandev"]`)
assertEqual(t, "project executor config", bundle.Projects[0].ExecutorConfig,
`{"type":"local_docker"}`)
// The project's status is the one value the round trip does not
// preserve: the importer forces every created project to "active".
assertEqual(t, "project status", bundle.Projects[0].Status, "active")
}
@@ -0,0 +1,325 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/kandev/kandev/internal/office/configloader"
"github.com/kandev/kandev/internal/office/models"
)
func TestMissingNames(t *testing.T) {
tests := []struct {
name string
existing []string
incoming []string
want []string
}{
{
name: "all present leaves nothing missing",
existing: []string{"a", "b"},
incoming: []string{"b", "a"},
want: nil,
},
{
name: "missing entries preserve existing order",
existing: []string{"a", "b", "c"},
incoming: []string{"b"},
want: []string{"a", "c"},
},
{
name: "empty incoming means everything is missing",
existing: []string{"a", "b"},
incoming: nil,
want: []string{"a", "b"},
},
{
name: "empty existing is never missing",
existing: nil,
incoming: []string{"a"},
want: nil,
},
{
name: "extra incoming names are not reported",
existing: []string{"a"},
incoming: []string{"a", "b", "c"},
want: nil,
},
{
name: "duplicate existing names each report",
existing: []string{"a", "a"},
incoming: []string{"b"},
want: []string{"a", "a"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assertStrings(t, "missingNames", missingNames(tc.existing, tc.incoming), tc.want)
})
}
}
func TestModelNameExtractors(t *testing.T) {
agents := []*models.AgentInstance{{Name: "ada"}, {Name: "grace"}}
assertStrings(t, "agentNames", agentNames(agents), []string{"ada", "grace"})
assertStrings(t, "agentNames empty", agentNames(nil), []string{})
skills := []*models.Skill{{Slug: "code-review"}, {Slug: "triage"}}
assertStrings(t, "skillSlugs", skillSlugs(skills), []string{"code-review", "triage"})
assertStrings(t, "skillSlugs empty", skillSlugs(nil), []string{})
routines := []*models.Routine{{Name: "standup"}}
assertStrings(t, "routineNames", routineNames(routines), []string{"standup"})
assertStrings(t, "routineNames empty", routineNames(nil), []string{})
projects := []*models.Project{{Name: "apollo"}, {Name: "gemini"}}
assertStrings(t, "projectNames", projectNames(projects), []string{"apollo", "gemini"})
assertStrings(t, "projectNames empty", projectNames(nil), []string{})
}
func TestBundleNameExtractors(t *testing.T) {
bundle := fullBundle()
bundle.Agents = append(bundle.Agents, AgentConfig{Name: "grace"})
bundle.Skills = append(bundle.Skills, SkillConfig{Slug: "triage"})
bundle.Routines = append(bundle.Routines, RoutineConfig{Name: "retro"})
bundle.Projects = append(bundle.Projects, ProjectConfig{Name: "gemini"})
assertStrings(t, "agentBundleNames", agentBundleNames(bundle.Agents), []string{"ada", "grace"})
assertStrings(t, "skillBundleSlugs", skillBundleSlugs(bundle.Skills), []string{"code-review", "triage"})
assertStrings(t, "routineBundleNames", routineBundleNames(bundle.Routines), []string{"standup", "retro"})
assertStrings(t, "projectBundleNames", projectBundleNames(bundle.Projects), []string{"apollo", "gemini"})
}
func TestBundleSets(t *testing.T) {
bundle := fullBundle()
// Agents key on Name, skills on Slug — a set keyed on the wrong field
// would make every diff report spurious creations.
agentSet := bundleAgentSet(bundle.Agents)
assertEqual(t, "agent set size", len(agentSet), 1)
assertEqual(t, "agent set has ada", agentSet["ada"], true)
assertEqual(t, "agent set lacks cto role", agentSet["cto"], false)
skillSet := bundleSkillSet(bundle.Skills)
assertEqual(t, "skill set has slug", skillSet["code-review"], true)
assertEqual(t, "skill set does not key on name", skillSet["Code Review"], false)
routineSet := bundleRoutineSet(bundle.Routines)
assertEqual(t, "routine set has standup", routineSet["standup"], true)
projectSet := bundleProjectSet(bundle.Projects)
assertEqual(t, "project set has apollo", projectSet["apollo"], true)
assertEqual(t, "empty agent set", len(bundleAgentSet(nil)), 0)
assertEqual(t, "empty skill set", len(bundleSkillSet(nil)), 0)
assertEqual(t, "empty routine set", len(bundleRoutineSet(nil)), 0)
assertEqual(t, "empty project set", len(bundleProjectSet(nil)), 0)
}
// TestWriteBundleToFS_AllFieldsReachDisk writes a fully-populated bundle and
// reads it back through the ConfigLoader. Every field that survives the
// YAML round trip is asserted; a field dropped by writeBundleEntities would
// come back zero-valued instead of failing loudly at write time.
func TestWriteBundleToFS_AllFieldsReachDisk(t *testing.T) {
env := newTestEnv(t)
bundle := fullBundle()
if err := writeBundleToFS(env.writer, bundle); err != nil {
t.Fatalf("writeBundleToFS: %v", err)
}
// The writer always targets the "default" FS workspace, not the
// bundle's settings name.
if _, err := os.Stat(env.wsPath("agents", "ada.yml")); err != nil {
t.Fatalf("agent file not written to default workspace: %v", err)
}
agents := env.loader.GetAgents(defaultWorkspaceName)
if len(agents) != 1 {
t.Fatalf("agents on disk: got %d, want 1", len(agents))
}
got := agents[0]
assertEqual(t, "agent name", got.Name, "ada")
assertEqual(t, "agent role", string(got.Role), "cto")
assertEqual(t, "agent icon", got.Icon, "🤖")
assertEqual(t, "agent budget", got.BudgetMonthlyCents, 4200)
assertEqual(t, "agent max sessions", got.MaxConcurrentSessions, 3)
assertEqual(t, "agent desired skills", got.DesiredSkills, `["go","sql"]`)
assertEqual(t, "agent executor preference", got.ExecutorPreference, "local_docker")
skills := env.loader.GetSkills(defaultWorkspaceName)
if len(skills) != 1 {
t.Fatalf("skills on disk: got %d, want 1", len(skills))
}
assertEqual(t, "skill slug", skills[0].Slug, "code-review")
assertEqual(t, "skill content", skills[0].Content, "# Code Review\n\nread the diff\n")
routines := env.loader.GetRoutines(defaultWorkspaceName)
if len(routines) != 1 {
t.Fatalf("routines on disk: got %d, want 1", len(routines))
}
assertEqual(t, "routine name", routines[0].Name, "standup")
assertEqual(t, "routine description", routines[0].Description, "daily standup")
assertEqual(t, "routine task template", routines[0].TaskTemplate, "summarise yesterday")
assertEqual(t, "routine concurrency", string(routines[0].ConcurrencyPolicy), "skip")
projects := env.loader.GetProjects(defaultWorkspaceName)
if len(projects) != 1 {
t.Fatalf("projects on disk: got %d, want 1", len(projects))
}
assertEqual(t, "project name", projects[0].Name, "apollo")
assertEqual(t, "project description", projects[0].Description, "moon shot")
assertEqual(t, "project status", string(projects[0].Status), "paused")
assertEqual(t, "project color", projects[0].Color, "#ff00ff")
assertEqual(t, "project budget", projects[0].BudgetCents, 99000)
assertEqual(t, "project repositories", projects[0].Repositories, `["kdlbs/kandev"]`)
assertEqual(t, "project executor config", projects[0].ExecutorConfig, `{"type":"local_docker"}`)
}
// TestWriteBundleToFS_EmptySkillContentGetsHeading covers the one value
// writeBundleEntities synthesises rather than copies.
func TestWriteBundleToFS_EmptySkillContentGetsHeading(t *testing.T) {
env := newTestEnv(t)
bundle := &ConfigBundle{Skills: []SkillConfig{{Name: "Triage Bugs", Slug: "triage"}}}
if err := writeBundleToFS(env.writer, bundle); err != nil {
t.Fatalf("writeBundleToFS: %v", err)
}
data, err := os.ReadFile(env.wsPath("skills", "triage", "SKILL.md"))
if err != nil {
t.Fatalf("read SKILL.md: %v", err)
}
assertEqual(t, "synthesised skill content", string(data), "# Triage Bugs\n")
}
func TestWriteBundleToFS_EmptyBundleWritesNothing(t *testing.T) {
env := newTestEnv(t)
if err := writeBundleToFS(env.writer, &ConfigBundle{}); err != nil {
t.Fatalf("writeBundleToFS: %v", err)
}
for _, dir := range []string{"agents", "skills", "routines", "projects"} {
if _, err := os.Stat(env.wsPath(dir)); !os.IsNotExist(err) {
t.Errorf("%s dir should not exist for an empty bundle (err=%v)", dir, err)
}
}
}
// TestWriteBundleToFS_RejectsUnsafeNames pins the FileWriter's path-component
// validation reaching back through writeBundleEntities: a traversal name must
// abort the write rather than escape the workspace directory.
func TestWriteBundleToFS_RejectsUnsafeNames(t *testing.T) {
// escapedPath is where the write would land if the name were joined
// unchecked: "../escape" climbs out of the per-entity subdirectory into
// the workspace root. Asserting on that exact path matters — an
// arbitrary non-existent path would pass no matter what the writer did.
tests := []struct {
name string
bundle *ConfigBundle
escapedPath []string
}{
{
"agent traversal",
&ConfigBundle{Agents: []AgentConfig{{Name: "../escape"}}},
[]string{"escape.yml"},
},
{
"skill traversal",
&ConfigBundle{Skills: []SkillConfig{{Slug: "../escape"}}},
[]string{"escape", "SKILL.md"},
},
{
"routine traversal",
&ConfigBundle{Routines: []RoutineConfig{{Name: "../escape"}}},
[]string{"escape.yml"},
},
{
"project traversal",
&ConfigBundle{Projects: []ProjectConfig{{Name: "../escape"}}},
[]string{"escape.yml"},
},
{
"empty agent name",
&ConfigBundle{Agents: []AgentConfig{{Name: ""}}},
nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
env := newTestEnv(t)
err := writeBundleToFS(env.writer, tc.bundle)
if err == nil {
t.Fatal("expected an error for an unsafe name, got nil")
}
if tc.escapedPath == nil {
return
}
escaped := env.wsPath(tc.escapedPath...)
if _, statErr := os.Stat(escaped); !os.IsNotExist(statErr) {
t.Errorf("write escaped into %s: %v", escaped, statErr)
}
})
}
}
// TestWriteBundleToFS_StopsAtFirstError proves the write is not best-effort:
// the bad entity aborts the loop, so the entity after it is never written.
func TestWriteBundleToFS_StopsAtFirstError(t *testing.T) {
env := newTestEnv(t)
bundle := &ConfigBundle{Agents: []AgentConfig{
{Name: "ada"},
{Name: "../escape"},
{Name: "grace"},
}}
if err := writeBundleToFS(env.writer, bundle); err == nil {
t.Fatal("expected an error, got nil")
}
if _, err := os.Stat(env.wsPath("agents", "ada.yml")); err != nil {
t.Errorf("agent before the failure should have been written: %v", err)
}
if _, err := os.Stat(env.wsPath("agents", "grace.yml")); !os.IsNotExist(err) {
t.Errorf("agent after the failure should not have been written: %v", err)
}
}
// TestWriteBundleToFS_UsesDefaultWorkspaceOnly documents that the on-disk
// target is always the "default" workspace directory: the bundle's settings
// name does not steer the writer.
func TestWriteBundleToFS_UsesDefaultWorkspaceOnly(t *testing.T) {
env := newTestEnv(t)
bundle := &ConfigBundle{
Settings: SettingsConfig{Name: "some-other-workspace"},
Agents: []AgentConfig{{Name: "ada", Role: "cto"}},
}
if err := writeBundleToFS(env.writer, bundle); err != nil {
t.Fatalf("writeBundleToFS: %v", err)
}
if _, err := os.Stat(env.wsPath("agents", "ada.yml")); err != nil {
t.Errorf("agent not written under %q: %v", defaultWorkspaceName, err)
}
other := filepath.Join(env.base, "workspaces", "some-other-workspace")
if _, err := os.Stat(other); !os.IsNotExist(err) {
t.Errorf("settings name should not create a workspace dir: %v", err)
}
}
// TestWriteBundleEntities_EmptyIsNoOp guards the direct helper entry point used
// by ApplyOutgoing after its nil check: an empty bundle creates no directories.
func TestWriteBundleEntities_EmptyIsNoOp(t *testing.T) {
base := t.TempDir()
loader := configloader.NewConfigLoader(base)
writer := configloader.NewFileWriter(base, loader)
if err := writeBundleEntities(writer, &ConfigBundle{}); err != nil {
t.Fatalf("writeBundleEntities: %v", err)
}
if _, err := os.Stat(filepath.Join(base, "workspaces")); !os.IsNotExist(err) {
t.Errorf("no workspace dir should be created: %v", err)
}
}
@@ -0,0 +1,172 @@
package config
import (
"context"
"strings"
"testing"
)
// TestApplyImport_PropagatesCreateErrors breaks the write handle after the
// tables are readable, so every List succeeds and every Create fails. Each
// entity's create branch must surface the error instead of counting a row it
// never wrote.
func TestApplyImport_PropagatesCreateErrors(t *testing.T) {
tests := []struct {
name string
bundle *ConfigBundle
wantPrefix string
}{
{"agents", &ConfigBundle{Agents: []AgentConfig{{Name: "ada"}}}, "apply agents:"},
{"skills", &ConfigBundle{Skills: []SkillConfig{{Slug: "code-review"}}}, "apply skills:"},
{"routines", &ConfigBundle{Routines: []RoutineConfig{{Name: "standup"}}}, "apply routines:"},
{"projects", &ConfigBundle{Projects: []ProjectConfig{{Name: "apollo"}}}, "apply projects:"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
env := newSplitTestEnv(t)
env.breakWrites(t)
result, err := env.svc.ApplyImport(context.Background(), testWorkspaceID, tc.bundle)
if err == nil {
t.Fatal("expected a write error, got nil")
}
if result != nil {
t.Errorf("result should be nil on error, got %+v", result)
}
if !strings.HasPrefix(err.Error(), tc.wantPrefix) {
t.Errorf("error %q does not start with %q", err.Error(), tc.wantPrefix)
}
})
}
}
// TestApplyImport_PropagatesUpdateErrors is the same guard for the update
// branch: the rows already exist, so each stage takes the update path.
func TestApplyImport_PropagatesUpdateErrors(t *testing.T) {
tests := []struct {
name string
bundle *ConfigBundle
wantPrefix string
}{
{"agents", &ConfigBundle{Agents: []AgentConfig{{Name: "ada"}}}, "apply agents:"},
{"skills", &ConfigBundle{Skills: []SkillConfig{{Slug: "code-review"}}}, "apply skills:"},
{"routines", &ConfigBundle{Routines: []RoutineConfig{{Name: "standup"}}}, "apply routines:"},
{"projects", &ConfigBundle{Projects: []ProjectConfig{{Name: "apollo"}}}, "apply projects:"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
env := newSplitTestEnv(t)
seedAgent(t, env, testWorkspaceID, "ada")
seedSkill(t, env, testWorkspaceID, "code-review")
seedRoutine(t, env, testWorkspaceID, "standup")
seedProject(t, env, testWorkspaceID, "apollo")
env.breakWrites(t)
_, err := env.svc.ApplyImport(context.Background(), testWorkspaceID, tc.bundle)
if err == nil {
t.Fatal("expected a write error, got nil")
}
if !strings.HasPrefix(err.Error(), tc.wantPrefix) {
t.Errorf("error %q does not start with %q", err.Error(), tc.wantPrefix)
}
})
}
}
// TestDeleteRowsMissingFromBundle_PropagatesDeleteErrors covers the prune
// path's write failures, one entity at a time.
//
// deleteRowsMissingFromBundle returns repository errors unwrapped, so the
// error text cannot say which stage failed. The stage is isolated by the
// fixture instead: each subtest seeds only its own entity, so every earlier
// stage finds nothing to delete and returns before attempting a write. The
// surviving-row assertion then confirms the delete was both attempted and
// refused, rather than skipped.
func TestDeleteRowsMissingFromBundle_PropagatesDeleteErrors(t *testing.T) {
tests := []struct {
name string
seed func(t *testing.T, env *testEnv)
remains func(t *testing.T, env *testEnv)
}{
{
"agents",
func(t *testing.T, env *testEnv) { seedAgent(t, env, testWorkspaceID, "gone") },
func(t *testing.T, env *testEnv) {
assertRemainingNames(t, env, testWorkspaceID, []string{"gone"}, nil, nil, nil)
},
},
{
"skills",
func(t *testing.T, env *testEnv) { seedSkill(t, env, testWorkspaceID, "gone") },
func(t *testing.T, env *testEnv) {
assertRemainingNames(t, env, testWorkspaceID, nil, []string{"gone"}, nil, nil)
},
},
{
"routines",
func(t *testing.T, env *testEnv) { seedRoutine(t, env, testWorkspaceID, "gone") },
func(t *testing.T, env *testEnv) {
assertRemainingNames(t, env, testWorkspaceID, nil, nil, []string{"gone"}, nil)
},
},
{
"projects",
func(t *testing.T, env *testEnv) { seedProject(t, env, testWorkspaceID, "gone") },
func(t *testing.T, env *testEnv) {
assertRemainingNames(t, env, testWorkspaceID, nil, nil, nil, []string{"gone"})
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
env := newSplitTestEnv(t)
tc.seed(t, env)
env.breakWrites(t)
err := env.svc.deleteRowsMissingFromBundle(
context.Background(), testWorkspaceID, &ConfigBundle{})
if err == nil {
t.Fatal("expected a delete error, got nil")
}
tc.remains(t, env)
})
}
}
// TestApplyIncoming_PropagatesPruneError proves a failed prune fails the whole
// sync rather than returning a success result for a half-applied state.
func TestApplyIncoming_PropagatesPruneError(t *testing.T) {
env := newSplitTestEnv(t)
seedAgent(t, env, testWorkspaceID, "gone")
env.breakWrites(t)
result, err := env.svc.ApplyIncoming(context.Background(), testWorkspaceID)
if err == nil {
t.Fatal("expected a prune error, got nil")
}
if result != nil {
t.Errorf("result should be nil on error, got %+v", result)
}
}
// TestPreviewImport_PropagatesPerEntityErrors drops one table at a time so
// each preview stage fails on its own.
func TestPreviewImport_PropagatesPerEntityErrors(t *testing.T) {
for _, table := range []string{
"agent_profiles", "office_skills", "office_routines", "office_projects",
} {
t.Run(table, func(t *testing.T) {
env := newTestEnv(t)
env.dropTable(t, table)
preview, err := env.svc.PreviewImport(
context.Background(), testWorkspaceID, fullBundle())
if err == nil {
t.Fatalf("expected an error after dropping %s, got nil", table)
}
if preview != nil {
t.Errorf("preview should be nil on error, got %+v", preview)
}
})
}
}