Working on "Join Game" feature.

This commit is contained in:
2023-01-23 17:25:41 -06:00
parent 26fd955aa4
commit 11b387b928
18 changed files with 509 additions and 344 deletions

View File

@@ -102,7 +102,7 @@ public class SessionsController : ControllerBase
}; };
} }
[HttpPut("{name}/Join")] [HttpPatch("{name}/Join")]
public async Task<IActionResult> JoinSession(string name) public async Task<IActionResult> JoinSession(string name)
{ {
var session = await sessionRepository.ReadSession(name); var session = await sessionRepository.ReadSession(name);
@@ -111,9 +111,12 @@ public class SessionsController : ControllerBase
if (string.IsNullOrEmpty(session.Player2)) if (string.IsNullOrEmpty(session.Player2))
{ {
session.AddPlayer2(User.GetShogiUserId()); session.AddPlayer2(User.GetShogiUserId());
}
await sessionRepository.SetPlayer2(name, User.GetShogiUserId());
return this.Ok(); return this.Ok();
} }
return this.Conflict("This game already has two players.");
}
[HttpPatch("{sessionName}/Move")] [HttpPatch("{sessionName}/Move")]
public async Task<IActionResult> Move([FromRoute] string sessionName, [FromBody] MovePieceCommand command) public async Task<IActionResult> Move([FromRoute] string sessionName, [FromBody] MovePieceCommand command)

View File

@@ -33,7 +33,6 @@ namespace Shogi.Api
.AllowCredentials(); .AllowCredentials();
}); });
}); });
ConfigureAuthentication(builder); ConfigureAuthentication(builder);
ConfigureControllers(builder); ConfigureControllers(builder);
ConfigureSwagger(builder); ConfigureSwagger(builder);

View File

@@ -11,7 +11,8 @@ public class QueryRepository : IQueryRespository
public QueryRepository(IConfiguration configuration) public QueryRepository(IConfiguration configuration)
{ {
connectionString = configuration.GetConnectionString("ShogiDatabase"); var connectionString = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("No database configured for QueryRepository.");
this.connectionString = connectionString;
} }
public async Task<ReadSessionsPlayerCountResponse> ReadSessionPlayerCount(string playerName) public async Task<ReadSessionsPlayerCountResponse> ReadSessionPlayerCount(string playerName)

View File

@@ -13,7 +13,7 @@ public class SessionRepository : ISessionRepository
public SessionRepository(IConfiguration configuration) public SessionRepository(IConfiguration configuration)
{ {
connectionString = configuration.GetConnectionString("ShogiDatabase"); connectionString = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("Database connection string not configured.");
} }
public async Task CreateSession(Session session) public async Task CreateSession(Session session)
@@ -71,7 +71,7 @@ public class SessionRepository : ISessionRepository
return session; return session;
} }
public async Task CreateMove(string sessionName, Contracts.Api.MovePieceCommand command) public async Task CreateMove(string sessionName, MovePieceCommand command)
{ {
using var connection = new SqlConnection(connectionString); using var connection = new SqlConnection(connectionString);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -86,6 +86,19 @@ public class SessionRepository : ISessionRepository
}, },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task SetPlayer2(string sessionName, string player2Name)
{
using var connection = new SqlConnection(connectionString);
await connection.ExecuteAsync(
"session.SetPlayer2",
new
{
SessionName = sessionName,
Player2Name = player2Name
},
commandType: CommandType.StoredProcedure);
}
} }
public interface ISessionRepository public interface ISessionRepository
@@ -94,4 +107,5 @@ public interface ISessionRepository
Task CreateSession(Session session); Task CreateSession(Session session);
Task DeleteSession(string name); Task DeleteSession(string name);
Task<Session?> ReadSession(string name); Task<Session?> ReadSession(string name);
Task SetPlayer2(string sessionName, string player2Name);
} }

View File

@@ -0,0 +1,16 @@
CREATE PROCEDURE [session].[SetPlayer2]
@SessionName [session].[SessionName],
@Player2Name [user].[UserName] NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @player2Id BIGINT;
SELECT @player2Id = Id FROM [user].[User] WHERE [Name] = @Player2Name;
UPDATE sess
SET Player2Id = @player2Id
FROM [session].[Session] sess
WHERE sess.[Name] = @SessionName;
END

