Skip to content
Open
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
27 changes: 27 additions & 0 deletions src/Dfe.SignIn.Core.Contracts/Users/GetUserOrganisations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Dfe.SignIn.Base.Framework;
using Dfe.SignIn.Core.Contracts.Organisations;

namespace Dfe.SignIn.Core.Contracts.Users;
/// <summary>
/// Request to get the list of organisations a user belongs to.
/// Hidden organisations (status = 0) are filtered out.
/// </summary>
[AssociatedResponse(typeof(GetUserOrganisationsResponse))]
public sealed record GetUserOrganisationsRequest
{
/// <summary>
/// The unique identifier of the user.
/// </summary>
public required Guid UserId { get; init; }
}

/// <summary>
/// Response model for request <see cref="GetUserOrganisationsRequest"/>.
/// </summary>
public sealed record GetUserOrganisationsResponse
{
/// <summary>
/// The visible organisations that the user belongs to.
/// </summary>
public required IEnumerable<Organisation> Organisations { get; init; }
}
49 changes: 49 additions & 0 deletions src/Dfe.SignIn.Core.UseCases/Users/GetUserOrganisationsUseCase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Dfe.SignIn.Base.Framework;
using Dfe.SignIn.Core.Contracts.Users;
using Dfe.SignIn.Core.Public;

namespace Dfe.SignIn.Core.UseCases.Users;

/// <summary>
/// Use case for the public API "Get organisations for user" endpoint.
/// Fetches all organisations associated with the user, filters out hidden ones
/// (status = 0), and returns 404 if none remain.
/// </summary>
/// <remarks>
/// <para>Data source: Organisations Node API, via
/// <see cref="GetOrganisationsAssociatedWithUserRequest"/>.</para>
/// <para>An organisation with <see cref="OrganisationStatus.Hidden"/> (id = 0)
/// is a hidden id-only org and must be excluded from the response.</para>
/// </remarks>
public sealed class GetUserOrganisationsUseCase(
IInteractionDispatcher interaction
) : Interactor<GetUserOrganisationsRequest, GetUserOrganisationsResponse>
{
/// <inheritdoc/>
public override async Task<GetUserOrganisationsResponse> InvokeAsync(
InteractionContext<GetUserOrganisationsRequest> context,
CancellationToken cancellationToken = default)
{
context.ThrowIfHasValidationErrors();

var result = await interaction.DispatchAsync(
new GetOrganisationsAssociatedWithUserRequest {
UserId = context.Request.UserId,
}
).To<GetOrganisationsAssociatedWithUserResponse>();

// Filter out hidden orgs (status.id = 0) — these are id-only orgs not
// visible to external API consumers.
var visible = result.Organisations
.Where(org => org.Status != OrganisationStatus.Hidden)
.ToList();

if (visible.Count == 0) {
throw UserNotFoundException.FromUserId(context.Request.UserId);
}

return new GetUserOrganisationsResponse {
Organisations = visible,
};
}
}
23 changes: 23 additions & 0 deletions src/Dfe.SignIn.PublicApi/Configuration/UserEndpointExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
using Dfe.SignIn.Base.Framework;
using Dfe.SignIn.Core.UseCases.Users;

namespace Dfe.SignIn.PublicApi.Configuration;

/// <summary>
/// Extension methods for setting up user-related interactions and endpoints.
/// </summary>
[ExcludeFromCodeCoverage]
public static class UserEndpointExtensions
{
/// <summary>
/// Registers use cases for user-related public API interactions.
/// </summary>
/// <param name="services">The collection to add services to.</param>
public static void SetupUserInteractions(this IServiceCollection services)
{
ExceptionHelpers.ThrowIfArgumentNull(services, nameof(services));

services.AddInteractor<GetUserOrganisationsUseCase>();
}
}
37 changes: 37 additions & 0 deletions src/Dfe.SignIn.PublicApi/Endpoints/Users/GetUserOrganisations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Dfe.SignIn.Base.Framework;
using Dfe.SignIn.Core.Contracts.Organisations;
using Dfe.SignIn.Core.Contracts.Users;
using Microsoft.AspNetCore.Http.HttpResults;

