nomadcode/services/core/internal/gitosync/scanner_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

273 lines
9.1 KiB
Go

package gitosync
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/nomadcode/nomadcode-core/internal/authoring"
"github.com/nomadcode/nomadcode-core/internal/gitoevents"
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
)
const milestonePath = "agent-roadmap/phase/p1/milestones/m1.md"
const milestoneMarkdownWithIdentity = `# Milestone m1
## Provider identity
- provider: plane
- tenant: acme
- project: proj-1
- work item id: wi-123
`
const milestoneMarkdownNoIdentity = `# Milestone m2
Just a body, no provider identity block.
`
// fakeRunner returns scripted stdout per (args joined) command and records the
// ordered sequence of commands it ran.
type fakeRunner struct {
responses map[string]string
errs map[string]error
calls []string
}
func newFakeRunner() *fakeRunner {
return &fakeRunner{responses: map[string]string{}, errs: map[string]error{}}
}
func (f *fakeRunner) Run(_ context.Context, _ string, name string, args ...string) (string, error) {
key := name + " " + strings.Join(args, " ")
f.calls = append(f.calls, key)
if err, ok := f.errs[key]; ok {
return "", err
}
return f.responses[key], nil
}
func newScanEvent() gitoevents.BranchUpdatedEvent {
return gitoevents.BranchUpdatedEvent{
RepoID: "nomadcode",
Branch: "develop",
Before: "aaaa",
After: "bbbb",
}
}
func newTestScanner(t *testing.T, runner CommandRunner) *BranchRevisionScanner {
t.Helper()
s, err := NewBranchRevisionScanner(runner, ScannerConfig{
DevelopRepoPath: "/repo",
RemoteName: "origin",
Branch: "develop",
})
if err != nil {
t.Fatalf("NewBranchRevisionScanner: %v", err)
}
return s
}
func TestScanRunsFetchRevParseDiffShowInOrder(t *testing.T) {
runner := newFakeRunner()
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb\n"
runner.responses["git diff --name-only aaaa..bbbb -- agent-roadmap/phase"] = milestonePath + "\n"
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
if err != nil {
t.Fatalf("Scan: %v", err)
}
wantOrder := []string{
"git fetch --prune origin develop",
"git rev-parse refs/remotes/origin/develop",
"git diff --name-only aaaa..bbbb -- agent-roadmap/phase",
"git show bbbb:" + milestonePath,
}
if len(runner.calls) != len(wantOrder) {
t.Fatalf("call count: got %d (%v), want %d", len(runner.calls), runner.calls, len(wantOrder))
}
for i, want := range wantOrder {
if runner.calls[i] != want {
t.Fatalf("call[%d]: got %q, want %q", i, runner.calls[i], want)
}
}
if out.Result.Revision.Revision != "bbbb" {
t.Fatalf("revision: got %q, want bbbb", out.Result.Revision.Revision)
}
if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestonePath {
t.Fatalf("scanned milestones: got %+v", out.Result.ScannedMilestones)
}
if got := out.Result.ScannedMilestones[0].Identity.WorkItemID; got != "wi-123" {
t.Fatalf("identity work item id: got %q, want wi-123", got)
}
if len(out.Docs) != 1 || out.Docs[0].Markdown != milestoneMarkdownWithIdentity {
t.Fatalf("docs: got %+v", out.Docs)
}
}
func TestScanReturnsNotReadyOnRevisionMismatch(t *testing.T) {
runner := newFakeRunner()
// Remote develop has not caught up to the event's `after`.
runner.responses["git rev-parse refs/remotes/origin/develop"] = "cccc\n"
_, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
if !errors.Is(err, ErrNotReady) {
t.Fatalf("Scan: got %v, want ErrNotReady", err)
}
// It must not diff/show once it knows the revision is stale.
for _, c := range runner.calls {
if strings.HasPrefix(c, "git diff") || strings.HasPrefix(c, "git show") {
t.Fatalf("unexpected command after mismatch: %q", c)
}
}
}
func TestScanDropsMilestoneWithoutProviderIdentity(t *testing.T) {
runner := newFakeRunner()
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
runner.responses["git diff --name-only aaaa..bbbb -- agent-roadmap/phase"] = milestonePath
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownNoIdentity
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
if err != nil {
t.Fatalf("Scan: %v", err)
}
// Kept in ChangedFiles (so the match gate's reason stays accurate) but not
// projectable.
if len(out.Result.ChangedFiles) != 1 {
t.Fatalf("changed files: got %v", out.Result.ChangedFiles)
}
if len(out.Result.ScannedMilestones) != 0 {
t.Fatalf("scanned milestones: want none, got %+v", out.Result.ScannedMilestones)
}
if len(out.Docs) != 0 {
t.Fatalf("docs: want none, got %+v", out.Docs)
}
}
func TestScanIgnoresNonMilestoneChangedFiles(t *testing.T) {
runner := newFakeRunner()
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
runner.responses["git diff --name-only aaaa..bbbb -- agent-roadmap/phase"] =
"agent-roadmap/phase/p1/PHASE.md\n" + milestonePath
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(out.Result.ChangedFiles) != 1 || out.Result.ChangedFiles[0] != milestonePath {
t.Fatalf("changed files: got %v, want only the milestone path", out.Result.ChangedFiles)
}
}
func TestScanUsesSingleRevisionRangeForNewBranchPush(t *testing.T) {
runner := newFakeRunner()
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
runner.responses["git diff --name-only bbbb -- agent-roadmap/phase"] = ""
ev := newScanEvent()
ev.Before = "0000000000000000000000000000000000000000"
if _, err := newTestScanner(t, runner).Scan(context.Background(), ev); err != nil {
t.Fatalf("Scan: %v", err)
}
for _, c := range runner.calls {
if strings.HasPrefix(c, "git diff") && !strings.Contains(c, "bbbb -- ") {
t.Fatalf("expected single-revision diff range, got %q", c)
}
}
}
func TestScanRegressionWithIdentityWriter(t *testing.T) {
// 1. Create a temporary git repo to run the real LocalIdentityWriter on it.
tmpDir, err := os.MkdirTemp("", "nomadcode-scanner-regression-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// git init
cmd := exec.Command("git", "init")
cmd.Dir = tmpDir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to git init: %v", err)
}
// git config user.name and user.email (just in case)
cmd = exec.Command("git", "config", "user.name", "test")
cmd.Dir = tmpDir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.email", "test@test.com")
cmd.Dir = tmpDir
_ = cmd.Run()
milestoneRelPath := "agent-roadmap/phase/p1/milestones/m1.md"
milestoneDir := filepath.Join(tmpDir, "agent-roadmap", "phase", "p1", "milestones")
if err := os.MkdirAll(milestoneDir, 0755); err != nil {
t.Fatalf("failed to create milestone dir: %v", err)
}
milestoneFullPath := filepath.Join(milestoneDir, "m1.md")
// write identity-less milestone markdown
if err := os.WriteFile(milestoneFullPath, []byte(milestoneMarkdownNoIdentity), 0644); err != nil {
t.Fatalf("failed to write milestone file: %v", err)
}
// 2. Run LocalIdentityWriter to inject identity block.
writer := authoring.NewLocalIdentityWriter()
ident := roadmapsync.Identity{
Provider: "plane",
WorkItemID: "wi-123",
Tenant: "acme",
Project: "proj-1",
}
res, err := writer.EnsureProviderIdentity(context.Background(), authoring.EnsureProviderIdentityInput{
WorkspacePath: tmpDir,
Identity: ident,
})
if err != nil {
t.Fatalf("EnsureProviderIdentity failed: %v", err)
}
if len(res.InjectedPaths) != 1 || res.InjectedPaths[0] != milestoneRelPath {
t.Fatalf("unexpected injected paths: %v", res.InjectedPaths)
}
// Read the modified markdown (which now has identity)
injectedMarkdownBytes, err := os.ReadFile(milestoneFullPath)
if err != nil {
t.Fatalf("failed to read injected file: %v", err)
}
injectedMarkdown := string(injectedMarkdownBytes)
// 3. Mock the runner with the injected markdown as the output of `git show`
runner := newFakeRunner()
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb\n"
runner.responses["git diff --name-only aaaa..bbbb -- agent-roadmap/phase"] = milestoneRelPath + "\n"
runner.responses["git show bbbb:"+milestoneRelPath] = injectedMarkdown
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
if err != nil {
t.Fatalf("Scan regression failed: %v", err)
}
// 4. Verify that the scanner now successfully parses it
if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestoneRelPath {
t.Fatalf("expected 1 scanned milestone, got %+v", out.Result.ScannedMilestones)
}
parsedIdent := out.Result.ScannedMilestones[0].Identity
if parsedIdent.Provider != "plane" || parsedIdent.WorkItemID != "wi-123" || parsedIdent.Tenant != "acme" || parsedIdent.Project != "proj-1" {
t.Errorf("unexpected parsed identity: %+v", parsedIdent)
}
if len(out.Docs) != 1 || out.Docs[0].Markdown != injectedMarkdown {
t.Fatalf("expected 1 doc with injected markdown, got %+v", out.Docs)
}
}