using System.Net; using System.Text.Json.Nodes; using ClaudeDo.Worker.AzureDevOps; using ClaudeDo.Worker.Tests.Tickets; namespace ClaudeDo.Worker.Tests.AzureDevOps; /// Port of pr-create.ps1: its -SelfTest cases plus the PR find-or-create flow, HTTP stubbed. public sealed class AzureDevOpsClientTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), $"cd_azdo_{Guid.NewGuid():N}"); public void Dispose() { try { Directory.Delete(_dir, true); } catch { } } internal static AzureDevOpsPatStore PatStore(string dir, string? pat = "geheim") { var store = new AzureDevOpsPatStore(new DpapiTokenStore(Path.Combine(dir, "azdo.pat"))); if (pat is not null) store.Store.Save(pat); return store; } private AzureDevOpsClient Client(StubHandler handler, string? pat = "geheim") => new(new StubHttpClientFactory(handler), PatStore(_dir, pat)); private static readonly AzdoRepo Repo = new("https://srv.local/Coll/Proj", "Repo", "https://srv.local/Coll/Proj/_git/Repo"); [Theory] [InlineData("https://mika@srv.local/Coll/Proj/_git/Repo.git", "https://srv.local/Coll/Proj", "Repo")] [InlineData("https://srv.local/tfs/Coll/Proj/_git/Mein%20Repo", "https://srv.local/tfs/Coll/Proj", "Mein Repo")] public void ParseRemote_SelfTestCases(string remote, string apiBase, string repo) { var ctx = AzureDevOpsClient.ParseRemote(remote); Assert.NotNull(ctx); Assert.Equal(apiBase, ctx!.ApiBase); Assert.Equal(repo, ctx.Repo); } [Fact] public void ParseRemote_StripsCredentialsFromWebUrl() { var ctx = AzureDevOpsClient.ParseRemote("https://mika:pw@srv.local/Coll/Proj/_git/Repo.git"); Assert.Equal("https://srv.local/Coll/Proj/_git/Repo", ctx!.WebUrl); } [Theory] [InlineData("https://example.com/mika/repo.git")] [InlineData("C:/repos/coll/_git/repo")] [InlineData("")] public void ParseRemote_NonAdoRemote_IsNull(string remote) => Assert.Null(AzureDevOpsClient.ParseRemote(remote)); [Fact] public void AuthHeader_MatchesScript() => Assert.Equal("Basic OmdlaGVpbQ==", AzureDevOpsClient.AuthHeaderValue("geheim")); [Fact] public void GitAuthEnv_PassesPatAsExtraHeaderAndNeverPrompts() { var env = AzureDevOpsClient.GitAuthEnv("geheim"); Assert.Equal("0", env["GIT_TERMINAL_PROMPT"]); Assert.Equal("1", env["GIT_CONFIG_COUNT"]); Assert.Equal("http.extraHeader", env["GIT_CONFIG_KEY_0"]); Assert.Equal("Authorization: Basic OmdlaGVpbQ==", env["GIT_CONFIG_VALUE_0"]); Assert.False(AzureDevOpsClient.GitAuthEnv(null).ContainsKey("GIT_CONFIG_COUNT")); } [Fact] public void SupportedVersion_ReadsOnlyTheOutOfRangeError() { Assert.Equal("6.0", AzureDevOpsClient.SupportedVersion( "The requested REST API version of 7.1 is out of range for this server. The latest version this server supports is 6.0.")); Assert.Null(AzureDevOpsClient.SupportedVersion("TF401019: irgendein anderer Fehler")); } [Fact] public async Task EnsureDraftPullRequest_ReusesActivePr_WithoutPost() { var handler = new StubHandler(_ => (HttpStatusCode.OK, """{"value":[{"pullRequestId":42}],"count":1}""")); var (url, created) = await Client(handler).EnsureDraftPullRequestAsync(Repo, "feature/1-x", "main", "t", "d", default); Assert.False(created); Assert.Equal("https://srv.local/Coll/Proj/_git/Repo/pullrequest/42", url); Assert.Single(handler.Requests); Assert.Contains("searchCriteria.sourceRefName=refs%2Fheads%2Ffeature%2F1-x", handler.Requests[0].RequestUri!.Query); Assert.Contains("searchCriteria.status=active", handler.Requests[0].RequestUri!.Query); Assert.Equal("Basic OmdlaGVpbQ==", handler.Requests[0].Headers.Authorization!.ToString()); } [Fact] public async Task EnsureDraftPullRequest_CreatesDraftWhenNoneActive() { var handler = new StubHandler(req => req.Method == HttpMethod.Get ? (HttpStatusCode.OK, """{"value":[],"count":0}""") : (HttpStatusCode.Created, """{"pullRequestId":7}""")); var (url, created) = await Client(handler).EnsureDraftPullRequestAsync(Repo, "feature/1-x", "dev", "Titel", "Beschreibung", default); Assert.True(created); Assert.EndsWith("/pullrequest/7", url); var body = JsonNode.Parse(handler.Bodies[1])!; Assert.True(body["isDraft"]!.GetValue()); Assert.Equal("refs/heads/feature/1-x", body["sourceRefName"]!.GetValue()); Assert.Equal("refs/heads/dev", body["targetRefName"]!.GetValue()); Assert.Equal("Titel", body["title"]!.GetValue()); Assert.Equal("Beschreibung", body["description"]!.GetValue()); Assert.Equal("https://srv.local/Coll/Proj/_apis/git/repositories/Repo/pullrequests", handler.Requests[1].RequestUri!.GetLeftPart(UriPartial.Path)); } [Fact] public async Task ApiVersion_FallsBackToTheVersionTheServerNames() { var handler = new StubHandler(req => req.RequestUri!.Query.Contains("api-version=6.0") ? (HttpStatusCode.BadRequest, """{"message":"The requested REST API version of 6.0 is out of range for this server. The latest version this server supports is 5.1."}""") : (HttpStatusCode.OK, """{"defaultBranch":"refs/heads/dev"}""")); var branch = await Client(handler).GetDefaultBranchAsync(Repo, default); Assert.Equal("dev", branch); Assert.Equal(2, handler.Requests.Count); Assert.Contains("api-version=5.1", handler.Requests[1].RequestUri!.Query); } [Fact] public async Task RestError_SurfacesStatusAndServerMessage() { var handler = new StubHandler(_ => (HttpStatusCode.Forbidden, """{"message":"TF401027: You need the Git 'PullRequestContribute' permission."}""")); var ex = await Assert.ThrowsAsync(() => Client(handler).GetDefaultBranchAsync(Repo, default)); Assert.Contains("403", ex.Message); Assert.Contains("TF401027", ex.Message); } [Fact] public async Task NoPat_FailsBeforeAnyRequest() { var handler = new StubHandler(_ => (HttpStatusCode.OK, "{}")); await Assert.ThrowsAsync(() => Client(handler, pat: null).GetDefaultBranchAsync(Repo, default)); Assert.Empty(handler.Requests); } [Fact] public async Task RejectedPat_203LoginPage_IsAPatError() { var handler = new StubHandler(_ => ((HttpStatusCode)203, "login")); var ex = await Assert.ThrowsAsync(() => Client(handler).GetDefaultBranchAsync(Repo, default)); Assert.Contains("PAT", ex.Message); } } internal sealed class StubHttpClientFactory(StubHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); }