Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
484 changes: 484 additions & 0 deletions .gitignore

Large diffs are not rendered by default.

138 changes: 138 additions & 0 deletions Flow.ConfluenceSearch.Test/ConfluenceQueryBuilderTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using Flow.ConfluenceSearch.ConfluenceClient;
using Shouldly;

namespace Flow.ConfluenceSearch.Test;

public class ConfluenceQueryBuilderTest
{
[Theory]
[InlineData(
"test",
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test*",
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test example",
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"test* example*\" OR text~\"test* example*\") order by lastmodified DESC"
)]
[InlineData(
"test* example*",
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"test* example*\" OR text~\"test* example*\") order by lastmodified DESC"
)]
[InlineData(
"*test",
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"\\*test*\" OR text~\"\\*test*\") order by lastmodified DESC"
)]
[InlineData(
"\\*+-!():^[]{}~?|&/\"'",
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"\\\\\\*\\+\\-\\!\\(\\)\\:\\^\\[\\]\\{\\}\\~\\?\\|\\&\\/\\\"\\\'*\" OR text~\"\\\\\\*\\+\\-\\!\\(\\)\\:\\^\\[\\]\\{\\}\\~\\?\\|\\&\\/\\\"\\\'*\") order by lastmodified DESC"
)]
[InlineData(
"test #myspace",
"space IN (myspace) AND type IN(page,blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test #myspace #yourspace",
"space IN (myspace,yourspace) AND type IN(page,blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test @me",
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (contributor = currentUser()) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test @john",
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (contributor.fullname ~ john) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test @john @me",
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (contributor = currentUser() OR contributor.fullname ~ john) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test +label1",
"space IN (AAA,BBB) AND type IN(page,blogpost) AND label IN (label1) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test +label1 +label2",
"space IN (AAA,BBB) AND type IN(page,blogpost) AND label IN (label1,label2) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test /",
"space IN (AAA,BBB) AND type IN(folder) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test .",
"space IN (AAA,BBB) AND type IN(page) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test \"",
"space IN (AAA,BBB) AND type IN(blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test / . \"",
"space IN (AAA,BBB) AND type IN(folder,blogpost,page) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test *",
"space IN (AAA,BBB) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
[InlineData(
"test / +label1 @me #myspace @john . +label2 \" #yourspace",
"space IN (myspace,yourspace) AND type IN(folder,blogpost,page) AND label IN (label1,label2) AND (contributor = currentUser() OR contributor.fullname ~ john) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
)]
public async Task BuildTextCqlTest(string input, string expectedOutput)
{
// Act
var queryBuilder = new ConfluenceQueryBuilder();

var output = await queryBuilder.BuildTextCql(
input,
["AAA", "BBB"],
TestContext.Current.CancellationToken
);

output.ShouldBe(expectedOutput);
}

[Theory]
[InlineData("test", "spaces=AAA,BBB&text=test")]
[InlineData("test*", "spaces=AAA,BBB&text=test*")]
[InlineData("test example", "spaces=AAA,BBB&text=test example")]
[InlineData("test* example*", "spaces=AAA,BBB&text=test* example*")]
[InlineData("*test", "spaces=AAA,BBB&text=*test")]
[InlineData(
"spaces=AAA,BBB&text=\\*+-!():^[]{}~?|&/\"'",
"spaces=AAA,BBB&text=spaces=AAA,BBB&text=\\*+-!():^[]{}~?|&/\"'"
)]
[InlineData("test #myspace", "space=myspace&text=test")]
[InlineData("test #myspace #yourspace", "space=myspace,yourspace&text=test")]
[InlineData("test @me", "spaces=AAA,BBB&text=test")]
[InlineData("test @john", "spaces=AAA,BBB&text=test")]
[InlineData("test @john @me", "spaces=AAA,BBB&text=test")]
[InlineData("test +label1", "spaces=AAA,BBB&labels=label1&text=test")]
[InlineData("test +label1 +label2", "spaces=AAA,BBB&labels=label1,label2&text=test")]
[InlineData("test /", "spaces=AAA,BBB&type=folder&text=test")]
[InlineData("test .", "spaces=AAA,BBB&type=page&text=test")]
[InlineData("test \"", "spaces=AAA,BBB&type=blogpost&text=test")]
[InlineData("test / . \"", "spaces=AAA,BBB&type=folder&type=blogpost&type=page&text=test")]
[InlineData("test *", "spaces=AAA,BBB&text=test")]
[InlineData(
"test / +label1 @me #myspace @john . +label2 : #yourspace",
"space=myspace,yourspace&type=folder&type=page&labels=label1,label2&text=test"
)]
public async Task BuildQueryForOpenInBrowserTest(string input, string expectedOutput)
{
// Act
var queryBuilder = new ConfluenceQueryBuilder();

var output = await queryBuilder.BuildQueryForOpenInBrowser(
input,
["AAA", "BBB"],
TestContext.Current.CancellationToken
);

output.ShouldBe(expectedOutput);
}
}
186 changes: 186 additions & 0 deletions Flow.ConfluenceSearch.Test/ConfluenceSearchClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
using System.Linq;
using System.Net;
using System.Text.Json;
using Flow.ConfluenceSearch.ConfluenceClient;
using Shouldly;

namespace Flow.ConfluenceSearch.Test;

public class ConfluenceSearchClientTests : IDisposable
{
private readonly TestHttpMessageHandler _httpMessageHandler;
private readonly HttpClient _httpClient;
private readonly ConfluenceSearchClient _searchClient;

public ConfluenceSearchClientTests()
{
_httpMessageHandler = new TestHttpMessageHandler();
_httpClient = new HttpClient(_httpMessageHandler)
{
BaseAddress = new Uri("https://test.atlassian.net/"),
};
var httpFactory = () => _httpClient;
_searchClient = new ConfluenceSearchClient(httpFactory);
}

[Fact]
public async Task SearchCqlAsync_WithValidCql_ReturnsConfluenceResponse()
{
// Arrange
const string cql = "project = TEST";
const int maxResults = 50;
var cancellationToken = CancellationToken.None;

var expectedResponse = new ContentSearchResponse
{
Results = new List<ConfluenceSearchItemDto>
{
new()
{
Excerpt = "TEST-1",
Content = new ConfluenceContentDto
{
Id = "Test Issue Summary",
History = new ConfluenceHistoryDto
{
LastUpdated = new ConfluenceLastUpdatedDto
{
By = new ConfluenceUserDto
{
DisplayName = "John Doe",
ProfilePicture = new ConfluenceProfilePictureDto
{
Path = "High",
},
},
},
},
},
Title = "TEST",
Url = "Bug",
ResultGlobalContainer = new ResultGlobalContainerDto
{
DisplayUrl = "/spaces/John Doe/pages",
},
},
},
};
_httpMessageHandler.SetResponse(
HttpStatusCode.OK,
JsonSerializer.Serialize(expectedResponse)
);

// Act
var result = await _searchClient.SearchCqlAsync(cql, maxResults, cancellationToken);

// Assert
result.ShouldNotBeNull();
result.Results.ShouldNotBeEmpty();
result.Results.Count.ShouldBe(1);

// Test issue details
var issue = result.Results[0];
issue.Excerpt.ShouldBe("TEST-1");
issue.Content.Id.ShouldBe("Test Issue Summary");
issue.LastUpdatedByAvatarPath.ShouldBe("High");
issue.SpaceKey.ShouldBe("John Doe");
issue.Title.ShouldBe("TEST");
issue.Url.ShouldBe("Bug");
}

[Fact]
public async Task SearchCqlAsync_WhenApiReturnsError_ThrowsApplicationException()
{
// Arrange
var errorContent = "server error details";
_httpMessageHandler.SetResponse(HttpStatusCode.InternalServerError, errorContent);

// Act & Assert
var ex = await Should.ThrowAsync<ApplicationException>(async () =>
await _searchClient.SearchCqlAsync("invalid cql", 10, CancellationToken.None)
);
ex.Message.ShouldContain(errorContent);
}

[Fact]
public async Task SearchCqlAsync_WithEmptyResults_ReturnsEmptyList()
{
// Arrange
var expected = new ContentSearchResponse { Results = new List<ConfluenceSearchItemDto>() };
_httpMessageHandler.SetResponse(HttpStatusCode.OK, JsonSerializer.Serialize(expected));

// Act
var result = await _searchClient.SearchCqlAsync("no results", 10, CancellationToken.None);

// Assert
result.ShouldNotBeNull();
result.Results.ShouldBeEmpty();
}

[Fact]
public async Task SearchCqlAsync_WithSpecialCharacters_PreservesFieldsAndBuildsBrowseUrl()
{
// Arrange
var special = "\\*+-!():^[]{}~?|&/\\\"'";
var expected = new ContentSearchResponse
{
Results = new List<ConfluenceSearchItemDto>
{
new()
{
Excerpt = special,
Title = special,
Url = "/pages/viewpage.action?pageId=123",
Content = new ConfluenceContentDto
{
Id = special,
History = new ConfluenceHistoryDto
{
LastUpdated = new ConfluenceLastUpdatedDto
{
By = new ConfluenceUserDto
{
DisplayName = special,
ProfilePicture = new ConfluenceProfilePictureDto
{
Path = "/avatars/1",
},
},
},
},
},
ResultGlobalContainer = new ResultGlobalContainerDto
{
DisplayUrl = "/spaces/KEY/pages",
},
},
},
};
_httpMessageHandler.SetResponse(HttpStatusCode.OK, JsonSerializer.Serialize(expected));

// Act
var result = await _searchClient.SearchCqlAsync("special", 10, CancellationToken.None);

// Assert
result.ShouldNotBeNull();
result.Results.Count.ShouldBe(1);
var item = result.Results.Single();
item.Title.ShouldBe(special);
item.Excerpt.ShouldBe(special);
item.Content.Id.ShouldBe(special);
item.LastUpdatedByName.ShouldBe(special);
item.LastUpdatedByAvatarPath.ShouldBe("/avatars/1");

// BrowseUrl should contain base + /wiki + url
var browse = item.BrowseUrl(_httpClient.BaseAddress!.ToString());
browse.ShouldContain("/wiki");
browse.ShouldContain("viewpage.action");
}

public void Dispose()
{
_httpClient?.Dispose();
_httpMessageHandler?.Dispose();
GC.SuppressFinalize(this);
}
}
25 changes: 25 additions & 0 deletions Flow.ConfluenceSearch.Test/Flow.ConfluenceSearch.Test.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit.v3" Version="3.1.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Flow.ConfluenceSearch\Flow.ConfluenceSearch.csproj" />
</ItemGroup>
</Project>
42 changes: 42 additions & 0 deletions Flow.ConfluenceSearch.Test/TestHttpMessageHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.Net;
using System.Text;

namespace Flow.ConfluenceSearch.Test;

public class TestHttpMessageHandler : HttpMessageHandler
{
private HttpStatusCode _statusCode = HttpStatusCode.OK;
private string _responseContent = "";

public HttpRequestMessage? LastRequest { get; private set; }
public string? LastRequestBody { get; private set; }
public int RequestCount { get; private set; }

public void SetResponse(HttpStatusCode statusCode, string content)
{
_statusCode = statusCode;
_responseContent = content;
}

protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken
)
{
RequestCount++;
LastRequest = request;

// Request Body lesen (falls vorhanden)
if (request.Content != null)
{
LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken);
}

var response = new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_responseContent, Encoding.UTF8, "application/json"),
};

return response;
}
}
1 change: 1 addition & 0 deletions Flow.ConfluenceSearch.Tests/Search/ResultContextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Loading