Files
kandev/apps/backend/internal/office/config/service_test.go
T

474 lines
15 KiB
Go

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, "")
}
func TestExportBundle_ScopesToWorkspace(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 in workspace", names, []string{"ada"})
assertStrings(t, "skills in workspace", skillBundleSlugs(bundle.Skills), []string{})
assertStrings(t, "routines in workspace", routineBundleNames(bundle.Routines), []string{})
assertStrings(t, "projects in workspace", projectBundleNames(bundle.Projects), []string{})
}
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")
}
}
func TestExportManifestMatchesZipBytes(t *testing.T) {
env := newTestEnv(t)
seedFullDBState(t, env, testWorkspaceID)
manifest, err := env.svc.ExportManifest(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ExportManifest: %v", err)
}
archive, err := env.svc.ExportZip(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ExportZip: %v", err)
}
entries := readZip(t, archive)
if len(entries) != len(manifest.Files) {
t.Fatalf("archive entries = %d, manifest files = %d", len(entries), len(manifest.Files))
}
for _, file := range manifest.Files {
if got := string(entries[file.Path]); got != file.Content {
t.Errorf("%s differs between manifest and archive", file.Path)
}
}
}
func TestExportSelectedZipRevisionConflict(t *testing.T) {
env := newTestEnv(t)
seedAgent(t, env, testWorkspaceID, "ada")
manifest, err := env.svc.ExportManifest(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ExportManifest: %v", err)
}
if _, err := env.svc.ExportSelectedZip(context.Background(), testWorkspaceID, "sha256:stale", []string{manifest.Files[0].Path}); err != ErrExportRevisionConflict {
t.Fatalf("error = %v, want ErrExportRevisionConflict", err)
}
}
func TestExportSelectedZipRejectsInvalidPaths(t *testing.T) {
env := newTestEnv(t)
seedAgent(t, env, testWorkspaceID, "ada")
manifest, err := env.svc.ExportManifest(context.Background(), testWorkspaceID)
if err != nil {
t.Fatalf("ExportManifest: %v", err)
}
if _, err := env.svc.ExportSelectedZip(context.Background(), testWorkspaceID, manifest.Revision, []string{".kandev/secret.yml"}); err != ErrInvalidExportSelection {
t.Fatalf("error = %v, want ErrInvalidExportSelection", err)
}
}
func TestExportSelectedZipRejectsEmptySelection(t *testing.T) {
env := newTestEnv(t)
if _, err := env.svc.ExportSelectedZip(context.Background(), testWorkspaceID, "sha256:any", nil); err != ErrInvalidExportSelection {
t.Fatalf("error = %v, want ErrInvalidExportSelection", err)
}
}
func TestBundleToFilesRejectsUnsafeAndDuplicatePaths(t *testing.T) {
unsafe := []string{"../escape", "/tmp/escape"}
for _, name := range unsafe {
t.Run(name, func(t *testing.T) {
_, err := bundleToFiles(&ConfigBundle{
Agents: []AgentConfig{{Name: name}},
})
if err == nil {
t.Fatalf("bundleToFiles(%q) returned nil error", name)
}
})
}
_, err := bundleToFiles(&ConfigBundle{
Agents: []AgentConfig{{Name: "same"}, {Name: "same"}},
})
if err == nil {
t.Fatal("bundleToFiles accepted duplicate archive paths")
}
}