diff --git a/README.md b/README.md index d84dd0d..4b77a94 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,17 @@ Or on a sub-command kickflip deploy --help +## Testing + +Kickflip has an automated test suite covering both unit and integration levels: + +- **Unit tests** exercise the individual services (`GitService`, `FileSystemService`, `IgnoreService`, `OutputService`, `PullRequestCommentComposer`, `Utilities`) directly. Git based tests build real temporary git repositories so the find modes are proven end-to-end. +- **Integration tests** drive the compiled CLI as an external process, verifying command wiring, argument validation, the different deployment modes (`Tags`, `GitHubMergePR`, `Folder`) and that a dry run reports the planned changes without ever connecting to the remote server. + +Run the whole suite with: + + dotnet test + ## Development Kickflip uses [dotnet/Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) to handle semantic versioning and branching to for releases. diff --git a/src/kickflip.Tests/CliIntegrationTests.cs b/src/kickflip.Tests/CliIntegrationTests.cs new file mode 100644 index 0000000..a93e29c --- /dev/null +++ b/src/kickflip.Tests/CliIntegrationTests.cs @@ -0,0 +1,145 @@ +using kickflip.Tests.TestHelpers; + +namespace kickflip.Tests; + +/// +/// End-to-end tests that drive the compiled CLI as an external process, +/// verifying command wiring, argument validation, the different deployment +/// modes and that a dry run makes no remote changes. +/// +[Collection("CLI")] +public class CliIntegrationTests +{ + [Fact] + public void Help_ListsTopLevelCommands() + { + var result = CliRunner.Run("--help"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("deploy", result.Output); + Assert.Contains("github", result.Output); + } + + [Fact] + public void Deploy_Help_DescribesModeAndDryOptions() + { + var result = CliRunner.Run("deploy", "--help"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("--mode", result.Output); + Assert.Contains("--dry", result.Output); + Assert.Contains("--hostname", result.Output); + } + + [Fact] + public void Deploy_MissingRequiredOptions_FailsWithError() + { + var result = CliRunner.Run("deploy"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("--hostname", result.Output); + } + + [Fact] + public void Deploy_TagsMode_DryRun_ReportsChangesAndDoesNotConnect() + { + using var repo = new GitRepositoryBuilder(); + repo.WriteFile("base.txt").Commit("initial"); + repo.Tag("v1.0"); + repo.WriteFile("added.txt").Commit("add file"); + + var result = CliRunner.Run( + "deploy", repo.Path, + "--mode", "Tags", + "--hostname", "nonexistent.invalid", + "--username", "user", + "--password", "password", + "--dry"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("Dry run", result.Output); + Assert.Contains("added.txt", result.Output); + Assert.Contains("Deployment successful!", result.Output); + // A dry run must never connect to the remote server. + Assert.DoesNotContain("Connecting to remote server", result.Output); + } + + [Fact] + public void Deploy_FolderMode_DryRun_UploadsFolderContents() + { + using var directory = new TempDirectory(); + directory.WriteFile("index.html"); + directory.WriteFile("assets/style.css"); + + var result = CliRunner.Run( + "deploy", directory.Path, + "--mode", "Folder", + "--deployment-path", "/public_html", + "--hostname", "nonexistent.invalid", + "--username", "user", + "--password", "password", + "--dry"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("index.html", result.Output); + Assert.Contains("style.css", result.Output); + Assert.Contains("Deployment successful!", result.Output); + Assert.DoesNotContain("Connecting to remote server", result.Output); + } + + [Fact] + public void Deploy_GitHubMergePrMode_DryRun_ReportsPostMergeChanges() + { + using var repo = new GitRepositoryBuilder(); + repo.WriteFile("base.txt").Commit("initial"); + repo.WriteFile("merged.txt").Commit("Merge pull request #1 from feature/a"); + repo.WriteFile("after.txt").Commit("work after merge"); + + var result = CliRunner.Run( + "deploy", repo.Path, + "--mode", "GitHubMergePR", + "--hostname", "nonexistent.invalid", + "--username", "user", + "--password", "password", + "--dry"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("after.txt", result.Output); + Assert.Contains("Deployment successful!", result.Output); + } + + [Fact] + public void Github_Help_DescribesPullRequestCommand() + { + var result = CliRunner.Run("github", "--help"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("pull-request", result.Output); + } + + [Fact] + public void Github_PullRequest_InvalidRepoFormat_FailsWithGuidance() + { + var result = CliRunner.Run( + "github", "pull-request", + "--repo", "invalidformat", + "--ref", "refs/pull/1/merge", + "--token", "token"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("owner", result.Output.ToLowerInvariant()); + } + + [Fact] + public void Github_PullRequest_InvalidRefFormat_FailsWithGuidance() + { + var result = CliRunner.Run( + "github", "pull-request", + "--repo", "owner/repo", + "--ref", "refs/heads/main", + "--token", "token"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("refs/pull/", result.Output); + } +} diff --git a/src/kickflip.Tests/FileSystemServiceTests.cs b/src/kickflip.Tests/FileSystemServiceTests.cs new file mode 100644 index 0000000..57db143 --- /dev/null +++ b/src/kickflip.Tests/FileSystemServiceTests.cs @@ -0,0 +1,52 @@ +using kickflip.Enums; +using kickflip.Models; +using kickflip.Services; +using kickflip.Tests.TestHelpers; + +namespace kickflip.Tests; + +public class FileSystemServiceTests +{ + [Fact] + public void GetChanges_ReturnsAddOrModifyForEveryFile() + { + using var directory = new TempDirectory(); + directory.WriteFile("index.html"); + directory.WriteFile("assets/style.css"); + + var service = new FileSystemService(new IgnoreService(directory.Path)); + var changes = service.GetChanges(directory.Path, "/public_html"); + + Assert.All(changes.Where(c => c.Action != DeploymentAction.Ignore), + change => Assert.Equal(DeploymentAction.AddOrModify, change.Action)); + Assert.All(changes, change => Assert.Equal(Source.Folder, change.Source)); + + var index = changes.Single(c => c.Path == "index.html"); + Assert.Equal("/public_html/index.html", index.DeploymentPath); + } + + [Fact] + public void GetChanges_MarksIgnoredFilesAsIgnored() + { + using var directory = new TempDirectory(); + directory.WriteFile(".kickflipignore", "*.log"); + directory.WriteFile("index.html"); + directory.WriteFile("app.log"); + + var service = new FileSystemService(new IgnoreService(directory.Path)); + var changes = service.GetChanges(directory.Path, "/"); + + var log = changes.Single(c => c.Path == "app.log"); + Assert.Equal(DeploymentAction.Ignore, log.Action); + Assert.Equal(string.Empty, log.DeploymentPath); + } + + [Fact] + public void GetChanges_ThrowsWhenDirectoryMissing() + { + var service = new FileSystemService(new IgnoreService(Path.GetTempPath())); + + Assert.Throws(() => + service.GetChanges(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), "/")); + } +} diff --git a/src/kickflip.Tests/GitServiceTests.cs b/src/kickflip.Tests/GitServiceTests.cs new file mode 100644 index 0000000..14e8b2e --- /dev/null +++ b/src/kickflip.Tests/GitServiceTests.cs @@ -0,0 +1,97 @@ +using kickflip.Enums; +using kickflip.Models; +using kickflip.Services; +using kickflip.Tests.TestHelpers; + +namespace kickflip.Tests; + +public class GitServiceTests +{ + private static GitService CreateService(string path) => new(new IgnoreService(path)); + + [Fact] + public void GetChanges_Tags_ComparesFromLastTagToHead() + { + using var repo = new GitRepositoryBuilder(); + repo.WriteFile("kept.txt").Commit("initial"); + repo.Tag("v1.0"); + + repo.WriteFile("added.txt").Commit("add new file"); + + var changes = CreateService(repo.Path).GetChanges(repo.Path, "/", FindMode.Tags); + + var added = changes.Single(c => c.Path == "added.txt"); + Assert.Equal(DeploymentAction.Add, added.Action); + Assert.Equal(Source.Git, added.Source); + Assert.DoesNotContain(changes, c => c.Path == "kept.txt"); + } + + [Fact] + public void GetChanges_Tags_DetectsModifiedAndDeletedFiles() + { + using var repo = new GitRepositoryBuilder(); + repo.WriteFile("modify.txt", "v1").WriteFile("delete.txt", "bye").Commit("initial"); + repo.Tag("v1.0"); + + repo.WriteFile("modify.txt", "v2").DeleteFile("delete.txt").Commit("changes"); + + var changes = CreateService(repo.Path).GetChanges(repo.Path, "/", FindMode.Tags); + + Assert.Equal(DeploymentAction.Modify, changes.Single(c => c.Path == "modify.txt").Action); + Assert.Equal(DeploymentAction.Delete, changes.Single(c => c.Path == "delete.txt").Action); + } + + [Fact] + public void GetChanges_Tags_WithNoTag_ComparesFromRoot() + { + using var repo = new GitRepositoryBuilder(); + repo.WriteFile("one.txt").Commit("initial"); + repo.WriteFile("two.txt").Commit("second"); + + var changes = CreateService(repo.Path).GetChanges(repo.Path, "/", FindMode.Tags); + + Assert.Contains(changes, c => c.Path == "one.txt"); + Assert.Contains(changes, c => c.Path == "two.txt"); + } + + [Fact] + public void GetChanges_GitHubMergePr_ComparesFromLastMergeCommit() + { + using var repo = new GitRepositoryBuilder(); + repo.WriteFile("base.txt").Commit("initial"); + repo.WriteFile("merged.txt").Commit("Merge pull request #1 from feature/a"); + repo.WriteFile("after-merge.txt").Commit("work after merge"); + + var changes = CreateService(repo.Path).GetChanges(repo.Path, "/", FindMode.GitHubMergePR); + + Assert.Contains(changes, c => c.Path == "after-merge.txt"); + Assert.DoesNotContain(changes, c => c.Path == "base.txt"); + } + + [Fact] + public void GetChanges_IgnoredFilesAreMarkedIgnored() + { + using var repo = new GitRepositoryBuilder(); + repo.WriteFile(".kickflipignore", "*.log").Commit("initial"); + repo.Tag("v1.0"); + repo.WriteFile("app.log").Commit("add log"); + + var changes = CreateService(repo.Path).GetChanges(repo.Path, "/", FindMode.Tags); + + Assert.Equal(DeploymentAction.Ignore, changes.Single(c => c.Path == "app.log").Action); + } + + [Fact] + public void GetChanges_AppliesDeploymentPathPrefix() + { + using var repo = new GitRepositoryBuilder(); + repo.WriteFile("base.txt").Commit("initial"); + repo.Tag("v1.0"); + repo.WriteFile("sub/file.txt").Commit("add nested file"); + + var changes = CreateService(repo.Path).GetChanges(repo.Path, "/public_html", FindMode.Tags); + + var change = changes.Single(c => c.Path == "sub/file.txt"); + Assert.Contains("public_html", change.DeploymentPath); + } +} diff --git a/src/kickflip.Tests/IgnoreServiceTests.cs b/src/kickflip.Tests/IgnoreServiceTests.cs new file mode 100644 index 0000000..2a6a51a --- /dev/null +++ b/src/kickflip.Tests/IgnoreServiceTests.cs @@ -0,0 +1,39 @@ +using kickflip.Services; +using kickflip.Tests.TestHelpers; + +namespace kickflip.Tests; + +public class IgnoreServiceTests +{ + [Fact] + public void IsIgnored_WithNoIgnoreFile_OnlyIgnoresKickflipIgnoreFiles() + { + using var directory = new TempDirectory(); + var service = new IgnoreService(directory.Path); + + Assert.False(service.IsIgnored("index.html")); + Assert.True(service.IsIgnored(".kickflipignore")); + } + + [Fact] + public void IsIgnored_HonoursPatternsInIgnoreFile() + { + using var directory = new TempDirectory(); + directory.WriteFile(".kickflipignore", "*.log\nsecrets/**"); + var service = new IgnoreService(directory.Path); + + Assert.True(service.IsIgnored("app.log")); + Assert.True(service.IsIgnored("secrets/password.txt")); + Assert.False(service.IsIgnored("index.html")); + } + + [Fact] + public void IsIgnored_AlwaysIgnoresTheIgnoreFileItself() + { + using var directory = new TempDirectory(); + directory.WriteFile(".kickflipignore", "*.log"); + var service = new IgnoreService(directory.Path); + + Assert.True(service.IsIgnored(".kickflipignore")); + } +} diff --git a/src/kickflip.Tests/OutputServiceTests.cs b/src/kickflip.Tests/OutputServiceTests.cs new file mode 100644 index 0000000..54b1d9d --- /dev/null +++ b/src/kickflip.Tests/OutputServiceTests.cs @@ -0,0 +1,64 @@ +using kickflip.Enums; +using kickflip.Models; +using kickflip.Services; + +namespace kickflip.Tests; + +public class OutputServiceTests +{ + private static List SampleChanges() => + [ + new(DeploymentAction.Add, Source.Git, "added.txt", "/added.txt"), + new(DeploymentAction.Delete, Source.Git, "gone.txt", "/gone.txt"), + new(DeploymentAction.Ignore, Source.Git, "app.log", ""), + ]; + + [Fact] + public void GetChangesMarkdown_RendersMarkdownTableWithEveryFile() + { + var output = new OutputService().GetChangesMarkdown(SampleChanges()); + + Assert.Contains("|", output); + Assert.Contains("added.txt", output); + Assert.Contains("gone.txt", output); + Assert.Contains("app.log", output); + Assert.Contains("Deployment", output); + } + + [Fact] + public void GetChangesConsole_RendersConsoleTableWithHeading() + { + var output = new OutputService().GetChangesConsole(SampleChanges()); + + Assert.Contains("Deployment Changes", output); + Assert.Contains("added.txt", output); + Assert.Contains("gone.txt", output); + } + + [Fact] + public void GetChanges_WithEmptyList_StillRendersHeaders() + { + var service = new OutputService(); + + Assert.Contains("Deployment Changes", service.GetChangesConsole([])); + Assert.Contains("Change", service.GetChangesMarkdown([])); + } + + [Theory] + [InlineData(DeploymentAction.Add, "Upload")] + [InlineData(DeploymentAction.Modify, "Upload")] + [InlineData(DeploymentAction.AddOrModify, "Upload")] + [InlineData(DeploymentAction.Delete, "Delete")] + [InlineData(DeploymentAction.Ignore, "None")] + public void GetChangesConsole_MapsActionsToFriendlyLabels(DeploymentAction action, string expectedLabel) + { + var changes = new List + { + new(action, Source.Git, "file.txt", "/file.txt"), + }; + + var output = new OutputService().GetChangesConsole(changes); + + Assert.Contains(expectedLabel, output); + } +} diff --git a/src/kickflip.Tests/SftpDeploymentServiceTests.cs b/src/kickflip.Tests/SftpDeploymentServiceTests.cs new file mode 100644 index 0000000..7e53dfa --- /dev/null +++ b/src/kickflip.Tests/SftpDeploymentServiceTests.cs @@ -0,0 +1,83 @@ +using kickflip.Enums; +using kickflip.Models; +using kickflip.Services; +using kickflip.Tests.TestHelpers; + +namespace kickflip.Tests; + +/// +/// The deployment service talks to a real SFTP server for live runs, which we +/// cannot stand up in unit tests. These tests focus on the dry-run behaviour, +/// which is the safety-critical path: a dry run must never connect to the +/// remote server nor touch the local file system, yet must report success. +/// +public class SftpDeploymentServiceTests +{ + private static SftpDeploymentService CreateService() => + new("nonexistent.invalid", 22, "user", "password", "/public_html"); + + [Fact] + public void DeployChanges_DryRun_DoesNotConnectAndReportsSuccess() + { + using var directory = new TempDirectory(); + directory.WriteFile("index.html"); + + var changes = new List + { + new(DeploymentAction.Add, Source.Git, "index.html", "/public_html/index.html"), + new(DeploymentAction.Delete, Source.Git, "old.html", "/public_html/old.html"), + new(DeploymentAction.Ignore, Source.Git, "app.log", ""), + }; + + // A hostname that cannot resolve means any attempt to connect would throw. + // If the dry run returns success without throwing, we know it did not connect. + var result = CreateService().DeployChanges(directory.Path, changes, isDryRun: true); + + Assert.True(result); + } + + [Fact] + public void DeployChanges_DryRun_DoesNotModifyLocalFiles() + { + using var directory = new TempDirectory(); + var filePath = directory.WriteFile("index.html", "original"); + + var changes = new List + { + new(DeploymentAction.Add, Source.Git, "index.html", "/public_html/index.html"), + new(DeploymentAction.Delete, Source.Git, "index.html", "/public_html/index.html"), + }; + + CreateService().DeployChanges(directory.Path, changes, isDryRun: true); + + Assert.True(File.Exists(filePath)); + Assert.Equal("original", File.ReadAllText(filePath)); + } + + [Fact] + public void DeployChanges_DryRun_WithNoChanges_ReturnsSuccess() + { + using var directory = new TempDirectory(); + + var result = CreateService().DeployChanges(directory.Path, [], isDryRun: true); + + Assert.True(result); + } + + [Fact] + public void DeployChanges_LiveRun_WithUnresolvableHost_FailsWithoutThrowing() + { + using var directory = new TempDirectory(); + directory.WriteFile("index.html"); + + var changes = new List + { + new(DeploymentAction.Ignore, Source.Git, "app.log", ""), + }; + + // Only an ignored change means Connect() is called but no upload/delete. + // Connecting to an invalid host throws inside Connect; ensure we surface it. + Assert.ThrowsAny(() => + CreateService().DeployChanges(directory.Path, changes, isDryRun: false)); + } +} diff --git a/src/kickflip.Tests/TestHelpers/CliRunner.cs b/src/kickflip.Tests/TestHelpers/CliRunner.cs new file mode 100644 index 0000000..44fc79c --- /dev/null +++ b/src/kickflip.Tests/TestHelpers/CliRunner.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; + +namespace kickflip.Tests.TestHelpers; + +/// +/// Runs the compiled kickflip CLI as an external process so the whole app can +/// be exercised end-to-end (argument parsing, command wiring and handlers). +/// +public static class CliRunner +{ + public record Result(int ExitCode, string StandardOutput, string StandardError) + { + public string Output => StandardOutput + StandardError; + } + + public static Result Run(params string[] arguments) + { + var assemblyPath = Path.Combine(AppContext.BaseDirectory, "kickflip.dll"); + if (!File.Exists(assemblyPath)) + { + throw new FileNotFoundException($"Could not find the kickflip CLI assembly at {assemblyPath}"); + } + + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + startInfo.ArgumentList.Add(assemblyPath); + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var standardOutput = process.StandardOutput.ReadToEnd(); + var standardError = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + return new Result(process.ExitCode, standardOutput, standardError); + } +} diff --git a/src/kickflip.Tests/TestHelpers/GitRepositoryBuilder.cs b/src/kickflip.Tests/TestHelpers/GitRepositoryBuilder.cs new file mode 100644 index 0000000..3733804 --- /dev/null +++ b/src/kickflip.Tests/TestHelpers/GitRepositoryBuilder.cs @@ -0,0 +1,50 @@ +using LibGit2Sharp; + +namespace kickflip.Tests.TestHelpers; + +/// +/// Builds a real (on-disk) git repository so the git based find modes can be +/// exercised end-to-end without mocking LibGit2Sharp. +/// +public sealed class GitRepositoryBuilder : IDisposable +{ + private readonly TempDirectory _tempDirectory = new(); + private readonly Signature _signature = new("Test", "test@example.com", DateTimeOffset.Now); + + public string Path => _tempDirectory.Path; + + public GitRepositoryBuilder() + { + Repository.Init(Path); + } + + public GitRepositoryBuilder WriteFile(string relativePath, string contents = "content") + { + _tempDirectory.WriteFile(relativePath, contents); + return this; + } + + public GitRepositoryBuilder DeleteFile(string relativePath) + { + _tempDirectory.DeleteFile(relativePath); + return this; + } + + public Commit Commit(string message) + { + using var repo = new Repository(Path); + Commands.Stage(repo, "*"); + return repo.Commit(message, _signature, _signature, new CommitOptions { AllowEmptyCommit = true }); + } + + public void Tag(string name) + { + using var repo = new Repository(Path); + repo.ApplyTag(name); + } + + public void Dispose() + { + _tempDirectory.Dispose(); + } +} diff --git a/src/kickflip.Tests/TestHelpers/TempDirectory.cs b/src/kickflip.Tests/TestHelpers/TempDirectory.cs new file mode 100644 index 0000000..6f8319a --- /dev/null +++ b/src/kickflip.Tests/TestHelpers/TempDirectory.cs @@ -0,0 +1,59 @@ +namespace kickflip.Tests.TestHelpers; + +/// +/// Creates a unique temporary directory that is deleted when disposed. +/// Used to give tests an isolated file system sandbox. +/// +public sealed class TempDirectory : IDisposable +{ + public string Path { get; } + + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "kickflip-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string WriteFile(string relativePath, string contents = "") + { + var fullPath = System.IO.Path.Combine(Path, relativePath); + var directory = System.IO.Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(fullPath, contents); + return fullPath; + } + + public void DeleteFile(string relativePath) + { + var fullPath = System.IO.Path.Combine(Path, relativePath); + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + // Git repositories mark objects read-only, clear before delete. + foreach (var file in Directory.EnumerateFiles(Path, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(Path, recursive: true); + } + } + catch + { + // Best effort cleanup; ignore failures during teardown. + } + } +} diff --git a/src/kickflip.Tests/UtilitiesTests.cs b/src/kickflip.Tests/UtilitiesTests.cs new file mode 100644 index 0000000..45511c2 --- /dev/null +++ b/src/kickflip.Tests/UtilitiesTests.cs @@ -0,0 +1,27 @@ +namespace kickflip.Tests; + +public class UtilitiesTests +{ + [Theory] + [InlineData("/public_html", "index.html", "/public_html/index.html")] + [InlineData("/public_html/", "/index.html", "/public_html/index.html")] + [InlineData("/", "index.html", "/index.html")] + [InlineData("", "index.html", "index.html")] + [InlineData("/public_html", "", "/public_html")] + public void UrlCombine_CombinesPathsWithSingleSeparator(string url1, string url2, string expected) + { + var result = kickflip.Utilities.UrlCombine(url1, url2); + + Assert.Equal(expected, result); + } + + [Fact] + public void UrlCombine_NormalisesDirectorySeparatorsToForwardSlashes() + { + var nested = "sub" + Path.DirectorySeparatorChar + "file.txt"; + + var result = kickflip.Utilities.UrlCombine("/public_html", nested); + + Assert.Equal("/public_html/sub/file.txt", result); + } +}