diff --git a/Database/Database.Core/scripts/01-schema/schema.sql b/Database/Database.Core/scripts/01-schema/schema.sql index 5a09894..1fdf3ba 100644 --- a/Database/Database.Core/scripts/01-schema/schema.sql +++ b/Database/Database.Core/scripts/01-schema/schema.sql @@ -171,7 +171,6 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted Timer ROWVERSION, - CONSTRAINT PK_UserCredential PRIMARY KEY (UserCredentialID), diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql index 548bbda..a99b12d 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql @@ -1,7 +1,7 @@ CREATE OR ALTER PROCEDURE usp_CreateUserAccount ( - @UserAccountId UNIQUEIDENTIFIER = NULL, + @UserAccountId UNIQUEIDENTIFIER OUTPUT, @Username VARCHAR(64), @FirstName NVARCHAR(128), @LastName NVARCHAR(128), @@ -10,13 +10,10 @@ CREATE OR ALTER PROCEDURE usp_CreateUserAccount ) AS BEGIN - SET NOCOUNT ON - SET XACT_ABORT ON - BEGIN TRANSACTION + SET NOCOUNT ON; INSERT INTO UserAccount ( - UserAccountID, Username, FirstName, LastName, @@ -25,12 +22,12 @@ BEGIN ) VALUES ( - COALESCE(@UserAccountId, NEWID()), @Username, @FirstName, @LastName, @DateOfBirth, @Email ); - COMMIT TRANSACTION + + SELECT @UserAccountId AS UserAccountId; END; diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql index 4813ad5..7ea5d3e 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql @@ -6,8 +6,6 @@ CREATE OR ALTER PROCEDURE usp_DeleteUserAccount AS BEGIN SET NOCOUNT ON - SET XACT_ABORT ON - BEGIN TRANSACTION IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId) BEGIN @@ -19,5 +17,4 @@ BEGIN DELETE FROM UserAccount WHERE UserAccountId = @UserAccountId; - COMMIT TRANSACTION END; diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql index 799debe..233acf5 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql @@ -1,4 +1,3 @@ - CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts AS BEGIN diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql index 22d3e44..5e6fc2e 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql @@ -1,6 +1,4 @@ - -CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail -( +CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail( @Email VARCHAR(128) ) AS diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql index ace7aa5..7113807 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql @@ -1,6 +1,4 @@ - -CREATE OR ALTER PROCEDURE usp_GetUserAccountById -( +CREATE OR ALTER PROCEDURE USP_GetUserAccountById( @UserAccountId UNIQUEIDENTIFIER ) AS @@ -18,4 +16,4 @@ BEGIN Timer FROM dbo.UserAccount WHERE UserAccountID = @UserAccountId; -END; +END diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql index 3f12b63..96fcca9 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql @@ -1,6 +1,4 @@ - -CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername -( +CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername( @Username VARCHAR(64) ) AS diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql index 1326ef5..e0355b8 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql @@ -1,6 +1,4 @@ - -CREATE OR ALTER PROCEDURE usp_UpdateUserAccount -( +CREATE OR ALTER PROCEDURE usp_UpdateUserAccount( @Username VARCHAR(64), @FirstName NVARCHAR(128), @LastName NVARCHAR(128), @@ -10,26 +8,20 @@ CREATE OR ALTER PROCEDURE usp_UpdateUserAccount ) AS BEGIN - SET NOCOUNT ON - SET XACT_ABORT ON - BEGIN TRANSACTION - - IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId) - BEGIN - RAISERROR('UserAccount with the specified ID does not exist.', 16, - 1); - ROLLBACK TRANSACTION - RETURN - END + SET + NOCOUNT ON; UPDATE UserAccount - SET - Username = @Username, - FirstName = @FirstName, - LastName = @LastName, + SET Username = @Username, + FirstName = @FirstName, + LastName = @LastName, DateOfBirth = @DateOfBirth, - Email = @Email + Email = @Email WHERE UserAccountId = @UserAccountId; - COMMIT TRANSACTION + IF @@ROWCOUNT = 0 + BEGIN + THROW + 50001, 'UserAccount with the specified ID does not exist.', 1; + END END; diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql b/Database/Database.Core/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql similarity index 94% rename from Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql rename to Database/Database.Core/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql index 0ff5aad..bc385e9 100644 --- a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_GetUserCredentialByUserAccountId.sql +++ b/Database/Database.Core/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql @@ -4,7 +4,6 @@ CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId( AS BEGIN SET NOCOUNT ON; - SET XACT_ABORT ON; SELECT UserCredentialId, diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_InvalidateUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql similarity index 54% rename from Database/Database.Core/scripts/03-crud/02-UserCredential/USP_InvalidateUserCredential.sql rename to Database/Database.Core/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql index ba9e601..08c7d53 100644 --- a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_InvalidateUserCredential.sql +++ b/Database/Database.Core/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql @@ -1,5 +1,5 @@ CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential( - @UserAccountId UNIQUEIDENTIFIER + @UserAccountId_ UNIQUEIDENTIFIER ) AS BEGIN @@ -8,18 +8,17 @@ BEGIN BEGIN TRANSACTION; - IF NOT EXISTS (SELECT 1 - FROM dbo.UserAccount - WHERE UserAccountID = @UserAccountId) - ROLLBACK TRANSACTION - + EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_; + IF @@ROWCOUNT = 0 + THROW 50001, 'User account not found', 1; -- invalidate all other credentials by setting them to revoked UPDATE dbo.UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() - WHERE UserAccountId = @UserAccountId AND IsRevoked != 1; - + WHERE UserAccountId = @UserAccountId_ + AND IsRevoked != 1; + COMMIT TRANSACTION; END; \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/02-Auth/USP_RegisterUser.sql b/Database/Database.Core/scripts/03-crud/02-Auth/USP_RegisterUser.sql new file mode 100644 index 0000000..8f10b83 --- /dev/null +++ b/Database/Database.Core/scripts/03-crud/02-Auth/USP_RegisterUser.sql @@ -0,0 +1,42 @@ +CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser( + @UserAccountId_ UNIQUEIDENTIFIER OUTPUT, + @Username VARCHAR(64), + @FirstName NVARCHAR(128), + @LastName NVARCHAR(128), + @DateOfBirth DATETIME, + @Email VARCHAR(128), + @Hash NVARCHAR(MAX) +) +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + BEGIN TRANSACTION; + + EXEC usp_CreateUserAccount + @UserAccountId = @UserAccountId_ OUTPUT, + @Username = @Username, + @FirstName = @FirstName, + @LastName = @LastName, + @DateOfBirth = @DateOfBirth, + @Email = @Email; + + IF @UserAccountId_ IS NULL + BEGIN + THROW 50000, 'Failed to create user account.', 1; + END + + + EXEC dbo.usp_RotateUserCredential + @UserAccountId = @UserAccountId_, + @Hash = @Hash; + + IF @@ROWCOUNT = 0 + BEGIN + THROW 50002, 'Failed to create user credential.', 1; + END + COMMIT TRANSACTION; + + +END \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql similarity index 53% rename from Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql rename to Database/Database.Core/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql index cd4c183..d4d4e60 100644 --- a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql +++ b/Database/Database.Core/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql @@ -1,30 +1,28 @@ CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential( - @UserAccountId UNIQUEIDENTIFIER, + @UserAccountId_ UNIQUEIDENTIFIER, @Hash NVARCHAR(MAX) ) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; - BEGIN TRANSACTION; - IF NOT EXISTS (SELECT 1 - FROM dbo.UserAccount - WHERE UserAccountID = @UserAccountId) - BEGIN - ROLLBACK TRANSACTION; - END + EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_ + + IF @@ROWCOUNT = 0 + THROW 50001, 'User account not found', 1; + -- invalidate all other credentials -- set them to revoked UPDATE dbo.UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() - WHERE UserAccountId = @UserAccountId; + WHERE UserAccountId = @UserAccountId_; INSERT INTO dbo.UserCredential (UserAccountId, Hash) - VALUES (@UserAccountId, @Hash); + VALUES (@UserAccountId_, @Hash); + - COMMIT TRANSACTION; END; \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql b/Database/Database.Core/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql index d556fa2..e2fbe68 100644 --- a/Database/Database.Core/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql +++ b/Database/Database.Core/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql @@ -1,6 +1,5 @@ -CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification - @UserAccountID uniqueidentifier, - @VerificationDateTime datetime = NULL +CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER, + @VerificationDateTime DATETIME = NULL AS BEGIN SET NOCOUNT ON; @@ -11,10 +10,13 @@ BEGIN BEGIN TRANSACTION; + EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_; + IF @@ROWCOUNT = 0 + THROW 50001, 'Could not find a user with that id', 1; + INSERT INTO dbo.UserVerification - (UserAccountId, VerificationDateTime) - VALUES - (@UserAccountID, @VerificationDateTime); + (UserAccountId, VerificationDateTime) + VALUES (@UserAccountID_, @VerificationDateTime); COMMIT TRANSACTION; END diff --git a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCity.sql b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCity.sql index bae29a1..0de7c52 100644 --- a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCity.sql +++ b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCity.sql @@ -1,5 +1,4 @@ -CREATE OR ALTER PROCEDURE dbo.USP_CreateCity -( +CREATE OR ALTER PROCEDURE dbo.USP_CreateCity( @CityName NVARCHAR(100), @StateProvinceCode NVARCHAR(6) ) @@ -8,23 +7,24 @@ BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; - DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode); - IF @StateProvinceId IS NULL - BEGIN - RAISERROR('State/province not found for code.', 16, 1); - RETURN; - END + BEGIN TRANSACTION + DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode); + IF @StateProvinceId IS NULL + BEGIN + THROW 50001, 'State/province does not exist', 1; + END - IF EXISTS ( - SELECT 1 - FROM dbo.City - WHERE CityName = @CityName - AND StateProvinceID = @StateProvinceId - ) - RETURN; + IF EXISTS (SELECT 1 + FROM dbo.City + WHERE CityName = @CityName + AND StateProvinceID = @StateProvinceId) + BEGIN - INSERT INTO dbo.City - (StateProvinceID, CityName) - VALUES - (@StateProvinceId, @CityName); + THROW 50002, 'City already exists.', 1; + END + + INSERT INTO dbo.City + (StateProvinceID, CityName) + VALUES (@StateProvinceId, @CityName); + COMMIT TRANSACTION END; diff --git a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCountry.sql b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCountry.sql index e6e79e1..e321746 100644 --- a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCountry.sql +++ b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCountry.sql @@ -1,5 +1,4 @@ -CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry -( +CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry( @CountryName NVARCHAR(100), @ISO3616_1 NVARCHAR(2) ) @@ -7,16 +6,15 @@ AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; + BEGIN TRANSACTION; - IF EXISTS ( - SELECT 1 - FROM dbo.Country - WHERE ISO3616_1 = @ISO3616_1 - ) - RETURN; + IF EXISTS (SELECT 1 + FROM dbo.Country + WHERE ISO3616_1 = @ISO3616_1) + THROW 50001, 'Country already exists', 1; INSERT INTO dbo.Country (CountryName, ISO3616_1) - VALUES - (@CountryName, @ISO3616_1); + VALUES (@CountryName, @ISO3616_1); + COMMIT TRANSACTION; END; diff --git a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateStateProvince.sql b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateStateProvince.sql index 7caca90..39e55c1 100644 --- a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateStateProvince.sql +++ b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateStateProvince.sql @@ -1,5 +1,4 @@ -CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince -( +CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince( @StateProvinceName NVARCHAR(100), @ISO3616_2 NVARCHAR(6), @CountryCode NVARCHAR(2) @@ -9,22 +8,19 @@ BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; - IF EXISTS ( - SELECT 1 - FROM dbo.StateProvince - WHERE ISO3616_2 = @ISO3616_2 - ) + IF EXISTS (SELECT 1 + FROM dbo.StateProvince + WHERE ISO3616_2 = @ISO3616_2) RETURN; DECLARE @CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode); IF @CountryId IS NULL - BEGIN - RAISERROR('Country not found for code.', 16, 1); - RETURN; - END + BEGIN + THROW 50001, 'Country does not exist', 1; + + END INSERT INTO dbo.StateProvince (StateProvinceName, ISO3616_2, CountryID) - VALUES - (@StateProvinceName, @ISO3616_2, @CountryId); + VALUES (@StateProvinceName, @ISO3616_2, @CountryId); END; diff --git a/Repository/Repository.Core/Repositories/Repository.cs b/Repository/Repository.Core/Repositories/Repository.cs index dd3de00..154c6d6 100644 --- a/Repository/Repository.Core/Repositories/Repository.cs +++ b/Repository/Repository.Core/Repositories/Repository.cs @@ -1,12 +1,12 @@ +using System.Data.Common; using DataAccessLayer.Sql; -using Microsoft.Data.SqlClient; namespace DataAccessLayer.Repositories { public abstract class Repository(ISqlConnectionFactory connectionFactory) where T : class { - protected async Task CreateConnection() + protected async Task CreateConnection() { var connection = connectionFactory.CreateConnection(); await connection.OpenAsync(); @@ -19,6 +19,6 @@ namespace DataAccessLayer.Repositories public abstract Task UpdateAsync(T entity); public abstract Task DeleteAsync(Guid id); - protected abstract T MapToEntity(SqlDataReader reader); + protected abstract T MapToEntity(DbDataReader reader); } } diff --git a/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs b/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs index a71d147..4dab2dc 100644 --- a/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs +++ b/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs @@ -1,24 +1,28 @@ using System.Data; +using System.Data.Common; using DataAccessLayer.Sql; -using Microsoft.Data.SqlClient; namespace DataAccessLayer.Repositories.UserAccount { public class UserAccountRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IUserAccountRepository { + /** + * @todo update the create user account stored proc to add user credential creation in + * a single transaction, use that transaction instead. + */ public override async Task AddAsync(Entities.UserAccount userAccount) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_CreateUserAccount", connection); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_CreateUserAccount"; command.CommandType = CommandType.StoredProcedure; - - command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = userAccount.UserAccountId; - command.Parameters.Add("@Username", SqlDbType.NVarChar, 100).Value = userAccount.Username; - command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 100).Value = userAccount.FirstName; - command.Parameters.Add("@LastName", SqlDbType.NVarChar, 100).Value = userAccount.LastName; - command.Parameters.Add("@Email", SqlDbType.NVarChar, 256).Value = userAccount.Email; - command.Parameters.Add("@DateOfBirth", SqlDbType.Date).Value = userAccount.DateOfBirth; + AddParameter(command, "@UserAccountId", userAccount.UserAccountId); + AddParameter(command, "@Username", userAccount.Username); + AddParameter(command, "@FirstName", userAccount.FirstName); + AddParameter(command, "@LastName", userAccount.LastName); + AddParameter(command, "@Email", userAccount.Email); + AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth); await command.ExecuteNonQueryAsync(); } @@ -26,12 +30,11 @@ namespace DataAccessLayer.Repositories.UserAccount public override async Task GetByIdAsync(Guid id) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_GetUserAccountById", connection) - { - CommandType = CommandType.StoredProcedure - }; + await using var command = connection.CreateCommand(); + command.CommandText = "usp_GetUserAccountById"; + command.CommandType = CommandType.StoredProcedure; - command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = id; + AddParameter(command, "@UserAccountId", id); await using var reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToEntity(reader) : null; @@ -40,14 +43,15 @@ namespace DataAccessLayer.Repositories.UserAccount public override async Task> GetAllAsync(int? limit, int? offset) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_GetAllUserAccounts", connection); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_GetAllUserAccounts"; command.CommandType = CommandType.StoredProcedure; if (limit.HasValue) - command.Parameters.Add("@Limit", SqlDbType.Int).Value = limit.Value; + AddParameter(command, "@Limit", limit.Value); if (offset.HasValue) - command.Parameters.Add("@Offset", SqlDbType.Int).Value = offset.Value; + AddParameter(command, "@Offset", offset.Value); await using var reader = await command.ExecuteReaderAsync(); var users = new List(); @@ -63,15 +67,16 @@ namespace DataAccessLayer.Repositories.UserAccount public override async Task UpdateAsync(Entities.UserAccount userAccount) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_UpdateUserAccount", connection); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_UpdateUserAccount"; command.CommandType = CommandType.StoredProcedure; - command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = userAccount.UserAccountId; - command.Parameters.Add("@Username", SqlDbType.NVarChar, 100).Value = userAccount.Username; - command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 100).Value = userAccount.FirstName; - command.Parameters.Add("@LastName", SqlDbType.NVarChar, 100).Value = userAccount.LastName; - command.Parameters.Add("@Email", SqlDbType.NVarChar, 256).Value = userAccount.Email; - command.Parameters.Add("@DateOfBirth", SqlDbType.Date).Value = userAccount.DateOfBirth; + AddParameter(command, "@UserAccountId", userAccount.UserAccountId); + AddParameter(command, "@Username", userAccount.Username); + AddParameter(command, "@FirstName", userAccount.FirstName); + AddParameter(command, "@LastName", userAccount.LastName); + AddParameter(command, "@Email", userAccount.Email); + AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth); await command.ExecuteNonQueryAsync(); } @@ -79,20 +84,22 @@ namespace DataAccessLayer.Repositories.UserAccount public override async Task DeleteAsync(Guid id) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_DeleteUserAccount", connection); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_DeleteUserAccount"; command.CommandType = CommandType.StoredProcedure; - command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = id; + AddParameter(command, "@UserAccountId", id); await command.ExecuteNonQueryAsync(); } public async Task GetByUsernameAsync(string username) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_GetUserAccountByUsername", connection); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_GetUserAccountByUsername"; command.CommandType = CommandType.StoredProcedure; - command.Parameters.Add("@Username", SqlDbType.NVarChar, 100).Value = username; + AddParameter(command, "@Username", username); await using var reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToEntity(reader) : null; @@ -101,16 +108,17 @@ namespace DataAccessLayer.Repositories.UserAccount public async Task GetByEmailAsync(string email) { await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_GetUserAccountByEmail", connection); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_GetUserAccountByEmail"; command.CommandType = CommandType.StoredProcedure; - command.Parameters.Add("@Email", SqlDbType.NVarChar, 256).Value = email; + AddParameter(command, "@Email", email); await using var reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToEntity(reader) : null; } - protected override Entities.UserAccount MapToEntity(SqlDataReader reader) + protected override Entities.UserAccount MapToEntity(DbDataReader reader) { return new Entities.UserAccount { @@ -129,5 +137,13 @@ namespace DataAccessLayer.Repositories.UserAccount : (byte[])reader["Timer"] }; } + + private static void AddParameter(DbCommand command, string name, object? value) + { + var p = command.CreateParameter(); + p.ParameterName = name; + p.Value = value ?? DBNull.Value; + command.Parameters.Add(p); + } } } diff --git a/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs b/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs index b6b4ae8..72dadb9 100644 --- a/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs +++ b/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs @@ -4,4 +4,5 @@ public interface IUserCredentialRepository { Task RotateCredentialAsync(Guid userAccountId, UserCredential credential); Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId); + Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId); } diff --git a/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs b/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs new file mode 100644 index 0000000..e27e03b --- /dev/null +++ b/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs @@ -0,0 +1,93 @@ +using System.Data; +using System.Data.Common; +using DataAccessLayer.Sql; + +namespace DataAccessLayer.Repositories.UserCredential +{ + public class UserCredentialRepository(ISqlConnectionFactory connectionFactory) + : DataAccessLayer.Repositories.Repository(connectionFactory), IUserCredentialRepository + { + public async Task RotateCredentialAsync(Guid userAccountId, Entities.UserCredential credential) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "USP_RotateUserCredential"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountId", userAccountId); + AddParameter(command, "@Hash", credential.Hash); + + await command.ExecuteNonQueryAsync(); + } + + public async Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "USP_GetActiveUserCredentialByUserAccountId"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountId", userAccountId); + + await using var reader = await command.ExecuteReaderAsync(); + return await reader.ReadAsync() ? MapToEntity(reader) : null; + } + + public async Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "USP_InvalidateUserCredential"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountId", userAccountId); + await command.ExecuteNonQueryAsync(); + } + + public override Task AddAsync(Entities.UserCredential entity) + => throw new NotSupportedException("Use RotateCredentialAsync for adding/rotating credentials."); + + public override Task> GetAllAsync(int? limit, int? offset) + => throw new NotSupportedException("Listing credentials is not supported."); + + public override Task GetByIdAsync(Guid id) + => throw new NotSupportedException("Fetching credential by ID is not supported."); + + public override Task UpdateAsync(Entities.UserCredential entity) + => throw new NotSupportedException("Use RotateCredentialAsync to update credentials."); + + public override Task DeleteAsync(Guid id) + => throw new NotSupportedException("Deleting a credential by ID is not supported."); + + protected override Entities.UserCredential MapToEntity(DbDataReader reader) + { + var entity = new Entities.UserCredential + { + UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")), + UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), + Hash = reader.GetString(reader.GetOrdinal("Hash")), + CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")) + }; + + // Optional columns + var hasTimer = reader.GetSchemaTable()?.Rows + .Cast() + .Any(r => string.Equals(r["ColumnName"]?.ToString(), "Timer", StringComparison.OrdinalIgnoreCase)) ?? false; + + if (hasTimer) + { + entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"]; + } + + return entity; + } + + private static void AddParameter(DbCommand command, string name, object? value) + { + var p = command.CreateParameter(); + p.ParameterName = name; + p.Value = value ?? DBNull.Value; + command.Parameters.Add(p); + } + } +} diff --git a/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs b/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs index 2011fd5..eb8abad 100644 --- a/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs +++ b/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs @@ -1,3 +1,4 @@ +using System.Data.Common; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; @@ -12,7 +13,7 @@ namespace DataAccessLayer.Sql "Database connection string not configured. Set DB_CONNECTION_STRING env var or ConnectionStrings:Default." ); - public SqlConnection CreateConnection() + public DbConnection CreateConnection() { return new SqlConnection(_connectionString); } diff --git a/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs b/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs index db981f5..db3de9b 100644 --- a/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs +++ b/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs @@ -1,9 +1,9 @@ -using Microsoft.Data.SqlClient; +using System.Data.Common; namespace DataAccessLayer.Sql { public interface ISqlConnectionFactory { - SqlConnection CreateConnection(); + DbConnection CreateConnection(); } } \ No newline at end of file diff --git a/Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs b/Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs new file mode 100644 index 0000000..33e9688 --- /dev/null +++ b/Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs @@ -0,0 +1,73 @@ +using DataAccessLayer.Sql; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; + +namespace Repository.Tests.Database; + +public class DefaultSqlConnectionFactoryTest +{ + private static IConfiguration EmptyConfig() + => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build(); + + [Fact] + public void CreateConnection_Uses_EnvVar_WhenAvailable() + { + var previous = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING"); + try + { + Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", "Server=localhost;Database=TestDb;Trusted_Connection=True;Encrypt=False"); + var factory = new DefaultSqlConnectionFactory(EmptyConfig()); + + var conn = factory.CreateConnection(); + conn.Should().BeOfType(); + conn.ConnectionString.Should().Contain("Database=TestDb"); + } + finally + { + Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous); + } + } + + [Fact] + public void CreateConnection_Uses_Config_WhenEnvMissing() + { + var previous = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING"); + try + { + Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", null); + var cfg = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "ConnectionStrings:Default", "Server=localhost;Database=CfgDb;Trusted_Connection=True;Encrypt=False" } + }) + .Build(); + + var factory = new DefaultSqlConnectionFactory(cfg); + var conn = factory.CreateConnection(); + conn.ConnectionString.Should().Contain("Database=CfgDb"); + } + finally + { + Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous); + } + } + + [Fact] + public void Constructor_Throws_When_NoEnv_NoConfig() + { + var previous = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING"); + try + { + Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", null); + var cfg = EmptyConfig(); + Action act = () => _ = new DefaultSqlConnectionFactory(cfg); + act.Should().Throw() + .WithMessage("*Database connection string not configured*"); + } + finally + { + Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous); + } + } +} \ No newline at end of file diff --git a/Repository/Repository.Tests/Database/TestConnectionFactory.cs b/Repository/Repository.Tests/Database/TestConnectionFactory.cs new file mode 100644 index 0000000..25c9e94 --- /dev/null +++ b/Repository/Repository.Tests/Database/TestConnectionFactory.cs @@ -0,0 +1,10 @@ +using System.Data.Common; +using DataAccessLayer.Sql; + +namespace Repository.Tests.Database; + +internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory +{ + private readonly DbConnection _conn = conn; + public DbConnection CreateConnection() => _conn; +} \ No newline at end of file diff --git a/Repository/Repository.Tests/Repository.Tests.csproj b/Repository/Repository.Tests/Repository.Tests.csproj index 4fa2c9e..a1d64e0 100644 --- a/Repository/Repository.Tests/Repository.Tests.csproj +++ b/Repository/Repository.Tests/Repository.Tests.csproj @@ -4,14 +4,21 @@ enable enable false - DALTests + Repository.Tests + + + + + + + @@ -21,4 +28,4 @@ - + \ No newline at end of file diff --git a/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs b/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs new file mode 100644 index 0000000..925017a --- /dev/null +++ b/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs @@ -0,0 +1,136 @@ +using Apps72.Dev.Data.DbMocker; +using DataAccessLayer.Repositories.UserAccount; +using FluentAssertions; +using Repository.Tests.Database; + +namespace Repository.Tests.UserAccount; + +public class UserAccountRepositoryTest +{ + private static UserAccountRepository CreateRepo(MockDbConnection conn) + => new(new TestConnectionFactory(conn)); + + [Fact] + public async Task GetByIdAsync_ReturnsRow_Mapped() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "usp_GetUserAccountById") + .ReturnsTable(MockTable.WithColumns( + ("UserAccountId", typeof(Guid)), + ("Username", typeof(string)), + ("FirstName", typeof(string)), + ("LastName", typeof(string)), + ("Email", typeof(string)), + ("CreatedAt", typeof(DateTime)), + ("UpdatedAt", typeof(DateTime?)), + ("DateOfBirth", typeof(DateTime)), + ("Timer", typeof(byte[])) + ).AddRow(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + "yerb","Aaron","Po","aaronpo@example.com", + new DateTime(2020,1,1), null, + new DateTime(1990,1,1), null)); + + var repo = CreateRepo(conn); + var result = await repo.GetByIdAsync(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")); + + result.Should().NotBeNull(); + result!.Username.Should().Be("yerb"); + result.Email.Should().Be("aaronpo@example.com"); + } + + [Fact] + public async Task GetAllAsync_ReturnsMultipleRows() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "usp_GetAllUserAccounts") + .ReturnsTable(MockTable.WithColumns( + ("UserAccountId", typeof(Guid)), + ("Username", typeof(string)), + ("FirstName", typeof(string)), + ("LastName", typeof(string)), + ("Email", typeof(string)), + ("CreatedAt", typeof(DateTime)), + ("UpdatedAt", typeof(DateTime?)), + ("DateOfBirth", typeof(DateTime)), + ("Timer", typeof(byte[])) + ).AddRow(Guid.NewGuid(), "a","A","A","a@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null) + .AddRow(Guid.NewGuid(), "b","B","B","b@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); + + var repo = CreateRepo(conn); + var results = (await repo.GetAllAsync(null, null)).ToList(); + results.Should().HaveCount(2); + results.Select(r => r.Username).Should().BeEquivalentTo(new[] { "a", "b" }); + } + + [Fact] + public async Task AddAsync_ExecutesStoredProcedure() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "usp_CreateUserAccount") + .ReturnsScalar(1); + + var repo = CreateRepo(conn); + var user = new DataAccessLayer.Entities.UserAccount + { + UserAccountId = Guid.NewGuid(), + Username = "newuser", + FirstName = "New", + LastName = "User", + Email = "newuser@example.com", + DateOfBirth = new DateTime(1991,1,1) + }; + + await repo.AddAsync(user); + } + + [Fact] + public async Task GetByUsername_ReturnsRow() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername") + .ReturnsTable(MockTable.WithColumns( + ("UserAccountId", typeof(Guid)), + ("Username", typeof(string)), + ("FirstName", typeof(string)), + ("LastName", typeof(string)), + ("Email", typeof(string)), + ("CreatedAt", typeof(DateTime)), + ("UpdatedAt", typeof(DateTime?)), + ("DateOfBirth", typeof(DateTime)), + ("Timer", typeof(byte[])) + ).AddRow(Guid.NewGuid(), "lookupuser","L","U","lookup@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); + + var repo = CreateRepo(conn); + var result = await repo.GetByUsernameAsync("lookupuser"); + result.Should().NotBeNull(); + result!.Email.Should().Be("lookup@example.com"); + } + + [Fact] + public async Task GetByEmail_ReturnsRow() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") + .ReturnsTable(MockTable.WithColumns( + ("UserAccountId", typeof(Guid)), + ("Username", typeof(string)), + ("FirstName", typeof(string)), + ("LastName", typeof(string)), + ("Email", typeof(string)), + ("CreatedAt", typeof(DateTime)), + ("UpdatedAt", typeof(DateTime?)), + ("DateOfBirth", typeof(DateTime)), + ("Timer", typeof(byte[])) + ).AddRow(Guid.NewGuid(), "byemail","B","E","byemail@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); + + var repo = CreateRepo(conn); + var result = await repo.GetByEmailAsync("byemail@example.com"); + result.Should().NotBeNull(); + result!.Username.Should().Be("byemail"); + } +} diff --git a/Repository/Repository.Tests/UserAccountRepositoryTests.cs b/Repository/Repository.Tests/UserAccountRepositoryTests.cs deleted file mode 100644 index 4c0cb1e..0000000 --- a/Repository/Repository.Tests/UserAccountRepositoryTests.cs +++ /dev/null @@ -1,281 +0,0 @@ -using DataAccessLayer; -using DataAccessLayer.Entities; -using DataAccessLayer.Repositories; -using DataAccessLayer.Repositories.UserAccount; - -namespace DALTests -{ - public class UserAccountRepositoryTests - { - private readonly IUserAccountRepository _repository = new InMemoryUserAccountRepository(); - - [Fact] - public async Task Add_ShouldInsertUserAccount() - { - // Arrange - var userAccount = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "testuser", - FirstName = "Test", - LastName = "User", - Email = "testuser@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1990, 1, 1), - }; - - // Act - await _repository.AddAsync(userAccount); - var retrievedUser = await _repository.GetByIdAsync(userAccount.UserAccountId); - - // Assert - Assert.NotNull(retrievedUser); - Assert.Equal(userAccount.Username, retrievedUser.Username); - } - - [Fact] - public async Task GetById_ShouldReturnUserAccount() - { - // Arrange - var userId = Guid.NewGuid(); - var userAccount = new UserAccount - { - UserAccountId = userId, - Username = "existinguser", - FirstName = "Existing", - LastName = "User", - Email = "existinguser@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1985, 5, 15), - }; - await _repository.AddAsync(userAccount); - - // Act - var retrievedUser = await _repository.GetByIdAsync(userId); - - // Assert - Assert.NotNull(retrievedUser); - Assert.Equal(userId, retrievedUser.UserAccountId); - } - - [Fact] - public async Task Update_ShouldModifyUserAccount() - { - // Arrange - var userAccount = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "updatableuser", - FirstName = "Updatable", - LastName = "User", - Email = "updatableuser@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1992, 3, 10), - }; - await _repository.AddAsync(userAccount); - - // Act - userAccount.FirstName = "Updated"; - await _repository.UpdateAsync(userAccount); - var updatedUser = await _repository.GetByIdAsync(userAccount.UserAccountId); - - // Assert - Assert.NotNull(updatedUser); - Assert.Equal("Updated", updatedUser.FirstName); - } - - [Fact] - public async Task Delete_ShouldRemoveUserAccount() - { - // Arrange - var userAccount = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "deletableuser", - FirstName = "Deletable", - LastName = "User", - Email = "deletableuser@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1995, 7, 20), - }; - await _repository.AddAsync(userAccount); - - // Act - await _repository.DeleteAsync(userAccount.UserAccountId); - var deletedUser = await _repository.GetByIdAsync(userAccount.UserAccountId); - - // Assert - Assert.Null(deletedUser); - } - - [Fact] - public async Task GetAll_ShouldReturnAllUserAccounts() - { - // Arrange - var user1 = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "user1", - FirstName = "User", - LastName = "One", - Email = "user1@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1990, 1, 1), - }; - var user2 = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "user2", - FirstName = "User", - LastName = "Two", - Email = "user2@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1992, 2, 2), - }; - await _repository.AddAsync(user1); - await _repository.AddAsync(user2); - - // Act - var allUsers = await _repository.GetAllAsync(null, null); - - // Assert - Assert.NotNull(allUsers); - Assert.True(allUsers.Count() >= 2); - } - - [Fact] - public async Task GetAll_WithPagination_ShouldRespectLimit() - { - // Arrange - var users = new List - { - new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = $"pageuser_{Guid.NewGuid():N}", - FirstName = "Page", - LastName = "User", - Email = $"pageuser_{Guid.NewGuid():N}@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1991, 4, 4), - }, - new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = $"pageuser_{Guid.NewGuid():N}", - FirstName = "Page", - LastName = "User", - Email = $"pageuser_{Guid.NewGuid():N}@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1992, 5, 5), - }, - new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = $"pageuser_{Guid.NewGuid():N}", - FirstName = "Page", - LastName = "User", - Email = $"pageuser_{Guid.NewGuid():N}@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1993, 6, 6), - }, - }; - - foreach (var user in users) - { - await _repository.AddAsync(user); - } - - // Act - var page = (await _repository.GetAllAsync(2, 0)).ToList(); - - // Assert - Assert.Equal(2, page.Count); - } - - [Fact] - public async Task GetAll_WithPagination_ShouldValidateArguments() - { - await Assert.ThrowsAsync(async () => - (await _repository.GetAllAsync(0, 0)).ToList() - ); - await Assert.ThrowsAsync(async () => - (await _repository.GetAllAsync(1, -1)).ToList() - ); - } - } - - internal class InMemoryUserAccountRepository : IUserAccountRepository - { - private readonly Dictionary _store = new(); - - public Task AddAsync(UserAccount userAccount) - { - if (userAccount.UserAccountId == Guid.Empty) - { - userAccount.UserAccountId = Guid.NewGuid(); - } - _store[userAccount.UserAccountId] = Clone(userAccount); - return Task.CompletedTask; - } - - public Task GetByIdAsync(Guid id) - { - _store.TryGetValue(id, out var user); - return Task.FromResult(user is null ? null : Clone(user)); - } - - public Task> GetAllAsync(int? limit, int? offset) - { - if (limit.HasValue && limit.Value <= 0) throw new ArgumentOutOfRangeException(nameof(limit)); - if (offset.HasValue && offset.Value < 0) throw new ArgumentOutOfRangeException(nameof(offset)); - - var query = _store.Values - .OrderBy(u => u.Username) - .Select(Clone); - - if (offset.HasValue) query = query.Skip(offset.Value); - if (limit.HasValue) query = query.Take(limit.Value); - - return Task.FromResult>(query.ToList()); - } - - public Task UpdateAsync(UserAccount userAccount) - { - if (!_store.ContainsKey(userAccount.UserAccountId)) return Task.CompletedTask; - _store[userAccount.UserAccountId] = Clone(userAccount); - return Task.CompletedTask; - } - - public Task DeleteAsync(Guid id) - { - _store.Remove(id); - return Task.CompletedTask; - } - - public Task GetByUsernameAsync(string username) - { - var user = _store.Values.FirstOrDefault(u => u.Username == username); - return Task.FromResult(user is null ? null : Clone(user)); - } - - public Task GetByEmailAsync(string email) - { - var user = _store.Values.FirstOrDefault(u => u.Email == email); - return Task.FromResult(user is null ? null : Clone(user)); - } - - private static UserAccount Clone(UserAccount u) => new() - { - UserAccountId = u.UserAccountId, - Username = u.Username, - FirstName = u.FirstName, - LastName = u.LastName, - Email = u.Email, - CreatedAt = u.CreatedAt, - UpdatedAt = u.UpdatedAt, - DateOfBirth = u.DateOfBirth, - Timer = u.Timer is null ? null : (byte[])u.Timer.Clone(), - }; - } -} diff --git a/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs b/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs new file mode 100644 index 0000000..708b2a5 --- /dev/null +++ b/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs @@ -0,0 +1,75 @@ +using Apps72.Dev.Data.DbMocker; +using DataAccessLayer.Repositories.UserCredential; +using DataAccessLayer.Sql; +using FluentAssertions; +using Moq; +using Repository.Tests.Database; + +namespace Repository.Tests.UserCredential; + +public class UserCredentialRepositoryTests +{ + private static UserCredentialRepository CreateRepo() + { + var factoryMock = new Mock(MockBehavior.Strict); + // NotSupported methods do not use the factory; keep strict to ensure no unexpected calls. + return new UserCredentialRepository(factoryMock.Object); + } + + [Fact] + public async Task AddAsync_ShouldThrow_NotSupported() + { + var repo = CreateRepo(); + var act = async () => await repo.AddAsync(new DataAccessLayer.Entities.UserCredential()); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetAllAsync_ShouldThrow_NotSupported() + { + var repo = CreateRepo(); + var act = async () => await repo.GetAllAsync(null, null); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetByIdAsync_ShouldThrow_NotSupported() + { + var repo = CreateRepo(); + var act = async () => await repo.GetByIdAsync(Guid.NewGuid()); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UpdateAsync_ShouldThrow_NotSupported() + { + var repo = CreateRepo(); + var act = async () => await repo.UpdateAsync(new DataAccessLayer.Entities.UserCredential()); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteAsync_ShouldThrow_NotSupported() + { + var repo = CreateRepo(); + var act = async () => await repo.DeleteAsync(Guid.NewGuid()); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RotateCredentialAsync_ExecutesWithoutError() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "USP_RotateUserCredential") + .ReturnsRow(0); + + var repo = new UserCredentialRepository(new TestConnectionFactory(conn)); + var credential = new DataAccessLayer.Entities.UserCredential + { + Hash = "hashed_password" + }; + await repo.RotateCredentialAsync(Guid.NewGuid(), credential); + + } +}