nomadcode/services/core/internal/projectsync/config_test.go
toki 74ba7ebd76 feat(core): 워크스페이스 슬롯 저장소를 추가한다
Milestone work item 생성 동기화에서 workspace slot 상태를 DB에 저장하고 available slot을 원자적으로 예약할 수 있어야 한다.

리뷰 루프 산출물을 archive하고, 현재 로드맵 우선순위를 Workbench Provider Slot Composition으로 조정한다.
2026-06-06 19:50:16 +09:00

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