View File

@@ -1,14 +0,0 @@
CREATE PROCEDURE [dbo].[UpdateSession]
@SessionName [session].[SessionName],
@BoardStateJson [session].[JsonDocument]
AS
BEGIN
SET NOCOUNT ON;
UPDATE bs
SET bs.Document = @BoardStateJson
FROM [session].[BoardState] bs
INNER JOIN [session].[Session] s on s.Id = bs.SessionId
WHERE s.Name = @SessionName;
END

View File

@@ -82,7 +82,7 @@
<Build Include="User\StoredProcedures\ReadUser.sql" /> <Build Include="User\StoredProcedures\ReadUser.sql" />
<Build Include="User\Tables\LoginPlatform.sql" /> <Build Include="User\Tables\LoginPlatform.sql" />
<None Include="Post Deployment\Scripts\PopulateLoginPlatforms.sql" /> <None Include="Post Deployment\Scripts\PopulateLoginPlatforms.sql" />
<Build Include="Session\Stored Procedures\UpdateSession.sql" /> <Build Include="Session\Stored Procedures\SetPlayer2.sql" />
<Build Include="Session\Stored Procedures\ReadSession.sql" /> <Build Include="Session\Stored Procedures\ReadSession.sql" />
<Build Include="Session\Tables\Move.sql" /> <Build Include="Session\Tables\Move.sql" />
<Build Include="Session\Tables\Piece.sql" /> <Build Include="Session\Tables\Piece.sql" />

View File

@@ -12,5 +12,6 @@ public interface IShogiApi
Task<CreateTokenResponse?> GetToken(WhichAccountPlatform whichAccountPlatform); Task<CreateTokenResponse?> GetToken(WhichAccountPlatform whichAccountPlatform);
Task GuestLogout(); Task GuestLogout();
Task Move(string sessionName, MovePieceCommand move); Task Move(string sessionName, MovePieceCommand move);
Task<HttpStatusCode> PatchJoinGame(string name);
Task<HttpStatusCode> PostSession(string name, bool isPrivate); Task<HttpStatusCode> PostSession(string name, bool isPrivate);
} }

View File

@@ -28,7 +28,7 @@ namespace Shogi.UI.Pages.Home.Api
{ {
WhichAccountPlatform.Guest => clientFactory.CreateClient(GuestClientName), WhichAccountPlatform.Guest => clientFactory.CreateClient(GuestClientName),
WhichAccountPlatform.Microsoft => clientFactory.CreateClient(MsalClientName), WhichAccountPlatform.Microsoft => clientFactory.CreateClient(MsalClientName),
_ => clientFactory.CreateClient(GuestClientName) _ => throw new InvalidOperationException("AccountState.User must not be null during API call.")
}; };
public async Task GuestLogout() public async Task GuestLogout()
@@ -49,7 +49,7 @@ namespace Shogi.UI.Pages.Home.Api
public async Task<ReadSessionsPlayerCountResponse?> GetSessionsPlayerCount() public async Task<ReadSessionsPlayerCountResponse?> GetSessionsPlayerCount()
{ {
var response = await HttpClient.GetAsync(new Uri("Sessions/PlayerCount", UriKind.Relative)); var response = await HttpClient.GetAsync(RelativeUri("Sessions/PlayerCount"));
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
return await response.Content.ReadFromJsonAsync<ReadSessionsPlayerCountResponse>(serializerOptions); return await response.Content.ReadFromJsonAsync<ReadSessionsPlayerCountResponse>(serializerOptions);
@@ -57,12 +57,15 @@ namespace Shogi.UI.Pages.Home.Api
return null; return null;
} }
/// <summary>
/// Logs the user into the API and returns a token which can be used to request a socket connection.
/// </summary>
public async Task<CreateTokenResponse?> GetToken(WhichAccountPlatform whichAccountPlatform) public async Task<CreateTokenResponse?> GetToken(WhichAccountPlatform whichAccountPlatform)
{ {
var httpClient = whichAccountPlatform == WhichAccountPlatform.Microsoft var httpClient = whichAccountPlatform == WhichAccountPlatform.Microsoft
? clientFactory.CreateClient(MsalClientName) ? clientFactory.CreateClient(MsalClientName)
: clientFactory.CreateClient(GuestClientName); : clientFactory.CreateClient(GuestClientName);
var response = await httpClient.GetFromJsonAsync<CreateTokenResponse>(new Uri("User/Token", UriKind.Relative), serializerOptions); var response = await httpClient.GetFromJsonAsync<CreateTokenResponse>(RelativeUri("User/Token"), serializerOptions);
return response; return response;
} }
@@ -73,11 +76,19 @@ namespace Shogi.UI.Pages.Home.Api
public async Task<HttpStatusCode> PostSession(string name, bool isPrivate) public async Task<HttpStatusCode> PostSession(string name, bool isPrivate)
{ {
var response = await HttpClient.PostAsJsonAsync(new Uri("Sessions", UriKind.Relative), new CreateSessionCommand var response = await HttpClient.PostAsJsonAsync(RelativeUri("Sessions"), new CreateSessionCommand
{ {
Name = name, Name = name,
}); });
return response.StatusCode; return response.StatusCode;
} }
public async Task<HttpStatusCode> PatchJoinGame(string name)
{
var response = await HttpClient.PatchAsync(RelativeUri($"Sessions/{name}/Join"), null);
return response.StatusCode;
}
private static Uri RelativeUri(string path) => new Uri(path, UriKind.Relative);
} }
} }

