From ac4d64d8c5a7dc4eef731e7eff9638c0bef97f29 Mon Sep 17 00:00:00 2001 From: Jonas Lima de Amorim Date: Sat, 25 Jul 2026 01:21:31 -0300 Subject: [PATCH] test: phase 2 coverage for ConfigService and HttpService Add a config-directory override to ConfigService and an injectable client factory to HttpService so config round-trip/merge/resolvers, the ResolvePullRequestUrl config path, and the work-item/pull-request HTTP paths can be tested without the real store or network. --- DevOps.Tests/ConfigServiceTests.cs | 108 +++++++++++++++ DevOps.Tests/HttpServiceTests.cs | 124 ++++++++++++++++++ .../ResolvePullRequestUrlConfigTests.cs | 40 ++++++ DevOps.Tests/StubHttpMessageHandler.cs | 35 +++++ DevOps.Tests/TempConfigDir.cs | 27 ++++ DevOps/Services/ConfigService.cs | 14 +- DevOps/Services/HttpService.cs | 10 +- 7 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 DevOps.Tests/ConfigServiceTests.cs create mode 100644 DevOps.Tests/HttpServiceTests.cs create mode 100644 DevOps.Tests/ResolvePullRequestUrlConfigTests.cs create mode 100644 DevOps.Tests/StubHttpMessageHandler.cs create mode 100644 DevOps.Tests/TempConfigDir.cs diff --git a/DevOps.Tests/ConfigServiceTests.cs b/DevOps.Tests/ConfigServiceTests.cs new file mode 100644 index 0000000..5b9d191 --- /dev/null +++ b/DevOps.Tests/ConfigServiceTests.cs @@ -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(() => 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(() => 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()); + } +} diff --git a/DevOps.Tests/HttpServiceTests.cs b/DevOps.Tests/HttpServiceTests.cs new file mode 100644 index 0000000..aaf791a --- /dev/null +++ b/DevOps.Tests/HttpServiceTests.cs @@ -0,0 +1,124 @@ +using System.Net; +using DevOps.Options; +using DevOps.Services; +using RestSharp; + +namespace DevOps.Tests; + +/// +/// 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. +/// +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(() => 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(() => 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(() => HttpService.AddPullRequestComment("MyProject", "repo", 7, "x")); + } +} diff --git a/DevOps.Tests/ResolvePullRequestUrlConfigTests.cs b/DevOps.Tests/ResolvePullRequestUrlConfigTests.cs new file mode 100644 index 0000000..c321c97 --- /dev/null +++ b/DevOps.Tests/ResolvePullRequestUrlConfigTests.cs @@ -0,0 +1,40 @@ +using DevOps.Actions; +using DevOps.Options; +using DevOps.Responses; +using DevOps.Services; + +namespace DevOps.Tests; + +/// +/// Covers the build-from-config fallback of ResolvePullRequestUrl, which reads the +/// org URL from the config store when the PR payload omits _links.web. +/// +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)); + } +} diff --git a/DevOps.Tests/StubHttpMessageHandler.cs b/DevOps.Tests/StubHttpMessageHandler.cs new file mode 100644 index 0000000..cdc3c08 --- /dev/null +++ b/DevOps.Tests/StubHttpMessageHandler.cs @@ -0,0 +1,35 @@ +using System.Net; +using System.Text; + +namespace DevOps.Tests; + +/// +/// Captures the outgoing request and returns a canned response, so HttpService methods can +/// be exercised without any real network access. +/// +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 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"), + }; + } +} diff --git a/DevOps.Tests/TempConfigDir.cs b/DevOps.Tests/TempConfigDir.cs new file mode 100644 index 0000000..f1716b3 --- /dev/null +++ b/DevOps.Tests/TempConfigDir.cs @@ -0,0 +1,27 @@ +namespace DevOps.Tests; + +/// +/// Points ConfigService at a throwaway directory via the DEVOPS_CONFIG_DIR override +/// for the lifetime of the instance, then removes it. Never touches the real store. +/// +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 */ } + } +} diff --git a/DevOps/Services/ConfigService.cs b/DevOps/Services/ConfigService.cs index 11a6814..af076bb 100644 --- a/DevOps/Services/ConfigService.cs +++ b/DevOps/Services/ConfigService.cs @@ -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); diff --git a/DevOps/Services/HttpService.cs b/DevOps/Services/HttpService.cs index 4e83579..6d607c6 100644 --- a/DevOps/Services/HttpService.cs +++ b/DevOps/Services/HttpService.cs @@ -18,16 +18,22 @@ public static class HttpService "System.Description,Microsoft.VSTS.Common.Priority,System.CreatedDate," + "System.ChangedDate,System.TeamProject,System.Parent"; + /// + /// Builds the REST client from options. Defaults to a real ; + /// tests swap it for a client wired to a stub message handler to avoid real network calls. + /// + internal static Func ClientFactory { get; set; } = options => new RestClient(options); + private static async Task 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 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 ResolveAuthenticatorAsync(Config config, CancellationToken cancellationToken)