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
108 changes: 108 additions & 0 deletions DevOps.Tests/ConfigServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using DevOps.Options;
using DevOps.Services;

namespace DevOps.Tests;

public class ConfigServiceTests
{
private static ConfigOptions FullConfig() => new()
{
OrgUrl = "https://dev.azure.com/acme",
Pat = "pat-secret",
Project = "MyProject",
};

[Fact]
public void ConfigExists_FalseWhenEmpty_TrueAfterSave()
{
using var dir = new TempConfigDir();

Assert.False(ConfigService.ConfigExists());

ConfigService.SaveConfig(FullConfig());

Assert.True(ConfigService.ConfigExists());
}

[Fact]
public void SaveThenLoad_RoundTripsValues()
{
using var dir = new TempConfigDir();

ConfigService.SaveConfig(FullConfig(), userDisplayName: "Jane", userEmail: "jane@acme.com", userId: "user-1");

var config = ConfigService.LoadConfig();

Assert.Equal("https://dev.azure.com/acme", config.OrgUrl);
Assert.Equal("pat-secret", config.Pat); // decrypted round-trip
Assert.Equal("MyProject", config.DefaultProject);
Assert.Equal("Jane", config.UserDisplayName);
Assert.Equal("jane@acme.com", config.UserEmail);
Assert.Equal("user-1", config.UserId);
Assert.Equal(AuthModes.Pat, config.AuthMode); // inferred from the PAT
}

[Fact]
public void SaveConfig_IsNonDestructive_PreservesUnsuppliedValues()
{
using var dir = new TempConfigDir();

ConfigService.SaveConfig(FullConfig(), userId: "user-1");

// Change only the border; everything else must survive.
ConfigService.SaveConfig(new ConfigOptions { Border = "square" });

var config = ConfigService.LoadConfig();

Assert.Equal("https://dev.azure.com/acme", config.OrgUrl);
Assert.Equal("pat-secret", config.Pat);
Assert.Equal("MyProject", config.DefaultProject);
Assert.Equal("user-1", config.UserId);
Assert.Equal("square", config.TableBorder);
}

[Fact]
public void ResolveProject_PrefersExplicitThenDefault()
{
using var dir = new TempConfigDir();
ConfigService.SaveConfig(FullConfig());

Assert.Equal("Explicit", ConfigService.ResolveProject("Explicit"));
Assert.Equal("MyProject", ConfigService.ResolveProject(null));
}

[Fact]
public void ResolveProject_ThrowsWhenNoneAvailable()
{
using var dir = new TempConfigDir();
ConfigService.SaveConfig(new ConfigOptions { OrgUrl = "https://dev.azure.com/acme", Pat = "p" });

Assert.Throws<InvalidOperationException>(() => ConfigService.ResolveProject(null));
}

[Fact]
public void ResolveUserId_ReturnsWhenPresent_ThrowsWhenMissing()
{
using var dir = new TempConfigDir();

ConfigService.SaveConfig(FullConfig(), userId: "user-1");
Assert.Equal("user-1", ConfigService.ResolveUserId());

ConfigService.DeleteConfig();
ConfigService.SaveConfig(FullConfig()); // no userId
Assert.Throws<InvalidOperationException>(() => ConfigService.ResolveUserId());
}

[Fact]
public void DeleteConfig_RemovesTheFile()
{
using var dir = new TempConfigDir();

ConfigService.SaveConfig(FullConfig());
Assert.True(ConfigService.ConfigExists());

ConfigService.DeleteConfig();

Assert.False(ConfigService.ConfigExists());
}
}
124 changes: 124 additions & 0 deletions DevOps.Tests/HttpServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Net;
using DevOps.Options;
using DevOps.Services;
using RestSharp;

namespace DevOps.Tests;

