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