nomadcode/services/core/internal/roadmapsync/retry_test.go
toki fad5627ae6 feat: milestone work item creation/sync 구현 및 roadmap sync 기능 추가
- Plane Todo projection 및 idempotency/retry 메커니즘 구현
- Roadmap sync identity 및 step 모델/DB 마이그레이션/쿼리 추가
- OpenAI 및 Plane adapter 개선
- roadmaps sync package: plane_format, plane_projection, retry 모듈 추가
- Storage layer: roadmap_sync_identities, roadmap_sync_steps 추가
- agent-task 아카이브 정리
2026-06-14 04:08:15 +09:00

184 lines
5.8 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 != StepOriginalCommentPreserved {
t.Errorf("next step: got %q, want original_comment_preserved", 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_DifferentTenantConflicts(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 != ActionConflict {
t.Fatalf("tenant mismatch should conflict, got %q (%s)", res.Action, res.Reason)
}
if res.IdentityKept {
t.Error("a non-matching persisted identity must not be reported as kept")
}
}
func TestReconcile_DifferentProjectConflicts(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 != ActionConflict {
t.Fatalf("project mismatch should conflict, 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,
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)
}
}