nomadcode/services/core/internal/projectsync/config_test.go
toki f9c6be03ef feat: milestone work-item creation sync 관련 변경사항 커밋
- projectsync: checkout, config 모듈 개선 및 테스트 추가
- workitempipeline: service 개선 및 테스트 추가
- http handlers 테스트 업데이트
- client: proto_socket_branch_event_service.dart 추가 및 테스트
- contracts notes 업데이트
- agent-task: milestone 하위 subtask PLAN/CODE_REVIEW 문서 추가
2026-06-13 16:45:10 +09:00

406 lines
13 KiB
Go

package projectsync
import (
"errors"
"testing"
"time"
"github.com/nomadcode/nomadcode-core/internal/db"
"github.com/nomadcode/nomadcode-core/internal/workitem"
)
func TestConfigNormalizeRequiresProjectSyncFields(t *testing.T) {
input := Config{
Target: ProviderProjectTarget{
Provider: workitem.ProviderID(" plane "),
Tenant: " general ",
Project: " project-1 ",
},
GitRemoteURL: " git@example.com:nomadcode.git ",
SourceBranch: " develop ",
WorkspaceID: " main ",
WorkspaceBasePath: " /home/user/workspace/ ",
RepoDirName: " nomadcode ",
}
got, err := input.Normalize()
if err != nil {
t.Fatalf("Normalize returned error: %v", err)
}
if got.Target.Provider != "plane" {
t.Errorf("provider: got %q", got.Target.Provider)
}
if got.Target.Tenant != "general" {
t.Errorf("tenant: got %q", got.Target.Tenant)
}
if got.Target.Project != "project-1" {
t.Errorf("project: got %q", got.Target.Project)
}
if got.GitRemoteURL != "git@example.com:nomadcode.git" {
t.Errorf("git remote: got %q", got.GitRemoteURL)
}
if got.SourceBranch != "develop" {
t.Errorf("source branch: got %q", got.SourceBranch)
}
if got.WorkspaceID != "main" {
t.Errorf("workspace id: got %q", got.WorkspaceID)
}
if got.WorkspaceBasePath != "/home/user/workspace" {
t.Errorf("workspace base path: got %q", got.WorkspaceBasePath)
}
if got.RepoDirName != "nomadcode" {
t.Errorf("repo dir name: got %q", got.RepoDirName)
}
}
func TestConfigNormalizeRejectsMissingRequiredFields(t *testing.T) {
valid := Config{
Target: ProviderProjectTarget{
Provider: "plane",
Tenant: "general",
Project: "project-1",
},
GitRemoteURL: "git@example.com:nomadcode.git",
SourceBranch: "develop",
WorkspaceID: "main",
WorkspaceBasePath: "/home/user/workspace",
RepoDirName: "nomadcode",
}
tests := map[string]Config{
"provider": func() Config { c := valid; c.Target.Provider = ""; return c }(),
"tenant": func() Config { c := valid; c.Target.Tenant = ""; return c }(),
"project": func() Config { c := valid; c.Target.Project = ""; return c }(),
"git remote": func() Config { c := valid; c.GitRemoteURL = ""; return c }(),
"source branch": func() Config { c := valid; c.SourceBranch = ""; return c }(),
"workspace id": func() Config { c := valid; c.WorkspaceID = ""; return c }(),
"workspace base path": func() Config { c := valid; c.WorkspaceBasePath = ""; return c }(),
"repo dir name": func() Config { c := valid; c.RepoDirName = ""; return c }(),
}
for name, input := range tests {
t.Run(name, func(t *testing.T) {
_, err := input.Normalize()
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("Normalize error: got %v, want ErrInvalidConfig", err)
}
})
}
}
func TestWorkspacePathsUseRepoRootAndZeroPaddedSlot(t *testing.T) {
root, err := ProjectWorkspaceRoot("/home/user/workspace", "nomadcode")
if err != nil {
t.Fatalf("ProjectWorkspaceRoot returned error: %v", err)
}
if root != "/home/user/workspace/nomadcode" {
t.Fatalf("root: got %q", root)
}
slotPath, err := SlotWorkspacePath("/home/user/workspace", "nomadcode", DefaultSlotIndex)
if err != nil {
t.Fatalf("SlotWorkspacePath returned error: %v", err)
}
if slotPath != "/home/user/workspace/nomadcode/slots/000" {
t.Fatalf("slot path: got %q", slotPath)
}
}
func TestSlotIndexFormattingAndBounds(t *testing.T) {
slot, err := NewSlotIndex(2)
if err != nil {
t.Fatalf("NewSlotIndex returned error: %v", err)
}
if slot.String() != "002" {
t.Fatalf("slot string: got %q", slot.String())
}
for _, index := range []int{-1, 1000} {
t.Run("invalid", func(t *testing.T) {
if _, err := NewSlotIndex(index); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("NewSlotIndex(%d) error: got %v, want ErrInvalidConfig", index, err)
}
})
}
}
func TestSlotStateValidationAndAllocation(t *testing.T) {
if !SlotStateAvailable.Valid() || !SlotStateInUse.Valid() || !SlotStateDirty.Valid() || !SlotStateError.Valid() {
t.Fatal("expected known slot states to be valid")
}
if !SlotStateAvailable.Allocatable() {
t.Fatal("available slot should be allocatable")
}
if SlotStateInUse.Allocatable() || SlotStateDirty.Allocatable() || SlotStateError.Allocatable() {
t.Fatal("only available slots should be allocatable")
}
if SlotState("unknown").Valid() {
t.Fatal("unknown slot state should be invalid")
}
}
func TestRepoDirNameRejectsNestedOrAbsolutePaths(t *testing.T) {
for _, repoDirName := range []string{"/nomadcode", "../nomadcode", "team/nomadcode", `team\nomadcode`} {
t.Run(repoDirName, func(t *testing.T) {
_, err := ProjectWorkspaceRoot("/home/user/workspace", repoDirName)
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("ProjectWorkspaceRoot error: got %v, want ErrInvalidConfig", err)
}
})
}
}
func TestSlotFromDBRecordPreservesTypedFields(t *testing.T) {
now := time.Now()
record := db.WorkspaceSlot{
ID: 11,
ProjectSyncSettingID: 5,
SlotIndex: 2,
State: string(SlotStateInUse),
Path: "/home/user/workspace/nomadcode/slots/002",
CreatedAt: now,
UpdatedAt: now,
}
slot := SlotFromDBRecord(record)
if slot.ID != 11 || slot.ProjectSyncSettingID != 5 {
t.Errorf("identity not preserved: %+v", slot)
}
if slot.Index != SlotIndex(2) {
t.Errorf("index: got %d", slot.Index)
}
if slot.State != SlotStateInUse {
t.Errorf("state: got %q", slot.State)
}
if slot.Path != "/home/user/workspace/nomadcode/slots/002" {
t.Errorf("path: got %q", slot.Path)
}
}
func TestToUpsertWorkspaceSlotParamsValidatesIndex(t *testing.T) {
params, err := ToUpsertWorkspaceSlotParams(5, DefaultSlotIndex, "/ws/nomadcode/slots/000")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if params.ProjectSyncSettingID != 5 || params.SlotIndex != 0 || params.Path != "/ws/nomadcode/slots/000" {
t.Errorf("unexpected params: %+v", params)
}
for _, index := range []SlotIndex{-1, MaxSlotIndex + 1} {
if _, err := ToUpsertWorkspaceSlotParams(5, index, "/ws/x"); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("index %d: expected ErrInvalidConfig, got %v", index, err)
}
}
}
func TestToUpdateWorkspaceSlotStateParamsAcceptsKnownStatesOnly(t *testing.T) {
for _, state := range []SlotState{SlotStateAvailable, SlotStateInUse, SlotStateDirty, SlotStateError} {
params, err := ToUpdateWorkspaceSlotStateParams(9, state)
if err != nil {
t.Fatalf("state %q: unexpected error: %v", state, err)
}
if params.ID != 9 || params.State != string(state) {
t.Errorf("state %q: unexpected params: %+v", state, params)
}
}
if _, err := ToUpdateWorkspaceSlotStateParams(9, SlotState("bogus")); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for unknown state, got %v", err)
}
}
// TestConfigToCreateProjectSyncParams verifies the DB params conversion.
func TestConfigToCreateProjectSyncParams(t *testing.T) {
config := Config{
Target: ProviderProjectTarget{
Provider: "plane",
Tenant: "general",
Project: "project-x",
},
GitRemoteURL: "git@gh.com:org/repo.git",
SourceBranch: "main",
WorkspaceID: "ws-abc",
WorkspaceBasePath: "/data/ws",
RepoDirName: "repo",
}
// Normalize first (required by contract)
normal, err := config.Normalize()
if err != nil {
t.Fatalf("Normalize failed: %v", err)
}
params := normal.ToCreateProjectSyncParams()
if params.Provider != "plane" {
t.Errorf("Provider: got %q", params.Provider)
}
if params.Tenant != "general" {
t.Errorf("Tenant: got %q", params.Tenant)
}
if params.Project != "project-x" {
t.Errorf("Project: got %q", params.Project)
}
if params.GitRemoteUrl != "git@gh.com:org/repo.git" {
t.Errorf("GitRemoteUrl: got %q", params.GitRemoteUrl)
}
if params.SourceBranch != "main" {
t.Errorf("SourceBranch: got %q", params.SourceBranch)
}
if params.WorkspaceID != "ws-abc" {
t.Errorf("WorkspaceID: got %q", params.WorkspaceID)
}
if params.WorkspaceBasePath != "/data/ws" {
t.Errorf("WorkspaceBasePath: got %q", params.WorkspaceBasePath)
}
if params.RepoDirName != "repo" {
t.Errorf("RepoDirName: got %q", params.RepoDirName)
}
}
// TestConfigFromDBRecord verifies the DB record → Config conversion.
func TestConfigFromDBRecord(t *testing.T) {
now := time.Now()
record := db.ProjectSyncSetting{
ID: 7,
Provider: "plane",
Tenant: "org2",
Project: "proj-y",
GitRemoteUrl: "git@bitbucket.com:team/app.git",
SourceBranch: "develop",
WorkspaceID: "ws-def",
WorkspaceBasePath: "/srv/ws",
RepoDirName: "app",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
cfg := ConfigFromDBRecord(record)
if cfg.Target.Provider != workitem.ProviderID("plane") {
t.Errorf("Target.Provider: got %q", cfg.Target.Provider)
}
if cfg.Target.Tenant != "org2" {
t.Errorf("Target.Tenant: got %q", cfg.Target.Tenant)
}
if cfg.Target.Project != "proj-y" {
t.Errorf("Target.Project: got %q", cfg.Target.Project)
}
if cfg.GitRemoteURL != "git@bitbucket.com:team/app.git" {
t.Errorf("GitRemoteURL: got %q", cfg.GitRemoteURL)
}
if cfg.SourceBranch != "develop" {
t.Errorf("SourceBranch: got %q", cfg.SourceBranch)
}
if cfg.WorkspaceID != "ws-def" {
t.Errorf("WorkspaceID: got %q", cfg.WorkspaceID)
}
if cfg.WorkspaceBasePath != "/srv/ws" {
t.Errorf("WorkspaceBasePath: got %q", cfg.WorkspaceBasePath)
}
if cfg.RepoDirName != "app" {
t.Errorf("RepoDirName: got %q", cfg.RepoDirName)
}
}
func TestBranchWorkspacePathsUseReservedBranches(t *testing.T) {
tests := []struct {
branch BranchName
wantPath string
}{
{BranchNameMain, "/home/user/workspace/nomadcode/branches/main"},
{BranchNameDevelop, "/home/user/workspace/nomadcode/branches/develop"},
}
for _, tc := range tests {
path, err := BranchWorkspacePath("/home/user/workspace", "nomadcode", tc.branch)
if err != nil {
t.Fatalf("BranchWorkspacePath(%q): unexpected error: %v", tc.branch, err)
}
if path != tc.wantPath {
t.Errorf("BranchWorkspacePath(%q): got %q, want %q", tc.branch, path, tc.wantPath)
}
}
// Non-reserved branch names must be rejected.
for _, bad := range []BranchName{"feature/x", "release", "main-fork", ""} {
if _, err := BranchWorkspacePath("/home/user/workspace", "nomadcode", bad); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("BranchWorkspacePath(%q): expected ErrInvalidConfig, got %v", bad, err)
}
}
// Branch path must not overlap with slot path.
slotPath, _ := SlotWorkspacePath("/home/user/workspace", "nomadcode", DefaultSlotIndex)
branchPath, _ := BranchWorkspacePath("/home/user/workspace", "nomadcode", BranchNameDevelop)
if slotPath == branchPath {
t.Errorf("branch and slot paths must not collide: both %q", slotPath)
}
}
func TestBranchSyncStateValidation(t *testing.T) {
valid := []BranchSyncState{BranchSyncStateSynced, BranchSyncStateStale, BranchSyncStateError}
for _, s := range valid {
if !s.Valid() {
t.Errorf("state %q should be valid", s)
}
}
if BranchSyncState("unknown").Valid() {
t.Error("unknown branch sync state should be invalid")
}
}
func TestRequiredBranchWorkspacesContainsMainAndDevelop(t *testing.T) {
required := RequiredBranchWorkspaces()
found := map[BranchName]bool{}
for _, b := range required {
found[b] = true
}
if !found[BranchNameMain] {
t.Error("RequiredBranchWorkspaces must include main")
}
if !found[BranchNameDevelop] {
t.Error("RequiredBranchWorkspaces must include develop")
}
}
// TestConfigRoundTrip verifies Config → DB params → Config preserves fields.
func TestConfigRoundTrip(t *testing.T) {
original := Config{
Target: ProviderProjectTarget{
Provider: "jira",
Tenant: "acme",
Project: "board-42",
},
GitRemoteURL: "git@github.com:acme/core.git",
SourceBranch: "release/v1",
WorkspaceID: "ws-rt",
WorkspaceBasePath: "/mnt/ws",
RepoDirName: "core",
}
normal, err := original.Normalize()
if err != nil {
t.Fatalf("Normalize: %v", err)
}
params := normal.ToCreateProjectSyncParams()
record := db.ProjectSyncSetting{
ID: 99,
Provider: params.Provider,
Tenant: params.Tenant,
Project: params.Project,
GitRemoteUrl: params.GitRemoteUrl,
SourceBranch: params.SourceBranch,
WorkspaceID: params.WorkspaceID,
WorkspaceBasePath: params.WorkspaceBasePath,
RepoDirName: params.RepoDirName,
Active: true,
}
recovered := ConfigFromDBRecord(record)
if recovered.Target.Provider != normal.Target.Provider {
t.Errorf("Provider round-trip: got %q", recovered.Target.Provider)
}
if recovered.GitRemoteURL != normal.GitRemoteURL {
t.Errorf("GitRemoteURL round-trip: got %q", recovered.GitRemoteURL)
}
}