/// <summary>
/// Exercises HttpService against a stub message handler (no network). A temp config supplies
/// the org URL and PAT so the client/authenticator can be built.
/// </summary>
public sealed class HttpServiceTests : IDisposable
{
private readonly TempConfigDir _configDir;

public HttpServiceTests()
{
_configDir = new TempConfigDir();
ConfigService.SaveConfig(new ConfigOptions
{
OrgUrl = "https://dev.azure.com/acme",
Pat = "pat-secret",
Project = "MyProject",
}, userId: "user-1");
}

public void Dispose()
{
HttpService.ClientFactory = options => new RestClient(options);
_configDir.Dispose();
}

private static StubHttpMessageHandler Arrange(HttpStatusCode status, string body = "")
{
var stub = new StubHttpMessageHandler(status, body);
HttpService.ClientFactory = options =>
{
options.ConfigureMessageHandler = _ => stub;
return new RestClient(options);
};
return stub;
}

// --- GetWorkItem -------------------------------------------------------

[Fact]
public async Task GetWorkItem_Success_ParsesFields()
{
Arrange(HttpStatusCode.OK, """{"id":123,"fields":{"System.Title":"Fix bug","System.State":"Active","System.WorkItemType":"Task"}}""");

var item = await HttpService.GetWorkItem(123, "MyProject");

Assert.Equal(123, item.Id);
Assert.Equal("Fix bug", item.Fields.Title);
Assert.Equal("Active", item.Fields.State);
}

[Fact]
public async Task GetWorkItem_Failure_Throws()
{
Arrange(HttpStatusCode.NotFound);

await Assert.ThrowsAsync<Exception>(() => HttpService.GetWorkItem(999, "MyProject"));
}

// --- GetPullRequest ----------------------------------------------------

[Fact]
public async Task GetPullRequest_Success_ParsesPr()
{
Arrange(HttpStatusCode.OK, """{"pullRequestId":7,"title":"My PR","status":"active","repository":{"name":"repo","project":{"name":"proj"}}}""");

var pr = await HttpService.GetPullRequest(7);

Assert.Equal(7, pr.PullRequestId);
Assert.Equal("My PR", pr.Title);
Assert.Equal("repo", pr.Repository.Name);
}

[Fact]
public async Task GetPullRequest_Failure_Throws()
{
Arrange(HttpStatusCode.Unauthorized);

await Assert.ThrowsAsync<Exception>(() => HttpService.GetPullRequest(7));
}

// --- CreatePullRequest -------------------------------------------------

[Fact]
public async Task CreatePullRequest_NormalizesBranchesAndSendsReviewers()
{
var stub = Arrange(HttpStatusCode.Created, """{"pullRequestId":42,"title":"Add login"}""");

var pr = await HttpService.CreatePullRequest(
"MyProject", "repo", "feature/x", "main", "Add login", "desc", isDraft: false,
reviewerIds: ["guid-1"]);

Assert.Equal(42, pr.PullRequestId);
Assert.Contains("\"sourceRefName\":\"refs/heads/feature/x\"", stub.LastRequestBody);
Assert.Contains("\"targetRefName\":\"refs/heads/main\"", stub.LastRequestBody);
Assert.Contains("guid-1", stub.LastRequestBody);
}

// --- AddPullRequestComment ---------------------------------------------

[Fact]
public async Task AddPullRequestComment_Success_ReturnsThreadId()
{
Arrange(HttpStatusCode.OK, """{"id":99}""");

var threadId = await HttpService.AddPullRequestComment("MyProject", "repo", 7, "Looks good");

Assert.Equal(99, threadId);
}

[Fact]
public async Task AddPullRequestComment_Failure_Throws()
{
Arrange(HttpStatusCode.BadRequest);

await Assert.ThrowsAsync<Exception>(() => HttpService.AddPullRequestComment("MyProject", "repo", 7, "x"));
}
}
40 changes: 40 additions & 0 deletions DevOps.Tests/ResolvePullRequestUrlConfigTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using DevOps.Actions;
using DevOps.Options;
using DevOps.Responses;
using DevOps.Services;

namespace DevOps.Tests;

/// <summary>
/// Covers the build-from-config fallback of <c>ResolvePullRequestUrl</c>, which reads the
/// org URL from the config store when the PR payload omits <c>_links.web</c>.
/// </summary>
public sealed class ResolvePullRequestUrlConfigTests : IDisposable
{
private readonly TempConfigDir _dir;

public ResolvePullRequestUrlConfigTests()
{
_dir = new TempConfigDir();
ConfigService.SaveConfig(new ConfigOptions { OrgUrl = "https://dev.azure.com/acme", Pat = "p" });
}

public void Dispose() => _dir.Dispose();

[Fact]
public void BuildsUrlFromOrgProjectRepoWhenWebLinkMissing()
{
var pr = new PullRequestResponse
{
PullRequestId = 7,
Repository = new PullRequestRepository
{
Name = "repo",
Project = new PullRequestProject { Name = "proj" },
},
};

Assert.Equal("https://dev.azure.com/acme/proj/_git/repo/pullrequest/7",
ActionHelpers.ResolvePullRequestUrl(pr));
}
}
35 changes: 35 additions & 0 deletions DevOps.Tests/StubHttpMessageHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Net;
using System.Text;

