- projectsync: checkout, config 모듈 개선 및 테스트 추가 - workitempipeline: service 개선 및 테스트 추가 - http handlers 테스트 업데이트 - client: proto_socket_branch_event_service.dart 추가 및 테스트 - contracts notes 업데이트 - agent-task: milestone 하위 subtask PLAN/CODE_REVIEW 문서 추가
406 lines
13 KiB
Go
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)
|
|
}
|
|
}
|