diff --git a/web/backend/API/API.Specs/API.Specs.csproj b/web/backend/API/API.Specs/API.Specs.csproj
index 2cd5bccc..527a04db 100644
--- a/web/backend/API/API.Specs/API.Specs.csproj
+++ b/web/backend/API/API.Specs/API.Specs.csproj
@@ -41,5 +41,7 @@
+
+
diff --git a/web/backend/API/API.Specs/Features/Breweries.feature b/web/backend/API/API.Specs/Features/Breweries.feature
new file mode 100644
index 00000000..7fbba78b
--- /dev/null
+++ b/web/backend/API/API.Specs/Features/Breweries.feature
@@ -0,0 +1,95 @@
+Feature: Brewery Management
+As an authenticated user
+I want to create, view, update and delete brewery posts
+So that I can share information about breweries with the community
+
+Background:
+ Given the API is running
+
+Scenario: Successful brewery creation
+ Given I have an existing account
+ And I am logged in
+ And a city exists
+ When I create a brewery with values:
+ | BreweryName | Description | AddressLine1 | PostalCode |
+ | Roundhouse Brewery | A cozy local brewery | 123 Main St | 90210 |
+ Then the response has HTTP status 201
+ And the response JSON should have "breweryName" equal "Roundhouse Brewery"
+
+Scenario: Brewery creation fails without authentication
+ Given a city exists
+ When I create a brewery with values:
+ | BreweryName | Description | AddressLine1 | PostalCode |
+ | No Auth Brew | A brewery | 1 Nowhere St | 00000 |
+ Then the response has HTTP status 401
+
+Scenario: Brewery creation fails with a non-existent city
+ Given I have an existing account
+ And I am logged in
+ When I create a brewery with a non-existent city
+ Then the response has HTTP status 404
+
+Scenario: Retrieve an existing brewery by ID
+ Given I have an existing account
+ And I am logged in
+ And a city exists
+ And I have created a brewery
+ When I retrieve the brewery by ID
+ Then the response has HTTP status 200
+ And the response JSON should have "breweryName" equal "Test Brewery"
+
+Scenario: Retrieving a non-existent brewery returns not found
+ When I retrieve a brewery by a non-existent ID
+ Then the response has HTTP status 404
+
+Scenario: Owner can update their brewery
+ Given I have an existing account
+ And I am logged in
+ And a city exists
+ And I have created a brewery
+ When I update the brewery with values:
+ | BreweryName | Description |
+ | Renamed Brewery | An updated description |
+ Then the response has HTTP status 200
+ And the response JSON should have "breweryName" equal "Renamed Brewery"
+
+Scenario: A different user cannot update someone else's brewery
+ Given I have an existing account
+ And I am logged in
+ And a city exists
+ And I have created a brewery
+ And I am logged in as a different user
+ When I update the brewery with values:
+ | BreweryName | Description |
+ | Hijacked | Nope |
+ Then the response has HTTP status 403
+
+Scenario: Updating a non-existent brewery returns not found
+ Given I have an existing account
+ And I am logged in
+ When I update a non-existent brewery
+ Then the response has HTTP status 404
+
+Scenario: Owner can delete their brewery
+ Given I have an existing account
+ And I am logged in
+ And a city exists
+ And I have created a brewery
+ When I delete the brewery
+ Then the response has HTTP status 200
+ And retrieving the brewery by ID should now return HTTP status 404
+
+Scenario: A different user cannot delete someone else's brewery
+ Given I have an existing account
+ And I am logged in
+ And a city exists
+ And I have created a brewery
+ And I am logged in as a different user
+ When I delete the brewery
+ Then the response has HTTP status 403
+
+Scenario: Deleting a non-existent brewery returns not found
+ Given I have an existing account
+ And I am logged in
+ When I delete a non-existent brewery
+ Then the response has HTTP status 404
diff --git a/web/backend/API/API.Specs/Steps/ApiStepsBase.cs b/web/backend/API/API.Specs/Steps/ApiStepsBase.cs
new file mode 100644
index 00000000..f4bd6766
--- /dev/null
+++ b/web/backend/API/API.Specs/Steps/ApiStepsBase.cs
@@ -0,0 +1,39 @@
+using Reqnroll;
+
+namespace API.Specs.Steps;
+
+///
+/// Shared / plumbing and the
+/// keys used to coordinate it, common to every step definition class that drives the test API.
+///
+public abstract class ApiStepsBase(ScenarioContext scenario)
+{
+ protected ScenarioContext Scenario { get; } = scenario;
+
+ protected const string ClientKey = "client";
+ protected const string FactoryKey = "factory";
+ protected const string ResponseKey = "response";
+ protected const string ResponseBodyKey = "responseBody";
+
+ protected TestApiFactory GetFactory()
+ {
+ TestApiFactory? factory = Scenario.TryGetValue(
+ FactoryKey,
+ out TestApiFactory? f
+ )
+ ? f
+ : new TestApiFactory();
+ Scenario[FactoryKey] = factory;
+ return factory;
+ }
+
+ protected HttpClient GetClient()
+ {
+ if (Scenario.TryGetValue(ClientKey, out HttpClient? client))
+ return client;
+
+ client = GetFactory().CreateClient();
+ Scenario[ClientKey] = client;
+ return client;
+ }
+}
diff --git a/web/backend/API/API.Specs/Steps/AuthSteps.cs b/web/backend/API/API.Specs/Steps/AuthSteps.cs
index 6a38c939..ab889921 100644
--- a/web/backend/API/API.Specs/Steps/AuthSteps.cs
+++ b/web/backend/API/API.Specs/Steps/AuthSteps.cs
@@ -10,40 +10,14 @@ using Reqnroll;
namespace API.Specs.Steps;
[Binding]
-public class AuthSteps(ScenarioContext scenario)
+public class AuthSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
{
- private const string ClientKey = "client";
- private const string FactoryKey = "factory";
- private const string ResponseKey = "response";
- private const string ResponseBodyKey = "responseBody";
private const string TestUserKey = "testUser";
private const string RegisteredUserIdKey = "registeredUserId";
private const string RegisteredUsernameKey = "registeredUsername";
private const string PreviousAccessTokenKey = "previousAccessToken";
private const string PreviousRefreshTokenKey = "previousRefreshToken";
- private TestApiFactory GetFactory()
- {
- TestApiFactory? factory = scenario.TryGetValue(
- FactoryKey,
- out TestApiFactory? f
- )
- ? f
- : new TestApiFactory();
- scenario[FactoryKey] = factory;
- return factory;
- }
-
- private HttpClient GetClient()
- {
- if (scenario.TryGetValue(ClientKey, out HttpClient? client))
- return client;
-
- client = GetFactory().CreateClient();
- scenario[ClientKey] = client;
- return client;
- }
-
///
/// Reads a required configuration value from the running 's own
/// , so forged tokens are always signed with the exact secret the
@@ -107,7 +81,7 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have an existing account")]
public async Task GivenIHaveAnExistingAccount()
{
- scenario[TestUserKey] = (FixtureUsername, FixturePassword);
+ Scenario[TestUserKey] = (FixtureUsername, FixturePassword);
HttpClient client = GetClient();
var registrationData = new
@@ -153,14 +127,14 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I do not have an existing account")]
public void GivenIDoNotHaveAnExistingAccount()
{
- scenario[TestUserKey] = ("Failing", "User");
+ Scenario[TestUserKey] = ("Failing", "User");
}
[When("I submit a login request with a username and password")]
public async Task WhenISubmitALoginRequestWithAUsernameAndPassword()
{
HttpClient client = GetClient();
- (string username, string password) = scenario.TryGetValue(TestUserKey, out (string username, string password) user)
+ (string username, string password) = Scenario.TryGetValue(TestUserKey, out (string username, string password) user)
? user
: ("test.user", "password");
@@ -174,8 +148,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a login request with a missing username")]
@@ -192,8 +166,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a login request with a missing password")]
@@ -210,8 +184,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a login request with both username and password missing")]
@@ -226,18 +200,18 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[Then("the response JSON should have an access token")]
public void ThenTheResponseJsonShouldHaveAnAccessToken()
{
- scenario
+ Scenario
.TryGetValue(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
- scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue();
+ Scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue();
JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
@@ -270,8 +244,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a registration request with values:")]
@@ -321,8 +295,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a registration request using a GET request")]
@@ -337,8 +311,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[Given("I have registered a new account")]
@@ -364,13 +338,13 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
using JsonDocument doc = JsonDocument.Parse(responseBody);
JsonElement root = doc.RootElement;
- scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root);
- scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
+ Scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root);
+ Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
}
[Given("I am logged in")]
@@ -396,12 +370,12 @@ public class AuthSteps(ScenarioContext scenario)
payloadElem.TryGetProperty("accessToken", out JsonElement tokenElem)
|| payloadElem.TryGetProperty("AccessToken", out tokenElem)
)
- scenario["accessToken"] = tokenElem.GetString();
+ Scenario["accessToken"] = tokenElem.GetString();
if (
payloadElem.TryGetProperty("refreshToken", out JsonElement refreshElem)
|| payloadElem.TryGetProperty("RefreshToken", out refreshElem)
)
- scenario["refreshToken"] = refreshElem.GetString();
+ Scenario["refreshToken"] = refreshElem.GetString();
}
}
@@ -421,15 +395,15 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have a valid access token for my account")]
public void GivenIHaveAValidAccessTokenForMyAccount()
{
- Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
+ Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found in scenario");
- string? username = scenario.TryGetValue(RegisteredUsernameKey, out string? user)
+ string? username = Scenario.TryGetValue(RegisteredUsernameKey, out string? user)
? user
: throw new InvalidOperationException("registered username not found in scenario");
string secret = GetRequiredConfigValue(ConfigurationKeys.AccessTokenSecret);
- scenario["accessToken"] = GenerateJwtToken(
+ Scenario["accessToken"] = GenerateJwtToken(
userId,
username,
secret,
@@ -440,15 +414,15 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have a valid confirmation token for my account")]
public void GivenIHaveAValidConfirmationTokenForMyAccount()
{
- Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
+ Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found in scenario");
- string? username = scenario.TryGetValue(RegisteredUsernameKey, out string? user)
+ string? username = Scenario.TryGetValue(RegisteredUsernameKey, out string? user)
? user
: throw new InvalidOperationException("registered username not found in scenario");
string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret);
- scenario["confirmationToken"] = GenerateJwtToken(
+ Scenario["confirmationToken"] = GenerateJwtToken(
userId,
username,
secret,
@@ -459,15 +433,15 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have an expired confirmation token for my account")]
public void GivenIHaveAnExpiredConfirmationTokenForMyAccount()
{
- Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
+ Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found in scenario");
- string? username = scenario.TryGetValue(RegisteredUsernameKey, out string? user)
+ string? username = Scenario.TryGetValue(RegisteredUsernameKey, out string? user)
? user
: throw new InvalidOperationException("registered username not found in scenario");
string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret);
- scenario["confirmationToken"] = GenerateJwtToken(
+ Scenario["confirmationToken"] = GenerateJwtToken(
userId,
username,
secret,
@@ -478,15 +452,15 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have a confirmation token signed with the wrong secret")]
public void GivenIHaveAConfirmationTokenSignedWithTheWrongSecret()
{
- Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
+ Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found in scenario");
- string? username = scenario.TryGetValue(RegisteredUsernameKey, out string? user)
+ string? username = Scenario.TryGetValue(RegisteredUsernameKey, out string? user)
? user
: throw new InvalidOperationException("registered username not found in scenario");
const string wrongSecret = "wrong-confirmation-secret-that-is-very-long-1234567890";
- scenario["confirmationToken"] = GenerateJwtToken(
+ Scenario["confirmationToken"] = GenerateJwtToken(
userId,
username,
wrongSecret,
@@ -498,7 +472,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken()
{
HttpClient client = GetClient();
- string? token = scenario.TryGetValue("accessToken", out string? t)
+ string? token = Scenario.TryGetValue("accessToken", out string? t)
? t
: "invalid-token";
@@ -509,8 +483,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a request to a protected endpoint with an invalid access token")]
@@ -524,18 +498,18 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the valid token")]
public async Task WhenISubmitAConfirmationRequestWithTheValidToken()
{
HttpClient client = GetClient();
- string? token = scenario.TryGetValue("confirmationToken", out string? t)
+ string? token = Scenario.TryGetValue("confirmationToken", out string? t)
? t
: "valid-token";
- string? accessToken = scenario.TryGetValue("accessToken", out string? at)
+ string? accessToken = Scenario.TryGetValue("accessToken", out string? at)
? at
: string.Empty;
@@ -548,18 +522,18 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit the same confirmation request again")]
public async Task WhenISubmitTheSameConfirmationRequestAgain()
{
HttpClient client = GetClient();
- string? token = scenario.TryGetValue("confirmationToken", out string? t)
+ string? token = Scenario.TryGetValue("confirmationToken", out string? t)
? t
: "valid-token";
- string? accessToken = scenario.TryGetValue("accessToken", out string? at)
+ string? accessToken = Scenario.TryGetValue("accessToken", out string? at)
? at
: string.Empty;
@@ -572,8 +546,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with a malformed token")]
@@ -581,7 +555,7 @@ public class AuthSteps(ScenarioContext scenario)
{
HttpClient client = GetClient();
const string token = "malformed-token-not-jwt";
- string? accessToken = scenario.TryGetValue("accessToken", out string? at)
+ string? accessToken = Scenario.TryGetValue("accessToken", out string? at)
? at
: string.Empty;
@@ -594,20 +568,20 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request with a valid refresh token")]
public async Task WhenISubmitARefreshTokenRequestWithTheValidRefreshToken()
{
HttpClient client = GetClient();
- if (scenario.TryGetValue("accessToken", out string? oldAccessToken))
- scenario[PreviousAccessTokenKey] = oldAccessToken;
- if (scenario.TryGetValue("refreshToken", out string? oldRefreshToken))
- scenario[PreviousRefreshTokenKey] = oldRefreshToken;
+ if (Scenario.TryGetValue("accessToken", out string? oldAccessToken))
+ Scenario[PreviousAccessTokenKey] = oldAccessToken;
+ if (Scenario.TryGetValue("refreshToken", out string? oldRefreshToken))
+ Scenario[PreviousRefreshTokenKey] = oldRefreshToken;
- string? token = scenario.TryGetValue("refreshToken", out string? t)
+ string? token = Scenario.TryGetValue("refreshToken", out string? t)
? t
: "valid-refresh-token";
@@ -616,8 +590,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request with an invalid refresh token")]
@@ -630,8 +604,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request with the expired refresh token")]
@@ -644,8 +618,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request with a missing refresh token")]
@@ -657,8 +631,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request using a GET request")]
@@ -672,8 +646,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
// Protected Endpoint Steps
@@ -685,22 +659,22 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[Given("I am logged in with an immediately-expiring access token")]
public Task GivenIAmLoggedInWithAnImmediatelyExpiringAccessToken()
{
// Simulate an expired access token for auth rejection behavior.
- scenario["accessToken"] = "expired-access-token";
+ Scenario["accessToken"] = "expired-access-token";
return Task.CompletedTask;
}
[Given("I have an access token signed with the wrong secret")]
public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret()
{
- scenario["accessToken"] =
+ Scenario["accessToken"] =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
}
@@ -708,7 +682,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithTheExpiredToken()
{
HttpClient client = GetClient();
- string? token = scenario.TryGetValue("accessToken", out string? t)
+ string? token = Scenario.TryGetValue("accessToken", out string? t)
? t
: "expired-token";
@@ -719,15 +693,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a request to a protected endpoint with the tampered token")]
public async Task WhenISubmitARequestToAProtectedEndpointWithTheTamperedToken()
{
HttpClient client = GetClient();
- string? token = scenario.TryGetValue("accessToken", out string? t)
+ string? token = Scenario.TryGetValue("accessToken", out string? t)
? t
: "tampered-token";
@@ -738,8 +712,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When(
@@ -748,7 +722,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithMyRefreshTokenInsteadOfAccessToken()
{
HttpClient client = GetClient();
- string? token = scenario.TryGetValue("refreshToken", out string? t)
+ string? token = Scenario.TryGetValue("refreshToken", out string? t)
? t
: "refresh-token";
@@ -759,24 +733,24 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[Given("I have a valid confirmation token")]
public void GivenIHaveAValidConfirmationToken()
{
- scenario["confirmationToken"] = "valid-confirmation-token";
+ Scenario["confirmationToken"] = "valid-confirmation-token";
}
[When("I submit a confirmation request with the expired token")]
public async Task WhenISubmitAConfirmationRequestWithTheExpiredToken()
{
HttpClient client = GetClient();
- string? token = scenario.TryGetValue("confirmationToken", out string? t)
+ string? token = Scenario.TryGetValue("confirmationToken", out string? t)
? t
: "expired-confirmation-token";
- string? accessToken = scenario.TryGetValue("accessToken", out string? at)
+ string? accessToken = Scenario.TryGetValue("accessToken", out string? at)
? at
: string.Empty;
@@ -789,18 +763,18 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the tampered token")]
public async Task WhenISubmitAConfirmationRequestWithTheTamperedToken()
{
HttpClient client = GetClient();
- string? token = scenario.TryGetValue("confirmationToken", out string? t)
+ string? token = Scenario.TryGetValue("confirmationToken", out string? t)
? t
: "tampered-confirmation-token";
- string? accessToken = scenario.TryGetValue("accessToken", out string? at)
+ string? accessToken = Scenario.TryGetValue("accessToken", out string? at)
? at
: string.Empty;
@@ -813,15 +787,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with a missing token")]
public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
{
HttpClient client = GetClient();
- string? accessToken = scenario.TryGetValue("accessToken", out string? at)
+ string? accessToken = Scenario.TryGetValue("accessToken", out string? at)
? at
: string.Empty;
@@ -831,15 +805,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request using an invalid HTTP method")]
public async Task WhenISubmitAConfirmationRequestUsingAnInvalidHttpMethod()
{
HttpClient client = GetClient();
- string? token = scenario.TryGetValue("confirmationToken", out string? t)
+ string? token = Scenario.TryGetValue("confirmationToken", out string? t)
? t
: "valid-confirmation-token";
@@ -850,8 +824,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When(
@@ -860,7 +834,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithMyConfirmationTokenInsteadOfAccessToken()
{
HttpClient client = GetClient();
- string? token = scenario.TryGetValue("confirmationToken", out string? t)
+ string? token = Scenario.TryGetValue("confirmationToken", out string? t)
? t
: "confirmation-token";
@@ -871,8 +845,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with an invalid token")]
@@ -880,7 +854,7 @@ public class AuthSteps(ScenarioContext scenario)
{
HttpClient client = GetClient();
const string token = "invalid-confirmation-token";
- string? accessToken = scenario.TryGetValue("accessToken", out string? at)
+ string? accessToken = Scenario.TryGetValue("accessToken", out string? at)
? at
: string.Empty;
@@ -893,15 +867,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the valid token without an access token")]
public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken()
{
HttpClient client = GetClient();
- string? token = scenario.TryGetValue("confirmationToken", out string? t)
+ string? token = Scenario.TryGetValue("confirmationToken", out string? t)
? t
: "valid-token";
@@ -912,14 +886,14 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[Then("the response JSON should have a new access token")]
public void ThenTheResponseJsonShouldHaveANewAccessToken()
{
- scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue();
+ Scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement payload = doc.RootElement.GetProperty("payload");
@@ -927,14 +901,14 @@ public class AuthSteps(ScenarioContext scenario)
accessToken.Should().NotBeNullOrWhiteSpace();
- if (scenario.TryGetValue(PreviousAccessTokenKey, out string? previousAccessToken))
+ if (Scenario.TryGetValue(PreviousAccessTokenKey, out string? previousAccessToken))
accessToken.Should().NotBe(previousAccessToken);
}
[Then("the response JSON should have a new refresh token")]
public void ThenTheResponseJsonShouldHaveANewRefreshToken()
{
- scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue();
+ Scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement payload = doc.RootElement.GetProperty("payload");
@@ -942,7 +916,7 @@ public class AuthSteps(ScenarioContext scenario)
refreshToken.Should().NotBeNullOrWhiteSpace();
- if (scenario.TryGetValue(PreviousRefreshTokenKey, out string? previousRefreshToken))
+ if (Scenario.TryGetValue(PreviousRefreshTokenKey, out string? previousRefreshToken))
refreshToken.Should().NotBe(previousRefreshToken);
}
@@ -950,10 +924,10 @@ public class AuthSteps(ScenarioContext scenario)
public async Task GivenIHaveConfirmedMyAccount()
{
HttpClient client = GetClient();
- string? token = scenario.TryGetValue("confirmationToken", out string? t)
+ string? token = Scenario.TryGetValue("confirmationToken", out string? t)
? t
: throw new InvalidOperationException("confirmation token not found");
- string? accessToken = scenario.TryGetValue("accessToken", out string? at)
+ string? accessToken = Scenario.TryGetValue("accessToken", out string? at)
? at
: string.Empty;
@@ -972,10 +946,10 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitAResendConfirmationRequestForMyAccount()
{
HttpClient client = GetClient();
- Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
+ Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found");
- string? accessToken = scenario.TryGetValue("accessToken", out string? at)
+ string? accessToken = Scenario.TryGetValue("accessToken", out string? at)
? at
: string.Empty;
@@ -988,8 +962,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a resend confirmation request for a non-existent user")]
@@ -997,7 +971,7 @@ public class AuthSteps(ScenarioContext scenario)
{
HttpClient client = GetClient();
Guid fakeUserId = Guid.NewGuid();
- string? accessToken = scenario.TryGetValue("accessToken", out string? at)
+ string? accessToken = Scenario.TryGetValue("accessToken", out string? at)
? at
: string.Empty;
@@ -1010,15 +984,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a resend confirmation request without an access token")]
public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken()
{
HttpClient client = GetClient();
- Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : Guid.NewGuid();
+ Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : Guid.NewGuid();
HttpRequestMessage requestMessage = new(
HttpMethod.Post,
@@ -1027,7 +1001,7 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
- scenario[ResponseKey] = response;
- scenario[ResponseBodyKey] = responseBody;
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
}
}
diff --git a/web/backend/API/API.Specs/Steps/BrewerySteps.cs b/web/backend/API/API.Specs/Steps/BrewerySteps.cs
new file mode 100644
index 00000000..92dc5085
--- /dev/null
+++ b/web/backend/API/API.Specs/Steps/BrewerySteps.cs
@@ -0,0 +1,329 @@
+using System.Text;
+using System.Text.Json;
+using Database.Connection;
+using Features.Locations.Dtos;
+using Features.Locations.Repository;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Reqnroll;
+
+namespace API.Specs.Steps;
+
+[Binding]
+public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
+{
+ private const string AccessTokenKey = "accessToken";
+ private const string CityIdKey = "cityId";
+ private const string BreweryPostIdKey = "breweryPostId";
+ private const string BreweryRowVersionKey = "breweryRowVersion";
+
+ private const string FixtureCountryName = "Fixture Country";
+ private const string FixtureCountryIsoCode = "FX";
+ private const string FixtureStateProvinceName = "Fixture State";
+ private const string FixtureStateProvinceIsoCode = "FX-TS";
+ private const string FixtureCityName = "Fixture City";
+
+ // GetOrCreateCityIdAsync is not fully race-safe under concurrent callers creating the same
+ // city (see its remarks); scenarios run in parallel across feature classes, so provisioning
+ // the shared fixture city is serialized the same way AuthSteps serializes its fixture account.
+ private static readonly SemaphoreSlim CityProvisioningLock = new(1, 1);
+
+ private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url)
+ {
+ HttpRequestMessage requestMessage = new(method, url);
+ if (Scenario.TryGetValue(AccessTokenKey, out string? accessToken))
+ requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
+ return requestMessage;
+ }
+
+ private async Task SendAsync(HttpRequestMessage requestMessage)
+ {
+ HttpClient client = GetClient();
+ HttpResponseMessage response = await client.SendAsync(requestMessage);
+ string responseBody = await response.Content.ReadAsStringAsync();
+
+ Scenario[ResponseKey] = response;
+ Scenario[ResponseBodyKey] = responseBody;
+ }
+
+ [Given("a city exists")]
+ public async Task GivenACityExists()
+ {
+ ISqlConnectionFactory connectionFactory = GetFactory()
+ .Services.GetRequiredService();
+ LocationRepository repository = new(connectionFactory);
+
+ CityLocation fixtureCity = new(
+ FixtureCityName,
+ FixtureStateProvinceName,
+ FixtureStateProvinceIsoCode,
+ FixtureCountryName,
+ FixtureCountryIsoCode
+ );
+
+ await CityProvisioningLock.WaitAsync();
+ Guid cityId;
+ try
+ {
+ cityId = await repository.GetOrCreateCityIdAsync(fixtureCity);
+ }
+ finally
+ {
+ CityProvisioningLock.Release();
+ }
+
+ Scenario[CityIdKey] = cityId;
+ }
+
+ private async Task CreateBreweryAsync(
+ string breweryName,
+ string description,
+ string addressLine1,
+ string postalCode,
+ Guid cityId
+ )
+ {
+ var createData = new
+ {
+ breweryName,
+ description,
+ location = new
+ {
+ cityId,
+ addressLine1,
+ addressLine2 = (string?)null,
+ postalCode,
+ coordinates = (object?)null,
+ },
+ };
+
+ HttpRequestMessage requestMessage = NewAuthenticatedRequest(
+ HttpMethod.Post,
+ "/api/brewery"
+ );
+ requestMessage.Content = new StringContent(
+ JsonSerializer.Serialize(createData),
+ Encoding.UTF8,
+ "application/json"
+ );
+
+ await SendAsync(requestMessage);
+ }
+
+ [When("I create a brewery with values:")]
+ public async Task WhenICreateABreweryWithValues(Table table)
+ {
+ DataTableRow row = table.Rows[0];
+ Guid cityId = Scenario.TryGetValue(CityIdKey, out Guid id) ? id : Guid.NewGuid();
+
+ await CreateBreweryAsync(
+ row["BreweryName"] ?? "",
+ row["Description"] ?? "",
+ row["AddressLine1"] ?? "",
+ row["PostalCode"] ?? "",
+ cityId
+ );
+ }
+
+ [When("I create a brewery with a non-existent city")]
+ public async Task WhenICreateABreweryWithANonExistentCity()
+ {
+ await CreateBreweryAsync(
+ "Ghost City Brewery",
+ "A brewery in a city that does not exist",
+ "1 Nowhere St",
+ "00000",
+ Guid.NewGuid()
+ );
+ }
+
+ [Given("I have created a brewery")]
+ public async Task GivenIHaveCreatedABrewery()
+ {
+ Guid cityId = Scenario.TryGetValue(CityIdKey, out Guid id)
+ ? id
+ : throw new InvalidOperationException("city ID not found in scenario");
+
+ await CreateBreweryAsync(
+ "Test Brewery",
+ "A test brewery",
+ "123 Test St",
+ "00000",
+ cityId
+ );
+
+ Scenario
+ .TryGetValue(ResponseKey, out HttpResponseMessage? response)
+ .Should()
+ .BeTrue();
+ ((int)response!.StatusCode).Should().Be(201, "fixture brewery creation must succeed");
+
+ Scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue();
+ using JsonDocument doc = JsonDocument.Parse(responseBody!);
+ JsonElement payload = doc.RootElement.GetProperty("payload");
+
+ Scenario[BreweryPostIdKey] = payload.GetProperty("breweryPostId").GetGuid();
+ Scenario[BreweryRowVersionKey] = payload.GetProperty("rowVersion").GetString()
+ ?? throw new InvalidOperationException("rowVersion missing from created brewery");
+ }
+
+ private Guid GetCreatedBreweryPostId()
+ {
+ return Scenario.TryGetValue(BreweryPostIdKey, out Guid id)
+ ? id
+ : throw new InvalidOperationException("brewery post ID not found in scenario");
+ }
+
+ [When("I retrieve the brewery by ID")]
+ public async Task WhenIRetrieveTheBreweryById()
+ {
+ HttpRequestMessage requestMessage = NewAuthenticatedRequest(
+ HttpMethod.Get,
+ $"/api/brewery/{GetCreatedBreweryPostId()}"
+ );
+ await SendAsync(requestMessage);
+ }
+
+ [When("I retrieve a brewery by a non-existent ID")]
+ public async Task WhenIRetrieveABreweryByANonExistentId()
+ {
+ HttpRequestMessage requestMessage = NewAuthenticatedRequest(
+ HttpMethod.Get,
+ $"/api/brewery/{Guid.NewGuid()}"
+ );
+ await SendAsync(requestMessage);
+ }
+
+ [When("I update the brewery with values:")]
+ public async Task WhenIUpdateTheBreweryWithValues(Table table)
+ {
+ DataTableRow row = table.Rows[0];
+ Guid breweryPostId = GetCreatedBreweryPostId();
+ string rowVersion = Scenario.TryGetValue(BreweryRowVersionKey, out string? rv)
+ ? rv
+ : throw new InvalidOperationException("row version not found in scenario");
+
+ var updateData = new
+ {
+ breweryPostId,
+ rowVersion,
+ breweryName = row["BreweryName"] ?? "",
+ description = row["Description"] ?? "",
+ location = (object?)null,
+ };
+
+ HttpRequestMessage requestMessage = NewAuthenticatedRequest(
+ HttpMethod.Put,
+ $"/api/brewery/{breweryPostId}"
+ );
+ requestMessage.Content = new StringContent(
+ JsonSerializer.Serialize(updateData),
+ Encoding.UTF8,
+ "application/json"
+ );
+
+ await SendAsync(requestMessage);
+ }
+
+ [When("I update a non-existent brewery")]
+ public async Task WhenIUpdateANonExistentBrewery()
+ {
+ Guid breweryPostId = Guid.NewGuid();
+ var updateData = new
+ {
+ breweryPostId,
+ rowVersion = Convert.ToBase64String([0x00, 0x00, 0x00, 0x00]),
+ breweryName = "Nonexistent",
+ description = "Nonexistent",
+ location = (object?)null,
+ };
+
+ HttpRequestMessage requestMessage = NewAuthenticatedRequest(
+ HttpMethod.Put,
+ $"/api/brewery/{breweryPostId}"
+ );
+ requestMessage.Content = new StringContent(
+ JsonSerializer.Serialize(updateData),
+ Encoding.UTF8,
+ "application/json"
+ );
+
+ await SendAsync(requestMessage);
+ }
+
+ [When("I delete the brewery")]
+ public async Task WhenIDeleteTheBrewery()
+ {
+ HttpRequestMessage requestMessage = NewAuthenticatedRequest(
+ HttpMethod.Delete,
+ $"/api/brewery/{GetCreatedBreweryPostId()}"
+ );
+ await SendAsync(requestMessage);
+ }
+
+ [When("I delete a non-existent brewery")]
+ public async Task WhenIDeleteANonExistentBrewery()
+ {
+ HttpRequestMessage requestMessage = NewAuthenticatedRequest(
+ HttpMethod.Delete,
+ $"/api/brewery/{Guid.NewGuid()}"
+ );
+ await SendAsync(requestMessage);
+ }
+
+ [Given("I am logged in as a different user")]
+ public async Task GivenIAmLoggedInAsADifferentUser()
+ {
+ HttpClient client = GetClient();
+ string suffix = Guid.NewGuid().ToString("N")[..8];
+ var registrationData = new
+ {
+ username = $"otheruser-{suffix}",
+ firstName = "Other",
+ lastName = "User",
+ email = $"otheruser-{suffix}@example.com",
+ dateOfBirth = "1990-01-01",
+ password = "Password1!",
+ };
+
+ HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
+ {
+ Content = new StringContent(
+ JsonSerializer.Serialize(registrationData),
+ Encoding.UTF8,
+ "application/json"
+ ),
+ };
+
+ HttpResponseMessage response = await client.SendAsync(requestMessage);
+ response.EnsureSuccessStatusCode();
+ string responseBody = await response.Content.ReadAsStringAsync();
+
+ using JsonDocument doc = JsonDocument.Parse(responseBody);
+ JsonElement payload = doc.RootElement.GetProperty("payload");
+ string accessToken =
+ (
+ payload.TryGetProperty("accessToken", out JsonElement tokenElem)
+ ? tokenElem.GetString()
+ : null
+ ) ?? throw new InvalidOperationException("accessToken missing from registration payload");
+
+ Scenario[AccessTokenKey] = accessToken;
+ }
+
+ [Then("retrieving the brewery by ID should now return HTTP status {int}")]
+ public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode)
+ {
+ HttpRequestMessage requestMessage = NewAuthenticatedRequest(
+ HttpMethod.Get,
+ $"/api/brewery/{GetCreatedBreweryPostId()}"
+ );
+ await SendAsync(requestMessage);
+
+ Scenario
+ .TryGetValue(ResponseKey, out HttpResponseMessage? response)
+ .Should()
+ .BeTrue();
+ ((int)response!.StatusCode).Should().Be(expectedCode);
+ }
+}
diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs
index 384473c1..d4aafa31 100644
--- a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs
+++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs
@@ -6,8 +6,8 @@ public sealed class BreweryPost
public Guid BreweryPostId { get; set; }
// FK References
+ // Note: An empty GUID signifies that this object is incomplete
public Guid PostedById { get; set; }
- public string PostedBy { get; set; } = string.Empty;
// Attributes
public string BreweryName { get; set; } = string.Empty;
@@ -21,6 +21,7 @@ public sealed class BreweryPost
// Navigation Properties
public BreweryPostLocation? Location { get; set; }
public List BeerPosts { get; set; } = [];
+ public string PostedBy { get; set; } = string.Empty;
// Query-computed
public DistanceInformation? Distance { get; set; }
diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs
index f1bfdb20..de0dc48a 100644
--- a/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs
+++ b/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs
@@ -32,6 +32,9 @@ public class CreateBreweryHandlerTests
.Setup(r => r.CreateAsync(It.IsAny()))
.Callback(b => persisted = b)
.Returns(Task.CompletedTask);
+ _repoMock
+ .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() => persisted);
DateTime before = DateTime.UtcNow;
BreweryDto result = await _handler.Handle(command, CancellationToken.None);
@@ -53,8 +56,27 @@ public class CreateBreweryHandlerTests
persisted.Location.PostalCode.Should().Be(command.Location.PostalCode);
_repoMock.Verify(r => r.CreateAsync(It.IsAny()), Times.Once);
+ _repoMock.Verify(
+ r => r.GetByIdAsync(persisted.BreweryPostId, It.IsAny()),
+ Times.Once
+ );
result.BreweryName.Should().Be("MyBrew");
result.Location.Should().NotBeNull();
}
+
+ [Fact]
+ public async Task Handle_ThrowsInvalidOperationException_WhenBreweryNotFoundAfterCreation()
+ {
+ CreateBreweryCommand command = new(Guid.NewGuid(), "MyBrew", "Desc", ValidLocation());
+
+ _repoMock.Setup(r => r.CreateAsync(It.IsAny())).Returns(Task.CompletedTask);
+ _repoMock
+ .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((BreweryPost?)null);
+
+ Func act = () => _handler.Handle(command, CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ }
}
diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/DeleteBreweryHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/DeleteBreweryHandlerTests.cs
index 39736f6a..2309b32c 100644
--- a/web/backend/Features/Features.Breweries.Tests/Commands/DeleteBreweryHandlerTests.cs
+++ b/web/backend/Features/Features.Breweries.Tests/Commands/DeleteBreweryHandlerTests.cs
@@ -1,21 +1,66 @@
+using Domain.Exceptions;
using Features.Breweries.Commands.DeleteBrewery;
using Features.Breweries.Repository;
+using FluentAssertions;
using Moq;
namespace Features.Breweries.Tests.Commands;
public class DeleteBreweryHandlerTests
{
- [Fact]
- public async Task Handle_DelegatesToRepository()
+ private readonly DeleteBreweryHandler _handler;
+ private readonly Mock _repoMock = new();
+
+ public DeleteBreweryHandlerTests()
{
- Mock repoMock = new();
- DeleteBreweryHandler handler = new(repoMock.Object);
- Guid id = Guid.NewGuid();
- repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask);
+ _handler = new DeleteBreweryHandler(_repoMock.Object);
+ }
- await handler.Handle(new DeleteBreweryCommand(id), CancellationToken.None);
+ private void SetUpExistingBrewery(Guid breweryPostId, Guid postedById)
+ {
+ _repoMock
+ .Setup(r => r.GetPostedByIdAsync(breweryPostId, It.IsAny()))
+ .ReturnsAsync(postedById);
+ }
- repoMock.Verify(r => r.DeleteAsync(id), Times.Once);
+ [Fact]
+ public async Task Handle_DelegatesToRepository_WhenRequestingUserIsThePoster()
+ {
+ Guid breweryPostId = Guid.NewGuid();
+ Guid requestingUserId = Guid.NewGuid();
+ SetUpExistingBrewery(breweryPostId, requestingUserId);
+ _repoMock.Setup(r => r.DeleteAsync(breweryPostId)).Returns(Task.CompletedTask);
+
+ await _handler.Handle(
+ new DeleteBreweryCommand(breweryPostId, requestingUserId),
+ CancellationToken.None
+ );
+
+ _repoMock.Verify(r => r.DeleteAsync(breweryPostId), Times.Once);
+ }
+
+ [Fact]
+ public async Task Handle_ThrowsNotFoundException_WhenBreweryDoesNotExist()
+ {
+ DeleteBreweryCommand command = new(Guid.NewGuid(), Guid.NewGuid());
+
+ Func act = () => _handler.Handle(command, CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ _repoMock.Verify(r => r.DeleteAsync(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Handle_ThrowsForbiddenException_WhenRequestingUserIsNotThePoster()
+ {
+ Guid breweryPostId = Guid.NewGuid();
+ SetUpExistingBrewery(breweryPostId, Guid.NewGuid());
+
+ DeleteBreweryCommand command = new(breweryPostId, Guid.NewGuid());
+
+ Func act = () => _handler.Handle(command, CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ _repoMock.Verify(r => r.DeleteAsync(It.IsAny()), Times.Never);
}
}
diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs
index ddbe0bc9..886338c7 100644
--- a/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs
+++ b/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs
@@ -1,4 +1,5 @@
using Domain.Entities;
+using Domain.Exceptions;
using Features.Breweries.Commands.UpdateBrewery;
using Features.Breweries.Repository;
using FluentAssertions;
@@ -16,13 +17,24 @@ public class UpdateBreweryHandlerTests
_handler = new UpdateBreweryHandler(_repoMock.Object);
}
+ private void SetUpExistingBrewery(Guid breweryPostId, Guid postedById)
+ {
+ _repoMock
+ .Setup(r => r.GetPostedByIdAsync(breweryPostId, It.IsAny()))
+ .ReturnsAsync(postedById);
+ }
+
[Fact]
public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt()
{
+ Guid breweryPostId = Guid.NewGuid();
+ Guid requestingUserId = Guid.NewGuid();
+ SetUpExistingBrewery(breweryPostId, requestingUserId);
+
UpdateBreweryCommand command = new(
- Guid.NewGuid(),
+ breweryPostId,
+ requestingUserId,
[0x01, 0x02],
- Guid.NewGuid(),
"Renamed",
"New description",
null
@@ -30,8 +42,8 @@ public class UpdateBreweryHandlerTests
BreweryPost? persisted = null;
_repoMock
- .Setup(r => r.UpdateAsync(It.IsAny()))
- .Callback(b => persisted = b)
+ .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny()))
+ .Callback((b, _) => persisted = b)
.ReturnsAsync(() => persisted!);
DateTime before = DateTime.UtcNow;
@@ -40,7 +52,6 @@ public class UpdateBreweryHandlerTests
persisted.Should().NotBeNull();
persisted!.BreweryPostId.Should().Be(command.BreweryPostId);
- persisted.PostedById.Should().Be(command.PostedById);
persisted.BreweryName.Should().Be("Renamed");
persisted.Description.Should().Be("New description");
persisted.RowVersion.Should().Equal(command.RowVersion);
@@ -51,10 +62,14 @@ public class UpdateBreweryHandlerTests
[Fact]
public async Task Handle_ClearsLocation_WhenCommandLocationIsNull()
{
+ Guid breweryPostId = Guid.NewGuid();
+ Guid requestingUserId = Guid.NewGuid();
+ SetUpExistingBrewery(breweryPostId, requestingUserId);
+
UpdateBreweryCommand command = new(
- Guid.NewGuid(),
+ breweryPostId,
+ requestingUserId,
[0x01, 0x02],
- Guid.NewGuid(),
"Name",
"Description",
null
@@ -62,8 +77,8 @@ public class UpdateBreweryHandlerTests
BreweryPost? persisted = null;
_repoMock
- .Setup(r => r.UpdateAsync(It.IsAny()))
- .Callback(b => persisted = b)
+ .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny()))
+ .Callback((b, _) => persisted = b)
.ReturnsAsync(() => persisted!);
await _handler.Handle(command, CancellationToken.None);
@@ -74,6 +89,10 @@ public class UpdateBreweryHandlerTests
[Fact]
public async Task Handle_SetsLocation_WhenCommandLocationProvided()
{
+ Guid breweryPostId = Guid.NewGuid();
+ Guid requestingUserId = Guid.NewGuid();
+ SetUpExistingBrewery(breweryPostId, requestingUserId);
+
UpdateBreweryLocation locationCommand = new(
Guid.NewGuid(),
Guid.NewGuid(),
@@ -83,9 +102,9 @@ public class UpdateBreweryHandlerTests
null
);
UpdateBreweryCommand command = new(
- Guid.NewGuid(),
+ breweryPostId,
+ requestingUserId,
[0x01, 0x02],
- Guid.NewGuid(),
"Name",
"Description",
locationCommand
@@ -93,8 +112,8 @@ public class UpdateBreweryHandlerTests
BreweryPost? persisted = null;
_repoMock
- .Setup(r => r.UpdateAsync(It.IsAny()))
- .Callback(b => persisted = b)
+ .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny()))
+ .Callback((b, _) => persisted = b)
.ReturnsAsync(() => persisted!);
await _handler.Handle(command, CancellationToken.None);
@@ -109,4 +128,41 @@ public class UpdateBreweryHandlerTests
persisted.Location.AddressLine2.Should().Be(locationCommand.AddressLine2);
persisted.Location.PostalCode.Should().Be(locationCommand.PostalCode);
}
+
+ [Fact]
+ public async Task Handle_ThrowsNotFoundException_WhenBreweryDoesNotExist()
+ {
+ UpdateBreweryCommand command = new(
+ Guid.NewGuid(),
+ Guid.NewGuid(),
+ [0x01, 0x02],
+ "Name",
+ "Description",
+ null
+ );
+
+ Func act = () => _handler.Handle(command, CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task Handle_ThrowsForbiddenException_WhenRequestingUserIsNotThePoster()
+ {
+ Guid breweryPostId = Guid.NewGuid();
+ SetUpExistingBrewery(breweryPostId, Guid.NewGuid());
+
+ UpdateBreweryCommand command = new(
+ breweryPostId,
+ Guid.NewGuid(),
+ [0x01, 0x02],
+ "Name",
+ "Description",
+ null
+ );
+
+ Func act = () => _handler.Handle(command, CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ }
}
diff --git a/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweryLocationsHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweryLocationsHandlerTests.cs
index d4f6f972..93fe73c6 100644
--- a/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweryLocationsHandlerTests.cs
+++ b/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweryLocationsHandlerTests.cs
@@ -22,7 +22,7 @@ public class GetAllBreweryLocationsHandlerTests
{
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(Array.Empty());
- IEnumerable result = await _handler.Handle(
+ IEnumerable result = await _handler.Handle(
new GetAllBreweryLocationsQuery(),
CancellationToken.None
);
@@ -41,7 +41,7 @@ public class GetAllBreweryLocationsHandlerTests
];
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries);
- IEnumerable result = await _handler.Handle(
+ IEnumerable result = await _handler.Handle(
new GetAllBreweryLocationsQuery(),
CancellationToken.None
);
diff --git a/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeHandlerTests.cs
index 71e863c3..8af18141 100644
--- a/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeHandlerTests.cs
+++ b/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeHandlerTests.cs
@@ -29,7 +29,7 @@ public class GetBreweryLocationsWithinRangeHandlerTests
)
.ReturnsAsync(Array.Empty());
- IEnumerable result = await _handler.Handle(
+ IEnumerable result = await _handler.Handle(
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
CancellationToken.None
);
@@ -57,7 +57,7 @@ public class GetBreweryLocationsWithinRangeHandlerTests
.Setup(r => r.GetAllLocationsWithinRange(It.IsAny(), It.IsAny()))
.ReturnsAsync(breweries);
- IEnumerable result = await _handler.Handle(
+ IEnumerable result = await _handler.Handle(
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
CancellationToken.None
);
diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs
index 6b4f71b7..2f0d3fd2 100644
--- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs
+++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs
@@ -23,3 +23,10 @@ public record CreateBreweryCommand(
string Description,
CreateBreweryLocation Location
) : IRequest;
+
+/// Request body for POST /api/brewery. PostedById comes from the caller's JWT.
+public record CreateBreweryRequest(
+ string BreweryName,
+ string Description,
+ CreateBreweryLocation Location
+);
\ No newline at end of file
diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs
index 83b3f285..3df6dbf8 100644
--- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs
+++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs
@@ -37,6 +37,17 @@ public class CreateBreweryHandler(IBreweryRepository repository)
};
await repository.CreateAsync(entity);
- return entity.ToDto();
+
+ // RowVersion is a DB-computed column, unset on the in-memory entity; re-fetch so callers
+ // get back the value needed for a subsequent optimistic-concurrency update. Uses
+ // CancellationToken.None since the write already committed: cancelling the caller's request
+ // at this point must not turn a successful creation into a thrown exception.
+ BreweryPost created =
+ await repository.GetByIdAsync(entity.BreweryPostId, CancellationToken.None)
+ ?? throw new InvalidOperationException(
+ $"Brewery '{entity.BreweryPostId}' was not found after a successful creation."
+ );
+
+ return created.ToDto();
}
}
diff --git a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs
index 97fe86f5..e0c391a6 100644
--- a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs
+++ b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs
@@ -2,5 +2,5 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery;
-/// Deletes a brewery post. Bound directly from the route of DELETE /api/brewery/{id}.
-public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
+/// Deletes a brewery post. is bound from the route of DELETE /api/brewery/{id}.
+public record DeleteBreweryCommand(Guid BreweryPostId, Guid RequestingUserId) : IRequest;
diff --git a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs
index e10e9185..557a6326 100644
--- a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs
+++ b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs
@@ -10,8 +10,17 @@ public class DeleteBreweryHandler(IBreweryRepository repository)
///
/// Thrown when no brewery exists with 's BreweryPostId.
///
- public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
+ ///
+ /// Thrown when 's RequestingUserId did not post the brewery.
+ ///
+ public async Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
{
- return repository.DeleteAsync(request.BreweryPostId);
+ await repository.EnsureCallerOwnsBreweryAsync(
+ request.BreweryPostId,
+ request.RequestingUserId,
+ cancellationToken
+ );
+
+ await repository.DeleteAsync(request.BreweryPostId);
}
}
diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs
index a86e2459..41e669fc 100644
--- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs
+++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs
@@ -19,16 +19,25 @@ public record UpdateBreweryLocation(
);
///
-/// Updates an existing brewery post. Bound directly from the request body of PUT /api/brewery/{id}.
-/// A null clears the brewery's location. must be the
-/// row-version last read for this brewery (e.g. from a prior GET); the update is rejected with a
-/// 409 Conflict if the brewery was modified since then.
+/// Updates an existing brewery post. A null clears the brewery's location.
+/// must be the row-version last read for this brewery (e.g. from a prior
+/// GET); the update is rejected with a 409 Conflict if the brewery was modified since then.
///
public record UpdateBreweryCommand(
Guid BreweryPostId,
+ Guid RequestingUserId,
byte[] RowVersion,
- Guid PostedById,
string BreweryName,
string Description,
UpdateBreweryLocation? Location
) : IRequest;
+
+/// Request body for PUT /api/brewery/{id}.
+public record UpdateBreweryRequest(
+ Guid BreweryPostId,
+ byte[] RowVersion,
+ string BreweryName,
+ string Description,
+ UpdateBreweryLocation? Location
+);
+
diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs
index 21d4d606..5c237148 100644
--- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs
+++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs
@@ -26,13 +26,18 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
CancellationToken cancellationToken
)
{
+ await repository.EnsureCallerOwnsBreweryAsync(
+ request.BreweryPostId,
+ request.RequestingUserId,
+ cancellationToken
+ );
+
BreweryPost entity = new()
{
BreweryPostId = request.BreweryPostId,
- PostedById = request.PostedById,
+ RowVersion = request.RowVersion,
BreweryName = request.BreweryName,
Description = request.Description,
- RowVersion = request.RowVersion,
UpdatedAt = DateTime.UtcNow,
Location = request.Location is null
? null
@@ -48,7 +53,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
},
};
- BreweryPost updated = await repository.UpdateAsync(entity);
+ BreweryPost updated = await repository.UpdateAsync(entity, cancellationToken);
return updated.ToDto();
}
}
diff --git a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs
index 5628fcfb..b95e0d06 100644
--- a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs
+++ b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs
@@ -70,13 +70,13 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// Allows anonymous access.
[AllowAnonymous]
[HttpGet("locations")]
- public async Task>>> GetAllLocations()
+ public async Task>>> GetAllLocations()
{
- IEnumerable breweries = await mediator.Send(
+ IEnumerable breweries = await mediator.Send(
new GetAllBreweryLocationsQuery()
);
return Ok(
- new ResponseBody>
+ new ResponseBody>
{
Message = "Brewery locations retrieved successfully.",
Payload = breweries,
@@ -91,17 +91,17 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// Allows anonymous access.
[AllowAnonymous]
[HttpGet("locations/nearby")]
- public async Task>>> GetLocationsWithinRange(
+ public async Task>>> GetLocationsWithinRange(
[FromQuery] double latitude,
[FromQuery] double longitude,
[FromQuery] double rangeInMetres
)
{
- IEnumerable breweries = await mediator.Send(
+ IEnumerable breweries = await mediator.Send(
new GetBreweryLocationsWithinRangeQuery(latitude, longitude, rangeInMetres)
);
return Ok(
- new ResponseBody>
+ new ResponseBody>
{
Message = "Brewery locations retrieved successfully.",
Payload = breweries,
@@ -113,10 +113,17 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// 201 Created with the newly created brewery.
[HttpPost]
public async Task>> Create(
- [FromBody] CreateBreweryCommand command
+ [FromBody] CreateBreweryRequest request
)
{
- BreweryDto brewery = await mediator.Send(command);
+ BreweryDto brewery = await mediator.Send(
+ new CreateBreweryCommand(
+ User.GetAuthenticatedUserId(),
+ request.BreweryName,
+ request.Description,
+ request.Location
+ )
+ );
return Created(
$"/api/brewery/{brewery.BreweryPostId}",
new ResponseBody
@@ -128,27 +135,37 @@ public class BreweryController(IMediator mediator) : ControllerBase
}
/// Updates an existing brewery post.
- /// Must match 's BreweryPostId.
+ /// Must match 's BreweryPostId.
///
/// 200 OK with the updated brewery; 400 Bad Request if the route ID does not match the
/// payload ID; 404 Not Found if the brewery or its CityId does not exist; or
- /// 409 Conflict if the brewery was modified since command.RowVersion was read.
+ /// 409 Conflict if the brewery was modified since request.RowVersion was read.
///
[HttpPut("{id:guid}")]
public async Task>> Update(
Guid id,
- [FromBody] UpdateBreweryCommand command
+ [FromBody] UpdateBreweryRequest request
)
{
- if (command.BreweryPostId != id)
+ if (request.BreweryPostId != id)
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
- BreweryDto brewery = await mediator.Send(command);
+ BreweryDto breweryUpdated = await mediator.Send(
+ new UpdateBreweryCommand(
+ request.BreweryPostId,
+ User.GetAuthenticatedUserId(),
+ request.RowVersion,
+ request.BreweryName,
+ request.Description,
+ request.Location
+ )
+ );
+
return Ok(
new ResponseBody
{
Message = "Brewery updated successfully.",
- Payload = brewery,
+ Payload = breweryUpdated,
}
);
}
@@ -158,7 +175,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
[HttpDelete("{id:guid}")]
public async Task> Delete(Guid id)
{
- await mediator.Send(new DeleteBreweryCommand(id));
+ await mediator.Send(new DeleteBreweryCommand(id, User.GetAuthenticatedUserId()));
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
}
-}
+}
\ No newline at end of file
diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs
index f9801023..a63ea99f 100644
--- a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs
+++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs
@@ -36,7 +36,7 @@ public record BreweryDto(
/// The distance, in metres, from a query's origin point, or for queries with no
/// origin point (e.g. GET /api/brewery/locations).
///
-public record SimplifiedBreweryDto(
+public record BreweryWithLocationDto(
Guid BreweryPostId,
string BreweryName,
BreweryLocationDto? Location,
diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs
index f8c5f792..4cbebeed 100644
--- a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs
+++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs
@@ -45,9 +45,9 @@ public static class BreweryDtoMapper
/// Converts a to its simplified DTO representation, used for
/// location-based queries that only need identifying and location details.
///
- public static SimplifiedBreweryDto ToSimplifiedDto(this BreweryPost brewery)
+ public static BreweryWithLocationDto ToSimplifiedDto(this BreweryPost brewery)
{
- return new SimplifiedBreweryDto(
+ return new BreweryWithLocationDto(
BreweryPostId: brewery.BreweryPostId,
BreweryName: brewery.BreweryName,
Location: brewery.Location is null
diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsHandler.cs
index d1551303..42e1c2c0 100644
--- a/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsHandler.cs
+++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsHandler.cs
@@ -7,10 +7,10 @@ namespace Features.Breweries.Queries.GetAllBreweryLocations;
/// Handles by retrieving all located brewery posts.
public class GetAllBreweryLocationsHandler(IBreweryRepository repository)
- : IRequestHandler>
+ : IRequestHandler>
{
/// Retrieves every brewery post that has a set location.
- public async Task> Handle(
+ public async Task> Handle(
GetAllBreweryLocationsQuery request,
CancellationToken cancellationToken
)
diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsQuery.cs
index bed55fc5..b6ea4ea3 100644
--- a/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsQuery.cs
+++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsQuery.cs
@@ -7,4 +7,4 @@ namespace Features.Breweries.Queries.GetAllBreweryLocations;
/// Retrieves all brewery posts that have a set location. Bound directly from
/// GET /api/brewery/locations.
///
-public record GetAllBreweryLocationsQuery : IRequest>;
+public record GetAllBreweryLocationsQuery : IRequest>;
diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs
index 0dcef849..1a27170b 100644
--- a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs
+++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs
@@ -15,7 +15,7 @@ public class GetBreweryByIdHandler(IBreweryRepository repository)
CancellationToken cancellationToken
)
{
- BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId);
+ BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId, cancellationToken);
return brewery?.ToDto();
}
}
diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeHandler.cs
index 51ce4c6a..00b7a7ce 100644
--- a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeHandler.cs
+++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeHandler.cs
@@ -9,10 +9,10 @@ namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// Handles by retrieving nearby located brewery posts.
///
public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository)
- : IRequestHandler>
+ : IRequestHandler>
{
/// Retrieves brewery posts within range of the requested coordinates, nearest first.
- public async Task> Handle(
+ public async Task> Handle(
GetBreweryLocationsWithinRangeQuery request,
CancellationToken cancellationToken
)
diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeQuery.cs
index c7a92765..f7322485 100644
--- a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeQuery.cs
+++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeQuery.cs
@@ -12,4 +12,4 @@ namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// The origin longitude, in decimal degrees.
/// The maximum distance, in metres, from the origin coordinates.
public record GetBreweryLocationsWithinRangeQuery(double Latitude, double Longitude, double RangeInMetres)
- : IRequest>;
+ : IRequest>;
diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryOwnershipExtensions.cs b/web/backend/Features/Features.Breweries/Repository/BreweryOwnershipExtensions.cs
new file mode 100644
index 00000000..83169d6f
--- /dev/null
+++ b/web/backend/Features/Features.Breweries/Repository/BreweryOwnershipExtensions.cs
@@ -0,0 +1,24 @@
+using Domain.Exceptions;
+
+namespace Features.Breweries.Repository;
+
+/// Shared ownership check for commands that mutate a specific brewery post.
+public static class BreweryOwnershipExtensions
+{
+ /// Thrown when no brewery exists with the given ID.
+ /// Thrown when did not post the brewery.
+ public static async Task EnsureCallerOwnsBreweryAsync(
+ this IBreweryRepository repository,
+ Guid breweryPostId,
+ Guid requestingUserId,
+ CancellationToken cancellationToken
+ )
+ {
+ Guid postedById =
+ await repository.GetPostedByIdAsync(breweryPostId, cancellationToken)
+ ?? throw new NotFoundException($"Brewery with ID {breweryPostId} not found.");
+
+ if (postedById != requestingUserId)
+ throw new ForbiddenException("You are not authorized to modify this brewery.");
+ }
+}
diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs
index ca796ff1..4cb9a823 100644
--- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs
+++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs
@@ -28,7 +28,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
}
///
- public async Task GetByIdAsync(Guid id)
+ public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
await using DbConnection connection = await CreateConnection();
IEnumerable results = await connection.QueryAsync<
@@ -39,43 +39,59 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
Country,
BreweryPost
>(
- """
- SELECT
- bp.BreweryPostID,
- bp.PostedByID,
- bp.BreweryName,
- bp.Description,
- bp.CreatedAt,
- bp.UpdatedAt,
- bp.RowVersion,
- bpl.BreweryPostLocationID,
- bpl.AddressLine1,
- bpl.AddressLine2,
- bpl.PostalCode,
- CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
- c.CityID,
- c.CityName,
- sp.StateProvinceID,
- sp.StateProvinceName,
- sp.ISO3166_2 AS Iso31662,
- co.CountryID,
- co.CountryName,
- co.ISO3166_1 AS Iso31661
- FROM dbo.BreweryPost bp
- LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
- LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
- LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
- LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID
- WHERE bp.BreweryPostID = @BreweryPostId
- """,
+ new CommandDefinition(
+ """
+ SELECT
+ bp.BreweryPostID,
+ bp.PostedByID,
+ bp.BreweryName,
+ bp.Description,
+ bp.CreatedAt,
+ bp.UpdatedAt,
+ bp.RowVersion,
+ bpl.BreweryPostLocationID,
+ bpl.AddressLine1,
+ bpl.AddressLine2,
+ bpl.PostalCode,
+ CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
+ c.CityID,
+ c.CityName,
+ sp.StateProvinceID,
+ sp.StateProvinceName,
+ sp.ISO3166_2 AS Iso31662,
+ co.CountryID,
+ co.CountryName,
+ co.ISO3166_1 AS Iso31661
+ FROM dbo.BreweryPost bp
+ LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
+ LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
+ LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
+ LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID
+ WHERE bp.BreweryPostID = @BreweryPostId
+ """,
+ new { BreweryPostId = id },
+ cancellationToken: cancellationToken
+ ),
MapBreweryRow,
- new { BreweryPostId = id },
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
);
return results.SingleOrDefault();
}
+ ///
+ public async Task GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ await using DbConnection connection = await CreateConnection();
+ return await connection.ExecuteScalarAsync(
+ new CommandDefinition(
+ "SELECT PostedByID FROM dbo.BreweryPost WHERE BreweryPostID = @BreweryPostId",
+ new { BreweryPostId = id },
+ cancellationToken: cancellationToken
+ )
+ );
+ }
+
///
public async Task> GetAllAsync(int? limit, int? offset)
{
@@ -221,10 +237,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
}
///
- public async Task UpdateAsync(BreweryPost brewery)
+ public async Task UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default)
{
await using DbConnection connection = await CreateConnection();
- await using DbTransaction transaction = await connection.BeginTransactionAsync();
+ await using DbTransaction transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
@@ -238,7 +254,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
- transaction
+ transaction,
+ cancellationToken: cancellationToken
)
)
is not null;
@@ -253,7 +270,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
new CommandDefinition(
"SELECT 1 FROM dbo.City WHERE CityID = @CityId",
new { brewery.Location.CityId },
- transaction
+ transaction,
+ cancellationToken: cancellationToken
)
)
is not null;
@@ -280,7 +298,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
brewery.Description,
brewery.RowVersion,
},
- transaction
+ transaction,
+ cancellationToken: cancellationToken
)
);
@@ -300,7 +319,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
WHERE BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
- transaction
+ transaction,
+ cancellationToken: cancellationToken
)
);
}
@@ -315,7 +335,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
WHERE BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
- transaction
+ transaction,
+ cancellationToken: cancellationToken
)
)
is not null;
@@ -346,7 +367,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
Longitude = brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid,
},
- transaction
+ transaction,
+ cancellationToken: cancellationToken
)
);
else
@@ -385,12 +407,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
Longitude = brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid,
},
- transaction
+ transaction,
+ cancellationToken: cancellationToken
)
);
}
- await transaction.CommitAsync();
+ await transaction.CommitAsync(cancellationToken);
}
catch
{
@@ -398,7 +421,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
throw;
}
- return await GetByIdAsync(brewery.BreweryPostId)
+ return await GetByIdAsync(brewery.BreweryPostId, cancellationToken)
?? throw new InvalidOperationException(
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update."
);
@@ -542,25 +565,27 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// Composes a joined row's , ,
/// , and fragments into a
/// single populated . When the row has no location (a left-joined
- /// brewery with no address on file), is a blank instance produced
- /// from all-NULL columns; the schema guarantees that whenever a location row was joined
- /// in, its City/StateProvince/Country chain was too.
+ /// brewery with no address on file), Dapper passes for
+ /// rather than a blank instance, since its identity column
+ /// (BreweryPostLocationID) is NULL; the schema guarantees that whenever a location
+ /// row was joined in, its City/StateProvince/Country chain was too.
///
private static BreweryPost MapBreweryRow(
BreweryPost post,
- BreweryPostLocation location,
- City city,
- StateProvince stateProvince,
- Country country
+ BreweryPostLocation? location,
+ City? city,
+ StateProvince? stateProvince,
+ Country? country
)
{
- if (location.BreweryPostLocationId == Guid.Empty)
+ if (location is null)
return post;
- stateProvince.CountryId = country.CountryId;
+ // The schema guarantees that whenever a location row was joined in, its City/StateProvince/
+ // Country chain was too, so these are never null alongside a non-null location.
+ city!.StateProvinceId = stateProvince!.StateProvinceId;
+ stateProvince.CountryId = country!.CountryId;
stateProvince.Country = country;
-
- city.StateProvinceId = stateProvince.StateProvinceId;
city.StateProvince = stateProvince;
location.BreweryPostId = post.BreweryPostId;
diff --git a/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs
index e108de4d..c429616a 100644
--- a/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs
+++ b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs
@@ -11,7 +11,13 @@ public interface IBreweryRepository
/// Retrieves a brewery post by ID, joined to its location. Returns null if no brewery post
/// exists with the given ID, or if it has no associated location.
///
- Task GetByIdAsync(Guid id);
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves the PostedById of the brewery post with the given ID, without joining its
+ /// location. Returns null if no brewery post exists with the given ID.
+ ///
+ Task GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default);
///
/// Retrieves all brewery posts, optionally paginated, ordered by creation date descending. Posts
@@ -50,7 +56,7 @@ public interface IBreweryRepository
/// Thrown when 's RowVersion no longer matches the stored row (it was
/// modified by another request since it was last read).
///
- Task UpdateAsync(BreweryPost brewery);
+ Task UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default);
/// Deletes a brewery post by ID. Its location and photos are removed via cascading foreign keys.
/// Thrown when no brewery exists with the given .
diff --git a/web/backend/Features/Features.Users/Controllers/AuthController.cs b/web/backend/Features/Features.Users/Controllers/AuthController.cs
index 70a4dfbf..ff1b6f87 100644
--- a/web/backend/Features/Features.Users/Controllers/AuthController.cs
+++ b/web/backend/Features/Features.Users/Controllers/AuthController.cs
@@ -1,5 +1,3 @@
-using System.IdentityModel.Tokens.Jwt;
-using Domain.Exceptions;
using Features.Auth.Commands.Account.DeleteAccount;
using Features.Auth.Commands.Account.UpdateEmail;
using Features.Auth.Commands.Account.UpdatePassword;
@@ -31,18 +29,6 @@ namespace Features.Auth.Controllers;
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(IMediator mediator) : ControllerBase
{
- ///
- /// Extracts the authenticated caller's user ID from the validated access token's sub claim.
- ///
- /// Thrown when the claim is missing or malformed.
- private Guid GetAuthenticatedUserId()
- {
- string? userIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
- if (!Guid.TryParse(userIdClaim, out Guid userId))
- throw new UnauthorizedException("Access token is missing a valid user ID claim");
- return userId;
- }
-
/// Registers a new user account.
///
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens.
@@ -150,7 +136,7 @@ public class AuthController(IMediator mediator) : ControllerBase
)
{
UpdateUsernamePayload payload = await mediator.Send(
- new UpdateUsernameCommand(GetAuthenticatedUserId(), request.NewUsername)
+ new UpdateUsernameCommand(User.GetAuthenticatedUserId(), request.NewUsername)
);
return Ok(
new ResponseBody
@@ -172,7 +158,7 @@ public class AuthController(IMediator mediator) : ControllerBase
)
{
UpdateEmailPayload payload = await mediator.Send(
- new UpdateEmailCommand(GetAuthenticatedUserId(), request.NewEmail)
+ new UpdateEmailCommand(User.GetAuthenticatedUserId(), request.NewEmail)
);
return Ok(
new ResponseBody
@@ -195,7 +181,7 @@ public class AuthController(IMediator mediator) : ControllerBase
{
UpdatePasswordPayload payload = await mediator.Send(
new UpdatePasswordCommand(
- GetAuthenticatedUserId(),
+ User.GetAuthenticatedUserId(),
request.CurrentPassword,
request.NewPassword
)
@@ -218,7 +204,7 @@ public class AuthController(IMediator mediator) : ControllerBase
{
UpdateProfilePayload payload = await mediator.Send(
new UpdateProfileCommand(
- GetAuthenticatedUserId(),
+ User.GetAuthenticatedUserId(),
request.FirstName,
request.LastName,
request.DateOfBirth
@@ -241,7 +227,7 @@ public class AuthController(IMediator mediator) : ControllerBase
[HttpDelete("account")]
public async Task> DeleteAccount()
{
- await mediator.Send(new DeleteAccountCommand(GetAuthenticatedUserId()));
+ await mediator.Send(new DeleteAccountCommand(User.GetAuthenticatedUserId()));
return Ok(new ResponseBody { Message = "Account deleted successfully." });
}
}
diff --git a/web/backend/Shared/Shared.Contracts/ClaimsPrincipalExtensions.cs b/web/backend/Shared/Shared.Contracts/ClaimsPrincipalExtensions.cs
new file mode 100644
index 00000000..baaa31fa
--- /dev/null
+++ b/web/backend/Shared/Shared.Contracts/ClaimsPrincipalExtensions.cs
@@ -0,0 +1,21 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using Domain.Exceptions;
+
+namespace Shared.Contracts;
+
+/// Extension methods for reading well-known claims off an authenticated .
+public static class ClaimsPrincipalExtensions
+{
+ ///
+ /// Extracts the authenticated caller's user ID from the validated access token's sub claim.
+ ///
+ /// Thrown when the claim is missing or malformed.
+ public static Guid GetAuthenticatedUserId(this ClaimsPrincipal user)
+ {
+ string? userIdClaim = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
+ if (!Guid.TryParse(userIdClaim, out Guid userId))
+ throw new UnauthorizedException("Access token is missing a valid user ID claim");
+ return userId;
+ }
+}
diff --git a/web/backend/Shared/Shared.Contracts/Shared.Contracts.csproj b/web/backend/Shared/Shared.Contracts/Shared.Contracts.csproj
index 3b0aefc6..cc014549 100644
--- a/web/backend/Shared/Shared.Contracts/Shared.Contracts.csproj
+++ b/web/backend/Shared/Shared.Contracts/Shared.Contracts.csproj
@@ -5,4 +5,12 @@
enable
Shared.Contracts
+
+
+
+
+
+
+
+