From 8542869663b0e52d951c73e641176dbdc36e0655 Mon Sep 17 00:00:00 2001 From: diomonogatari <17832206+diomonogatari@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:11:05 +0000 Subject: [PATCH 1/4] feat: implement Bitbucket Server code search API models and client methods --- .../Common/Models/Search/CodeSearchRequest.cs | 62 ++++++++ .../Models/Search/CodeSearchResponse.cs | 146 ++++++++++++++++++ .../Core/Search/BitbucketClient.cs | 92 +++++++++++ .../Serialization/BitbucketJsonContext.cs | 21 +++ 4 files changed, 321 insertions(+) create mode 100644 src/Bitbucket.Net/Common/Models/Search/CodeSearchRequest.cs create mode 100644 src/Bitbucket.Net/Common/Models/Search/CodeSearchResponse.cs create mode 100644 src/Bitbucket.Net/Core/Search/BitbucketClient.cs diff --git a/src/Bitbucket.Net/Common/Models/Search/CodeSearchRequest.cs b/src/Bitbucket.Net/Common/Models/Search/CodeSearchRequest.cs new file mode 100644 index 0000000..bf32857 --- /dev/null +++ b/src/Bitbucket.Net/Common/Models/Search/CodeSearchRequest.cs @@ -0,0 +1,62 @@ +namespace Bitbucket.Net.Common.Models.Search; + +/// +/// Request body for the Bitbucket Server code search API. +/// POST /rest/search/latest/search +/// +public class CodeSearchRequest +{ + /// + /// The search query string. Supports Bitbucket search syntax: + /// repo:slug, project:KEY, lang:, ext:, path: + /// + public required string Query { get; set; } + + /// + /// Entity types to search. Use for code search. + /// + public required SearchEntities Entities { get; set; } + + /// + /// Pagination limits for the search results. + /// + public required SearchLimits Limits { get; set; } +} + +/// +/// Specifies which entity types to search for. +/// +public class SearchEntities +{ + /// + /// Include code results. Set to an empty object to enable code search. + /// + public SearchEntityFilter? Code { get; set; } + + /// + /// Creates entities for a code-only search. + /// + public static SearchEntities CodeOnly => new() { Code = new SearchEntityFilter() }; +} + +/// +/// Marker class representing an entity filter in search requests. +/// Serializes to an empty JSON object {}. +/// +public class SearchEntityFilter { } + +/// +/// Pagination limits for search results. +/// +public class SearchLimits +{ + /// + /// Maximum number of primary results to return. Default: 25. + /// + public int Primary { get; set; } = 25; + + /// + /// Maximum number of secondary results per primary result. Default: 10. + /// + public int Secondary { get; set; } = 10; +} \ No newline at end of file diff --git a/src/Bitbucket.Net/Common/Models/Search/CodeSearchResponse.cs b/src/Bitbucket.Net/Common/Models/Search/CodeSearchResponse.cs new file mode 100644 index 0000000..56d79fa --- /dev/null +++ b/src/Bitbucket.Net/Common/Models/Search/CodeSearchResponse.cs @@ -0,0 +1,146 @@ +using Bitbucket.Net.Models.Core.Projects; + +namespace Bitbucket.Net.Common.Models.Search; + +/// +/// Top-level response from the Bitbucket Server code search API. +/// +public class CodeSearchResponse +{ + /// + /// The scope of the search (e.g., GLOBAL). + /// + public SearchScope? Scope { get; set; } + + /// + /// Code search results. + /// + public CodeSearchCategory? Code { get; set; } + + /// + /// Query metadata including whether query substitution occurred. + /// + public SearchQuery? Query { get; set; } +} + +/// +/// Scope metadata for a search. +/// +public class SearchScope +{ + /// + /// The scope type, e.g., "GLOBAL". + /// + public string? Type { get; set; } +} + +/// +/// Query metadata. +/// +public class SearchQuery +{ + /// + /// Whether the query was substituted (e.g., spell correction). + /// + public bool Substituted { get; set; } +} + +/// +/// Category of code search results with pagination info. +/// +public class CodeSearchCategory +{ + /// + /// Result category name (e.g., "primary"). + /// + public string? Category { get; set; } + + /// + /// Whether this is the last page of results. + /// + public bool IsLastPage { get; set; } + + /// + /// Total number of results matching the query. + /// + public int Count { get; set; } + + /// + /// Starting index of the current page. + /// + public int Start { get; set; } + + /// + /// Starting index for the next page, if available. + /// + public int? NextStart { get; set; } + + /// + /// The code search result items. + /// + public List Values { get; set; } = []; +} + +/// +/// A single code search result representing a file with matching content. +/// +public class CodeSearchResult +{ + /// + /// The repository containing the matching file. + /// + public Repository? Repository { get; set; } + + /// + /// The file path within the repository. + /// + public string? File { get; set; } + + /// + /// Groups of matching lines with surrounding context. + /// Each inner list represents a contiguous block of context lines. + /// + public List>? HitContexts { get; set; } + + /// + /// Segments of the file path that matched the query. + /// + public List? PathMatches { get; set; } + + /// + /// Total number of hits in this file. + /// + public int HitCount { get; set; } +} + +/// +/// A single line in a code search hit context block. +/// +public class CodeSearchHitLine +{ + /// + /// The 1-based line number. + /// + public int Line { get; set; } + + /// + /// The line text content. May contain <em> tags highlighting matched terms. + /// + public string? Text { get; set; } +} + +/// +/// Represents a matching segment in the file path. +/// +public class SearchPathMatch +{ + /// + /// Starting character index of the match in the path. + /// + public int Start { get; set; } + + /// + /// Length of the matching text. + /// + public int Length { get; set; } +} \ No newline at end of file diff --git a/src/Bitbucket.Net/Core/Search/BitbucketClient.cs b/src/Bitbucket.Net/Core/Search/BitbucketClient.cs new file mode 100644 index 0000000..507f609 --- /dev/null +++ b/src/Bitbucket.Net/Core/Search/BitbucketClient.cs @@ -0,0 +1,92 @@ +using Bitbucket.Net.Common.Models.Search; +using Flurl; +using Flurl.Http; + +namespace Bitbucket.Net; + +/// +/// Provides operations for the Bitbucket Server code search API (Elasticsearch-backed). +/// Requires the Bitbucket Code Search add-on to be installed on the server. +/// +public partial class BitbucketClient +{ + private IFlurlRequest GetSearchUrl() => GetBaseUrl("/search", "latest"); + + /// + /// Performs a server-side code search using the Bitbucket Code Search API. + /// This is backed by Elasticsearch and significantly faster than client-side file scanning. + /// + /// + /// The search query string. Supports Bitbucket search syntax: + /// + /// repo:slug — filter to a specific repository + /// project:KEY — filter to a specific project + /// lang:csharp — filter by language + /// ext:cs — filter by file extension + /// path:src/ — filter by file path + /// + /// + /// Maximum number of file results to return. Default: 25. + /// Maximum number of hit contexts per file. Default: 10. + /// Token to cancel the operation. + /// The code search response containing matching files and hit contexts. + /// + /// Thrown when the server returns an error (e.g., 404 if Code Search is not installed). + /// + public async Task SearchCodeAsync( + string query, + int primaryLimit = 25, + int secondaryLimit = 10, + CancellationToken cancellationToken = default) + { + var request = new CodeSearchRequest + { + Query = query, + Entities = SearchEntities.CodeOnly, + Limits = new SearchLimits + { + Primary = primaryLimit, + Secondary = secondaryLimit + } + }; + + var response = await GetSearchUrl() + .AppendPathSegment("/search") + .SendAsync(HttpMethod.Post, CreateJsonContent(request), cancellationToken: cancellationToken) + .ConfigureAwait(false); + + return await HandleResponseAsync(response, cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Checks whether the Bitbucket Code Search API is available on the server. + /// Returns true if the search endpoint responds successfully, false otherwise. + /// + /// Token to cancel the operation. + /// True if server-side search is available; false otherwise. + public async Task IsSearchAvailableAsync(CancellationToken cancellationToken = default) + { + try + { + // Perform a minimal search to probe the endpoint + var request = new CodeSearchRequest + { + Query = "test", + Entities = SearchEntities.CodeOnly, + Limits = new SearchLimits { Primary = 1, Secondary = 1 } + }; + + var response = await GetSearchUrl() + .AppendPathSegment("/search") + .SendAsync(HttpMethod.Post, CreateJsonContent(request), cancellationToken: cancellationToken) + .ConfigureAwait(false); + + return response.StatusCode < 400; + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/src/Bitbucket.Net/Serialization/BitbucketJsonContext.cs b/src/Bitbucket.Net/Serialization/BitbucketJsonContext.cs index 07b49db..2ae2c2a 100644 --- a/src/Bitbucket.Net/Serialization/BitbucketJsonContext.cs +++ b/src/Bitbucket.Net/Serialization/BitbucketJsonContext.cs @@ -1,4 +1,6 @@ using Bitbucket.Net.Common.Models; +// Search +using Bitbucket.Net.Common.Models.Search; // Audit using Bitbucket.Net.Models.Audit; // Branches @@ -97,6 +99,25 @@ namespace Bitbucket.Net.Serialization; [JsonSerializable(typeof(PagedResults))] [JsonSerializable(typeof(PagedResults))] +// ============================================================================ +// Search Models +// ============================================================================ +[JsonSerializable(typeof(CodeSearchRequest))] +[JsonSerializable(typeof(CodeSearchResponse))] +[JsonSerializable(typeof(CodeSearchCategory))] +[JsonSerializable(typeof(CodeSearchResult))] +[JsonSerializable(typeof(CodeSearchHitLine))] +[JsonSerializable(typeof(SearchEntities))] +[JsonSerializable(typeof(SearchEntityFilter))] +[JsonSerializable(typeof(SearchLimits))] +[JsonSerializable(typeof(SearchPathMatch))] +[JsonSerializable(typeof(SearchQuery))] +[JsonSerializable(typeof(SearchScope))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List>))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] + // ============================================================================ // Audit Models // ============================================================================ From 3af24d51541136f6a30fad4e66a13cfc1e9781ef Mon Sep 17 00:00:00 2001 From: diomonogatari <17832206+diomonogatari@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:11:05 +0000 Subject: [PATCH 2/4] feat: add test fixtures for Bitbucket Server code search API responses --- .../Fixtures/Search/code-search-empty.json | 15 +++ .../Fixtures/Search/code-search-results.json | 94 +++++++++++++++++++ .../Search/code-search-single-hit.json | 45 +++++++++ .../Search/code-search-substituted.json | 37 ++++++++ 4 files changed, 191 insertions(+) create mode 100644 test/Bitbucket.Net.Tests/Fixtures/Search/code-search-empty.json create mode 100644 test/Bitbucket.Net.Tests/Fixtures/Search/code-search-results.json create mode 100644 test/Bitbucket.Net.Tests/Fixtures/Search/code-search-single-hit.json create mode 100644 test/Bitbucket.Net.Tests/Fixtures/Search/code-search-substituted.json diff --git a/test/Bitbucket.Net.Tests/Fixtures/Search/code-search-empty.json b/test/Bitbucket.Net.Tests/Fixtures/Search/code-search-empty.json new file mode 100644 index 0000000..ac3937f --- /dev/null +++ b/test/Bitbucket.Net.Tests/Fixtures/Search/code-search-empty.json @@ -0,0 +1,15 @@ +{ + "scope": { + "type": "GLOBAL" + }, + "code": { + "category": "primary", + "isLastPage": true, + "count": 0, + "start": 0, + "values": [] + }, + "query": { + "substituted": false + } +} \ No newline at end of file diff --git a/test/Bitbucket.Net.Tests/Fixtures/Search/code-search-results.json b/test/Bitbucket.Net.Tests/Fixtures/Search/code-search-results.json new file mode 100644 index 0000000..00004d0 --- /dev/null +++ b/test/Bitbucket.Net.Tests/Fixtures/Search/code-search-results.json @@ -0,0 +1,94 @@ +{ + "scope": { + "type": "GLOBAL" + }, + "code": { + "category": "primary", + "isLastPage": false, + "count": 61, + "start": 0, + "nextStart": 25, + "values": [ + { + "repository": { + "slug": "test-repo", + "id": 1, + "name": "Test Repository", + "project": { + "key": "TEST" + } + }, + "file": "src/Middleware/RequestHandler.cs", + "hitContexts": [ + [ + { + "line": 14, + "text": "using System.Net.Http;" + }, + { + "line": 15, + "text": "" + }, + { + "line": 16, + "text": "public class RequestHandler" + } + ], + [ + { + "line": 42, + "text": " var client = new HttpClient();" + }, + { + "line": 43, + "text": " var response = await client.GetAsync(url);" + }, + { + "line": 44, + "text": " return response;" + } + ] + ], + "pathMatches": [], + "hitCount": 2 + }, + { + "repository": { + "slug": "test-repo", + "id": 1, + "name": "Test Repository", + "project": { + "key": "TEST" + } + }, + "file": "tests/HandlerTests.cs", + "hitContexts": [ + [ + { + "line": 8, + "text": " private readonly HttpClient _client;" + }, + { + "line": 9, + "text": "" + }, + { + "line": 10, + "text": " public HandlerTests()" + } + ] + ], + "pathMatches": [ + { + "start": 6, + "length": 7 + } + ], + "hitCount": 1 + } + ] + }, + "query": { + "substituted": false + } +} \ No newline at end of file diff --git a/test/Bitbucket.Net.Tests/Fixtures/Search/code-search-single-hit.json b/test/Bitbucket.Net.Tests/Fixtures/Search/code-search-single-hit.json new file mode 100644 index 0000000..2714738 --- /dev/null +++ b/test/Bitbucket.Net.Tests/Fixtures/Search/code-search-single-hit.json @@ -0,0 +1,45 @@ +{ + "scope": { + "type": "GLOBAL" + }, + "code": { + "category": "primary", + "isLastPage": true, + "count": 1, + "start": 0, + "values": [ + { + "repository": { + "slug": "test-repo", + "id": 1, + "name": "Test Repository", + "project": { + "key": "TEST" + } + }, + "file": "src/Config.cs", + "hitContexts": [ + [ + { + "line": 10, + "text": " // Configuration settings" + }, + { + "line": 11, + "text": " public string ConnectionString { get; set; }" + }, + { + "line": 12, + "text": " public int Timeout { get; set; } = 30;" + } + ] + ], + "pathMatches": [], + "hitCount": 1 + } + ] + }, + "query": { + "substituted": false + } +} \ No newline at end of file diff --git a/test/Bitbucket.Net.Tests/Fixtures/Search/code-search-substituted.json b/test/Bitbucket.Net.Tests/Fixtures/Search/code-search-substituted.json new file mode 100644 index 0000000..23d999f --- /dev/null +++ b/test/Bitbucket.Net.Tests/Fixtures/Search/code-search-substituted.json @@ -0,0 +1,37 @@ +{ + "scope": { + "type": "GLOBAL" + }, + "code": { + "category": "primary", + "isLastPage": true, + "count": 1, + "start": 0, + "values": [ + { + "repository": { + "slug": "test-repo", + "id": 1, + "name": "Test Repository", + "project": { + "key": "TEST" + } + }, + "file": "README.md", + "hitContexts": [ + [ + { + "line": 1, + "text": "# Test Repository" + } + ] + ], + "pathMatches": [], + "hitCount": 1 + } + ] + }, + "query": { + "substituted": true + } +} \ No newline at end of file From 6055330380ab5413637a8d83ce8f0dce66fe261e Mon Sep 17 00:00:00 2001 From: diomonogatari <17832206+diomonogatari@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:11:06 +0000 Subject: [PATCH 3/4] feat: add integration and serialization tests for Bitbucket Server Code Search API --- .../Infrastructure/MockSetupExtensions.cs | 32 + .../MockTests/SearchMockTests.cs | 247 ++++++++ .../UnitTests/CodeSearchSerializationTests.cs | 556 ++++++++++++++++++ 3 files changed, 835 insertions(+) create mode 100644 test/Bitbucket.Net.Tests/MockTests/SearchMockTests.cs create mode 100644 test/Bitbucket.Net.Tests/UnitTests/CodeSearchSerializationTests.cs diff --git a/test/Bitbucket.Net.Tests/Infrastructure/MockSetupExtensions.cs b/test/Bitbucket.Net.Tests/Infrastructure/MockSetupExtensions.cs index 06cd610..cdf59ce 100644 --- a/test/Bitbucket.Net.Tests/Infrastructure/MockSetupExtensions.cs +++ b/test/Bitbucket.Net.Tests/Infrastructure/MockSetupExtensions.cs @@ -3355,6 +3355,38 @@ public static WireMockServer SetupGetApplicationProperties(this WireMockServer s #endregion + #region Code Search + + private const string SearchBasePath = "/rest/search/latest"; + + public static WireMockServer SetupSearchCode(this WireMockServer server, string fixtureFile) + { + server.Given(Request.Create() + .WithPath($"{SearchBasePath}/search") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("Content-Type", "application/json") + .WithBodyFromFile(GetFixturePath("Search", fixtureFile))); + + return server; + } + + public static WireMockServer SetupSearchCodeError(this WireMockServer server, HttpStatusCode statusCode) + { + server.Given(Request.Create() + .WithPath($"{SearchBasePath}/search") + .UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(statusCode) + .WithHeader("Content-Type", "application/json") + .WithBody("""{"errors":[{"context":null,"message":"Search is not available","exceptionName":"com.atlassian.bitbucket.search.SearchUnavailableException"}]}""")); + + return server; + } + + #endregion + private static string GetFixturePath(string category, string fileName) { return Path.Combine(FixturesBasePath, category, fileName); diff --git a/test/Bitbucket.Net.Tests/MockTests/SearchMockTests.cs b/test/Bitbucket.Net.Tests/MockTests/SearchMockTests.cs new file mode 100644 index 0000000..2d2cc03 --- /dev/null +++ b/test/Bitbucket.Net.Tests/MockTests/SearchMockTests.cs @@ -0,0 +1,247 @@ +using System.Net; +using Bitbucket.Net.Common.Exceptions; +using Bitbucket.Net.Tests.Infrastructure; +using Xunit; + +namespace Bitbucket.Net.Tests.MockTests; + +/// +/// WireMock-based integration tests for the Bitbucket Server Code Search API. +/// Verifies the full HTTP round-trip: request serialization, POST to /rest/search/latest/search, +/// and response deserialization, using fixture data modeled after the real (undocumented) API shape. +/// +public class SearchMockTests(BitbucketMockFixture fixture) : IClassFixture +{ + private readonly BitbucketMockFixture _fixture = fixture; + + [Fact] + public async Task SearchCodeAsync_ReturnsResults_FromMultipleFiles() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCode("code-search-results.json"); + var client = _fixture.CreateClient(); + + var result = await client.SearchCodeAsync("HttpClient"); + + Assert.NotNull(result); + Assert.NotNull(result.Code); + Assert.Equal(61, result.Code.Count); + Assert.False(result.Code.IsLastPage); + Assert.Equal(25, result.Code.NextStart); + Assert.Equal(2, result.Code.Values.Count); + + var hit1 = result.Code.Values[0]; + Assert.Equal("src/Middleware/RequestHandler.cs", hit1.File); + Assert.Equal(2, hit1.HitCount); + Assert.NotNull(hit1.HitContexts); + Assert.Equal(2, hit1.HitContexts.Count); + Assert.Equal(3, hit1.HitContexts[0].Count); + Assert.Equal(14, hit1.HitContexts[0][0].Line); + Assert.Equal("using System.Net.Http;", hit1.HitContexts[0][0].Text); + Assert.Contains("HttpClient", hit1.HitContexts[1][0].Text); + + var hit2 = result.Code.Values[1]; + Assert.Equal("tests/HandlerTests.cs", hit2.File); + Assert.Equal(1, hit2.HitCount); + Assert.NotNull(hit2.PathMatches); + Assert.Single(hit2.PathMatches); + Assert.Equal(6, hit2.PathMatches[0].Start); + Assert.Equal(7, hit2.PathMatches[0].Length); + } + + [Fact] + public async Task SearchCodeAsync_EmptyResults_ReturnsEmptyValues() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCode("code-search-empty.json"); + var client = _fixture.CreateClient(); + + var result = await client.SearchCodeAsync("nonexistent-query-xyz"); + + Assert.NotNull(result); + Assert.NotNull(result.Code); + Assert.Equal(0, result.Code.Count); + Assert.True(result.Code.IsLastPage); + Assert.Empty(result.Code.Values); + } + + [Fact] + public async Task SearchCodeAsync_SingleHit_DeserializesCorrectly() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCode("code-search-single-hit.json"); + var client = _fixture.CreateClient(); + + var result = await client.SearchCodeAsync("ConnectionString"); + + Assert.NotNull(result); + Assert.NotNull(result.Code); + Assert.Equal(1, result.Code.Count); + Assert.True(result.Code.IsLastPage); + Assert.Single(result.Code.Values); + + var hit = result.Code.Values[0]; + Assert.Equal("src/Config.cs", hit.File); + Assert.Equal(1, hit.HitCount); + Assert.NotNull(hit.Repository); + Assert.Equal(TestConstants.TestRepositorySlug, hit.Repository.Slug); + Assert.NotNull(hit.Repository.Project); + Assert.Equal(TestConstants.TestProjectKey, hit.Repository.Project.Key); + } + + [Fact] + public async Task SearchCodeAsync_SubstitutedQuery_ReflectsInResponse() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCode("code-search-substituted.json"); + var client = _fixture.CreateClient(); + + var result = await client.SearchCodeAsync("Tset"); + + Assert.NotNull(result); + Assert.NotNull(result.Query); + Assert.True(result.Query.Substituted); + } + + [Fact] + public async Task SearchCodeAsync_DefaultScope_IsGlobal() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCode("code-search-results.json"); + var client = _fixture.CreateClient(); + + var result = await client.SearchCodeAsync("HttpClient"); + + Assert.NotNull(result.Scope); + Assert.Equal("GLOBAL", result.Scope.Type); + } + + [Fact] + public async Task SearchCodeAsync_CustomLimits_AreSerializedInRequest() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCode("code-search-single-hit.json"); + var client = _fixture.CreateClient(); + + var result = await client.SearchCodeAsync("test", primaryLimit: 5, secondaryLimit: 3); + + Assert.NotNull(result); + Assert.NotNull(result.Code); + + var logs = _fixture.Server.LogEntries; + var searchLog = Assert.Single(logs, l => l.RequestMessage.Path == "/rest/search/latest/search"); + Assert.Equal("POST", searchLog.RequestMessage.Method, StringComparer.OrdinalIgnoreCase); + Assert.Contains("\"primary\":5", searchLog.RequestMessage.Body); + Assert.Contains("\"secondary\":3", searchLog.RequestMessage.Body); + } + + [Fact] + public async Task SearchCodeAsync_RequestBody_ContainsCodeEntitiesAndQuery() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCode("code-search-empty.json"); + var client = _fixture.CreateClient(); + + await client.SearchCodeAsync("project:TEST repo:test-repo HttpClient"); + + var logs = _fixture.Server.LogEntries; + var searchLog = Assert.Single(logs, l => l.RequestMessage.Path == "/rest/search/latest/search"); + var body = searchLog.RequestMessage.Body; + + Assert.Contains("\"query\":\"project:TEST repo:test-repo HttpClient\"", body); + Assert.Contains("\"code\":{}", body); + Assert.Contains("\"primary\":25", body); + Assert.Contains("\"secondary\":10", body); + } + + [Fact] + public async Task SearchCodeAsync_HitContextLines_PreserveHighlightTags() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCode("code-search-results.json"); + var client = _fixture.CreateClient(); + + var result = await client.SearchCodeAsync("HttpClient"); + + var firstFile = result.Code!.Values[0]; + var highlightedLine = firstFile.HitContexts![1][0]; + Assert.Equal(42, highlightedLine.Line); + Assert.Equal(" var client = new HttpClient();", highlightedLine.Text); + } + + [Fact] + public async Task SearchCodeAsync_ServerReturns404_ThrowsBitbucketNotFoundException() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCodeError(HttpStatusCode.NotFound); + var client = _fixture.CreateClient(); + + var ex = await Assert.ThrowsAsync( + () => client.SearchCodeAsync("anything")); + Assert.Contains("Search is not available", ex.Message); + } + + [Fact] + public async Task SearchCodeAsync_ServerReturns500_ThrowsBitbucketServerException() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCodeError(HttpStatusCode.InternalServerError); + var client = _fixture.CreateClient(); + + var ex = await Assert.ThrowsAsync( + () => client.SearchCodeAsync("anything")); + Assert.Contains("Search is not available", ex.Message); + } + + [Fact] + public async Task IsSearchAvailableAsync_ReturnsTrue_WhenEndpointResponds() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCode("code-search-empty.json"); + var client = _fixture.CreateClient(); + + var available = await client.IsSearchAvailableAsync(); + + Assert.True(available); + } + + [Fact] + public async Task IsSearchAvailableAsync_ReturnsFalse_WhenEndpointReturns404() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCodeError(HttpStatusCode.NotFound); + var client = _fixture.CreateClient(); + + var available = await client.IsSearchAvailableAsync(); + + Assert.False(available); + } + + [Fact] + public async Task IsSearchAvailableAsync_ReturnsFalse_WhenEndpointReturns503() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCodeError(HttpStatusCode.ServiceUnavailable); + var client = _fixture.CreateClient(); + + var available = await client.IsSearchAvailableAsync(); + + Assert.False(available); + } + + [Fact] + public async Task SearchCodeAsync_CancellationToken_IsPropagated() + { + _fixture.Reset(); + _fixture.Server.SetupSearchCode("code-search-results.json"); + var client = _fixture.CreateClient(); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Flurl wraps TaskCanceledException in FlurlHttpException + var ex = await Assert.ThrowsAsync( + () => client.SearchCodeAsync("test", cancellationToken: cts.Token)); + Assert.IsAssignableFrom(ex.InnerException); + } +} diff --git a/test/Bitbucket.Net.Tests/UnitTests/CodeSearchSerializationTests.cs b/test/Bitbucket.Net.Tests/UnitTests/CodeSearchSerializationTests.cs new file mode 100644 index 0000000..f44e1b2 --- /dev/null +++ b/test/Bitbucket.Net.Tests/UnitTests/CodeSearchSerializationTests.cs @@ -0,0 +1,556 @@ +using Bitbucket.Net.Common.Models.Search; +using Bitbucket.Net.Serialization; +using System.Text.Json; +using Xunit; + +namespace Bitbucket.Net.Tests.UnitTests; + +/// +/// Unit tests for the code search model serialization and deserialization. +/// These verify that the source-generated JSON context handles the undocumented +/// Bitbucket Server Code Search API contract correctly. +/// +public class CodeSearchSerializationTests +{ + private readonly JsonSerializerOptions _options = BitbucketJsonContext.Default.Options; + + #region CodeSearchRequest Serialization + + [Fact] + public void CodeSearchRequest_RoundTrips() + { + var request = new CodeSearchRequest + { + Query = "project:PROJ repo:my-repo HttpClient", + Entities = SearchEntities.CodeOnly, + Limits = new SearchLimits { Primary = 10, Secondary = 5 } + }; + + var json = JsonSerializer.Serialize(request, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + Assert.NotNull(deserialized); + Assert.Equal(request.Query, deserialized.Query); + Assert.NotNull(deserialized.Entities); + Assert.NotNull(deserialized.Entities.Code); + Assert.Equal(10, deserialized.Limits.Primary); + Assert.Equal(5, deserialized.Limits.Secondary); + } + + [Fact] + public void CodeSearchRequest_CodeEntities_SerializesAsEmptyObject() + { + var request = new CodeSearchRequest + { + Query = "test", + Entities = SearchEntities.CodeOnly, + Limits = new SearchLimits() + }; + + var json = JsonSerializer.Serialize(request, _options); + + Assert.Contains("\"code\":{}", json); + } + + [Fact] + public void CodeSearchRequest_DefaultLimits_AreCorrect() + { + var request = new CodeSearchRequest + { + Query = "test", + Entities = SearchEntities.CodeOnly, + Limits = new SearchLimits() + }; + + var json = JsonSerializer.Serialize(request, _options); + + Assert.Contains("\"primary\":25", json); + Assert.Contains("\"secondary\":10", json); + } + + [Fact] + public void CodeSearchRequest_CustomLimits_Serialize() + { + var request = new CodeSearchRequest + { + Query = "test", + Entities = SearchEntities.CodeOnly, + Limits = new SearchLimits { Primary = 50, Secondary = 20 } + }; + + var json = JsonSerializer.Serialize(request, _options); + + Assert.Contains("\"primary\":50", json); + Assert.Contains("\"secondary\":20", json); + } + + [Fact] + public void CodeSearchRequest_Query_PreservesSearchSyntax() + { + var query = "project:PROJ repo:my-repo ext:cs path:src/ async await"; + var request = new CodeSearchRequest + { + Query = query, + Entities = SearchEntities.CodeOnly, + Limits = new SearchLimits() + }; + + var json = JsonSerializer.Serialize(request, _options); + + Assert.Contains(query, json); + } + + #endregion + + #region SearchEntities + + [Fact] + public void SearchEntities_CodeOnly_HasNonNullCode() + { + var entities = SearchEntities.CodeOnly; + + Assert.NotNull(entities.Code); + } + + [Fact] + public void SearchEntities_Default_HasNullCode() + { + var entities = new SearchEntities(); + + Assert.Null(entities.Code); + } + + [Fact] + public void SearchEntities_NullCode_OmittedFromJson() + { + var entities = new SearchEntities(); + var json = JsonSerializer.Serialize(entities, _options); + + Assert.DoesNotContain("\"code\"", json); + } + + #endregion + + #region CodeSearchResponse Deserialization + + [Fact] + public void CodeSearchResponse_FullResponse_Deserializes() + { + var json = """ + { + "scope": { "type": "GLOBAL" }, + "code": { + "category": "primary", + "isLastPage": false, + "count": 61, + "start": 0, + "nextStart": 25, + "values": [ + { + "repository": { + "slug": "my-repo", + "id": 123, + "name": "My Repo", + "project": { "key": "PROJ" } + }, + "file": "src/Handler.cs", + "hitContexts": [ + [ + { "line": 10, "text": " var x = await DoWork();" }, + { "line": 11, "text": " return x;" } + ] + ], + "pathMatches": [], + "hitCount": 3 + } + ] + }, + "query": { "substituted": false } + } + """; + + var result = JsonSerializer.Deserialize(json, _options); + + Assert.NotNull(result); + Assert.NotNull(result.Scope); + Assert.Equal("GLOBAL", result.Scope.Type); + Assert.NotNull(result.Code); + Assert.Equal("primary", result.Code.Category); + Assert.False(result.Code.IsLastPage); + Assert.Equal(61, result.Code.Count); + Assert.Equal(0, result.Code.Start); + Assert.Equal(25, result.Code.NextStart); + Assert.Single(result.Code.Values); + Assert.NotNull(result.Query); + Assert.False(result.Query.Substituted); + } + + [Fact] + public void CodeSearchResult_Repository_DeserializesNestedProjectRef() + { + var json = """ + { + "code": { + "isLastPage": true, + "count": 1, + "start": 0, + "values": [ + { + "repository": { + "slug": "my-repo", + "id": 42, + "name": "My Repo", + "project": { + "key": "PROJ" + } + }, + "file": "test.cs", + "hitContexts": [], + "pathMatches": [], + "hitCount": 0 + } + ] + } + } + """; + + var result = JsonSerializer.Deserialize(json, _options); + var repo = result!.Code!.Values[0].Repository; + + Assert.NotNull(repo); + Assert.Equal("my-repo", repo.Slug); + Assert.Equal(42, repo.Id); + Assert.Equal("My Repo", repo.Name); + Assert.NotNull(repo.Project); + Assert.Equal("PROJ", repo.Project.Key); + } + + [Fact] + public void CodeSearchResult_MultipleHitContextBlocks_Deserialize() + { + var json = """ + { + "code": { + "isLastPage": true, + "count": 1, + "start": 0, + "values": [ + { + "file": "app.cs", + "hitContexts": [ + [ + { "line": 5, "text": "before" }, + { "line": 6, "text": "match1" }, + { "line": 7, "text": "after" } + ], + [ + { "line": 20, "text": "match2" } + ] + ], + "pathMatches": [], + "hitCount": 2 + } + ] + } + } + """; + + var result = JsonSerializer.Deserialize(json, _options); + var hit = result!.Code!.Values[0]; + + Assert.NotNull(hit.HitContexts); + Assert.Equal(2, hit.HitContexts.Count); + Assert.Equal(3, hit.HitContexts[0].Count); + Assert.Single(hit.HitContexts[1]); + Assert.Equal(6, hit.HitContexts[0][1].Line); + Assert.Equal("match1", hit.HitContexts[0][1].Text); + Assert.Equal(20, hit.HitContexts[1][0].Line); + } + + [Fact] + public void CodeSearchResult_PathMatches_Deserialize() + { + var json = """ + { + "code": { + "isLastPage": true, + "count": 1, + "start": 0, + "values": [ + { + "file": "src/SearchService.cs", + "hitContexts": [], + "pathMatches": [ + { "start": 4, "length": 6 }, + { "start": 18, "length": 6 } + ], + "hitCount": 0 + } + ] + } + } + """; + + var result = JsonSerializer.Deserialize(json, _options); + var pathMatches = result!.Code!.Values[0].PathMatches; + + Assert.NotNull(pathMatches); + Assert.Equal(2, pathMatches.Count); + Assert.Equal(4, pathMatches[0].Start); + Assert.Equal(6, pathMatches[0].Length); + Assert.Equal(18, pathMatches[1].Start); + } + + [Fact] + public void CodeSearchResponse_EmptyResults_Deserializes() + { + var json = """ + { + "scope": { "type": "GLOBAL" }, + "code": { + "category": "primary", + "isLastPage": true, + "count": 0, + "start": 0, + "values": [] + }, + "query": { "substituted": false } + } + """; + + var result = JsonSerializer.Deserialize(json, _options); + + Assert.NotNull(result); + Assert.NotNull(result.Code); + Assert.Equal(0, result.Code.Count); + Assert.True(result.Code.IsLastPage); + Assert.Empty(result.Code.Values); + } + + [Fact] + public void CodeSearchResponse_NullNextStart_WhenLastPage() + { + var json = """ + { + "code": { + "isLastPage": true, + "count": 5, + "start": 0, + "values": [] + } + } + """; + + var result = JsonSerializer.Deserialize(json, _options); + + Assert.Null(result!.Code!.NextStart); + Assert.True(result.Code.IsLastPage); + } + + [Fact] + public void CodeSearchResponse_QuerySubstitution_Detected() + { + var json = """ + { + "code": { "isLastPage": true, "count": 0, "start": 0, "values": [] }, + "query": { "substituted": true } + } + """; + + var result = JsonSerializer.Deserialize(json, _options); + + Assert.NotNull(result!.Query); + Assert.True(result.Query.Substituted); + } + + [Fact] + public void CodeSearchResponse_MissingOptionalFields_DefaultsGracefully() + { + var json = """ + { + "code": { + "isLastPage": true, + "count": 1, + "start": 0, + "values": [ + { + "file": "readme.md", + "hitCount": 1 + } + ] + } + } + """; + + var result = JsonSerializer.Deserialize(json, _options); + + Assert.Null(result!.Scope); + Assert.Null(result.Query); + var hit = result.Code!.Values[0]; + Assert.Null(hit.Repository); + Assert.Null(hit.HitContexts); + Assert.Null(hit.PathMatches); + Assert.Equal("readme.md", hit.File); + Assert.Equal(1, hit.HitCount); + } + + [Fact] + public void CodeSearchResponse_HighlightTags_PreservedInDeserialization() + { + var json = """ + { + "code": { + "isLastPage": true, + "count": 1, + "start": 0, + "values": [ + { + "file": "test.cs", + "hitContexts": [ + [ + { "line": 1, "text": "var x = await Task.Run();" } + ] + ], + "hitCount": 1 + } + ] + } + } + """; + + var result = JsonSerializer.Deserialize(json, _options); + var text = result!.Code!.Values[0].HitContexts![0][0].Text; + + Assert.Equal("var x = await Task.Run();", text); + } + + #endregion + + #region SearchLimits Defaults + + [Fact] + public void SearchLimits_DefaultValues_Are25And10() + { + var limits = new SearchLimits(); + + Assert.Equal(25, limits.Primary); + Assert.Equal(10, limits.Secondary); + } + + [Fact] + public void SearchLimits_RoundTrips() + { + var limits = new SearchLimits { Primary = 100, Secondary = 50 }; + + var json = JsonSerializer.Serialize(limits, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + Assert.NotNull(deserialized); + Assert.Equal(100, deserialized.Primary); + Assert.Equal(50, deserialized.Secondary); + } + + #endregion + + #region SearchScope + + [Fact] + public void SearchScope_RoundTrips() + { + var scope = new SearchScope { Type = "REPOSITORY" }; + + var json = JsonSerializer.Serialize(scope, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + Assert.NotNull(deserialized); + Assert.Equal("REPOSITORY", deserialized.Type); + } + + #endregion + + #region SearchPathMatch + + [Fact] + public void SearchPathMatch_RoundTrips() + { + var match = new SearchPathMatch { Start = 10, Length = 5 }; + + var json = JsonSerializer.Serialize(match, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + Assert.NotNull(deserialized); + Assert.Equal(10, deserialized.Start); + Assert.Equal(5, deserialized.Length); + } + + #endregion + + #region CodeSearchHitLine + + [Fact] + public void CodeSearchHitLine_RoundTrips() + { + var hitLine = new CodeSearchHitLine { Line = 42, Text = "the answer" }; + + var json = JsonSerializer.Serialize(hitLine, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + Assert.NotNull(deserialized); + Assert.Equal(42, deserialized.Line); + Assert.Equal("the answer", deserialized.Text); + } + + [Fact] + public void CodeSearchHitLine_NullText_Allowed() + { + var hitLine = new CodeSearchHitLine { Line = 1 }; + + var json = JsonSerializer.Serialize(hitLine, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + Assert.NotNull(deserialized); + Assert.Null(deserialized.Text); + } + + #endregion + + #region SearchQuery + + [Fact] + public void SearchQuery_RoundTrips() + { + var query = new SearchQuery { Substituted = true }; + + var json = JsonSerializer.Serialize(query, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + Assert.NotNull(deserialized); + Assert.True(deserialized.Substituted); + } + + #endregion + + #region SearchEntityFilter + + [Fact] + public void SearchEntityFilter_SerializesToEmptyObject() + { + var filter = new SearchEntityFilter(); + var json = JsonSerializer.Serialize(filter, _options); + + Assert.Equal("{}", json); + } + + [Fact] + public void SearchEntityFilter_RoundTrips() + { + var filter = new SearchEntityFilter(); + + var json = JsonSerializer.Serialize(filter, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + Assert.NotNull(deserialized); + } + + #endregion +} From 7912c58c0a9bb7e384aaf744ac2d7fbbd1ddea92 Mon Sep 17 00:00:00 2001 From: diomonogatari <17832206+diomonogatari@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:11:06 +0000 Subject: [PATCH 4/4] fix: add missing newline at end of file in search mock and serialization tests --- test/Bitbucket.Net.Tests/MockTests/SearchMockTests.cs | 4 ++-- .../UnitTests/CodeSearchSerializationTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/Bitbucket.Net.Tests/MockTests/SearchMockTests.cs b/test/Bitbucket.Net.Tests/MockTests/SearchMockTests.cs index 2d2cc03..95483e4 100644 --- a/test/Bitbucket.Net.Tests/MockTests/SearchMockTests.cs +++ b/test/Bitbucket.Net.Tests/MockTests/SearchMockTests.cs @@ -1,6 +1,6 @@ -using System.Net; using Bitbucket.Net.Common.Exceptions; using Bitbucket.Net.Tests.Infrastructure; +using System.Net; using Xunit; namespace Bitbucket.Net.Tests.MockTests; @@ -244,4 +244,4 @@ public async Task SearchCodeAsync_CancellationToken_IsPropagated() () => client.SearchCodeAsync("test", cancellationToken: cts.Token)); Assert.IsAssignableFrom(ex.InnerException); } -} +} \ No newline at end of file diff --git a/test/Bitbucket.Net.Tests/UnitTests/CodeSearchSerializationTests.cs b/test/Bitbucket.Net.Tests/UnitTests/CodeSearchSerializationTests.cs index f44e1b2..619501a 100644 --- a/test/Bitbucket.Net.Tests/UnitTests/CodeSearchSerializationTests.cs +++ b/test/Bitbucket.Net.Tests/UnitTests/CodeSearchSerializationTests.cs @@ -553,4 +553,4 @@ public void SearchEntityFilter_RoundTrips() } #endregion -} +} \ No newline at end of file