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) } }