package runners_test import ( "context" "errors" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" "github.com/yan1h/agent-coding-workflow/internal/jobs/runners" ) // fakeWorkspaceFetchRepo: inline mock holding a few workspaces + recorded calls. type fakeWorkspaceFetchRepo struct { listed []runners.WorkspaceFetchTarget startCAS map[uuid.UUID]bool finished map[uuid.UUID]string // markSyncingErrFor: if an entry exists for ID, MarkSyncing returns (false, err). markSyncingErrFor map[uuid.UUID]error // markSyncingLost: if true (and no error matched), returns (false, nil) to simulate CAS lost. markSyncingLost bool // markFetchResultErr: if non-nil, MarkFetchResult returns it (after recording the call). markFetchResultErr error } func (f *fakeWorkspaceFetchRepo) ListFetchTargets(_ context.Context) ([]runners.WorkspaceFetchTarget, error) { return f.listed, nil } func (f *fakeWorkspaceFetchRepo) MarkSyncing(_ context.Context, id uuid.UUID) (bool, error) { if f.startCAS == nil { f.startCAS = map[uuid.UUID]bool{} } if err, ok := f.markSyncingErrFor[id]; ok { return false, err } if f.markSyncingLost { return false, nil } f.startCAS[id] = true return true, nil } func (f *fakeWorkspaceFetchRepo) MarkFetchResult(_ context.Context, id uuid.UUID, success bool, errMsg string) error { if f.finished == nil { f.finished = map[uuid.UUID]string{} } if success { f.finished[id] = "ok" } else { f.finished[id] = errMsg } return f.markFetchResultErr } type fakeFetcher struct { err error calls int fetched map[uuid.UUID]bool } func (f *fakeFetcher) Fetch(_ context.Context, _ string, id uuid.UUID) error { f.calls++ if f.fetched == nil { f.fetched = map[uuid.UUID]bool{} } f.fetched[id] = true return f.err } type recordingDispatcher struct{ msgs []notify.Message } func (r *recordingDispatcher) Dispatch(_ context.Context, m notify.Message) error { r.msgs = append(r.msgs, m) return nil } func TestWorkspaceFetch_SuccessUpdatesIdle(t *testing.T) { t.Parallel() id := uuid.New() ownerID := uuid.New() repo := &fakeWorkspaceFetchRepo{listed: []runners.WorkspaceFetchTarget{ {ID: id, OwnerID: ownerID, Name: "ws", MainPath: "/x"}, }} r := runners.NewWorkspaceFetch(repo, &fakeFetcher{}, &recordingDispatcher{}, discardLogger()) r.Run(context.Background()) assert.True(t, repo.startCAS[id]) assert.Equal(t, "ok", repo.finished[id]) } func TestWorkspaceFetch_FailureMarksErrorAndDispatches(t *testing.T) { t.Parallel() id := uuid.New() ownerID := uuid.New() repo := &fakeWorkspaceFetchRepo{listed: []runners.WorkspaceFetchTarget{ {ID: id, OwnerID: ownerID, Name: "ws", MainPath: "/x"}, }} disp := &recordingDispatcher{} r := runners.NewWorkspaceFetch(repo, &fakeFetcher{err: errors.New("network")}, disp, discardLogger()) r.Run(context.Background()) assert.Contains(t, repo.finished[id], "network") require.Len(t, disp.msgs, 1) assert.Equal(t, "workspace.sync_failed", disp.msgs[0].Topic) assert.Equal(t, ownerID, disp.msgs[0].UserID) assert.Equal(t, notify.SeverityError, disp.msgs[0].Severity) } func TestWorkspaceFetch_NoTargetsNoOp(t *testing.T) { t.Parallel() repo := &fakeWorkspaceFetchRepo{} r := runners.NewWorkspaceFetch(repo, &fakeFetcher{}, &recordingDispatcher{}, discardLogger()) r.Run(context.Background()) assert.Empty(t, repo.startCAS) } func TestWorkspaceFetch_CASLostSkipsSilently(t *testing.T) { t.Parallel() id := uuid.New() repo := &fakeWorkspaceFetchRepo{ listed: []runners.WorkspaceFetchTarget{ {ID: id, OwnerID: uuid.New(), Name: "ws", MainPath: "/x"}, }, markSyncingLost: true, } fetcher := &fakeFetcher{} disp := &recordingDispatcher{} r := runners.NewWorkspaceFetch(repo, fetcher, disp, discardLogger()) r.Run(context.Background()) assert.Zero(t, fetcher.calls, "Fetch must not be called when CAS lost") assert.NotContains(t, repo.finished, id, "MarkFetchResult must not be called when CAS lost") assert.Empty(t, disp.msgs, "no dispatch when CAS lost") } func TestWorkspaceFetch_MarkSyncingErrorContinuesLoop(t *testing.T) { t.Parallel() bad := uuid.New() good := uuid.New() repo := &fakeWorkspaceFetchRepo{ listed: []runners.WorkspaceFetchTarget{ {ID: bad, OwnerID: uuid.New(), Name: "bad", MainPath: "/b"}, {ID: good, OwnerID: uuid.New(), Name: "good", MainPath: "/g"}, }, markSyncingErrFor: map[uuid.UUID]error{bad: errors.New("db down")}, } fetcher := &fakeFetcher{} r := runners.NewWorkspaceFetch(repo, fetcher, &recordingDispatcher{}, discardLogger()) r.Run(context.Background()) assert.False(t, fetcher.fetched[bad], "Fetch must not run when MarkSyncing errors") assert.True(t, fetcher.fetched[good], "loop must continue to next target") assert.Equal(t, "ok", repo.finished[good]) } func TestWorkspaceFetch_MarkFetchResultErrorOnSuccessDoesNotDispatch(t *testing.T) { t.Parallel() id := uuid.New() repo := &fakeWorkspaceFetchRepo{ listed: []runners.WorkspaceFetchTarget{ {ID: id, OwnerID: uuid.New(), Name: "ws", MainPath: "/x"}, }, markFetchResultErr: errors.New("db blip"), } fetcher := &fakeFetcher{} disp := &recordingDispatcher{} r := runners.NewWorkspaceFetch(repo, fetcher, disp, discardLogger()) r.Run(context.Background()) assert.Empty(t, disp.msgs, "success path must not dispatch even if MarkFetchResult errors") }