diff --git a/test/integration/unapply_test.go b/test/integration/unapply_test.go index af34435..d170dd8 100644 --- a/test/integration/unapply_test.go +++ b/test/integration/unapply_test.go @@ -2,8 +2,10 @@ package integration import ( "context" + "errors" "os" "path/filepath" + "strings" "testing" "github.com/danieljhkim/monodev/internal/engine" @@ -259,54 +261,106 @@ func TestUnapply_DryRun(t *testing.T) { } func TestUnapply_DriftDetection(t *testing.T) { - eng, fs, stateStore, _, hasher := setupTestEngine(t) - ctx := context.Background() + const ( + cwd = "/repo/workspace" + relPath = "config.json" + originalChecksum = "original-hash" + modifiedContent = `{"modified": true}` + ) + + setupDriftedCopy := func(t *testing.T) (*engine.Engine, *testFS, *testStateStore, string, string) { + t.Helper() + + eng, fs, stateStore, _, hasher := setupTestEngine(t) + workspaceID := state.ComputeWorkspaceID("repo-fingerprint-123", "workspace") + workspaceState := state.NewWorkspaceState("repo-fingerprint-123", "workspace", "copy") + workspaceState.Applied = true + workspaceState.ActiveStore = "store1" + workspaceState.Paths = map[string]state.PathOwnership{ + relPath: { + Store: "store1", + Type: "copy", + Checksum: originalChecksum, + }, + } + _ = stateStore.SaveWorkspace(workspaceID, workspaceState) - // Setup workspace state with a file in copy mode - workspaceID := state.ComputeWorkspaceID("repo-fingerprint-123", "workspace") - workspaceState := state.NewWorkspaceState("repo-fingerprint-123", "workspace", "copy") - workspaceState.Applied = true - workspaceState.ActiveStore = "store1" // Set active store + configPath := filepath.Join(cwd, relPath) + fs.files[configPath] = []byte(modifiedContent) + hasher.SetHash(configPath, "modified-hash") - originalChecksum := "original-hash" - workspaceState.Paths = map[string]state.PathOwnership{ - "config.json": { - Store: "store1", - Type: "copy", - Checksum: originalChecksum, - }, + return eng, fs, stateStore, workspaceID, configPath } - _ = stateStore.SaveWorkspace(workspaceID, workspaceState) - cwd := "/repo/workspace" - configPath := filepath.Join(cwd, "config.json") - fs.files[configPath] = []byte(`{"modified": true}`) // Modified content + t.Run("non-force protects drifted copy", func(t *testing.T) { + eng, fs, stateStore, workspaceID, configPath := setupDriftedCopy(t) - // Set up hasher to return different checksum (simulating drift) - hasher.SetHash(configPath, "modified-hash") // Different from original + result, err := eng.Unapply(context.Background(), &engine.UnapplyRequest{ + CWD: cwd, + Force: false, + }) - // Unapply with validation (no force) - req := &engine.UnapplyRequest{ - CWD: cwd, - Force: false, - } + if result != nil { + t.Fatalf("Unapply() result = %#v, want nil", result) + } + if err == nil { + t.Fatal("Unapply() error = nil, want drift validation error") + } + if !errors.Is(err, engine.ErrValidation) { + t.Fatalf("Unapply() error = %v, want ErrValidation", err) + } + if !errors.Is(err, engine.ErrDrift) { + t.Fatalf("Unapply() error = %v, want ErrDrift", err) + } + errText := err.Error() + if !strings.Contains(errText, relPath) { + t.Fatalf("Unapply() error = %q, want workspace-relative path", errText) + } + if !strings.Contains(errText, "local modifications detected") { + t.Fatalf("Unapply() error = %q, want local modifications message", errText) + } - // Note: Current implementation doesn't return warnings for drift, - // but validation should still pass (we remove the file anyway) - result, err := eng.Unapply(ctx, req) - if err != nil { - t.Fatalf("Unapply() error = %v", err) - } + content, ok := fs.files[configPath] + if !ok { + t.Fatal("expected drifted file to remain on disk") + } + if string(content) != modifiedContent { + t.Fatalf("drifted file content = %q, want %q", string(content), modifiedContent) + } - // Verify file was still removed (drift doesn't prevent removal) - if len(result.Removed) != 1 { - t.Errorf("expected 1 path removed, got %d", len(result.Removed)) - } + updatedState, err := stateStore.LoadWorkspace(workspaceID) + if err != nil { + t.Fatalf("expected workspace state to remain: %v", err) + } + ownership, ok := updatedState.Paths[relPath] + if !ok { + t.Fatalf("expected workspace state to retain %s", relPath) + } + if ownership.Checksum != originalChecksum { + t.Fatalf("checksum = %q, want %q", ownership.Checksum, originalChecksum) + } + if !updatedState.Applied { + t.Fatal("expected workspace state to remain applied") + } + }) - // Verify file was removed from filesystem - if _, ok := fs.files[configPath]; ok { - t.Error("expected file to be removed despite drift") - } + t.Run("force removes drifted copy", func(t *testing.T) { + eng, fs, stateStore, workspaceID, configPath := setupDriftedCopy(t) + + result, err := eng.Unapply(context.Background(), &engine.UnapplyRequest{ + CWD: cwd, + Force: true, + }) + if err != nil { + t.Fatalf("Unapply() error = %v", err) + } + + assertRemovedOrder(t, result.Removed, []string{relPath}) + if _, ok := fs.files[configPath]; ok { + t.Fatal("expected force unapply to remove drifted copy") + } + assertWorkspaceDeleted(t, stateStore, workspaceID) + }) } func TestUnapply_ForceMode(t *testing.T) {