Skip to content

Commit bcdfb49

Browse files
authored
Merge pull request #2 from haugjan/feature
Init
2 parents 4a15cd4 + f8eb25e commit bcdfb49

31 files changed

Lines changed: 2250 additions & 0 deletions

.gitignore

Lines changed: 484 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
using Flow.ConfluenceSearch.ConfluenceClient;
2+
using Shouldly;
3+
4+
namespace Flow.ConfluenceSearch.Test;
5+
6+
public class ConfluenceQueryBuilderTest
7+
{
8+
[Theory]
9+
[InlineData(
10+
"test",
11+
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
12+
)]
13+
[InlineData(
14+
"test*",
15+
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
16+
)]
17+
[InlineData(
18+
"test example",
19+
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"test* example*\" OR text~\"test* example*\") order by lastmodified DESC"
20+
)]
21+
[InlineData(
22+
"test* example*",
23+
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"test* example*\" OR text~\"test* example*\") order by lastmodified DESC"
24+
)]
25+
[InlineData(
26+
"*test",
27+
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"\\*test*\" OR text~\"\\*test*\") order by lastmodified DESC"
28+
)]
29+
[InlineData(
30+
"\\*+-!():^[]{}~?|&/\"'",
31+
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"\\\\\\*\\+\\-\\!\\(\\)\\:\\^\\[\\]\\{\\}\\~\\?\\|\\&\\/\\\"\\\'*\" OR text~\"\\\\\\*\\+\\-\\!\\(\\)\\:\\^\\[\\]\\{\\}\\~\\?\\|\\&\\/\\\"\\\'*\") order by lastmodified DESC"
32+
)]
33+
[InlineData(
34+
"test #myspace",
35+
"space IN (myspace) AND type IN(page,blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
36+
)]
37+
[InlineData(
38+
"test #myspace #yourspace",
39+
"space IN (myspace,yourspace) AND type IN(page,blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
40+
)]
41+
[InlineData(
42+
"test @me",
43+
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (contributor = currentUser()) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
44+
)]
45+
[InlineData(
46+
"test @john",
47+
"space IN (AAA,BBB) AND type IN(page,blogpost) AND (contributor.fullname ~ john) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
48+
)]
49+
[InlineData(
50+
"test @john @me",
51+
"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"
52+
)]
53+
[InlineData(
54+
"test +label1",
55+
"space IN (AAA,BBB) AND type IN(page,blogpost) AND label IN (label1) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
56+
)]
57+
[InlineData(
58+
"test +label1 +label2",
59+
"space IN (AAA,BBB) AND type IN(page,blogpost) AND label IN (label1,label2) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
60+
)]
61+
[InlineData(
62+
"test /",
63+
"space IN (AAA,BBB) AND type IN(folder) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
64+
)]
65+
[InlineData(
66+
"test .",
67+
"space IN (AAA,BBB) AND type IN(page) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
68+
)]
69+
[InlineData(
70+
"test \"",
71+
"space IN (AAA,BBB) AND type IN(blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
72+
)]
73+
[InlineData(
74+
"test / . \"",
75+
"space IN (AAA,BBB) AND type IN(folder,blogpost,page) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
76+
)]
77+
[InlineData(
78+
"test *",
79+
"space IN (AAA,BBB) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC"
80+
)]
81+
[InlineData(
82+
"test / +label1 @me #myspace @john . +label2 \" #yourspace",
83+
"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"
84+
)]
85+
public async Task BuildTextCqlTest(string input, string expectedOutput)
86+
{
87+
// Act
88+
var queryBuilder = new ConfluenceQueryBuilder();
89+
90+
var output = await queryBuilder.BuildTextCql(
91+
input,
92+
["AAA", "BBB"],
93+
TestContext.Current.CancellationToken
94+
);
95+
96+
output.ShouldBe(expectedOutput);
97+
}
98+
99+
[Theory]
100+
[InlineData("test", "spaces=AAA,BBB&text=test")]
101+
[InlineData("test*", "spaces=AAA,BBB&text=test*")]
102+
[InlineData("test example", "spaces=AAA,BBB&text=test example")]
103+
[InlineData("test* example*", "spaces=AAA,BBB&text=test* example*")]
104+
[InlineData("*test", "spaces=AAA,BBB&text=*test")]
105+
[InlineData(
106+
"spaces=AAA,BBB&text=\\*+-!():^[]{}~?|&/\"'",
107+
"spaces=AAA,BBB&text=spaces=AAA,BBB&text=\\*+-!():^[]{}~?|&/\"'"
108+
)]
109+
[InlineData("test #myspace", "space=myspace&text=test")]
110+
[InlineData("test #myspace #yourspace", "space=myspace,yourspace&text=test")]
111+
[InlineData("test @me", "spaces=AAA,BBB&text=test")]
112+
[InlineData("test @john", "spaces=AAA,BBB&text=test")]
113+
[InlineData("test @john @me", "spaces=AAA,BBB&text=test")]
114+
[InlineData("test +label1", "spaces=AAA,BBB&labels=label1&text=test")]
115+
[InlineData("test +label1 +label2", "spaces=AAA,BBB&labels=label1,label2&text=test")]
116+
[InlineData("test /", "spaces=AAA,BBB&type=folder&text=test")]
117+
[InlineData("test .", "spaces=AAA,BBB&type=page&text=test")]
118+
[InlineData("test \"", "spaces=AAA,BBB&type=blogpost&text=test")]
119+
[InlineData("test / . \"", "spaces=AAA,BBB&type=folder&type=blogpost&type=page&text=test")]
120+
[InlineData("test *", "spaces=AAA,BBB&text=test")]
121+
[InlineData(
122+
"test / +label1 @me #myspace @john . +label2 : #yourspace",
123+
"space=myspace,yourspace&type=folder&type=page&labels=label1,label2&text=test"
124+
)]
125+
public async Task BuildQueryForOpenInBrowserTest(string input, string expectedOutput)
126+
{
127+
// Act
128+
var queryBuilder = new ConfluenceQueryBuilder();
129+
130+
var output = await queryBuilder.BuildQueryForOpenInBrowser(
131+
input,
132+
["AAA", "BBB"],
133+
TestContext.Current.CancellationToken
134+
);
135+
136+
output.ShouldBe(expectedOutput);
137+
}
138+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
using System.Linq;
2+
using System.Net;
3+
using System.Text.Json;
4+
using Flow.ConfluenceSearch.ConfluenceClient;
5+
using Shouldly;
6+
7+
namespace Flow.ConfluenceSearch.Test;
8+
9+
public class ConfluenceSearchClientTests : IDisposable
10+
{
11+
private readonly TestHttpMessageHandler _httpMessageHandler;
12+
private readonly HttpClient _httpClient;
13+
private readonly ConfluenceSearchClient _searchClient;
14+
15+
public ConfluenceSearchClientTests()
16+
{
17+
_httpMessageHandler = new TestHttpMessageHandler();
18+
_httpClient = new HttpClient(_httpMessageHandler)
19+
{
20+
BaseAddress = new Uri("https://test.atlassian.net/"),
21+
};
22+
var httpFactory = () => _httpClient;
23+
_searchClient = new ConfluenceSearchClient(httpFactory);
24+
}
25+
26+
[Fact]
27+
public async Task SearchCqlAsync_WithValidCql_ReturnsConfluenceResponse()
28+
{
29+
// Arrange
30+
const string cql = "project = TEST";
31+
const int maxResults = 50;
32+
var cancellationToken = CancellationToken.None;
33+
34+
var expectedResponse = new ContentSearchResponse
35+
{
36+
Results = new List<ConfluenceSearchItemDto>
37+
{
38+
new()
39+
{
40+
Excerpt = "TEST-1",
41+
Content = new ConfluenceContentDto
42+
{
43+
Id = "Test Issue Summary",
44+
History = new ConfluenceHistoryDto
45+
{
46+
LastUpdated = new ConfluenceLastUpdatedDto
47+
{
48+
By = new ConfluenceUserDto
49+
{
50+
DisplayName = "John Doe",
51+
ProfilePicture = new ConfluenceProfilePictureDto
52+
{
53+
Path = "High",
54+
},
55+
},
56+
},
57+
},
58+
},
59+
Title = "TEST",
60+
Url = "Bug",
61+
ResultGlobalContainer = new ResultGlobalContainerDto
62+
{
63+
DisplayUrl = "/spaces/John Doe/pages",
64+
},
65+
},
66+
},
67+
};
68+
_httpMessageHandler.SetResponse(
69+
HttpStatusCode.OK,
70+
JsonSerializer.Serialize(expectedResponse)
71+
);
72+
73+
// Act
74+
var result = await _searchClient.SearchCqlAsync(cql, maxResults, cancellationToken);
75+
76+
// Assert
77+
result.ShouldNotBeNull();
78+
result.Results.ShouldNotBeEmpty();
79+
result.Results.Count.ShouldBe(1);
80+
81+
// Test issue details
82+
var issue = result.Results[0];
83+
issue.Excerpt.ShouldBe("TEST-1");
84+
issue.Content.Id.ShouldBe("Test Issue Summary");
85+
issue.LastUpdatedByAvatarPath.ShouldBe("High");
86+
issue.SpaceKey.ShouldBe("John Doe");
87+
issue.Title.ShouldBe("TEST");
88+
issue.Url.ShouldBe("Bug");
89+
}
90+
91+
[Fact]
92+
public async Task SearchCqlAsync_WhenApiReturnsError_ThrowsApplicationException()
93+
{
94+
// Arrange
95+
var errorContent = "server error details";
96+
_httpMessageHandler.SetResponse(HttpStatusCode.InternalServerError, errorContent);
97+
98+
// Act & Assert
99+
var ex = await Should.ThrowAsync<ApplicationException>(async () =>
100+
await _searchClient.SearchCqlAsync("invalid cql", 10, CancellationToken.None)
101+
);
102+
ex.Message.ShouldContain(errorContent);
103+
}
104+
105+
[Fact]
106+
public async Task SearchCqlAsync_WithEmptyResults_ReturnsEmptyList()
107+
{
108+
// Arrange
109+
var expected = new ContentSearchResponse { Results = new List<ConfluenceSearchItemDto>() };
110+
_httpMessageHandler.SetResponse(HttpStatusCode.OK, JsonSerializer.Serialize(expected));
111+
112+
// Act
113+
var result = await _searchClient.SearchCqlAsync("no results", 10, CancellationToken.None);
114+
115+
// Assert
116+
result.ShouldNotBeNull();
117+
result.Results.ShouldBeEmpty();
118+
}
119+
120+
[Fact]
121+
public async Task SearchCqlAsync_WithSpecialCharacters_PreservesFieldsAndBuildsBrowseUrl()
122+
{
123+
// Arrange
124+
var special = "\\*+-!():^[]{}~?|&/\\\"'";
125+
var expected = new ContentSearchResponse
126+
{
127+
Results = new List<ConfluenceSearchItemDto>
128+
{
129+
new()
130+
{
131+
Excerpt = special,
132+
Title = special,
133+
Url = "/pages/viewpage.action?pageId=123",
134+
Content = new ConfluenceContentDto
135+
{
136+
Id = special,
137+
History = new ConfluenceHistoryDto
138+
{
139+
LastUpdated = new ConfluenceLastUpdatedDto
140+
{
141+
By = new ConfluenceUserDto
142+
{
143+
DisplayName = special,
144+
ProfilePicture = new ConfluenceProfilePictureDto
145+
{
146+
Path = "/avatars/1",
147+
},
148+
},
149+
},
150+
},
151+
},
152+
ResultGlobalContainer = new ResultGlobalContainerDto
153+
{
154+
DisplayUrl = "/spaces/KEY/pages",
155+
},
156+
},
157+
},
158+
};
159+
_httpMessageHandler.SetResponse(HttpStatusCode.OK, JsonSerializer.Serialize(expected));
160+
161+
// Act
162+
var result = await _searchClient.SearchCqlAsync("special", 10, CancellationToken.None);
163+
164+
// Assert
165+
result.ShouldNotBeNull();
166+
result.Results.Count.ShouldBe(1);
167+
var item = result.Results.Single();
168+
item.Title.ShouldBe(special);
169+
item.Excerpt.ShouldBe(special);
170+
item.Content.Id.ShouldBe(special);
171+
item.LastUpdatedByName.ShouldBe(special);
172+
item.LastUpdatedByAvatarPath.ShouldBe("/avatars/1");
173+
174+
// BrowseUrl should contain base + /wiki + url
175+
var browse = item.BrowseUrl(_httpClient.BaseAddress!.ToString());
176+
browse.ShouldContain("/wiki");
177+
browse.ShouldContain("viewpage.action");
178+
}
179+
180+
public void Dispose()
181+
{
182+
_httpClient?.Dispose();
183+
_httpMessageHandler?.Dispose();
184+
GC.SuppressFinalize(this);
185+
}
186+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net9.0-windows</TargetFramework>
4+
<ImplicitUsings>enable</ImplicitUsings>
5+
<Nullable>enable</Nullable>
6+
<IsPackable>false</IsPackable>
7+
</PropertyGroup>
8+
<ItemGroup>
9+
<PackageReference Include="coverlet.collector" Version="6.0.2" />
10+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
11+
<PackageReference Include="NSubstitute" Version="5.3.0" />
12+
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17">
13+
<PrivateAssets>all</PrivateAssets>
14+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
15+
</PackageReference>
16+
<PackageReference Include="Shouldly" Version="4.3.0" />
17+
<PackageReference Include="xunit.v3" Version="3.1.0" />
18+
</ItemGroup>
19+
<ItemGroup>
20+
<Using Include="Xunit" />
21+
</ItemGroup>
22+
<ItemGroup>
23+
<ProjectReference Include="..\Flow.ConfluenceSearch\Flow.ConfluenceSearch.csproj" />
24+
</ItemGroup>
25+
</Project>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.Net;
2+
using System.Text;
3+
4+
namespace Flow.ConfluenceSearch.Test;
5+
6+
public class TestHttpMessageHandler : HttpMessageHandler
7+
{
8+
private HttpStatusCode _statusCode = HttpStatusCode.OK;
9+
private string _responseContent = "";
10+
11+
public HttpRequestMessage? LastRequest { get; private set; }
12+
public string? LastRequestBody { get; private set; }
13+
public int RequestCount { get; private set; }
14+
15+
public void SetResponse(HttpStatusCode statusCode, string content)
16+
{
17+
_statusCode = statusCode;
18+
_responseContent = content;
19+
}
20+
21+
protected override async Task<HttpResponseMessage> SendAsync(
22+
HttpRequestMessage request,
23+
CancellationToken cancellationToken
24+
)
25+
{
26+
RequestCount++;
27+
LastRequest = request;
28+
29+
// Request Body lesen (falls vorhanden)
30+
if (request.Content != null)
31+
{
32+
LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken);
33+
}
34+
35+
var response = new HttpResponseMessage(_statusCode)
36+
{
37+
Content = new StringContent(_responseContent, Encoding.UTF8, "application/json"),
38+
};
39+
40+
return response;
41+
}
42+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+


0 commit comments

Comments
 (0)