namespace Dfe.SignIn.PublicApi.Endpoints.Users;

public static partial class UserEndpoints
{
/// <summary>
/// Gets the list of organisations a user belongs to.
/// Hidden organisations (status = 0) are excluded.
/// </summary>
/// <returns>
/// <para>200 with an array of organisations when the user belongs to at least one
/// visible organisation.</para>
/// <para>404 when the user belongs to no organisations, or all are hidden.</para>
/// </returns>
public static async Task<Results<Ok<IEnumerable<Organisation>>, NotFound>> GetUserOrganisations(
Guid userId,
// ---
IInteractionDispatcher interaction)
{
try {
var response = await interaction.DispatchAsync(
new GetUserOrganisationsRequest {
UserId = userId,
}
).To<GetUserOrganisationsResponse>();

return TypedResults.Ok(response.Organisations);
}
catch (NotFoundInteractionException) {
return TypedResults.NotFound();
}
}
}
1 change: 1 addition & 0 deletions src/Dfe.SignIn.PublicApi/Endpoints/Users/UserEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ public static partial class UserEndpoints
public static void UseUserEndpoints(this WebApplication app)
{
app.MapPost("v2/users/{userId}/organisations/{organisationId}/query", PostQueryUserOrganisation);
Comment thread
stephan-williamson marked this conversation as resolved.
app.MapGet("users/{userId}/organisations", GetUserOrganisations);
}
}
1 change: 1 addition & 0 deletions src/Dfe.SignIn.PublicApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
.SetupSelectOrganisationInteractions();

builder.Services.SetupApiSecretEncryption(builder.Configuration);
builder.Services.SetupUserInteractions();

var app = builder.Build();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using Dfe.SignIn.Core.Contracts.Organisations;
using Dfe.SignIn.Core.Contracts.Users;
using Dfe.SignIn.Core.Public;
using Dfe.SignIn.Core.UseCases.Users;
using Moq.AutoMock;

namespace Dfe.SignIn.Core.UseCases.UnitTests.Users;

