From 22a8d3708ee81dc93325682fec5ce8514f39aef1 Mon Sep 17 00:00:00 2001 From: RALPH Date: Mon, 13 Jul 2026 19:26:43 +0000 Subject: [PATCH] RALPH: Add automated testing harness (closes #10) Add unit and integration tests covering the tool's behaviour. Key decisions: - Unit tests exercise services directly; git tests build real temp repositories (GitRepositoryBuilder) so find modes are proven end-to-end. - Integration tests (CliIntegrationTests) drive the compiled CLI as an external process to verify command wiring, argument validation, all deployment modes (Tags, GitHubMergePR, Folder) and that dry run reports planned changes without connecting to the remote server. - SFTP live deployment cannot be stood up in tests, so coverage focuses on the safety-critical dry-run path (no connect, no local file changes). Files changed: - src/kickflip.Tests/TestHelpers/{TempDirectory,GitRepositoryBuilder,CliRunner}.cs - src/kickflip.Tests/{Utilities,IgnoreService,FileSystemService,GitService,OutputService,SftpDeploymentService,CliIntegration}Tests.cs - README.md (Testing section) Blockers for next iteration: the CI token lacks 'workflow' scope so I could not add a 'dotnet test' step to .github/workflows/main.yml. A maintainer should add that step. Live SFTP/FTP end-to-end testing would need a containerised server; deferred as dry-run covers the safety path. --- README.md | 11 ++ src/kickflip.Tests/CliIntegrationTests.cs | 145 ++++++++++++++++++ src/kickflip.Tests/FileSystemServiceTests.cs | 52 +++++++ src/kickflip.Tests/GitServiceTests.cs | 97 ++++++++++++ src/kickflip.Tests/IgnoreServiceTests.cs | 39 +++++ src/kickflip.Tests/OutputServiceTests.cs | 64 ++++++++ .../SftpDeploymentServiceTests.cs | 83 ++++++++++ src/kickflip.Tests/TestHelpers/CliRunner.cs | 48 ++++++ .../TestHelpers/GitRepositoryBuilder.cs | 50 ++++++ .../TestHelpers/TempDirectory.cs | 59 +++++++ src/kickflip.Tests/UtilitiesTests.cs | 27 ++++ 11 files changed, 675 insertions(+) create mode 100644 src/kickflip.Tests/CliIntegrationTests.cs create mode 100644 src/kickflip.Tests/FileSystemServiceTests.cs create mode 100644 src/kickflip.Tests/GitServiceTests.cs create mode 100644 src/kickflip.Tests/IgnoreServiceTests.cs create mode 100644 src/kickflip.Tests/OutputServiceTests.cs create mode 100644 src/kickflip.Tests/SftpDeploymentServiceTests.cs create mode 100644 src/kickflip.Tests/TestHelpers/CliRunner.cs create mode 100644 src/kickflip.Tests/TestHelpers/GitRepositoryBuilder.cs create mode 100644 src/kickflip.Tests/TestHelpers/TempDirectory.cs create mode 100644 src/kickflip.Tests/UtilitiesTests.cs 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); + } +}