Milestone work item 생성 동기화에서 workspace slot 상태를 DB에 저장하고 available slot을 원자적으로 예약할 수 있어야 한다. 리뷰 루프 산출물을 archive하고, 현재 로드맵 우선순위를 Workbench Provider Slot Composition으로 조정한다.
347 lines
11 KiB
Go
347 lines
11 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/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/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/002" {
|
|
t.Errorf("path: got %q", slot.Path)
|
|
}
|
|
}
|
|
|
|
func TestToUpsertWorkspaceSlotParamsValidatesIndex(t *testing.T) {
|
|
params, err := ToUpsertWorkspaceSlotParams(5, DefaultSlotIndex, "/ws/nomadcode/000")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if params.ProjectSyncSettingID != 5 || params.SlotIndex != 0 || params.Path != "/ws/nomadcode/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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|