View File

@@ -15,7 +15,7 @@ else if (isSpectating)
} }
else else
{ {
<SeatedGameBoard Perspective="perspective" Session="session" /> <SeatedGameBoard Perspective="perspective" Session="session" OnRefetchSession="RefetchSession" />
} }
@@ -28,6 +28,11 @@ else
private bool isSpectating; private bool isSpectating;
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{
await RefetchSession();
}
async Task RefetchSession()
{ {
if (!string.IsNullOrWhiteSpace(SessionName)) if (!string.IsNullOrWhiteSpace(SessionName))
{ {
@@ -42,4 +47,6 @@ else
} }
} }
} }
} }

View File

@@ -1,5 +1,6 @@
@using Shogi.Contracts.Types; @using Shogi.Contracts.Types;
@inject PromotePrompt PromotePrompt; @inject PromotePrompt PromotePrompt;
@inject AccountState AccountState;
<article class="game-board"> <article class="game-board">
@if (IsSpectating) @if (IsSpectating)
@@ -65,25 +66,56 @@
@if (Session != null) @if (Session != null)
{ {
<aside class="side-board"> <aside class="side-board">
<div class="player-area">
<div class="hand"> <div class="hand">
@if (OpponentHand.Any())
{
@foreach (var piece in OpponentHand) @foreach (var piece in OpponentHand)
{ {
<div class="tile"> <div class="tile">
<GamePiece Piece="piece" Perspective="Perspective" /> <GamePiece Piece="piece" Perspective="Perspective" />
</div> </div>
} }
}
else
{
<i class="place-self-center">Hand is empty.</i>
}
</div>
</div> </div>
<div class="spacer" /> <div class="spacer place-self-center">
</div>
<div class="player-area">
@if (Session.Player2 == null && Session.Player1 != AccountState.User?.Id)
{
<div class="place-self-center">
<p>Seat is Empty</p>
<button @onclick="OnClickJoinGameInternal()">Join Game</button>
</div>
}
else
{
<div class="hand"> <div class="hand">
@if (UserHand.Any())
{
@foreach (var piece in UserHand) @foreach (var piece in UserHand)
{ {
<div class="title" @onclick="OnClickHandInternal(piece)"> <div class="title" @onclick="OnClickHandInternal(piece)">
<GamePiece Piece="piece" Perspective="Perspective" /> <GamePiece Piece="piece" Perspective="Perspective" />
</div> </div>
} }
}
else
{
<i class="place-self-center">Hand is empty.</i>
}
</div> </div>
}
</div>
</aside> </aside>
} }
</article> </article>
@@ -94,8 +126,9 @@
[Parameter] public Session? Session { get; set; } [Parameter] public Session? Session { get; set; }
[Parameter] public string? SelectedPosition { get; set; } [Parameter] public string? SelectedPosition { get; set; }
// TODO: Exchange these OnClick actions for events like "SelectionChangedEvent" and "MoveFromBoardEvent" and "MoveFromHandEvent". // TODO: Exchange these OnClick actions for events like "SelectionChangedEvent" and "MoveFromBoardEvent" and "MoveFromHandEvent".
[Parameter] public Action<Piece?, string>? OnClickTile { get; set; } [Parameter] public Func<Piece?, string, Task>? OnClickTile { get; set; }
[Parameter] public Action<Piece>? OnClickHand { get; set; } [Parameter] public Func<Piece, Task>? OnClickHand { get; set; }
[Parameter] public Func<Task>? OnClickJoinGame { get; set; }
static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" }; static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" };
@@ -124,4 +157,5 @@
private Action OnClickTileInternal(Piece? piece, string position) => () => OnClickTile?.Invoke(piece, position); private Action OnClickTileInternal(Piece? piece, string position) => () => OnClickTile?.Invoke(piece, position);
private Action OnClickHandInternal(Piece piece) => () => OnClickHand?.Invoke(piece); private Action OnClickHandInternal(Piece piece) => () => OnClickHand?.Invoke(piece);
private Action OnClickJoinGameInternal() => () => OnClickJoinGame?.Invoke();
} }

