Files
sgg-sgg-ai-skill-manage-win…/internal/service/service_test.go

345 lines
9.8 KiB
Go

package service
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"sgg-ai-skill-manager/internal/domain"
"sgg-ai-skill-manager/internal/gitops"
"sgg-ai-skill-manager/internal/skillstore"
)
func TestSaveConfigStoresSecretOutsideJSON(t *testing.T) {
svc, paths, secrets, _, _, _ := newTestService(t)
req := domain.SaveConfigRequest{
Config: domain.Config{
Gitea: domain.GiteaConfig{
BaseURL: "https://gitea.example.com",
Org: "skills",
AuthType: "password",
Username: "alice",
CredentialKey: "sgg-ai-skill-manager:gitea",
},
Update: domain.UpdateConfig{AutoUpdate: true, CheckOnStartup: true, IntervalMinutes: 60},
},
Password: "password-secret",
}
if err := svc.SaveConfig(context.Background(), req); err != nil {
t.Fatalf("SaveConfig returned error: %v", err)
}
if got := secrets.values["sgg-ai-skill-manager:gitea/alice"]; got != "password-secret" {
t.Fatalf("stored secret = %q", got)
}
raw, err := os.ReadFile(paths.ConfigPath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if stringContains(string(raw), "password-secret") {
t.Fatalf("config leaked secret: %s", string(raw))
}
}
func TestListRemoteSkillsMergesDownloadedStatus(t *testing.T) {
svc, _, _, remote, _, _ := newTestService(t)
saveValidConfig(t, svc)
state := domain.State{Skills: []domain.SkillState{{Org: "skills", Repo: "demo", LocalPath: "local"}}}
if err := svc.store.Save(state); err != nil {
t.Fatal(err)
}
remote.skills = []domain.RemoteSkill{
{Name: "demo", FullName: "skills/demo", CloneURL: "https://example/demo.git"},
{Name: "new", FullName: "skills/new", CloneURL: "https://example/new.git"},
}
got, err := svc.ListRemoteSkills(context.Background())
if err != nil {
t.Fatalf("ListRemoteSkills returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("skill count = %d", len(got))
}
if !got[0].IsDownloaded || got[0].Status != "downloaded" {
t.Fatalf("downloaded skill not marked: %+v", got[0])
}
if got[1].IsDownloaded {
t.Fatalf("new skill should not be downloaded: %+v", got[1])
}
}
func TestInstallSkillUpdatesStateAfterTargetInstall(t *testing.T) {
svc, _, _, _, _, target := newTestService(t)
saveValidConfig(t, svc)
local := filepath.Join(t.TempDir(), "demo")
if err := os.MkdirAll(local, 0o755); err != nil {
t.Fatal(err)
}
state := domain.State{Skills: []domain.SkillState{{Org: "skills", Repo: "demo", LocalPath: local}}}
if err := svc.store.Save(state); err != nil {
t.Fatal(err)
}
target.installResult = domain.SkillState{
Org: "skills",
Repo: "demo",
LocalPath: local,
InstalledTargets: map[string]domain.InstalledTarget{
"codex": {Path: "link", LinkType: "junction", TargetPath: local},
},
}
got, err := svc.InstallSkill(context.Background(), "skills", "demo", "codex")
if err != nil {
t.Fatalf("InstallSkill returned error: %v", err)
}
if got.InstalledTargets["codex"].LinkType != "junction" {
t.Fatalf("install record = %+v", got.InstalledTargets)
}
reloaded, err := svc.store.Load()
if err != nil {
t.Fatal(err)
}
saved, _ := skillstore.FindSkill(reloaded, "skills", "demo")
if saved.InstalledTargets["codex"].Path != "link" {
t.Fatalf("saved install record = %+v", saved.InstalledTargets)
}
}
func TestDeleteSkillUninstallsBeforeRemovingLocalRepo(t *testing.T) {
svc, paths, _, _, _, target := newTestService(t)
saveValidConfig(t, svc)
local := filepath.Join(paths.ReposRoot, "skills", "demo")
if err := os.MkdirAll(local, 0o755); err != nil {
t.Fatal(err)
}
state := domain.State{Skills: []domain.SkillState{{
Org: "skills",
Repo: "demo",
LocalPath: local,
InstalledTargets: map[string]domain.InstalledTarget{
"codex": {Path: "link", LinkType: "junction", TargetPath: local},
},
}}}
if err := svc.store.Save(state); err != nil {
t.Fatal(err)
}
if err := svc.DeleteSkill(context.Background(), "skills", "demo"); err != nil {
t.Fatalf("DeleteSkill returned error: %v", err)
}
if target.uninstallCalls != 1 {
t.Fatalf("uninstall calls = %d, want 1", target.uninstallCalls)
}
if _, err := os.Stat(local); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("local repo should be removed, err=%v", err)
}
reloaded, err := svc.store.Load()
if err != nil {
t.Fatal(err)
}
if _, ok := skillstore.FindSkill(reloaded, "skills", "demo"); ok {
t.Fatal("deleted skill still exists in state")
}
}
func TestRunAutoUpdateSkipsDirtyRepositories(t *testing.T) {
svc, _, _, _, git, _ := newTestService(t)
saveValidConfig(t, svc)
local := filepath.Join(t.TempDir(), "demo")
state := domain.State{Skills: []domain.SkillState{{
Org: "skills",
Repo: "demo",
LocalPath: local,
DefaultBranch: "main",
CurrentCommit: "old",
}}}
if err := svc.store.Save(state); err != nil {
t.Fatal(err)
}
git.dirty[local] = true
git.remoteCommit[local] = "new"
if err := svc.RunAutoUpdate(context.Background()); err != nil {
t.Fatalf("RunAutoUpdate returned error: %v", err)
}
if git.pullCalls != 0 {
t.Fatalf("pull calls = %d, want 0", git.pullCalls)
}
reloaded, err := svc.store.Load()
if err != nil {
t.Fatal(err)
}
saved, _ := skillstore.FindSkill(reloaded, "skills", "demo")
if saved.LastError != "local changes present" {
t.Fatalf("LastError = %q", saved.LastError)
}
}
func TestRunAutoUpdateKeepsCurrentCommitError(t *testing.T) {
svc, _, _, _, git, _ := newTestService(t)
saveValidConfig(t, svc)
local := filepath.Join(t.TempDir(), "demo")
state := domain.State{Skills: []domain.SkillState{{
Org: "skills",
Repo: "demo",
LocalPath: local,
DefaultBranch: "main",
CurrentCommit: "old",
}}}
if err := svc.store.Save(state); err != nil {
t.Fatal(err)
}
git.remoteCommit[local] = "new"
git.currentErr[local] = errors.New("rev-parse failed")
if err := svc.RunAutoUpdate(context.Background()); err != nil {
t.Fatalf("RunAutoUpdate returned error: %v", err)
}
reloaded, err := svc.store.Load()
if err != nil {
t.Fatal(err)
}
saved, _ := skillstore.FindSkill(reloaded, "skills", "demo")
if saved.LastError != "rev-parse failed" {
t.Fatalf("LastError = %q, want rev-parse failed", saved.LastError)
}
}
func newTestService(t *testing.T) (*Service, Paths, *fakeSecretStore, *fakeRemoteClient, *fakeGit, *fakeTargets) {
t.Helper()
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, "config.json"),
StatePath: filepath.Join(dir, "state.json"),
ReposRoot: filepath.Join(dir, "repos"),
HomeDir: filepath.Join(dir, "home"),
}
secrets := &fakeSecretStore{values: map[string]string{}}
remote := &fakeRemoteClient{}
git := &fakeGit{dirty: map[string]bool{}, currentCommit: map[string]string{}, remoteCommit: map[string]string{}, currentErr: map[string]error{}}
target := &fakeTargets{}
svc := New(Options{
Paths: paths,
Secrets: secrets,
Git: git,
Targets: target,
RemoteMaker: func(context.Context, domain.Config, string) (RemoteClient, error) { return remote, nil },
Opener: fakeOpener{},
})
return svc, paths, secrets, remote, git, target
}
func saveValidConfig(t *testing.T, svc *Service) {
t.Helper()
req := domain.SaveConfigRequest{
Config: domain.Config{
Gitea: domain.GiteaConfig{
BaseURL: "https://gitea.example.com",
Org: "skills",
AuthType: "password",
Username: "alice",
CredentialKey: "sgg-ai-skill-manager:gitea",
},
Update: domain.UpdateConfig{AutoUpdate: true, CheckOnStartup: true, IntervalMinutes: 60},
},
Password: "secret",
}
if err := svc.SaveConfig(context.Background(), req); err != nil {
t.Fatal(err)
}
}
func stringContains(text, needle string) bool {
return strings.Contains(text, needle)
}
type fakeSecretStore struct {
values map[string]string
}
func (f *fakeSecretStore) Set(service, user, secret string) error {
f.values[service+"/"+user] = secret
return nil
}
func (f *fakeSecretStore) Get(service, user string) (string, error) {
return f.values[service+"/"+user], nil
}
func (f *fakeSecretStore) Delete(service, user string) error {
delete(f.values, service+"/"+user)
return nil
}
type fakeRemoteClient struct {
skills []domain.RemoteSkill
}
func (f *fakeRemoteClient) ListOrgSkills(context.Context, string) ([]domain.RemoteSkill, error) {
return f.skills, nil
}
func (f *fakeRemoteClient) TestConnection(context.Context, string) (domain.TestConnectionResult, error) {
return domain.TestConnectionResult{OK: true, Username: "alice", Org: "skills"}, nil
}
type fakeGit struct {
dirty map[string]bool
currentCommit map[string]string
remoteCommit map[string]string
currentErr map[string]error
pullCalls int
}
func (f *fakeGit) Clone(context.Context, string, string, gitops.Credentials) error { return nil }
func (f *fakeGit) Pull(context.Context, string, gitops.Credentials) error {
f.pullCalls++
return nil
}
func (f *fakeGit) CurrentCommit(path string) (string, error) {
if err := f.currentErr[path]; err != nil {
return "", err
}
if commit := f.currentCommit[path]; commit != "" {
return commit, nil
}
return "current", nil
}
func (f *fakeGit) RemoteCommit(_ context.Context, path, _ string, _ gitops.Credentials) (string, error) {
return f.remoteCommit[path], nil
}
func (f *fakeGit) HasLocalChanges(path string) (bool, error) {
return f.dirty[path], nil
}
type fakeTargets struct {
installResult domain.SkillState
uninstallCalls int
}
func (f *fakeTargets) Install(domain.SkillState, string) (domain.SkillState, error) {
return f.installResult, nil
}
func (f *fakeTargets) Uninstall(skill domain.SkillState, targetID string) (domain.SkillState, error) {
f.uninstallCalls++
delete(skill.InstalledTargets, targetID)
return skill, nil
}
func (f *fakeTargets) Status(domain.SkillState, string) string {
return "not_installed"
}
type fakeOpener struct{}
func (fakeOpener) OpenVSCode(string) error { return nil }
func (fakeOpener) OpenFolder(string) error { return nil }