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) ([]uuid.UUID, error) { return nil, nil } func (f *fakeRepo) ListFetchTargets(_ context.Context) ([]FetchTarget, error) { return nil, nil } func (f *fakeRepo) MarkSyncing(_ context.Context, _ uuid.UUID) (bool, error) { return false, nil } func (f *fakeRepo) MarkFetchResult(_ context.Context, _ uuid.UUID, _ bool, _ string) 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) ListWorktreesNeedingPruneWarning(_ context.Context, _ time.Time) ([]*Worktree, error) { return nil, nil } func (f *fakeRepo) ListWorktreesNeedingPrune(_ context.Context, _ time.Time) ([]*Worktree, error) { return nil, nil } func (f *fakeRepo) MarkPruneWarning(_ context.Context, _ uuid.UUID) error { return nil } func (f *fakeRepo) TryStartPrune(_ context.Context, _ uuid.UUID) (*Worktree, error) { return nil, nil } func (f *fakeRepo) FinishPruneSuccess(_ context.Context, _ uuid.UUID) error { return nil } func (f *fakeRepo) FinishPruneRollback(_ context.Context, _ uuid.UUID) error { return nil } func (f *fakeRepo) ReleaseWorktreesByHolder(_ context.Context, _ string) ([]*Worktree, error) { return nil, 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) GetWorktreeByIDForUpdate(_ 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) MergeFFOnly(_ context.Context, _ string) error { return 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_NilScheduler_NoPanic(t *testing.T) { // app 装配有"先建 service(scheduler=nil)→ 再用 service 构 cloneRunner → SetScheduler 回填"的环依赖。 // Create 必须在 scheduler==nil 时安全跳过 Schedule,否则首个 workspace 会 panic。 t.Parallel() pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true} svc, repo := newSvc(t, pa, nil, &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) _, ok := repo.creds[ws.ID] require.True(t, ok) } func TestWorkspaceService_SetScheduler_BackfillSchedules(t *testing.T) { // SetScheduler 把 nil scheduler 替换为真正实现,下一次 Create 应触发 Schedule。 t.Parallel() pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true} svc, _ := newSvc(t, pa, nil, &fakeGit{}) sched := &fakeSched{} // SchedulerSetter 接口契约:app.go 在构建 CloneRunner 后回填。 var setter SchedulerSetter = svc setter.SetScheduler(sched) caller := Caller{UserID: uuid.New()} ws, err := svc.Create(context.Background(), caller, "p", CreateWorkspaceInput{ Slug: "ws2", Name: "W2", GitRemoteURL: "https://x", DefaultBranch: "main", Credential: UpsertCredentialInput{Kind: CredKindNone}, }) require.NoError(t, err) require.Len(t, sched.called, 1) require.Equal(t, ws.ID, sched.called[0]) } func TestWorkspaceService_LoadDecryptedCredential_PassThrough(t *testing.T) { // CredentialAccessor 接口暴露内部 loadDecryptedCredential,CloneRunner / GitOpsService 复用。 t.Parallel() pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true} svc, _ := newSvc(t, pa, nil, &fakeGit{}) var acc CredentialAccessor = svc cred, err := acc.LoadDecryptedCredential(context.Background(), uuid.New()) require.NoError(t, err) require.NotNil(t, cred) // 没有凭据行 → 返回 git.CredentialNone(loadDecryptedCredential 的兜底)。 require.Equal(t, git.CredentialNone, cred.Kind) } 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") }