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 + + + + + + + +