nomadcode/services/core/internal/roadmapsync/retry_test.go
toki 0d619b446c feat(sync): roadmap identity backfill 기능과 git backfiller를 추가한다
roadmap sync 과정에서 origin 프로젝트 간행본의 identity
정보를 채우는 backfill 파이프라인을 구현한다.

- git_backfiller: git 레이블/태그 간행본의 identity 백필
- migration: backfill 완료 단계 테이블 스키마 추가
- pipeline/service: backfill 호출 흐름 연동
- identity_write: backfill 결과 쓰기 통합
2026-07-03 04:19:10 +09:00

219 lines
7 KiB
Go

package roadmapsync
import (
"testing"
"github.com/nomadcode/nomadcode-core/internal/workitem"
)
func reconcileIdentity() Identity {
return Identity{
Shape: ShapeMilestone,
RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md",
Provider: workitem.ProviderID("plane"),
Tenant: "general",
Project: "NOMAD",
WorkItemID: "NOMAD-13",
}
}
func TestReconcile_SamePlaneTicketReusesIdentity(t *testing.T) {
id := reconcileIdentity()
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Existing: ExistingIdentity{Found: true, Identity: id},
CompletedSteps: map[Step]bool{
StepDevelopMatched: true,
},
})
if res.Action != ActionResume {
t.Fatalf("action: got %q, want resume (%s)", res.Action, res.Reason)
}
if !res.IdentityKept {
t.Error("expected identity reuse for the same Plane ticket")
}
if res.NextStep != StepIdentityBackfilled {
t.Errorf("next step: got %q, want identity_backfilled", res.NextStep)
}
}
func TestReconcile_SameMilestonePathReusesIdentity(t *testing.T) {
id := reconcileIdentity()
// Existing row found via the Milestone path: same path + provider work item.
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Existing: ExistingIdentity{Found: true, Identity: id},
CompletedSteps: map[Step]bool{},
})
if res.Action != ActionResume || !res.IdentityKept {
t.Fatalf("expected resume with identity kept, got %q kept=%v (%s)", res.Action, res.IdentityKept, res.Reason)
}
if res.NextStep != StepDevelopMatched {
t.Errorf("fresh ledger should resume at develop_matched, got %q", res.NextStep)
}
}
func TestReconcile_ProviderRevisionMismatchConflicts(t *testing.T) {
id := reconcileIdentity()
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
ProviderRevision: "rev-2",
Existing: ExistingIdentity{
Found: true, Identity: id, ProviderRevision: "rev-1",
},
})
if res.Action != ActionConflict {
t.Fatalf("action: got %q, want conflict (%s)", res.Action, res.Reason)
}
if !res.IdentityKept {
t.Error("a conflict on a matched identity should still report the identity as kept")
}
}
func TestReconcile_RoadmapRevisionMismatchConflicts(t *testing.T) {
id := reconcileIdentity()
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
RoadmapRevision: "abc",
Existing: ExistingIdentity{
Found: true, Identity: id, RoadmapRevision: "def",
},
})
if res.Action != ActionConflict {
t.Fatalf("action: got %q, want conflict (%s)", res.Action, res.Reason)
}
}
func TestReconcile_NomadCodeActorIsNotNewTrigger(t *testing.T) {
id := reconcileIdentity()
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Actor: "nomadcode",
SelfActor: "nomadcode",
Existing: ExistingIdentity{Found: true, Identity: id},
})
if res.Action != ActionSkipSelfMutation {
t.Fatalf("action: got %q, want skip_self_mutation (%s)", res.Action, res.Reason)
}
}
func TestReconcile_DifferentTenantIsAllowedAsSafeAttributeUpdate(t *testing.T) {
id := reconcileIdentity()
existing := id
existing.Tenant = "other-workspace"
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Existing: ExistingIdentity{Found: true, Identity: existing},
CompletedSteps: map[Step]bool{},
})
if res.Action != ActionResume {
t.Fatalf("tenant mismatch should be allowed for safe attribute update, got %q (%s)", res.Action, res.Reason)
}
if !res.IdentityKept {
t.Error("expected identity to be kept")
}
}
func TestReconcile_DifferentProjectIsAllowedAsSafeAttributeUpdate(t *testing.T) {
id := reconcileIdentity()
existing := id
existing.Project = "OTHER"
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Existing: ExistingIdentity{Found: true, Identity: existing},
CompletedSteps: map[Step]bool{},
})
if res.Action != ActionResume {
t.Fatalf("project mismatch should be allowed for safe attribute update, got %q (%s)", res.Action, res.Reason)
}
if !res.IdentityKept {
t.Error("expected identity to be kept")
}
}
func TestReconcile_DifferentWorkItemIDIsConflict(t *testing.T) {
id := reconcileIdentity()
existing := id
existing.WorkItemID = "NOMAD-OTHER"
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Existing: ExistingIdentity{Found: true, Identity: existing},
CompletedSteps: map[Step]bool{},
})
if res.Action != ActionConflict {
t.Fatalf("work item id mismatch must be a conflict, got %q (%s)", res.Action, res.Reason)
}
if !res.IdentityKept {
t.Error("a conflict on an existing identity should report identity as kept")
}
}
func TestReconcile_DifferentPathIsAllowedAsSafeAttributeUpdate(t *testing.T) {
id := reconcileIdentity()
existing := id
existing.RoadmapMilestonePath = "agent-roadmap/phase/other/milestones/m.md"
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Existing: ExistingIdentity{Found: true, Identity: existing},
CompletedSteps: map[Step]bool{},
})
if res.Action != ActionResume {
t.Fatalf("path mismatch should be allowed for safe attribute update, got %q (%s)", res.Action, res.Reason)
}
}
func TestReconcile_AllStepsCompleteReturnsComplete(t *testing.T) {
id := reconcileIdentity()
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Existing: ExistingIdentity{Found: true, Identity: id},
CompletedSteps: map[Step]bool{
StepDevelopMatched: true,
StepIdentityBackfilled: true,
StepOriginalCommentPreserved: true,
StepPlaneBodyUpdated: true,
StepPlaneTodoMoved: true,
},
})
if res.Action != ActionComplete {
t.Fatalf("action: got %q, want complete (%s)", res.Action, res.Reason)
}
}
func TestReconcile_InvalidIdentityConflicts(t *testing.T) {
res := ReconcileCreationCycle(ReconcileInput{
Identity: Identity{Provider: workitem.ProviderID("plane")}, // missing path + work item id
})
if res.Action != ActionConflict {
t.Fatalf("action: got %q, want conflict (%s)", res.Action, res.Reason)
}
}
func TestReconcile_FreshCycleNoExistingResumesAtFirstStep(t *testing.T) {
res := ReconcileCreationCycle(ReconcileInput{
Identity: reconcileIdentity(),
Existing: ExistingIdentity{Found: false},
CompletedSteps: map[Step]bool{},
})
if res.Action != ActionResume || res.NextStep != StepDevelopMatched {
t.Fatalf("fresh cycle should resume at develop_matched, got %q step=%q", res.Action, res.NextStep)
}
if res.IdentityKept {
t.Error("no existing identity should report IdentityKept=false")
}
}
func TestReconcile_FirstCycleRevisionUnknownDoesNotConflict(t *testing.T) {
id := reconcileIdentity()
// Existing identity has no recorded revision yet; a trigger that carries one
// must not be blocked.
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
ProviderRevision: "rev-1",
Existing: ExistingIdentity{Found: true, Identity: id, ProviderRevision: ""},
CompletedSteps: map[Step]bool{},
})
if res.Action != ActionResume {
t.Fatalf("unknown persisted revision should not conflict, got %q (%s)", res.Action, res.Reason)
}
}