nomadcode/services/core/internal/roadmapsyncpipeline/service_test.go
toki 32fda2a844 feat: milestone work-item creation sync 및 roadmap sync pipeline 구현
- Plane 프로젝트의 work-item 생성 시 Orchestrator로 동기화
- scheduler에 roadmap sync job 등록 및 river client 지원
- roadmapsycpipeline adapter 패키지 추가
- 관련 테스트 파일 및 브리지 문서 추가
2026-06-14 12:39:31 +09:00

308 lines
9.2 KiB
Go

package roadmapsyncpipeline
import (
"context"
"errors"
"testing"
"github.com/nomadcode/nomadcode-core/internal/db"
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
"github.com/nomadcode/nomadcode-core/internal/storage"
"github.com/nomadcode/nomadcode-core/internal/workitem"
)
const (
testMilestonePath = "agent-roadmap/phase/p1/milestones/m1.md"
testRevision = "rev-abc"
)
// fakeProvider records the order of provider facet calls and can fail a chosen
// step so a stop-before-later-step test is possible.
type fakeProvider struct {
calls []string
failOn string
failErr error
}
func (f *fakeProvider) AppendComment(_ context.Context, _ workitem.CommentInput) error {
f.calls = append(f.calls, "comment")
if f.failOn == "comment" {
return f.failErr
}
return nil
}
func (f *fakeProvider) ProjectBody(_ context.Context, _ workitem.BodyProjection) error {
f.calls = append(f.calls, "body")
if f.failOn == "body" {
return f.failErr
}
return nil
}
func (f *fakeProvider) ProjectStatus(_ context.Context, _ workitem.StatusProjection) error {
f.calls = append(f.calls, "status")
if f.failOn == "status" {
return f.failErr
}
return nil
}
// fakeStore is an in-memory Store. identity is the single persisted row (id 0
// means "not found"); marked records the steps MarkStepCompleted was called
// with, in order.
type fakeStore struct {
identity db.RoadmapSyncIdentity
found bool
completed map[storage.RoadmapSyncStep]bool
marked []storage.RoadmapSyncStep
upserted bool
markErr error
}
func newFakeStore() *fakeStore {
return &fakeStore{completed: map[storage.RoadmapSyncStep]bool{}}
}
func (f *fakeStore) GetRoadmapSyncIdentityByWorkItem(_ context.Context, _ db.GetRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) {
if !f.found {
return db.RoadmapSyncIdentity{}, storage.ErrRoadmapSyncIdentityNotFound
}
return f.identity, nil
}
func (f *fakeStore) UpsertRoadmapSyncIdentity(_ context.Context, args db.UpsertRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) {
f.upserted = true
f.identity = db.RoadmapSyncIdentity{
ID: 1,
Shape: args.Shape,
RoadmapMilestonePath: args.RoadmapMilestonePath,
Provider: args.Provider,
Tenant: args.Tenant,
Project: args.Project,
WorkItemID: args.WorkItemID,
ProviderRevision: args.ProviderRevision,
RoadmapRevision: args.RoadmapRevision,
}
f.found = true
return f.identity, nil
}
func (f *fakeStore) CompletedSteps(_ context.Context, _ int64) (map[storage.RoadmapSyncStep]bool, error) {
out := make(map[storage.RoadmapSyncStep]bool, len(f.completed))
for k, v := range f.completed {
out[k] = v
}
return out, nil
}
func (f *fakeStore) MarkStepCompleted(_ context.Context, _ int64, step storage.RoadmapSyncStep) (db.RoadmapSyncStep, error) {
if f.markErr != nil {
return db.RoadmapSyncStep{}, f.markErr
}
f.marked = append(f.marked, step)
f.completed[step] = true
return db.RoadmapSyncStep{Step: string(step)}, nil
}
func readyInput() SyncCreationInput {
identity := roadmapsync.Identity{
Shape: roadmapsync.ShapeMilestone,
RoadmapMilestonePath: testMilestonePath,
Provider: "plane",
Tenant: "general",
Project: "nomad",
WorkItemID: "NOMAD-1",
}
return SyncCreationInput{
Scan: roadmapsync.ScanResult{
Revision: roadmapsync.Revision{Branch: "develop", Revision: testRevision},
ChangedFiles: []string{testMilestonePath},
ScannedMilestones: []roadmapsync.ScannedMilestone{
{Path: testMilestonePath, Identity: identity},
},
},
Expected: identity,
Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "nomad", ID: "NOMAD-1"},
OriginalBody: "<p>user request</p>",
TodoStateID: "todo-state",
MilestoneMarkdown: "# Milestone\n\nbody",
ProviderRevision: testRevision,
RoadmapRevision: testRevision,
ExternalSource: "nomadcode",
ExternalID: "sync-1",
}
}
func TestSyncCreationNotReadyDoesNotCallProvider(t *testing.T) {
store := newFakeStore()
provider := &fakeProvider{}
svc := NewService(store, provider, "")
in := readyInput()
// Break the develop match: no changed milestone file.
in.Scan.ChangedFiles = nil
res, err := svc.SyncCreation(context.Background(), in)
if err != nil {
t.Fatalf("SyncCreation returned error: %v", err)
}
if res.Action != SyncActionNotReady {
t.Fatalf("expected not_ready, got %s (%s)", res.Action, res.Reason)
}
if len(provider.calls) != 0 {
t.Fatalf("expected no provider calls, got %v", provider.calls)
}
if len(store.marked) != 0 || store.upserted {
t.Fatalf("expected no ledger marks or upsert, marked=%v upserted=%v", store.marked, store.upserted)
}
}
func TestSyncCreationReadyRunsStepsAndMarksLedger(t *testing.T) {
store := newFakeStore()
provider := &fakeProvider{}
svc := NewService(store, provider, "")
res, err := svc.SyncCreation(context.Background(), readyInput())
if err != nil {
t.Fatalf("SyncCreation returned error: %v", err)
}
if res.Action != SyncActionProjected {
t.Fatalf("expected projected, got %s (%s)", res.Action, res.Reason)
}
wantProviderOrder := []string{"comment", "body", "status"}
if !equalStrings(provider.calls, wantProviderOrder) {
t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder)
}
wantLedger := []storage.RoadmapSyncStep{
storage.StepDevelopMatched,
storage.StepOriginalCommentPreserved,
storage.StepPlaneBodyUpdated,
storage.StepPlaneTodoMoved,
}
if !equalSteps(store.marked, wantLedger) {
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
}
if res.NextStep != roadmapsync.StepPlaneTodoMoved {
t.Fatalf("expected last step plane_todo_moved, got %s", res.NextStep)
}
}
func TestSyncCreationRetrySkipsCompletedSteps(t *testing.T) {
store := newFakeStore()
// Identity already exists with the first two steps recorded.
store.found = true
store.identity = db.RoadmapSyncIdentity{
ID: 1,
Shape: string(roadmapsync.ShapeMilestone),
RoadmapMilestonePath: testMilestonePath,
Provider: "plane",
Tenant: "general",
Project: "nomad",
WorkItemID: "NOMAD-1",
}
store.completed[storage.StepDevelopMatched] = true
store.completed[storage.StepOriginalCommentPreserved] = true
provider := &fakeProvider{}
svc := NewService(store, provider, "")
res, err := svc.SyncCreation(context.Background(), readyInput())
if err != nil {
t.Fatalf("SyncCreation returned error: %v", err)
}
if res.Action != SyncActionProjected {
t.Fatalf("expected projected, got %s (%s)", res.Action, res.Reason)
}
if store.upserted {
t.Fatal("expected no upsert when identity already exists")
}
// Comment was already preserved; only body+status should run again.
wantProviderOrder := []string{"body", "status"}
if !equalStrings(provider.calls, wantProviderOrder) {
t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder)
}
wantLedger := []storage.RoadmapSyncStep{
storage.StepPlaneBodyUpdated,
storage.StepPlaneTodoMoved,
}
if !equalSteps(store.marked, wantLedger) {
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
}
}
func TestSyncCreationStopsBeforeLaterStepOnProviderError(t *testing.T) {
store := newFakeStore()
bodyErr := errors.New("plane body projection failed")
provider := &fakeProvider{failOn: "body", failErr: bodyErr}
svc := NewService(store, provider, "")
_, err := svc.SyncCreation(context.Background(), readyInput())
if !errors.Is(err, bodyErr) {
t.Fatalf("expected body error, got %v", err)
}
// Comment ran and was marked; body ran but failed; status must not run.
wantProviderOrder := []string{"comment", "body"}
if !equalStrings(provider.calls, wantProviderOrder) {
t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder)
}
// develop_matched + original_comment_preserved recorded; body never marked.
wantLedger := []storage.RoadmapSyncStep{
storage.StepDevelopMatched,
storage.StepOriginalCommentPreserved,
}
if !equalSteps(store.marked, wantLedger) {
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
}
}
func TestSyncCreationSkipsSelfMutation(t *testing.T) {
store := newFakeStore()
provider := &fakeProvider{}
svc := NewService(store, provider, "nomadcode-bot")
in := readyInput()
in.Actor = "nomadcode-bot"
res, err := svc.SyncCreation(context.Background(), in)
if err != nil {
t.Fatalf("SyncCreation returned error: %v", err)
}
if res.Action != SyncActionSkipSelfMutation {
t.Fatalf("expected skip_self_mutation, got %s (%s)", res.Action, res.Reason)
}
if len(provider.calls) != 0 {
t.Fatalf("expected no provider calls on self mutation, got %v", provider.calls)
}
if len(store.marked) != 0 {
t.Fatalf("expected no ledger marks on self mutation, got %v", store.marked)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func equalSteps(a, b []storage.RoadmapSyncStep) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}