nomadcode/services/core/internal/gitosync/bridge_test.go
toki d1699ef724 feat(authoring): identity write 구현 및 scheduler/river 리팩토링
- authoring: identity write 도메인 구현 (identity_write.go)
- authoring: request/result 도메인 정리 및 테스트 개선
- scheduler: job/river 구조 리팩토링 및 테스트 보완
- gitosync: bridge/scanner 테스트 개선
- roadmap: plane-origin-authoring-roundtrip-sync 마일스톤 업데이트
2026-06-20 21:09:26 +09:00

305 lines
9.5 KiB
Go

package gitosync
import (
"context"
"errors"
"testing"
"github.com/nomadcode/nomadcode-core/internal/gitoevents"
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
"github.com/nomadcode/nomadcode-core/internal/scheduler"
"github.com/nomadcode/nomadcode-core/internal/workitem"
)
type fakeScanner struct {
out ScanOutput
err error
}
func (f fakeScanner) Scan(context.Context, gitoevents.BranchUpdatedEvent) (ScanOutput, error) {
return f.out, f.err
}
type fakeReader struct {
item workitem.WorkItem
err error
refs []workitem.Ref
}
func (f *fakeReader) FetchWorkItem(_ context.Context, ref workitem.Ref) (workitem.WorkItem, error) {
f.refs = append(f.refs, ref)
if f.err != nil {
return workitem.WorkItem{}, f.err
}
return f.item, nil
}
type fakeEnqueuer struct {
jobs []scheduler.RoadmapCreationSyncJobArgs
err error
}
func (f *fakeEnqueuer) EnqueueRoadmapCreationSync(_ context.Context, args scheduler.RoadmapCreationSyncJobArgs) error {
if f.err != nil {
return f.err
}
f.jobs = append(f.jobs, args)
return nil
}
func matchedIdentity() roadmapsync.Identity {
id, err := roadmapsync.Identity{
Shape: roadmapsync.ShapeMilestone,
RoadmapMilestonePath: milestonePath,
Provider: "plane",
Tenant: "acme",
Project: "proj-1",
WorkItemID: "wi-123",
}.Normalize()
if err != nil {
panic(err)
}
return id
}
func matchedScanOutput() ScanOutput {
identity := matchedIdentity()
scanned := roadmapsync.ScannedMilestone{Path: milestonePath, Identity: identity}
return ScanOutput{
Result: roadmapsync.ScanResult{
Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"},
ChangedFiles: []string{milestonePath},
ScannedMilestones: []roadmapsync.ScannedMilestone{scanned},
},
Docs: []ScannedMilestoneDoc{{Milestone: scanned, Markdown: milestoneMarkdownWithIdentity}},
}
}
func newTestBridge(t *testing.T, scanner Scanner, reader workitem.Reader, enq Enqueuer) *Bridge {
t.Helper()
b, err := NewBridge(scanner, reader, enq, NewInMemoryRevisionStore(), BridgeConfig{
RepoID: "nomadcode",
Branch: "develop",
TodoStateID: "state-todo",
}, nil)
if err != nil {
t.Fatalf("NewBridge: %v", err)
}
return b
}
func TestBridgeEnqueuesExactJobArgs(t *testing.T) {
reader := &fakeReader{item: workitem.WorkItem{
Title: "WI",
DescriptionHTML: "<p>original html</p>",
DescriptionText: "original text",
State: "state-backlog",
}}
enq := &fakeEnqueuer{}
bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, reader, enq)
if err := bridge.Handle(context.Background(), newScanEvent()); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(enq.jobs) != 1 {
t.Fatalf("jobs: got %d, want 1", len(enq.jobs))
}
job := enq.jobs[0]
if job.Expected.WorkItemID != "wi-123" {
t.Fatalf("Expected.WorkItemID: got %q", job.Expected.WorkItemID)
}
if job.Ref.Provider != "plane" || job.Ref.ID != "wi-123" || job.Ref.Project != "proj-1" {
t.Fatalf("Ref: got %+v", job.Ref)
}
if job.OriginalBody != "<p>original html</p>" {
t.Fatalf("OriginalBody: got %q, want HTML preferred", job.OriginalBody)
}
if job.TodoStateID != "state-todo" {
t.Fatalf("TodoStateID: got %q", job.TodoStateID)
}
if job.MilestoneMarkdown != milestoneMarkdownWithIdentity {
t.Fatalf("MilestoneMarkdown mismatch")
}
if job.RoadmapRevision != "bbbb" {
t.Fatalf("RoadmapRevision: got %q", job.RoadmapRevision)
}
// Plane State is mutable and the sync moves it to Todo; it must never become
// the ProviderRevision, or a same-develop-revision reprocess would conflict
// before the step ledger is consulted.
if job.ProviderRevision != "" {
t.Fatalf("ProviderRevision: got %q, want empty (mutable Plane state must not be a revision)", job.ProviderRevision)
}
if job.ExternalSource != "nomadcode" || job.ExternalID != "wi-123" {
t.Fatalf("External tags: got source=%q id=%q", job.ExternalSource, job.ExternalID)
}
}
// TestBridgeSameRoadmapRevisionReprocessDoesNotConflictOnProviderState pins the
// reconcile contract this review targets: with ProviderRevision left empty, a
// re-process of the SAME develop revision after the Plane state has moved (e.g.
// backlog -> todo) reconciles cleanly (complete/resume), while a DIFFERENT
// roadmap revision is still guarded by RoadmapRevision.
func TestBridgeSameRoadmapRevisionReprocessDoesNotConflictOnProviderState(t *testing.T) {
identity := matchedIdentity()
// Persisted identity from a first cycle: same develop revision, no provider
// revision recorded, and the Plane state already moved to todo since.
existing := roadmapsync.ExistingIdentity{
Found: true,
Identity: identity,
ProviderRevision: "",
RoadmapRevision: "bbbb",
}
// Trigger args as the bridge now builds them: empty ProviderRevision, develop
// revision in RoadmapRevision.
sameRevision := roadmapsync.ReconcileCreationCycle(roadmapsync.ReconcileInput{
Identity: identity,
ProviderRevision: "",
RoadmapRevision: "bbbb",
Existing: existing,
CompletedSteps: map[roadmapsync.Step]bool{},
})
if sameRevision.Action == roadmapsync.ActionConflict {
t.Fatalf("same develop revision reprocess must not conflict, got %q (%s)",
sameRevision.Action, sameRevision.Reason)
}
diffRevision := roadmapsync.ReconcileCreationCycle(roadmapsync.ReconcileInput{
Identity: identity,
ProviderRevision: "",
RoadmapRevision: "cccc",
Existing: existing,
CompletedSteps: map[roadmapsync.Step]bool{},
})
if diffRevision.Action != roadmapsync.ActionConflict {
t.Fatalf("different develop revision reprocess must conflict via RoadmapRevision guard, got %q",
diffRevision.Action)
}
}
func TestBridgeOriginalBodyFallsBackToText(t *testing.T) {
reader := &fakeReader{item: workitem.WorkItem{DescriptionText: "only text"}}
enq := &fakeEnqueuer{}
bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, reader, enq)
if err := bridge.Handle(context.Background(), newScanEvent()); err != nil {
t.Fatalf("Handle: %v", err)
}
if enq.jobs[0].OriginalBody != "only text" {
t.Fatalf("OriginalBody fallback: got %q", enq.jobs[0].OriginalBody)
}
}
func TestBridgeIgnoresWrongRepoBranch(t *testing.T) {
enq := &fakeEnqueuer{}
bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, &fakeReader{}, enq)
ev := newScanEvent()
ev.RepoID = "other-repo"
if err := bridge.Handle(context.Background(), ev); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(enq.jobs) != 0 {
t.Fatalf("wrong repo: want no enqueue, got %d", len(enq.jobs))
}
}
func TestBridgeDeduplicatesSameRevision(t *testing.T) {
enq := &fakeEnqueuer{}
reader := &fakeReader{item: workitem.WorkItem{DescriptionText: "body"}}
bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, reader, enq)
ev := newScanEvent()
if err := bridge.Handle(context.Background(), ev); err != nil {
t.Fatalf("Handle first: %v", err)
}
if err := bridge.Handle(context.Background(), ev); err != nil {
t.Fatalf("Handle second: %v", err)
}
if len(enq.jobs) != 1 {
t.Fatalf("dedup: want 1 enqueue across two identical events, got %d", len(enq.jobs))
}
}
func TestBridgeNotReadyDoesNotMarkProcessed(t *testing.T) {
enq := &fakeEnqueuer{}
store := NewInMemoryRevisionStore()
b, err := NewBridge(fakeScanner{err: ErrNotReady}, &fakeReader{}, enq, store, BridgeConfig{
RepoID: "nomadcode",
Branch: "develop",
TodoStateID: "state-todo",
}, nil)
if err != nil {
t.Fatalf("NewBridge: %v", err)
}
ev := newScanEvent()
if err := b.Handle(context.Background(), ev); !errors.Is(err, ErrNotReady) {
t.Fatalf("Handle: got %v, want ErrNotReady", err)
}
if store.Seen(ev.RepoID, ev.Branch, ev.After) {
t.Fatal("not-ready revision must not be marked processed")
}
}
func TestBridgeFetchErrorIsSurfaced(t *testing.T) {
enq := &fakeEnqueuer{}
reader := &fakeReader{err: errors.New("fetch boom")}
bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, reader, enq)
if err := bridge.Handle(context.Background(), newScanEvent()); err == nil {
t.Fatal("Handle: want fetch error surfaced, got nil")
}
if len(enq.jobs) != 0 {
t.Fatalf("want no enqueue on fetch error, got %d", len(enq.jobs))
}
}
func TestNewBridgeRequiresTodoStateID(t *testing.T) {
_, err := NewBridge(fakeScanner{}, &fakeReader{}, &fakeEnqueuer{}, nil, BridgeConfig{
RepoID: "nomadcode",
}, nil)
if err == nil {
t.Fatal("NewBridge: want error when todo state id is empty")
}
}
func TestBridgeNoDocRevisionIsProcessed(t *testing.T) {
enq := &fakeEnqueuer{}
store := NewInMemoryRevisionStore()
// ScanOutput with no docs but valid revision (simulating scanner dropping milestone without identity)
emptyDocsOutput := ScanOutput{
Result: roadmapsync.ScanResult{
Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"},
ChangedFiles: []string{milestonePath},
},
Docs: nil,
}
b, err := NewBridge(fakeScanner{out: emptyDocsOutput}, &fakeReader{}, enq, store, BridgeConfig{
RepoID: "nomadcode",
Branch: "develop",
TodoStateID: "state-todo",
}, nil)
if err != nil {
t.Fatalf("NewBridge: %v", err)
}
ev := newScanEvent()
if err := b.Handle(context.Background(), ev); err != nil {
t.Fatalf("Handle: %v", err)
}
// 1. Should not enqueue any jobs
if len(enq.jobs) != 0 {
t.Fatalf("expected no jobs enqueued, got %d", len(enq.jobs))
}
// 2. Revision must still be marked as processed/seen
if !store.Seen(ev.RepoID, ev.Branch, ev.After) {
t.Fatal("expected revision to be marked processed even when no docs were found")
}
}