Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
31c870c
updates E2E test to work with polling
tomhawkin Mar 26, 2026
f1f704a
updates based on feedback
tomhawkin Mar 27, 2026
468ad14
SUI-1547: small tidy/updates:
robshakespeare Mar 30, 2026
2a02b89
Resolve code analysis warnings
robshakespeare Mar 30, 2026
a351732
SQ strangeness
robshakespeare Mar 30, 2026
4f5fe0c
SQ strangeness 2
robshakespeare Mar 30, 2026
89f6f78
Resolve SQ issue
robshakespeare Mar 30, 2026
8ab5c83
Merge remote-tracking branch 'origin/main' into feat/sui-1547
robshakespeare Mar 30, 2026
4f0ff55
Merge branch 'main' into feat/sui-1547
tomhawkin Mar 31, 2026
3b67256
sui-1653: remove duplicated auth-clients.json file
udara-jay Apr 1, 2026
7273a7b
sui-1653: recommit deleted file
udara-jay Apr 1, 2026
1fcf056
chore: sui-1653 - rename json auth client files part 1
udara-jay Apr 1, 2026
a81d8c5
sui-1653 - update gitleaks, trufflehog exclude paths and remove dupli…
udara-jay Apr 1, 2026
5c741ef
fix: SUI-1542 - polling fixes
robshakespeare Apr 1, 2026
6faf1ae
fix: SUI-1542 - polling fix: multiple records can be submitted per jo…
robshakespeare Apr 1, 2026
40dbcca
Merge branch 'polling-fixes-2' into feat/sui-1547_TEST
robshakespeare Apr 1, 2026
518c6ed
e2e: SUI-1547 - more updates to cater for the differences between ver…
robshakespeare Apr 2, 2026
bbf6cca
feat: SUI-1662 - mask record URLs in the Search Results, in the polli…
robshakespeare Apr 2, 2026
b8134b8
e2e: SUI-1547 - polling implementation is inherently slower, so needs…
robshakespeare Apr 2, 2026
8149340
Merge branch 'SUI-1662_mask-polling-rec-urls' into feat/sui-1547
robshakespeare Apr 2, 2026
3720492
fix: problem response from Custodian should set correct status code (…
robshakespeare Apr 2, 2026
875c24c
fix: HTTP->HTTPS redirect was breaking fetch record, so allow Stubs t…
robshakespeare Apr 2, 2026
5f0da67
workaround: increase URL TTL, the Polling Architecture needs longer b…
robshakespeare Apr 2, 2026
00f34ac
SUI-1664: fix AI found code analysis issues
robshakespeare Apr 2, 2026
d8cc51d
fix: SUI-1664 - corrected fundamental design flaw in logic to calcula…
robshakespeare Apr 2, 2026
0c5b8b1
Merge branch 'SUI-1664' into feat/sui-1547
robshakespeare Apr 2, 2026
0ebeaf8
chore: improved assertion
robshakespeare Apr 2, 2026
29f4bb4
fix: codeQL issue
robshakespeare Apr 2, 2026
9d51a22
Merge remote-tracking branch 'origin/main' into feat/sui-1547
robshakespeare Apr 2, 2026
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
14 changes: 3 additions & 11 deletions Apps/Find/src/SUI.Find.Application/Dtos/SearchResultEntry.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
using SUI.Find.Application.Models;

namespace SUI.Find.Application.Dtos;

public class SearchResultEntry
public record SearchResultEntry : SearchResultItem
{
/// <summary>
/// Submitting custodian's Org ID
/// </summary>
public required string CustodianId { get; init; }

public required string SystemId { get; init; }

/// <summary>
/// Submitting custodian's Org Name
/// </summary>
public required string CustodianName { get; init; }

public required string RecordType { get; init; }
public required string RecordUrl { get; init; }
public string? RecordId { get; init; }
public DateTimeOffset SubmittedAtUtc { get; init; }
public required string JobId { get; init; }
public required string WorkItemId { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace SUI.Find.Application.Models;

public sealed record SearchResultItem
public record SearchResultItem
{
public required string RecordType { get; init; }
public required string RecordUrl { get; init; }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
using SUI.Find.Application.Dtos;
using SUI.Find.Application.Enums;

namespace SUI.Find.Application.Models;

public record SearchResultsV2Dto
public record SearchResultsV2Dto : SearchResultsDto
{
public string WorkItemId { get; init; } = string.Empty;
public string Suid { get; init; } = string.Empty;
public SearchStatus Status { get; init; }
public IReadOnlyList<SearchResultEntry> Items { get; init; } = [];
public int CompletenessPercentage { get; init; }
}
14 changes: 4 additions & 10 deletions Apps/Find/src/SUI.Find.FindApi/Models/SearchResults.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
using System.Text.Json.Serialization;
using SUI.Find.Application.Enums;
using SUI.Find.Application.Models;

namespace SUI.Find.FindApi.Models;

public record SearchResults
public record SearchResults : SearchResultsBase
{
public required string JobId { get; set; } = string.Empty;
public required string Suid { get; set; } = string.Empty;

[JsonConverter(typeof(JsonStringEnumConverter))]
public required SearchStatus Status { get; set; }
public required SearchResultItem[] Items { get; set; } = [];
public required string JobId { get; init; } = string.Empty;

[JsonPropertyName("_links")]
public required Dictionary<string, HalLink> Links { get; set; } = [];
public required Dictionary<string, HalLink> Links { get; init; } = [];

public static SearchResults FromDto(SearchResultsDto dto)
{
Expand All @@ -24,7 +18,7 @@ public static SearchResults FromDto(SearchResultsDto dto)
Suid = dto.Suid,
Status = dto.Status,
Items = dto.Items,
Links = new Dictionary<string, HalLink>()
Links = new Dictionary<string, HalLink>
{
{ "self", new HalLink($"/search/{dto.JobId}/results", "GET") },
{ "job", new HalLink($"/search/{dto.JobId}", "GET") },
Expand Down
17 changes: 17 additions & 0 deletions Apps/Find/src/SUI.Find.FindApi/Models/SearchResultsBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using SUI.Find.Application.Enums;
using SUI.Find.Application.Models;

namespace SUI.Find.FindApi.Models;

public record SearchResultsBase
{
[ExcludeFromCodeCoverage]
public required string Suid { get; init; } = string.Empty;

[JsonConverter(typeof(JsonStringEnumConverter))]
public required SearchStatus Status { get; init; }

public SearchResultItem[] Items { get; init; } = [];
}
10 changes: 1 addition & 9 deletions Apps/Find/src/SUI.Find.FindApi/Models/SearchResultsV2.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
using System.Text.Json.Serialization;
using SUI.Find.Application.Dtos;
using SUI.Find.Application.Enums;
using SUI.Find.Application.Models;

namespace SUI.Find.FindApi.Models;

public record SearchResultsV2
public record SearchResultsV2 : SearchResultsBase
{
public required string WorkItemId { get; set; } = string.Empty;
public required string Suid { get; set; } = string.Empty;

[JsonConverter(typeof(JsonStringEnumConverter))]
public required SearchStatus Status { get; set; }

public required int CompletenessPercentage { get; set; }
public required IReadOnlyList<SearchResultEntry> Items { get; set; } = [];

public static SearchResultsV2 FromDto(SearchResultsV2Dto dto)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ await workItemJobCountRepository.GetByWorkItemIdAndJobTypeAsync(
WorkItemId = workItemId,
Suid = payload?.Sui ?? string.Empty,
Status = status,
Items = completedRecords,
Items = completedRecords.Select(SearchResultItem (x) => x).ToArray(),
CompletenessPercentage = completenessPercentage,
};

Expand Down
122 changes: 92 additions & 30 deletions Apps/Find/tests/SUI.Find.E2ETests/StartANewSearchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class StartANewSearchTests(FunctionTestFixture fixture, ITestOutputHelper
"fetch-record.read",
];

private record SearchStatusResponse(string? Status);
private record SearchStatusResponse(string? Status, int? CompletenessPercentage);

public async Task InitializeAsync()
{
Expand All @@ -47,6 +47,8 @@ await ResetAzureTablesAsync([
"ResultsUrlMappings",
"TestHubNameHistory",
"TestHubNameInstances",
"Jobs",
"WorkItemJobCounts",
]);
}
}
Expand Down Expand Up @@ -149,7 +151,24 @@ private async Task ResetAzureTablesAsync(string[] tableNames)
"xUnit1045",
Justification = "The `TestData` is a C# record, and the default string serialization of records provides distinct text for the purposes of test exploration, identification and results."
)]
public async Task Should_PersistSearchData_When_OrchestrationCompletes(TestData testData)
public async Task Should_PersistSearchData_When_OrchestrationCompletesV1(TestData testData)
{
await RunTest(testData, false);
}

[Theory]
[MemberData(nameof(TestData))]
[SuppressMessage(
"Usage",
"xUnit1045",
Justification = "The `TestData` is a C# record, and the default string serialization of records provides distinct text for the purposes of test exploration, identification and results."
)]
public async Task Should_PersistSearchData_When_OrchestrationCompletesV2(TestData testData)
{
await RunTest(testData, true);
}

private async Task RunTest(TestData testData, bool usePolling)
{
var authToken = await GetAuthTokenAsync(
testData.TestClientId,
Expand All @@ -162,33 +181,40 @@ public async Task Should_PersistSearchData_When_OrchestrationCompletes(TestData
);

// Step 1, start a new search
var newSearchJob = await RunAndAssertNewSearchEndpoint(
Fixture.Config.UseEncryptedIds ? testData.EncryptedSui : testData.Sui
var searchJobLinks = await RunAndAssertNewSearchEndpoint(
Fixture.Config.UseEncryptedIds ? testData.EncryptedSui : testData.Sui,
usePolling
);

var hasStatusLink = newSearchJob.Links.TryGetValue("status", out var statusLink);
Assert.True(hasStatusLink);
var hasStatusLink = searchJobLinks.TryGetValue("status", out var statusLink);
if (!usePolling)
{
Assert.True(hasStatusLink);
}

var hasResultsLink = newSearchJob.Links.TryGetValue("results", out var resultsLink);
var hasResultsLink = searchJobLinks.TryGetValue("results", out var resultsLink);
Assert.True(hasResultsLink);

// Step 2, check the status
var statusResult = await RunAndAwaitAndAssertSearchStatusCompletion(
statusLink!.Href,
await RunAndAwaitAndAssertSearchStatusCompletion(
statusLink != null ? statusLink.Href : resultsLink!.Href,
resultsLink!.Href,
testData
testData,
usePolling
);
Assert.NotNull(statusResult);

// Step 3, Get the results from fetch and assert
await RunAndAssertFetchEndpoints(resultsLink.Href, testData);

// Fin
}

private async Task<SearchJob> RunAndAssertNewSearchEndpoint(string suid)
private async Task<Dictionary<string, HalLink>> RunAndAssertNewSearchEndpoint(
string suid,
bool usePolling
)
{
const string startSearchUrl = "v1/searches";
var startSearchUrl = usePolling ? "v2/searches" : "v1/searches";

TestOutputHelper.WriteLine(
$"Starting new search to find records for SUI: {suid} ({Fixture.Client.BaseAddress}{startSearchUrl})"
Expand All @@ -204,8 +230,28 @@ private async Task<SearchJob> RunAndAssertNewSearchEndpoint(string suid)
// We then want to assert the returned body and status code
Assert.Equal(HttpStatusCode.Accepted, newSearchJobResult.StatusCode);
var searchJobContent = await newSearchJobResult.Content.ReadAsStringAsync();
var searchJob = JsonSerializer.Deserialize<SearchJob>(searchJobContent);
Assert.False(string.IsNullOrEmpty(searchJob!.JobId));
var searchId = string.Empty;
var links = new Dictionary<string, HalLink>();
if (usePolling)
{
var searchJob = JsonSerializer.Deserialize<SearchJobV2>(searchJobContent);
if (searchJob != null)
{
searchId = searchJob.WorkItemId;
links = searchJob.Links;
}
}
else
{
var searchJob = JsonSerializer.Deserialize<SearchJob>(searchJobContent);
if (searchJob != null)
{
searchId = searchJob.JobId;
links = searchJob.Links;
}
}

Assert.False(string.IsNullOrEmpty(searchId));

var traceId =
(
Expand All @@ -221,8 +267,9 @@ private async Task<SearchJob> RunAndAssertNewSearchEndpoint(string suid)
: null
) ?? "Unknown";

var topLevelTaskName = usePolling ? "workItemId" : "jobId";
TestOutputHelper.WriteLine(
$"Search started: {new { traceId, invocationId, jobId = searchJob.JobId }}"
$"Search started: traceId={traceId}, invocationId={invocationId}, {topLevelTaskName}={searchId}"
);

var observabilityLink = Fixture.Config.IsLocal
Expand All @@ -233,7 +280,7 @@ private async Task<SearchJob> RunAndAssertNewSearchEndpoint(string suid)
TestOutputHelper.WriteLine($"Trace observability: {observabilityLink}");
TestOutputHelper.WriteLine("");

return searchJob;
return links;
}

private async Task RunAndAssertPartialSearchResults(string url, TestData testData)
Expand All @@ -246,7 +293,8 @@ private async Task RunAndAssertPartialSearchResults(string url, TestData testDat

var searchResults = await Fixture.Client.GetAsync(url);
var searchResultContent = await searchResults.Content.ReadAsStringAsync();
var searchResultTypedContent = JsonSerializer.Deserialize<SearchResults>(

var searchResultTypedContent = JsonSerializer.Deserialize<SearchResultsBase>(
searchResultContent
);

Expand Down Expand Up @@ -281,7 +329,7 @@ private async Task RunAndAssertFetchEndpoints(string url, TestData testData)
var searchResults = await Fixture.Client.GetAsync(url);
// Look for URL link and save as variable
var searchResultContent = await searchResults.Content.ReadAsStringAsync();
var searchResultTypedContent = JsonSerializer.Deserialize<SearchResults>(
var searchResultTypedContent = JsonSerializer.Deserialize<SearchResultsBase>(
searchResultContent
);
Assert.True(
Expand Down Expand Up @@ -369,18 +417,22 @@ private async Task RunAndAssertFetchEndpoints(string url, TestData testData)
/// <param name="statusUrl">URL of the Search Status endpoint</param>
/// <param name="resultsUrl">URL of the Search Results endpoint</param>
/// <param name="testData">The test data for this search run</param>
private async Task<SearchJob> RunAndAwaitAndAssertSearchStatusCompletion(
/// <param name="usePolling">If true, specifies the tests are being run for the Polling Architecture, rather than Fan-out.</param>
private async Task RunAndAwaitAndAssertSearchStatusCompletion(
string statusUrl,
string resultsUrl,
TestData testData
TestData testData,
bool usePolling
)
{
statusUrl = RemoveLeadingSlashFromUrl(statusUrl);

const int retryCount = 150;
var timeout = TimeSpan.FromMinutes(usePolling ? 10 : 5);
var retryDelay = TimeSpan.FromSeconds(2);
var retryCount = (int)Math.Round(timeout / retryDelay);

var isCompleted = false;
var status = "unknown";
var statusMessage = "unknown";

TestOutputHelper.WriteLine(
$"Checking for search completion: {Fixture.Client.BaseAddress}{statusUrl}"
Expand All @@ -391,13 +443,14 @@ TestData testData
{
if (!r.IsSuccessStatusCode)
{
status = $"{r.StatusCode} - {r.Content.ReadAsStringAsync().Result}";
statusMessage = $"{r.StatusCode} - {r.Content.ReadAsStringAsync().Result}";
return true; // i.e. retry
}

// Read response, if status is NOT "Completed", keep retrying
var content = r.Content.ReadFromJsonAsync<SearchStatusResponse>().Result;
status = content?.Status;
var status = content?.Status;

switch (status)
{
case "Completed":
Expand All @@ -410,29 +463,38 @@ TestData testData
}

var retry = status is "Queued" or "Running";

statusMessage =
content?.CompletenessPercentage != null
? $"{status} ({content.CompletenessPercentage}%)"
: status;

return retry;
})
.WaitAndRetryAsync(
retryCount,
retryAttempt =>
{
TestOutputHelper.WriteLine(
$"Still checking for search completion, status was {status}, retry {retryAttempt} / {retryCount}..."
$"Still checking for search completion, status was {statusMessage}, retry {retryAttempt} / {retryCount}..."
);
return TimeSpan.FromSeconds(2);
return retryDelay;
}
);

await retryPolicy.ExecuteAsync(() => Fixture.Client.GetAsync(statusUrl));

TestOutputHelper.WriteLine(
$"Finished checking for search completion, final status was {status}, is completed: {isCompleted}"
$"Finished checking for search completion, final status was {statusMessage}, is completed: {isCompleted}"
);

Assert.True(isCompleted);

var typedResult = await Fixture.Client.GetFromJsonAsync<SearchJob>(statusUrl);
return typedResult!;
object? typedResult = usePolling
? await Fixture.Client.GetFromJsonAsync<SearchJobV2>(statusUrl)
: await Fixture.Client.GetFromJsonAsync<SearchJob>(statusUrl);

Assert.NotNull(typedResult);
}

private static string RemoveLeadingSlashFromUrl(string url) =>
Expand Down
Loading
Loading