View File

@@ -14,6 +14,7 @@
.side-board { .side-board {
grid-area: side-board; grid-area: side-board;
} }
.icons { .icons {
grid-area: icons; grid-area: icons;
} }
@@ -90,12 +91,19 @@
.side-board { .side-board {
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-auto-rows: 1fr;
padding: 1rem;
background-color: var(--contrast-color);
} }
.side-board .player-area {
display: grid;
}
.side-board .hand { .side-board .hand {
display: flex; display: grid;
flex-wrap: wrap; grid-auto-columns: 1fr;
border: 1px solid #ccc;
} }
.promote-prompt { .promote-prompt {
@@ -116,9 +124,5 @@
} }
.spectating { .spectating {
position: absolute;
right: 0;
top: 0;
z-index: 100;
color: var(--contrast-color) color: var(--contrast-color)
} }

View File

@@ -1,14 +1,22 @@
@using Shogi.Contracts.Api; @using Shogi.Contracts.Api;
@using Shogi.Contracts.Types; @using Shogi.Contracts.Types;
@using System.Text.RegularExpressions; @using System.Text.RegularExpressions;
@using System.Net;
@inject PromotePrompt PromotePrompt; @inject PromotePrompt PromotePrompt;
@inject IShogiApi ShogiApi; @inject IShogiApi ShogiApi;
<GameBoardPresentation OnClickHand="OnClickHand" OnClickTile="OnClickTile" Session="Session" Perspective="Perspective" /> <GameBoardPresentation Session="Session"
Perspective="Perspective"
OnClickHand="OnClickHand"
OnClickTile="OnClickTile"
OnClickJoinGame="OnClickJoinGame" />
@code { @code {
[Parameter] public WhichPlayer Perspective { get; set; } [Parameter, EditorRequired]
[Parameter] public Session Session { get; set; } public WhichPlayer Perspective { get; set; }
[Parameter, EditorRequired]
public Session Session { get; set; }
[Parameter] public Func<Task>? OnRefetchSession { get; set; }
private bool IsMyTurn => Session?.BoardState.WhoseTurn == Perspective; private bool IsMyTurn => Session?.BoardState.WhoseTurn == Perspective;
private string? selectedBoardPosition; private string? selectedBoardPosition;
private WhichPiece? selectedPieceFromHand; private WhichPiece? selectedPieceFromHand;
@@ -26,7 +34,7 @@
return false; return false;
} }
async void OnClickTile(Piece? piece, string position) async Task OnClickTile(Piece? piece, string position)
{ {
if (!IsMyTurn) return; if (!IsMyTurn) return;
@@ -64,8 +72,21 @@
} }
} }
void OnClickHand(Piece piece) async Task OnClickHand(Piece piece)
{ {
selectedPieceFromHand = piece.WhichPiece; selectedPieceFromHand = piece.WhichPiece;
await Task.CompletedTask;
}
async Task OnClickJoinGame()
{
if (Session != null && OnRefetchSession != null)
{
var status = await ShogiApi.PatchJoinGame(Session.SessionName);
if (status == HttpStatusCode.OK)
{
await OnRefetchSession.Invoke();
}
}
} }
} }

