diff --git a/web/backend/API/API.Specs/Features/Registration.feature b/web/backend/API/API.Specs/Features/Registration.feature index edc33671..7af06b8b 100644 --- a/web/backend/API/API.Specs/Features/Registration.feature +++ b/web/backend/API/API.Specs/Features/Registration.feature @@ -14,6 +14,7 @@ Scenario: Successful registration with valid details Scenario: Registration fails with existing username Given the API is running + And I have an existing account When I submit a registration request with values: | Username | FirstName | LastName | Email | DateOfBirth | Password | | test.user | Test | User | example@example.com | 2001-11-11 | Password1! | @@ -21,6 +22,7 @@ Scenario: Registration fails with existing username Scenario: Registration fails with existing email Given the API is running + And I have an existing account When I submit a registration request with values: | Username | FirstName | LastName | Email | DateOfBirth | Password | | newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! | diff --git a/web/backend/API/API.Specs/Steps/AuthSteps.cs b/web/backend/API/API.Specs/Steps/AuthSteps.cs index 605859c9..0f4c045e 100644 --- a/web/backend/API/API.Specs/Steps/AuthSteps.cs +++ b/web/backend/API/API.Specs/Steps/AuthSteps.cs @@ -83,10 +83,59 @@ public class AuthSteps(ScenarioContext scenario) ); } + private const string FixtureUsername = "test.user"; + private const string FixturePassword = "Password1!"; + private const string FixtureEmail = "test.user@thebiergarten.app"; + + // Scenarios run in parallel across feature classes, and this fixture is shared by name. + // Serializing provisioning avoids two scenarios racing to INSERT the same username/email, + // which would otherwise surface as a raw SQL unique-constraint error instead of a clean 409. + private static readonly SemaphoreSlim FixtureProvisioningLock = new(1, 1); + [Given("I have an existing account")] - public void GivenIHaveAnExistingAccount() + public async Task GivenIHaveAnExistingAccount() { - scenario[TestUserKey] = ("test.user", "password"); + scenario[TestUserKey] = (FixtureUsername, FixturePassword); + + HttpClient client = GetClient(); + var registrationData = new + { + username = FixtureUsername, + firstName = "Test", + lastName = "User", + email = FixtureEmail, + dateOfBirth = "1990-01-01", + password = FixturePassword, + }; + + HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register") + { + Content = new StringContent( + JsonSerializer.Serialize(registrationData), + Encoding.UTF8, + "application/json" + ), + }; + + // Registration is used (rather than relying on seeded data) so the fixture account is + // guaranteed to exist regardless of run order. A 409 means another scenario in this run + // already provisioned it, which is equally fine. + await FixtureProvisioningLock.WaitAsync(); + HttpResponseMessage response; + try + { + response = await client.SendAsync(requestMessage); + } + finally + { + FixtureProvisioningLock.Release(); + } + + if ((int)response.StatusCode is not (201 or 409)) + throw new InvalidOperationException( + $"Failed to provision fixture account '{FixtureUsername}': " + + $"{(int)response.StatusCode} {response.StatusCode}" + ); } [Given("I do not have an existing account")] @@ -320,7 +369,7 @@ public class AuthSteps(ScenarioContext scenario) public async Task GivenIAmLoggedIn() { HttpClient client = GetClient(); - var loginData = new { username = "test.user", password = "password" }; + var loginData = new { username = FixtureUsername, password = FixturePassword }; string body = JsonSerializer.Serialize(loginData); HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/login") diff --git a/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs b/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs index 252c9a9e..c5b99ac3 100644 --- a/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs @@ -45,7 +45,7 @@ public class AuthRepositoryTests ("CreatedAt", typeof(DateTime)), ("UpdatedAt", typeof(DateTime?)), ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) + ("RowVersion", typeof(byte[])) ) .AddRow( expectedUserId, @@ -99,7 +99,7 @@ public class AuthRepositoryTests ("CreatedAt", typeof(DateTime)), ("UpdatedAt", typeof(DateTime?)), ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) + ("RowVersion", typeof(byte[])) ) .AddRow( userId, @@ -157,7 +157,7 @@ public class AuthRepositoryTests ("CreatedAt", typeof(DateTime)), ("UpdatedAt", typeof(DateTime?)), ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) + ("RowVersion", typeof(byte[])) ) .AddRow( userId, @@ -210,7 +210,7 @@ public class AuthRepositoryTests ("UserAccountId", typeof(Guid)), ("Hash", typeof(string)), ("CreatedAt", typeof(DateTime)), - ("Timer", typeof(byte[])) + ("RowVersion", typeof(byte[])) ) .AddRow(credentialId, userId, "hashed_password_value", DateTime.UtcNow, null) ); diff --git a/web/backend/Features/Features.Auth/Repository/AuthRepository.cs b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs index 2b145b3d..ae861637 100644 --- a/web/backend/Features/Features.Auth/Repository/AuthRepository.cs +++ b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs @@ -17,7 +17,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) IAuthRepository { private const string SelectColumns = - "UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, Timer"; + "UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion"; /// /// The account and credential are inserted within a single database transaction. @@ -92,7 +92,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) await using DbConnection connection = await CreateConnection(); return await connection.QueryFirstOrDefaultAsync( """ - SELECT UserCredentialId, UserAccountId, Hash, CreatedAt, Timer + SELECT UserCredentialId, UserAccountId, Hash, CreatedAt, RowVersion FROM dbo.UserCredential WHERE UserAccountId = @UserAccountId AND IsRevoked = 0 """, diff --git a/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs b/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs index de217dfa..54fb2d50 100644 --- a/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs +++ b/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs @@ -29,7 +29,7 @@ public class UserAccountRepositoryTests ("CreatedAt", typeof(DateTime)), ("UpdatedAt", typeof(DateTime?)), ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) + ("RowVersion", typeof(byte[])) ) .AddRow( Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), @@ -70,7 +70,7 @@ public class UserAccountRepositoryTests ("CreatedAt", typeof(DateTime)), ("UpdatedAt", typeof(DateTime?)), ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) + ("RowVersion", typeof(byte[])) ) .AddRow( Guid.NewGuid(), @@ -118,7 +118,7 @@ public class UserAccountRepositoryTests ("CreatedAt", typeof(DateTime)), ("UpdatedAt", typeof(DateTime?)), ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) + ("RowVersion", typeof(byte[])) ) .AddRow( Guid.NewGuid(), @@ -155,7 +155,7 @@ public class UserAccountRepositoryTests ("CreatedAt", typeof(DateTime)), ("UpdatedAt", typeof(DateTime?)), ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) + ("RowVersion", typeof(byte[])) ) .AddRow( Guid.NewGuid(), diff --git a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs index c911007e..13c3b26b 100644 --- a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs +++ b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs @@ -14,7 +14,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) IUserAccountRepository { private const string SelectColumns = - "UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, Timer"; + "UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion"; /// public async Task GetByIdAsync(Guid id)