[TestClass]
public sealed class GetUserOrganisationsUseCaseTests
{
private static readonly Guid UserId = Guid.Parse("a1b2c3d4-0000-0000-0000-000000000001");

private static readonly GetUserOrganisationsRequest ValidRequest = new() {
UserId = UserId,
};

private static readonly Organisation OpenOrg = new() {
Id = Guid.Parse("a1b2c3d4-0000-0000-0000-000000000010"),
Name = "Open Org",
Status = OrganisationStatus.Open,
};

private static readonly Organisation HiddenOrg = new() {
Id = Guid.Parse("a1b2c3d4-0000-0000-0000-000000000011"),
Name = "Hidden Org",
Status = OrganisationStatus.Hidden,
};

[TestMethod]
public Task Throws_WhenRequestIsInvalid()
{
return InteractionAssert.ThrowsWhenRequestIsInvalid<
GetUserOrganisationsRequest,
GetUserOrganisationsUseCase
>();
}

[TestMethod]
public async Task Throws_WhenNoOrganisationsReturned()
{
var autoMocker = new AutoMocker();

autoMocker.MockResponse<GetOrganisationsAssociatedWithUserRequest>(
new GetOrganisationsAssociatedWithUserResponse { Organisations = [] }
);

var useCase = autoMocker.CreateInstance<GetUserOrganisationsUseCase>();

await Assert.ThrowsExactlyAsync<UserNotFoundException>(()
=> useCase.InvokeAsync(ValidRequest));
}

[TestMethod]
public async Task Throws_WhenAllOrganisationsAreHidden()
{
var autoMocker = new AutoMocker();

autoMocker.MockResponse<GetOrganisationsAssociatedWithUserRequest>(
new GetOrganisationsAssociatedWithUserResponse {
Organisations = [HiddenOrg],
}
);

var useCase = autoMocker.CreateInstance<GetUserOrganisationsUseCase>();

await Assert.ThrowsExactlyAsync<UserNotFoundException>(()
=> useCase.InvokeAsync(ValidRequest));
}

[TestMethod]
public async Task FiltersOutHiddenOrganisations()
{
var autoMocker = new AutoMocker();

autoMocker.MockResponse<GetOrganisationsAssociatedWithUserRequest>(
new GetOrganisationsAssociatedWithUserResponse {
Organisations = [OpenOrg, HiddenOrg],
}
);

var useCase = autoMocker.CreateInstance<GetUserOrganisationsUseCase>();
var response = await useCase.InvokeAsync(ValidRequest);

var orgs = response.Organisations.ToArray();
Assert.HasCount(1, orgs);
Assert.AreEqual(OpenOrg.Id, orgs[0].Id);
}

[TestMethod]
public async Task ReturnsAllVisibleOrganisations()
{
var org2 = new Organisation {
Id = Guid.Parse("a1b2c3d4-0000-0000-0000-000000000012"),
Name = "Open Org 2",
Status = OrganisationStatus.Open,
};

var autoMocker = new AutoMocker();

autoMocker.MockResponse<GetOrganisationsAssociatedWithUserRequest>(
new GetOrganisationsAssociatedWithUserResponse {
Organisations = [OpenOrg, org2],
}
);

var useCase = autoMocker.CreateInstance<GetUserOrganisationsUseCase>();
var response = await useCase.InvokeAsync(ValidRequest);

Assert.HasCount(2, response.Organisations.ToArray());
}

[TestMethod]
public async Task DispatchesRequestWithCorrectUserId()
{
var autoMocker = new AutoMocker();
GetOrganisationsAssociatedWithUserRequest? capturedRequest = null;

autoMocker.CaptureRequest<GetOrganisationsAssociatedWithUserRequest>(
r => capturedRequest = r,
new GetOrganisationsAssociatedWithUserResponse {
Organisations = [OpenOrg],
}
);

var useCase = autoMocker.CreateInstance<GetUserOrganisationsUseCase>();
await useCase.InvokeAsync(ValidRequest);

Assert.IsNotNull(capturedRequest);
Assert.AreEqual(UserId, capturedRequest.UserId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using Dfe.SignIn.Base.Framework;
using Dfe.SignIn.Core.Contracts.Organisations;
using Dfe.SignIn.Core.Contracts.Users;
using Dfe.SignIn.Core.Public;
using Dfe.SignIn.PublicApi.Endpoints.Users;
using Microsoft.AspNetCore.Http.HttpResults;
using Moq.AutoMock;

namespace Dfe.SignIn.PublicApi.UnitTests.Endpoints.Users;

[TestClass]
public sealed class GetUserOrganisationsTests
{
private static readonly Guid FakeUserId = new("a1b2c3d4-0000-0000-0000-000000000001");

private static readonly Organisation FakeOrganisation = new() {
Id = new Guid("a1b2c3d4-0000-0000-0000-000000000010"),
Name = "Test Organisation",
Status = OrganisationStatus.Open,
};

[TestMethod]
public async Task Returns200_WithOrganisations_WhenUserHasVisibleOrgs()
{
var autoMocker = new AutoMocker();

autoMocker.MockResponse<GetUserOrganisationsRequest>(
new GetUserOrganisationsResponse {
Organisations = [FakeOrganisation],
}
);

var result = await UserEndpoints.GetUserOrganisations(
FakeUserId,
autoMocker.Get<IInteractionDispatcher>()
);

var ok = result.Result as Ok<IEnumerable<Organisation>>;
Assert.IsNotNull(ok);
Assert.HasCount(1, ok.Value!.ToArray());
Assert.AreEqual(FakeOrganisation.Id, ok.Value!.First().Id);
}

[TestMethod]
public async Task Returns404_WhenUserNotFoundException()
{
var autoMocker = new AutoMocker();

autoMocker.MockThrows<GetUserOrganisationsRequest>(
UserNotFoundException.FromUserId(FakeUserId)
);

var result = await UserEndpoints.GetUserOrganisations(
FakeUserId,
autoMocker.Get<IInteractionDispatcher>()
);

Assert.IsInstanceOfType<NotFound>(result.Result);
}
}
Loading