View File

@@ -28,9 +28,6 @@ static void ConfigureDependencies(IServiceCollection services, IConfiguration co
services services
.AddHttpClient(ShogiApi.GuestClientName, client => client.BaseAddress = shogiApiUrl) .AddHttpClient(ShogiApi.GuestClientName, client => client.BaseAddress = shogiApiUrl)
.AddHttpMessageHandler<CookieCredentialsMessageHandler>(); .AddHttpMessageHandler<CookieCredentialsMessageHandler>();
//services
// .AddHttpClient(ShogiApi.AnonymousClientName, client => client.BaseAddress = shogiApiUrl)
// .AddHttpMessageHandler<CookieCredentialsMessageHandler>();
// Authorization // Authorization
services.AddMsalAuthentication(options => services.AddMsalAuthentication(options =>

View File

@@ -22,7 +22,7 @@ public class ShogiSocket : IDisposable
{ {
this.socket = socket; this.socket = socket;
this.serializerOptions = serializerOptions; this.serializerOptions = serializerOptions;
this.uriBuilder = new UriBuilder(configuration["SocketUrl"]); this.uriBuilder = new UriBuilder(configuration["SocketUrl"] ?? throw new InvalidOperationException("SocketUrl configuration is missing."));
this.cancelToken = new CancellationTokenSource(); this.cancelToken = new CancellationTokenSource();
this.memoryOwner = MemoryPool<byte>.Shared.Rent(1024 * 2); this.memoryOwner = MemoryPool<byte>.Shared.Rent(1024 * 2);
} }
@@ -35,11 +35,18 @@ public class ShogiSocket : IDisposable
}.ToQueryString().Value; }.ToQueryString().Value;
await socket.ConnectAsync(this.uriBuilder.Uri, cancelToken.Token); await socket.ConnectAsync(this.uriBuilder.Uri, cancelToken.Token);
Console.WriteLine("CONNECTED"); Console.WriteLine("Socket Connected");
Listen(); // Fire and forget! I'm way too lazy to write my own javascript interop to a web worker. Nooo thanks.
var listening = Listen().ContinueWith(antecedent =>
{
if (antecedent.Exception != null)
{
throw antecedent.Exception;
}
}, TaskContinuationOptions.OnlyOnFaulted);
} }
private async void Listen() private async Task Listen()
{ {
while (socket.State == WebSocketState.Open && !cancelToken.IsCancellationRequested) while (socket.State == WebSocketState.Open && !cancelToken.IsCancellationRequested)
{ {

View File

@@ -121,3 +121,7 @@ button.btn-link {
button.btn.btn-link:not(:hover) { button.btn.btn-link:not(:hover) {
text-decoration: none; text-decoration: none;
} }
.place-self-center {
place-self: center;
}

View File

@@ -12,16 +12,62 @@ namespace Shogi.AcceptanceTests;
public class AcceptanceTests : IClassFixture<GuestTestFixture> public class AcceptanceTests : IClassFixture<GuestTestFixture>
#pragma warning restore xUnit1033 #pragma warning restore xUnit1033
{ {
private readonly GuestTestFixture fixture; private readonly HttpClient guest1HttpClient;
private readonly HttpClient guest2HttpClient;
private readonly ITestOutputHelper console; private readonly ITestOutputHelper console;
public AcceptanceTests(GuestTestFixture fixture, ITestOutputHelper console) public AcceptanceTests(GuestTestFixture fixture, ITestOutputHelper console)
{ {
this.fixture = fixture; this.guest1HttpClient = fixture.Guest1ServiceClient;
this.guest2HttpClient = fixture.Guest2ServiceClient;
this.console = console; this.console = console;
} }
private HttpClient Service => fixture.Service; [Fact]
public async Task JoinSession_Player2IsNotSet_SetsPlayer2()
{
try
{
// Arrange
await SetupTestSession();
// Act
var joinResponse = await guest2HttpClient.PatchAsync(new Uri("Sessions/Acceptance Tests/Join", UriKind.Relative), null);
// Assert
joinResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var readSessionResponse = await ReadTestSession();
readSessionResponse.Session.Player2.Should().NotBeNullOrEmpty();
}
finally
{
await DeleteTestSession();
}
}
[Fact]
public async Task JoinSession_SessionIsFull_Conflict()
{
try
{
// Arrange
await SetupTestSession();
var joinResponse = await guest2HttpClient.PatchAsync(new Uri("Sessions/Acceptance Tests/Join", UriKind.Relative), null);
joinResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var readSessionResponse = await ReadTestSession();
readSessionResponse.Session.Player2.Should().NotBeNullOrEmpty();
// Act
joinResponse = await guest2HttpClient.PatchAsync(new Uri("Sessions/Acceptance Tests/Join", UriKind.Relative), null);
// Assert
joinResponse.StatusCode.Should().Be(HttpStatusCode.Conflict);
}
finally
{
await DeleteTestSession();
}
}
[Fact] [Fact]
public async Task ReadSessionsPlayerCount() public async Task ReadSessionsPlayerCount()
@@ -32,7 +78,7 @@ public class AcceptanceTests : IClassFixture<GuestTestFixture>
await SetupTestSession(); await SetupTestSession();
// Act // Act
var readAllResponse = await Service var readAllResponse = await guest1HttpClient
.GetFromJsonAsync<ReadSessionsPlayerCountResponse>(new Uri("Sessions/PlayerCount", UriKind.Relative), .GetFromJsonAsync<ReadSessionsPlayerCountResponse>(new Uri("Sessions/PlayerCount", UriKind.Relative),
Contracts.ShogiApiJsonSerializerSettings.SystemTextJsonSerializerOptions); Contracts.ShogiApiJsonSerializerSettings.SystemTextJsonSerializerOptions);
@@ -42,7 +88,7 @@ public class AcceptanceTests : IClassFixture<GuestTestFixture>
.PlayerHasJoinedSessions .PlayerHasJoinedSessions
.Should() .Should()
.ContainSingle(session => session.Name == "Acceptance Tests" && session.PlayerCount == 1); .ContainSingle(session => session.Name == "Acceptance Tests" && session.PlayerCount == 1);
readAllResponse.AllOtherSessions.Should().BeEmpty(); readAllResponse.AllOtherSessions.Should().NotBeNull();
} }
finally finally
{ {
@@ -60,7 +106,7 @@ public class AcceptanceTests : IClassFixture<GuestTestFixture>
await SetupTestSession(); await SetupTestSession();
// Act // Act
var response = await Service.GetFromJsonAsync<ReadSessionResponse>( var response = await guest1HttpClient.GetFromJsonAsync<ReadSessionResponse>(
new Uri("Sessions/Acceptance Tests", UriKind.Relative), new Uri("Sessions/Acceptance Tests", UriKind.Relative),
Contracts.ShogiApiJsonSerializerSettings.SystemTextJsonSerializerOptions); Contracts.ShogiApiJsonSerializerSettings.SystemTextJsonSerializerOptions);
@@ -273,7 +319,7 @@ public class AcceptanceTests : IClassFixture<GuestTestFixture>
}; };
// Act // Act
var response = await Service.PatchAsync(new Uri("Sessions/Acceptance Tests/Move", UriKind.Relative), JsonContent.Create(movePawnCommand)); var response = await guest1HttpClient.PatchAsync(new Uri("Sessions/Acceptance Tests/Move", UriKind.Relative), JsonContent.Create(movePawnCommand));
response.StatusCode.Should().Be(HttpStatusCode.NoContent, because: await response.Content.ReadAsStringAsync()); response.StatusCode.Should().Be(HttpStatusCode.NoContent, because: await response.Content.ReadAsStringAsync());
// Assert // Assert
@@ -293,7 +339,7 @@ public class AcceptanceTests : IClassFixture<GuestTestFixture>
private async Task SetupTestSession() private async Task SetupTestSession()
{ {
var createResponse = await Service.PostAsJsonAsync( var createResponse = await guest1HttpClient.PostAsJsonAsync(
new Uri("Sessions", UriKind.Relative), new Uri("Sessions", UriKind.Relative),
new CreateSessionCommand { Name = "Acceptance Tests" }, new CreateSessionCommand { Name = "Acceptance Tests" },
Contracts.ShogiApiJsonSerializerSettings.SystemTextJsonSerializerOptions); Contracts.ShogiApiJsonSerializerSettings.SystemTextJsonSerializerOptions);
@@ -302,12 +348,12 @@ public class AcceptanceTests : IClassFixture<GuestTestFixture>
private Task<ReadSessionResponse> ReadTestSession() private Task<ReadSessionResponse> ReadTestSession()
{ {
return Service.GetFromJsonAsync<ReadSessionResponse>(new Uri("Sessions/Acceptance Tests", UriKind.Relative))!; return guest1HttpClient.GetFromJsonAsync<ReadSessionResponse>(new Uri("Sessions/Acceptance Tests", UriKind.Relative))!;
} }
private async Task DeleteTestSession() private async Task DeleteTestSession()
{ {
var response = await Service.DeleteAsync(new Uri("Sessions/Acceptance Tests", UriKind.Relative)); var response = await guest1HttpClient.DeleteAsync(new Uri("Sessions/Acceptance Tests", UriKind.Relative));
response.StatusCode.Should().Be(HttpStatusCode.NoContent, because: await response.Content.ReadAsStringAsync()); response.StatusCode.Should().Be(HttpStatusCode.NoContent, because: await response.Content.ReadAsStringAsync());
} }

View File

@@ -15,22 +15,36 @@ public class GuestTestFixture : IAsyncLifetime, IDisposable
.AddJsonFile("appsettings.json") .AddJsonFile("appsettings.json")
.Build(); .Build();
Service = new HttpClient var baseUrl = Configuration["ServiceUrl"] ?? throw new InvalidOperationException("ServiceUrl configuration missing.");
var baseAddress = new Uri(baseUrl, UriKind.Absolute);
Guest1ServiceClient = new HttpClient
{ {
BaseAddress = new Uri(Configuration["ServiceUrl"], UriKind.Absolute) BaseAddress = baseAddress
};
Guest2ServiceClient = new HttpClient
{
BaseAddress = baseAddress
}; };
} }
public IConfiguration Configuration { get; private set; } public IConfiguration Configuration { get; private set; }
public HttpClient Service { get; } public HttpClient Guest2ServiceClient { get; }
public HttpClient Guest1ServiceClient { get; }
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
// Log in as a guest account and retain the session cookie for future requests. // Log in as some guest accounts and retain the session cookie for future requests.
var loginResponse = await Service.GetAsync(new Uri("User/LoginAsGuest", UriKind.Relative)); var guestLoginUri = new Uri("User/LoginAsGuest", UriKind.Relative);
var loginResponse = await Guest1ServiceClient.GetAsync(guestLoginUri);
loginResponse.IsSuccessStatusCode.Should().BeTrue(because: "Guest accounts should work"); loginResponse.IsSuccessStatusCode.Should().BeTrue(because: "Guest accounts should work");
var guestSessionCookie = loginResponse.Headers.GetValues("Set-Cookie").SingleOrDefault(); var guestSessionCookie = loginResponse.Headers.GetValues("Set-Cookie").Single();
Service.DefaultRequestHeaders.Add("Set-Cookie", guestSessionCookie); Guest1ServiceClient.DefaultRequestHeaders.Add("Set-Cookie", guestSessionCookie);
loginResponse = await Guest2ServiceClient.GetAsync(guestLoginUri);
loginResponse.IsSuccessStatusCode.Should().BeTrue(because: "Guest accounts should work twice");
guestSessionCookie = loginResponse.Headers.GetValues("Set-Cookie").Single();
Guest2ServiceClient.DefaultRequestHeaders.Add("Set-Cookie", guestSessionCookie);
} }
protected virtual void Dispose(bool disposing) protected virtual void Dispose(bool disposing)
@@ -39,7 +53,7 @@ public class GuestTestFixture : IAsyncLifetime, IDisposable
{ {
if (disposing) if (disposing)
{ {
Service.Dispose(); Guest1ServiceClient.Dispose();
} }
disposedValue = true; disposedValue = true;