Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
145 changes: 145 additions & 0 deletions src/kickflip.Tests/CliIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using kickflip.Tests.TestHelpers;

namespace kickflip.Tests;

/// <summary>
/// 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.
/// </summary>
[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);
}
}
52 changes: 52 additions & 0 deletions src/kickflip.Tests/FileSystemServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<DirectoryNotFoundException>(() =>
service.GetChanges(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), "/"));
}
}
97 changes: 97 additions & 0 deletions src/kickflip.Tests/GitServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
39 changes: 39 additions & 0 deletions src/kickflip.Tests/IgnoreServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Loading
Loading