namespace DevOps.Tests;

/// <summary>
/// Captures the outgoing request and returns a canned response, so HttpService methods can
/// be exercised without any real network access.
/// </summary>
internal sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly HttpStatusCode _status;
private readonly string _body;

public HttpRequestMessage LastRequest { get; private set; }
public string LastRequestBody { get; private set; }

public StubHttpMessageHandler(HttpStatusCode status, string body = "")
{
_status = status;
_body = body;
}

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
if (request.Content != null)
LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken);

return new HttpResponseMessage(_status)
{
Content = new StringContent(_body, Encoding.UTF8, "application/json"),
};
}
}
27 changes: 27 additions & 0 deletions DevOps.Tests/TempConfigDir.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace DevOps.Tests;

/// <summary>
/// Points <c>ConfigService</c> at a throwaway directory via the DEVOPS_CONFIG_DIR override
/// for the lifetime of the instance, then removes it. Never touches the real store.
/// </summary>
internal sealed class TempConfigDir : IDisposable
{
private const string EnvVar = "DEVOPS_CONFIG_DIR";
private readonly string _previous;

public string Path { get; }

public TempConfigDir()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "devops-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
_previous = Environment.GetEnvironmentVariable(EnvVar);
Environment.SetEnvironmentVariable(EnvVar, Path);
}

public void Dispose()
{
Environment.SetEnvironmentVariable(EnvVar, _previous);
try { Directory.Delete(Path, recursive: true); } catch { /* best-effort cleanup */ }
}
}
14 changes: 12 additions & 2 deletions DevOps/Services/ConfigService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,20 @@ public static class ConfigService
private const string APPLICATION_NAME = "DevOps.Console";
private const string JSON_FILE_NAME = "config.json";

public static string GetConfigDirectory() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
// Overrides the config directory when set (used by tests to avoid touching the real
// user store). Not documented as a public feature; the runtime path is unchanged.
private const string CONFIG_DIR_ENV = "DEVOPS_CONFIG_DIR";

public static string GetConfigDirectory()
{
var overrideDir = Environment.GetEnvironmentVariable(CONFIG_DIR_ENV);
if (!string.IsNullOrEmpty(overrideDir))
return overrideDir;

return RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), APPLICATION_NAME)
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", APPLICATION_NAME);
}

private static string GetConfigPath() => Path.Combine(GetConfigDirectory(), JSON_FILE_NAME);

Expand Down
10 changes: 8 additions & 2 deletions DevOps/Services/HttpService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,22 @@ public static class HttpService
"System.Description,Microsoft.VSTS.Common.Priority,System.CreatedDate," +
"System.ChangedDate,System.TeamProject,System.Parent";

/// <summary>
/// Builds the REST client from options. Defaults to a real <see cref="RestClient"/>;
/// tests swap it for a client wired to a stub message handler to avoid real network calls.
/// </summary>
internal static Func<RestClientOptions, RestClient> ClientFactory { get; set; } = options => new RestClient(options);

private static async Task<RestClient> CreateClientAsync(CancellationToken cancellationToken)
{
var config = ConfigService.LoadConfig();
return new RestClient(new RestClientOptions(config.OrgUrl) { Authenticator = await ResolveAuthenticatorAsync(config, cancellationToken) });
return ClientFactory(new RestClientOptions(config.OrgUrl) { Authenticator = await ResolveAuthenticatorAsync(config, cancellationToken) });
}

private static async Task<RestClient> CreateClientAsync(string baseUrl, CancellationToken cancellationToken)
{
var config = ConfigService.LoadConfig();
return new RestClient(new RestClientOptions(baseUrl) { Authenticator = await ResolveAuthenticatorAsync(config, cancellationToken) });
return ClientFactory(new RestClientOptions(baseUrl) { Authenticator = await ResolveAuthenticatorAsync(config, cancellationToken) });
}

private static async Task<IAuthenticator> ResolveAuthenticatorAsync(Config config, CancellationToken cancellationToken)
Expand Down
Loading