Files
agentic-coding-workflow/internal/workspace/workspace_service_test.go
T

270 lines
8.8 KiB
Go

package workspace
import (
"context"
"errors"
"log/slog"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
)
type fakeRepo struct {
mu sync.Mutex
wss map[uuid.UUID]*Workspace
creds map[uuid.UUID]*Credential
wts map[uuid.UUID]*Worktree
errOn map[string]error
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
wss: map[uuid.UUID]*Workspace{}, creds: map[uuid.UUID]*Credential{},
wts: map[uuid.UUID]*Worktree{}, errOn: map[string]error{},
}
}
func (f *fakeRepo) CreateWorkspace(_ context.Context, w *Workspace) (*Workspace, error) {
f.mu.Lock()
defer f.mu.Unlock()
if e := f.errOn["create"]; e != nil {
return nil, e
}
cp := *w
f.wss[w.ID] = &cp
return &cp, nil
}
func (f *fakeRepo) GetWorkspaceByID(_ context.Context, id uuid.UUID) (*Workspace, error) {
f.mu.Lock()
defer f.mu.Unlock()
if w, ok := f.wss[id]; ok {
cp := *w
return &cp, nil
}
return nil, errors.New("not found")
}
func (f *fakeRepo) GetWorkspaceBySlug(_ context.Context, _, _ string) (*Workspace, error) {
return nil, nil
}
func (f *fakeRepo) ListWorkspacesByProject(_ context.Context, _ uuid.UUID) ([]*Workspace, error) {
out := make([]*Workspace, 0, len(f.wss))
for _, w := range f.wss {
out = append(out, w)
}
return out, nil
}
func (f *fakeRepo) UpdateWorkspaceCore(_ context.Context, id uuid.UUID, name, desc, br string) (*Workspace, error) {
w := f.wss[id]
w.Name, w.Description, w.DefaultBranch = name, desc, br
return w, nil
}
func (f *fakeRepo) UpdateWorkspaceSyncStatus(_ context.Context, id uuid.UUID, st SyncStatus, _ *time.Time, msg string) (*Workspace, error) {
w := f.wss[id]
w.SyncStatus, w.LastSyncError = st, msg
return w, nil
}
func (f *fakeRepo) DeleteWorkspace(_ context.Context, id uuid.UUID) error {
delete(f.wss, id)
return nil
}
func (f *fakeRepo) ResetStuckSyncStatuses(_ context.Context) error { return nil }
func (f *fakeRepo) GetWorktreeByID(_ context.Context, id uuid.UUID) (*Worktree, error) {
if w, ok := f.wts[id]; ok {
return w, nil
}
return nil, errors.New("not found")
}
func (f *fakeRepo) ListWorktreesByWorkspace(_ context.Context, _ uuid.UUID) ([]*Worktree, error) {
return nil, nil
}
func (f *fakeRepo) DeleteWorktree(_ context.Context, _ uuid.UUID) error { return nil }
func (f *fakeRepo) UpsertCredential(_ context.Context, c *Credential) (*Credential, error) {
cp := *c
f.creds[c.WorkspaceID] = &cp
return &cp, nil
}
func (f *fakeRepo) GetCredentialByWorkspace(_ context.Context, ws uuid.UUID) (*Credential, error) {
if c, ok := f.creds[ws]; ok {
cp := *c
return &cp, nil
}
return nil, errs.New(errs.CodeNotFound, "credential not found")
}
func (f *fakeRepo) InTx(_ context.Context, fn func(Tx) error) error { return fn(&fakeTx{f: f}) }
type fakeTx struct{ f *fakeRepo }
func (t *fakeTx) GetWorktreeForUpdate(_ context.Context, ws uuid.UUID, br string) (*Worktree, error) {
for _, w := range t.f.wts {
if w.WorkspaceID == ws && w.Branch == br {
cp := *w
return &cp, nil
}
}
return nil, &errsNotFound{}
}
func (t *fakeTx) GetWorktreeByID(_ context.Context, id uuid.UUID) (*Worktree, error) {
if w, ok := t.f.wts[id]; ok {
cp := *w
return &cp, nil
}
return nil, &errsNotFound{}
}
func (t *fakeTx) InsertWorktree(_ context.Context, w *Worktree) (*Worktree, error) {
cp := *w
t.f.wts[w.ID] = &cp
return &cp, nil
}
func (t *fakeTx) SetWorktreeActive(_ context.Context, id uuid.UUID, holder string) (*Worktree, error) {
w := t.f.wts[id]
w.Status, w.ActiveHolder = WorktreeStatusActive, holder
return w, nil
}
func (t *fakeTx) SetWorktreeIdle(_ context.Context, id uuid.UUID) (*Worktree, error) {
w := t.f.wts[id]
w.Status, w.ActiveHolder = WorktreeStatusIdle, ""
return w, nil
}
func (t *fakeTx) SetWorktreePruning(_ context.Context, id uuid.UUID) (*Worktree, error) {
w := t.f.wts[id]
w.Status = WorktreeStatusPruning
return w, nil
}
type errsNotFound struct{}
func (e *errsNotFound) Error() string { return "not found" }
type fakePA struct {
pid uuid.UUID
canRead bool
canWrite bool
}
func (f *fakePA) Resolve(_ context.Context, _ uuid.UUID, _ bool, _ string) (uuid.UUID, bool, bool, error) {
return f.pid, f.canRead, f.canWrite, nil
}
func (f *fakePA) ResolveByID(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, bool, error) {
return f.canRead, f.canWrite, nil
}
type fakeSched struct{ called []uuid.UUID }
func (s *fakeSched) Schedule(id uuid.UUID) { s.called = append(s.called, id) }
type fakeGit struct {
cloneErr error
fetchErr error
cloneSeen []string
}
func (f *fakeGit) Clone(_ context.Context, dst, _, _ string, _ *git.Credential) error {
f.cloneSeen = append(f.cloneSeen, dst)
return f.cloneErr
}
func (f *fakeGit) Fetch(_ context.Context, _ string, _ *git.Credential) error { return f.fetchErr }
func (f *fakeGit) Push(_ context.Context, _, _ string, _ *git.Credential) error { return nil }
func (f *fakeGit) Commit(_ context.Context, _, _ string, _ bool) (string, error) {
return "deadbeef", nil
}
func (f *fakeGit) Status(_ context.Context, _ string) ([]git.FileStatus, error) { return nil, nil }
func (f *fakeGit) WorktreeAdd(_ context.Context, _, _, _ string) error { return nil }
func (f *fakeGit) WorktreeRemove(_ context.Context, _, _ string) error { return nil }
func (f *fakeGit) WorktreeList(_ context.Context, _ string) ([]git.Worktree, error) {
return nil, nil
}
type noopAudit struct{}
func (noopAudit) Record(_ context.Context, _ audit.Entry) error { return nil }
func newSvc(t *testing.T, pa *fakePA, sched CloneScheduler, gitr git.Runner) (*workspaceService, *fakeRepo) {
t.Helper()
enc, err := crypto.NewEncryptor(make([]byte, 32))
require.NoError(t, err)
repo := newFakeRepo()
svc := NewWorkspaceService(repo, noopAudit{}, enc, gitr, pa, sched, "/data").(*workspaceService)
return svc, repo
}
func TestWorkspaceService_Create_HappyPath(t *testing.T) {
t.Parallel()
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
sched := &fakeSched{}
svc, repo := newSvc(t, pa, sched, &fakeGit{})
caller := Caller{UserID: uuid.New()}
ws, err := svc.Create(context.Background(), caller, "p", CreateWorkspaceInput{
Slug: "ws1", Name: "W1", GitRemoteURL: "https://x", DefaultBranch: "main",
Credential: UpsertCredentialInput{Kind: CredKindNone},
})
require.NoError(t, err)
require.Equal(t, SyncStatusCloning, ws.SyncStatus)
require.Len(t, sched.called, 1)
require.Equal(t, ws.ID, sched.called[0])
_, ok := repo.creds[ws.ID]
require.True(t, ok)
}
func TestWorkspaceService_Create_BadSlug(t *testing.T) {
t.Parallel()
pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}
svc, _ := newSvc(t, pa, &fakeSched{}, &fakeGit{})
_, err := svc.Create(context.Background(), Caller{UserID: uuid.New()}, "p", CreateWorkspaceInput{
Slug: "BAD-SLUG", Credential: UpsertCredentialInput{Kind: CredKindNone},
})
require.Error(t, err)
}
func TestWorkspaceService_Sync_BlockedWhenCloning(t *testing.T) {
t.Parallel()
pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}
svc, repo := newSvc(t, pa, &fakeSched{}, &fakeGit{})
id := uuid.New()
repo.wss[id] = &Workspace{ID: id, ProjectID: pa.pid, MainPath: "/x", SyncStatus: SyncStatusCloning}
_, err := svc.Sync(context.Background(), Caller{UserID: uuid.New()}, id)
require.Error(t, err)
}
func TestCloneRunner_HappyPath(t *testing.T) {
t.Parallel()
pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}
gitr := &fakeGit{}
svc, repo := newSvc(t, pa, &fakeSched{}, gitr)
id := uuid.New()
repo.wss[id] = &Workspace{ID: id, MainPath: "/data/main", DefaultBranch: "main", SyncStatus: SyncStatusCloning}
runner := NewCloneRunner(repo, noopAudit{}, gitr, slog.Default(),
func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) {
return svc.loadDecryptedCredential(ctx, wsID)
}, 0)
runner.Schedule(id)
runner.Wait()
got, _ := repo.GetWorkspaceByID(context.Background(), id)
require.Equal(t, SyncStatusIdle, got.SyncStatus)
}
func TestCloneRunner_Failure(t *testing.T) {
t.Parallel()
pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}
gitr := &fakeGit{cloneErr: errors.New("boom")}
svc, repo := newSvc(t, pa, &fakeSched{}, gitr)
id := uuid.New()
repo.wss[id] = &Workspace{ID: id, MainPath: "/x", DefaultBranch: "main", SyncStatus: SyncStatusCloning}
runner := NewCloneRunner(repo, noopAudit{}, gitr, slog.Default(),
func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) {
return svc.loadDecryptedCredential(ctx, wsID)
}, 0)
runner.Schedule(id)
runner.Wait()
got, _ := repo.GetWorkspaceByID(context.Background(), id)
require.Equal(t, SyncStatusError, got.SyncStatus)
require.Contains(t, got.LastSyncError, "boom")
}