From 9c44ac12802569d0ab8908a7be33f55be729c383 Mon Sep 17 00:00:00 2001 From: actbit <57023457+actbit@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:27:11 +0900 Subject: [PATCH 1/3] [add] tests --- .../OpenLibraryRent.Tests.csproj | 27 ++ .../Services/Caching/CacheKeysTests.cs | 120 ++++++ .../Caching/CacheServiceExtensionsTests.cs | 133 +++++++ .../Caching/MemoryCacheServiceTests.cs | 219 +++++++++++ .../Services/EncryptionServiceTests.cs | 285 ++++++++++++++ .../Services/RentalServiceTests.cs | 355 ++++++++++++++++++ OpenLibraryRent.slnx | 1 + 7 files changed, 1140 insertions(+) create mode 100644 OpenLibraryRent.Tests/OpenLibraryRent.Tests.csproj create mode 100644 OpenLibraryRent.Tests/Services/Caching/CacheKeysTests.cs create mode 100644 OpenLibraryRent.Tests/Services/Caching/CacheServiceExtensionsTests.cs create mode 100644 OpenLibraryRent.Tests/Services/Caching/MemoryCacheServiceTests.cs create mode 100644 OpenLibraryRent.Tests/Services/EncryptionServiceTests.cs create mode 100644 OpenLibraryRent.Tests/Services/RentalServiceTests.cs diff --git a/OpenLibraryRent.Tests/OpenLibraryRent.Tests.csproj b/OpenLibraryRent.Tests/OpenLibraryRent.Tests.csproj new file mode 100644 index 0000000..cfc4f75 --- /dev/null +++ b/OpenLibraryRent.Tests/OpenLibraryRent.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/OpenLibraryRent.Tests/Services/Caching/CacheKeysTests.cs b/OpenLibraryRent.Tests/Services/Caching/CacheKeysTests.cs new file mode 100644 index 0000000..c62f1c3 --- /dev/null +++ b/OpenLibraryRent.Tests/Services/Caching/CacheKeysTests.cs @@ -0,0 +1,120 @@ +using OpenLibraryRent.Services.Caching; +using Xunit; + +namespace OpenLibraryRent.Tests.Services.Caching; + +public class CacheKeysTests +{ + [Fact] + public void TenantInfo_Returns_Correct_Format() + { + // Arrange + var tenantId = "tenant-123"; + + // Act + var key = CacheKeys.TenantInfo(tenantId); + + // Assert + Assert.Equal("OpenLibraryRent:tenant:tenant-123:info", key); + } + + [Fact] + public void TenantSettings_Returns_Correct_Format() + { + // Arrange + var tenantId = "tenant-456"; + + // Act + var key = CacheKeys.TenantSettings(tenantId); + + // Assert + Assert.Equal("OpenLibraryRent:tenant:tenant-456:settings", key); + } + + [Fact] + public void BookByIsbn_Returns_Correct_Format() + { + // Arrange + var isbn = "978-4-123456-78-9"; + + // Act + var key = CacheKeys.BookByIsbn(isbn); + + // Assert + Assert.Equal("OpenLibraryRent:book:isbn:978-4-123456-78-9", key); + } + + [Fact] + public void UserPermissions_Returns_Correct_Format() + { + // Arrange + var tenantId = "tenant-123"; + var userId = "user-456"; + + // Act + var key = CacheKeys.UserPermissions(tenantId, userId); + + // Assert + Assert.Equal("OpenLibraryRent:tenant:tenant-123:user:user-456:permissions", key); + } + + [Fact] + public void TenantAll_Returns_Correct_Pattern_Format() + { + // Arrange + var tenantId = "tenant-123"; + + // Act + var pattern = CacheKeys.TenantAll(tenantId); + + // Assert + Assert.Equal("OpenLibraryRent:tenant:tenant-123:*", pattern); + } + + [Theory] + [InlineData("tenant-1")] + [InlineData("abc123")] + [InlineData("test-tenant-id")] + public void TenantInfo_Consistent_For_Same_TenantId(string tenantId) + { + // Act + var key1 = CacheKeys.TenantInfo(tenantId); + var key2 = CacheKeys.TenantInfo(tenantId); + + // Assert + Assert.Equal(key1, key2); + } + + [Fact] + public void CacheKeys_Are_Deterministic() + { + // Arrange + var tenantId = "test"; + var isbn = "1234567890"; + var userId = "user1"; + + // Act - Call multiple times + var tenantKey1 = CacheKeys.TenantInfo(tenantId); + var tenantKey2 = CacheKeys.TenantInfo(tenantId); + var bookKey1 = CacheKeys.BookByIsbn(isbn); + var bookKey2 = CacheKeys.BookByIsbn(isbn); + var permKey1 = CacheKeys.UserPermissions(tenantId, userId); + var permKey2 = CacheKeys.UserPermissions(tenantId, userId); + + // Assert - Same inputs produce same keys + Assert.Equal(tenantKey1, tenantKey2); + Assert.Equal(bookKey1, bookKey2); + Assert.Equal(permKey1, permKey2); + } + + [Fact] + public void Different_Inputs_Produce_Different_Keys() + { + // Arrange & Act + var key1 = CacheKeys.TenantInfo("tenant-1"); + var key2 = CacheKeys.TenantInfo("tenant-2"); + + // Assert + Assert.NotEqual(key1, key2); + } +} diff --git a/OpenLibraryRent.Tests/Services/Caching/CacheServiceExtensionsTests.cs b/OpenLibraryRent.Tests/Services/Caching/CacheServiceExtensionsTests.cs new file mode 100644 index 0000000..b0c924f --- /dev/null +++ b/OpenLibraryRent.Tests/Services/Caching/CacheServiceExtensionsTests.cs @@ -0,0 +1,133 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenLibraryRent.Services.Caching; +using Xunit; + +namespace OpenLibraryRent.Tests.Services.Caching; + +public class CacheServiceExtensionsTests +{ + [Fact] + public void AddCacheService_Without_Redis_Uses_MemoryCache() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:redis"] = null + }) + .Build(); + + // Act + services.AddCacheService(configuration); + var serviceProvider = services.BuildServiceProvider(); + var cacheService = serviceProvider.GetService(); + + // Assert + Assert.NotNull(cacheService); + Assert.IsType(cacheService); + } + + [Fact] + public void AddCacheService_With_Redis_Uses_RedisCache() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:redis"] = "localhost:6379" + }) + .Build(); + + // Act + services.AddCacheService(configuration); + var serviceProvider = services.BuildServiceProvider(); + var cacheService = serviceProvider.GetService(); + + // Assert + Assert.NotNull(cacheService); + Assert.IsType(cacheService); + } + + [Fact] + public void AddMemoryCacheService_Registers_MemoryCacheService() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + // Act + services.AddMemoryCacheService(); + var serviceProvider = services.BuildServiceProvider(); + var cacheService = serviceProvider.GetService(); + + // Assert + Assert.NotNull(cacheService); + Assert.IsType(cacheService); + } + + [Fact] + public void AddRedisCacheService_Registers_RedisCacheService() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddStackExchangeRedisCache(options => + { + options.Configuration = "localhost:6379"; + }); + + // Act + services.AddRedisCacheService(); + var serviceProvider = services.BuildServiceProvider(); + var cacheService = serviceProvider.GetService(); + + // Assert + Assert.NotNull(cacheService); + Assert.IsType(cacheService); + } + + [Fact] + public void AddCacheService_Registers_As_Singleton() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + var configuration = new ConfigurationBuilder().Build(); + + // Act + services.AddCacheService(configuration); + var serviceProvider = services.BuildServiceProvider(); + var cacheService1 = serviceProvider.GetService(); + var cacheService2 = serviceProvider.GetService(); + + // Assert + Assert.Same(cacheService1, cacheService2); + } + + [Fact] + public async Task Cached_Value_Persists_Across_Service_Resolution() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + var configuration = new ConfigurationBuilder().Build(); + services.AddCacheService(configuration); + var serviceProvider = services.BuildServiceProvider(); + + var cacheService1 = serviceProvider.GetRequiredService(); + var cacheService2 = serviceProvider.GetRequiredService(); + + // Act + await cacheService1.SetAsync("test-key", "test-value"); + var value = await cacheService2.GetAsync("test-key"); + + // Assert + Assert.Equal("test-value", value); + } +} diff --git a/OpenLibraryRent.Tests/Services/Caching/MemoryCacheServiceTests.cs b/OpenLibraryRent.Tests/Services/Caching/MemoryCacheServiceTests.cs new file mode 100644 index 0000000..0c96959 --- /dev/null +++ b/OpenLibraryRent.Tests/Services/Caching/MemoryCacheServiceTests.cs @@ -0,0 +1,219 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Moq; +using OpenLibraryRent.Services.Caching; + +namespace OpenLibraryRent.Tests.Services.Caching; + +public class MemoryCacheServiceTests : IDisposable +{ + private readonly MemoryCache _memoryCache; + private readonly Mock> _loggerMock; + private readonly MemoryCacheService _cacheService; + + public MemoryCacheServiceTests() + { + _memoryCache = new MemoryCache(new MemoryCacheOptions()); + _loggerMock = new Mock>(); + _cacheService = new MemoryCacheService(_memoryCache, _loggerMock.Object); + } + + public void Dispose() + { + _memoryCache.Dispose(); + } + + [Fact] + public async Task SetAsync_And_GetAsync_Returns_Cached_Value() + { + // Arrange + var key = "test-key"; + var value = new TestObject { Id = 1, Name = "Test" }; + + // Act + await _cacheService.SetAsync(key, value); + var result = await _cacheService.GetAsync(key); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result.Id); + Assert.Equal("Test", result.Name); + } + + [Fact] + public async Task GetAsync_Returns_Null_For_NonExistent_Key() + { + // Arrange + var key = "non-existent-key"; + + // Act + var result = await _cacheService.GetAsync(key); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task RemoveAsync_Removes_Cached_Value() + { + // Arrange + var key = "test-key"; + var value = new TestObject { Id = 1, Name = "Test" }; + await _cacheService.SetAsync(key, value); + + // Act + await _cacheService.RemoveAsync(key); + var result = await _cacheService.GetAsync(key); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task ExistsAsync_Returns_True_For_Cached_Key() + { + // Arrange + var key = "test-key"; + var value = new TestObject { Id = 1, Name = "Test" }; + await _cacheService.SetAsync(key, value); + + // Act + var exists = await _cacheService.ExistsAsync(key); + + // Assert + Assert.True(exists); + } + + [Fact] + public async Task ExistsAsync_Returns_False_For_NonExistent_Key() + { + // Arrange + var key = "non-existent-key"; + + // Act + var exists = await _cacheService.ExistsAsync(key); + + // Assert + Assert.False(exists); + } + + [Fact] + public async Task GetOrCreateAsync_Returns_Cached_Value_When_Exists() + { + // Arrange + var key = "test-key"; + var cachedValue = new TestObject { Id = 1, Name = "Cached" }; + await _cacheService.SetAsync(key, cachedValue); + + var factoryCalled = false; + + // Act + var result = await _cacheService.GetOrCreateAsync( + key, + _ => + { + factoryCalled = true; + return Task.FromResult(new TestObject { Id = 2, Name = "New" }); + }); + + // Assert + Assert.False(factoryCalled); + Assert.Equal(1, result.Id); + Assert.Equal("Cached", result.Name); + } + + [Fact] + public async Task GetOrCreateAsync_Calls_Factory_And_Caches_When_Not_Exists() + { + // Arrange + var key = "test-key"; + var factoryCalled = false; + + // Act + var result = await _cacheService.GetOrCreateAsync( + key, + _ => + { + factoryCalled = true; + return Task.FromResult(new TestObject { Id = 2, Name = "New" }); + }); + + // Assert + Assert.True(factoryCalled); + Assert.Equal(2, result.Id); + Assert.Equal("New", result.Name); + + // Verify it's cached + var cachedResult = await _cacheService.GetAsync(key); + Assert.NotNull(cachedResult); + Assert.Equal(2, cachedResult.Id); + } + + [Fact] + public async Task SetAsync_With_Expiration_Expires_After_Timeout() + { + // Arrange + var key = "test-key"; + var value = new TestObject { Id = 1, Name = "Test" }; + var expiration = TimeSpan.FromMilliseconds(100); + + // Act + await _cacheService.SetAsync(key, value, expiration); + + // Assert - immediately available + var immediateResult = await _cacheService.GetAsync(key); + Assert.NotNull(immediateResult); + + // Wait for expiration + await Task.Delay(200); + + // Assert - expired + var expiredResult = await _cacheService.GetAsync(key); + Assert.Null(expiredResult); + } + + [Fact] + public async Task RemoveByPatternAsync_Removes_Matching_Keys() + { + // Arrange + await _cacheService.SetAsync("tenant:1:info", new TestObject { Id = 1 }); + await _cacheService.SetAsync("tenant:1:settings", new TestObject { Id = 2 }); + await _cacheService.SetAsync("tenant:2:info", new TestObject { Id = 3 }); + await _cacheService.SetAsync("book:isbn:123", new TestObject { Id = 4 }); + + // Act + await _cacheService.RemoveByPatternAsync("tenant:1:*"); + + // Assert + Assert.Null(await _cacheService.GetAsync("tenant:1:info")); + Assert.Null(await _cacheService.GetAsync("tenant:1:settings")); + Assert.NotNull(await _cacheService.GetAsync("tenant:2:info")); + Assert.NotNull(await _cacheService.GetAsync("book:isbn:123")); + } + + [Fact] + public async Task Cache_Works_With_Different_Types() + { + // Arrange & Act - String + await _cacheService.SetAsync("string-key", "test value"); + var stringResult = await _cacheService.GetAsync("string-key"); + Assert.Equal("test value", stringResult); + + // Arrange & Act - Int + await _cacheService.SetAsync("int-key", 42); + var intResult = await _cacheService.GetAsync("int-key"); + Assert.Equal(42, intResult); + + // Arrange & Act - List + await _cacheService.SetAsync("list-key", new List { 1, 2, 3 }); + var listResult = await _cacheService.GetAsync>("list-key"); + Assert.NotNull(listResult); + Assert.Equal(3, listResult.Count); + } + + private class TestObject + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } +} diff --git a/OpenLibraryRent.Tests/Services/EncryptionServiceTests.cs b/OpenLibraryRent.Tests/Services/EncryptionServiceTests.cs new file mode 100644 index 0000000..7609120 --- /dev/null +++ b/OpenLibraryRent.Tests/Services/EncryptionServiceTests.cs @@ -0,0 +1,285 @@ +using Microsoft.Extensions.Logging; +using Moq; +using OpenLibraryRent.Services; +using Xunit; + +namespace OpenLibraryRent.Tests.Services; + +public class EncryptionServiceTests +{ + private readonly Mock _loggerMock; + private readonly string _validKey; + private readonly EncryptionService _service; + + public EncryptionServiceTests() + { + _loggerMock = new Mock(); + _validKey = EncryptionService.GenerateNewKey(); + _service = new EncryptionService(_validKey, _loggerMock.Object); + } + + [Fact] + public void GenerateNewKey_Returns_Valid_Base64_Key() + { + // Act + var key = EncryptionService.GenerateNewKey(); + + // Assert + Assert.NotNull(key); + Assert.NotEmpty(key); + + // Should be valid base64 + var keyBytes = Convert.FromBase64String(key); + Assert.Equal(32, keyBytes.Length); // 256 bits + } + + [Fact] + public void GenerateNewKey_Generates_Different_Keys() + { + // Act + var key1 = EncryptionService.GenerateNewKey(); + var key2 = EncryptionService.GenerateNewKey(); + + // Assert + Assert.NotEqual(key1, key2); + } + + [Fact] + public void Constructor_With_Valid_Key_Succeeds() + { + // Arrange & Act + var service = new EncryptionService(_validKey, _loggerMock.Object); + + // Assert - no exception thrown + Assert.NotNull(service); + } + + [Fact] + public void Constructor_With_Invalid_Base64_Key_Throws() + { + // Arrange & Act & Assert + Assert.Throws(() => + new EncryptionService("not-valid-base64!", _loggerMock.Object)); + } + + [Fact] + public void Constructor_With_Wrong_Length_Key_Throws() + { + // Arrange + var shortKey = Convert.ToBase64String(new byte[16]); // 128 bits instead of 256 + + // Act & Assert + Assert.Throws(() => + new EncryptionService(shortKey, _loggerMock.Object)); + } + + [Fact] + public void Encrypt_Returns_NonEmpty_String() + { + // Arrange + var plaintext = "test-secret"; + + // Act + var encrypted = _service.Encrypt(plaintext); + + // Assert + Assert.NotNull(encrypted); + Assert.NotEmpty(encrypted); + } + + [Fact] + public void Encrypt_Returns_Different_Values_For_Same_Input() + { + // Arrange + var plaintext = "test-secret"; + + // Act + var encrypted1 = _service.Encrypt(plaintext); + var encrypted2 = _service.Encrypt(plaintext); + + // Assert + Assert.NotEqual(encrypted1, encrypted2); // Different due to random nonce + } + + [Fact] + public void Encrypt_Produces_Expected_Format() + { + // Arrange + var plaintext = "test-secret"; + + // Act + var encrypted = _service.Encrypt(plaintext); + + // Assert + var parts = encrypted.Split(':'); + Assert.Equal(3, parts.Length); + + // Each part should be valid base64 + Convert.FromBase64String(parts[0]); // nonce + Convert.FromBase64String(parts[1]); // ciphertext + Convert.FromBase64String(parts[2]); // tag + } + + [Fact] + public void Encrypt_With_Null_Throws() + { + // Act & Assert + Assert.Throws(() => _service.Encrypt(null!)); + } + + [Fact] + public void Encrypt_With_Empty_Throws() + { + // Act & Assert + Assert.Throws(() => _service.Encrypt(string.Empty)); + } + + [Fact] + public void Decrypt_RoundTrip_Returns_Original_Plaintext() + { + // Arrange + var plaintext = "my-secret-password-123"; + + // Act + var encrypted = _service.Encrypt(plaintext); + var decrypted = _service.Decrypt(encrypted); + + // Assert + Assert.Equal(plaintext, decrypted); + } + + [Fact] + public void Decrypt_With_Null_Throws() + { + // Act & Assert + Assert.Throws(() => _service.Decrypt(null!)); + } + + [Fact] + public void Decrypt_With_Empty_Throws() + { + // Act & Assert + Assert.Throws(() => _service.Decrypt(string.Empty)); + } + + [Fact] + public void Decrypt_With_Invalid_Format_Throws() + { + // Act & Assert + Assert.Throws(() => _service.Decrypt("invalid-format")); + } + + [Fact] + public void Decrypt_With_Wrong_Key_Throws() + { + // Arrange + var plaintext = "secret-data"; + var encrypted = _service.Encrypt(plaintext); + + var differentKey = EncryptionService.GenerateNewKey(); + var differentService = new EncryptionService(differentKey, _loggerMock.Object); + + // Act & Assert + Assert.Throws(() => + differentService.Decrypt(encrypted)); + } + + [Fact] + public void Decrypt_With_Tampered_Data_Throws() + { + // Arrange + var plaintext = "secret-data"; + var encrypted = _service.Encrypt(plaintext); + + // Tamper with the ciphertext + var parts = encrypted.Split(':'); + var tamperedCiphertext = Convert.FromBase64String(parts[1]); + tamperedCiphertext[0] ^= 0xFF; // Flip some bits + var tamperedEncrypted = $"{parts[0]}:{Convert.ToBase64String(tamperedCiphertext)}:{parts[2]}"; + + // Act & Assert + Assert.Throws(() => _service.Decrypt(tamperedEncrypted)); + } + + [Fact] + public void DecryptWithTenantKey_RoundTrip_Succeeds() + { + // Arrange + var tenantKey = EncryptionService.GenerateNewKey(); + var masterService = new EncryptionService(_validKey, _loggerMock.Object); + var encryptedTenantKey = masterService.Encrypt(tenantKey); + + var tenantService = new EncryptionService(tenantKey, _loggerMock.Object); + var secretData = "oidc-client-secret"; + var encryptedData = tenantService.Encrypt(secretData); + + // Act + var decrypted = masterService.DecryptWithTenantKey(encryptedTenantKey, encryptedData); + + // Assert + Assert.Equal(secretData, decrypted); + } + + [Fact] + public void DecryptWithTenantKey_With_Null_EncryptedTenantKey_Throws() + { + // Act & Assert + Assert.Throws(() => + _service.DecryptWithTenantKey(null!, "some-encrypted-data")); + } + + [Fact] + public void DecryptWithTenantKey_With_Null_EncryptedData_Throws() + { + // Arrange + var encryptedTenantKey = _service.Encrypt(EncryptionService.GenerateNewKey()); + + // Act & Assert + Assert.Throws(() => + _service.DecryptWithTenantKey(encryptedTenantKey, null!)); + } + + [Fact] + public void DecryptWithPlainTenantKey_RoundTrip_Succeeds() + { + // Arrange + var tenantKey = EncryptionService.GenerateNewKey(); + var tenantService = new EncryptionService(tenantKey, _loggerMock.Object); + var secretData = "another-secret"; + var encryptedData = tenantService.Encrypt(secretData); + + // Act + var decrypted = _service.DecryptWithPlainTenantKey(tenantKey, encryptedData); + + // Assert + Assert.Equal(secretData, decrypted); + } + + [Fact] + public void Encryption_Works_With_Unicode_Characters() + { + // Arrange + var plaintext = "日本語パスワード🔐🔒"; + + // Act + var encrypted = _service.Encrypt(plaintext); + var decrypted = _service.Decrypt(encrypted); + + // Assert + Assert.Equal(plaintext, decrypted); + } + + [Fact] + public void Encryption_Works_With_Long_Text() + { + // Arrange + var plaintext = new string('A', 10000); + + // Act + var encrypted = _service.Encrypt(plaintext); + var decrypted = _service.Decrypt(encrypted); + + // Assert + Assert.Equal(plaintext, decrypted); + } +} diff --git a/OpenLibraryRent.Tests/Services/RentalServiceTests.cs b/OpenLibraryRent.Tests/Services/RentalServiceTests.cs new file mode 100644 index 0000000..a89aa0e --- /dev/null +++ b/OpenLibraryRent.Tests/Services/RentalServiceTests.cs @@ -0,0 +1,355 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using OpenLibraryRent.Models; +using OpenLibraryRent.Services; +using Xunit; + +namespace OpenLibraryRent.Tests.Services; + +public class RentalServiceTests : IDisposable +{ + private readonly ApplicationDbContext _dbContext; + private readonly Mock> _loggerMock; + private readonly RentalService _service; + private readonly string _tenantId = "test-tenant"; + + public RentalServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + var multiTenantContextAccessor = new MockMultiTenantContextAccessor(_tenantId); + _dbContext = new ApplicationDbContext(multiTenantContextAccessor, options); + _loggerMock = new Mock>(); + _service = new RentalService(_dbContext, _loggerMock.Object); + } + + public void Dispose() + { + _dbContext.Dispose(); + } + + [Fact] + public async Task BorrowAsync_Creates_Rental_When_BookCopy_Available() + { + // Arrange + var user = await CreateTestUserAsync(); + var book = await CreateTestBookAsync(); + var bookCopy = await CreateTestBookCopyAsync(book.Id); + + // Act + var rental = await _service.BorrowAsync(user.Id, bookCopy.Id, loanPeriodDays: 14); + + // Assert + Assert.NotNull(rental); + Assert.Equal(user.Id, rental.UserId); + Assert.Equal(bookCopy.Id, rental.BookCopyId); + Assert.Equal(RentalStatus.Active, rental.Status); + Assert.True(rental.DueDate > rental.BorrowedAt); + } + + [Fact] + public async Task BorrowAsync_Throws_When_BookCopy_Not_Found() + { + // Arrange + var user = await CreateTestUserAsync(); + + // Act & Assert + await Assert.ThrowsAsync( + () => _service.BorrowAsync(user.Id, Guid.NewGuid(), loanPeriodDays: 14)); + } + + [Fact] + public async Task BorrowAsync_Throws_When_BookCopy_Not_Available() + { + // Arrange + var user = await CreateTestUserAsync(); + var book = await CreateTestBookAsync(); + var bookCopy = await CreateTestBookCopyAsync(book.Id, BookCopyStatus.Borrowed); + + // Act & Assert + await Assert.ThrowsAsync( + () => _service.BorrowAsync(user.Id, bookCopy.Id, loanPeriodDays: 14)); + } + + [Fact] + public async Task BorrowAsync_Updates_BookCopy_Status() + { + // Arrange + var user = await CreateTestUserAsync(); + var book = await CreateTestBookAsync(); + var bookCopy = await CreateTestBookCopyAsync(book.Id); + + // Act + await _service.BorrowAsync(user.Id, bookCopy.Id, loanPeriodDays: 14); + + // Assert + var updatedCopy = await _dbContext.BookCopies.FindAsync(bookCopy.Id); + Assert.Equal(BookCopyStatus.Borrowed, updatedCopy!.Status); + } + + [Fact] + public async Task BorrowByIsbnAsync_Returns_Null_When_Book_Not_Found() + { + // Arrange + var user = await CreateTestUserAsync(); + + // Act + var result = await _service.BorrowByIsbnAsync(user.Id, "9999999999", loanPeriodDays: 14); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task BorrowByIsbnAsync_Returns_Null_When_No_Available_Copy() + { + // Arrange + var user = await CreateTestUserAsync(); + var book = await CreateTestBookAsync(isbn: "1234567890"); + await CreateTestBookCopyAsync(book.Id, BookCopyStatus.Borrowed); + + // Act + var result = await _service.BorrowByIsbnAsync(user.Id, "1234567890", loanPeriodDays: 14); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task BorrowByIsbnAsync_Creates_Rental_When_Available() + { + // Arrange + var user = await CreateTestUserAsync(); + var book = await CreateTestBookAsync(isbn: "1234567890"); + var bookCopy = await CreateTestBookCopyAsync(book.Id); + + // Act + var rental = await _service.BorrowByIsbnAsync(user.Id, "1234567890", loanPeriodDays: 14); + + // Assert + Assert.NotNull(rental); + Assert.Equal(bookCopy.Id, rental.BookCopyId); + } + + [Fact] + public async Task ReturnAsync_Creates_History_And_Updates_Rental() + { + // Arrange + var user = await CreateTestUserAsync(); + var book = await CreateTestBookAsync(); + var bookCopy = await CreateTestBookCopyAsync(book.Id); + var rental = await _service.BorrowAsync(user.Id, bookCopy.Id, loanPeriodDays: 14); + + // Act + var history = await _service.ReturnAsync(rental.Id); + + // Assert + Assert.NotNull(history); + Assert.Equal(rental.Id, history.OriginalRentalId); + Assert.True(history.ReturnedAt >= history.BorrowedAt); + Assert.Equal(0, history.OverdueDays); + + var updatedRental = await _dbContext.Rentals.FindAsync(rental.Id); + Assert.Equal(RentalStatus.Returned, updatedRental!.Status); + } + + [Fact] + public async Task ReturnAsync_Throws_When_Rental_Not_Found() + { + // Act & Assert + await Assert.ThrowsAsync( + () => _service.ReturnAsync(Guid.NewGuid())); + } + + [Fact] + public async Task ReturnAsync_Throws_When_Rental_Already_Returned() + { + // Arrange + var user = await CreateTestUserAsync(); + var book = await CreateTestBookAsync(); + var bookCopy = await CreateTestBookCopyAsync(book.Id); + var rental = await _service.BorrowAsync(user.Id, bookCopy.Id, loanPeriodDays: 14); + await _service.ReturnAsync(rental.Id); + + // Act & Assert + await Assert.ThrowsAsync( + () => _service.ReturnAsync(rental.Id)); + } + + [Fact] + public async Task ReturnAsync_Updates_BookCopy_Status_To_Available() + { + // Arrange + var user = await CreateTestUserAsync(); + var book = await CreateTestBookAsync(); + var bookCopy = await CreateTestBookCopyAsync(book.Id); + var rental = await _service.BorrowAsync(user.Id, bookCopy.Id, loanPeriodDays: 14); + + // Act + await _service.ReturnAsync(rental.Id); + + // Assert + var updatedCopy = await _dbContext.BookCopies.FindAsync(bookCopy.Id); + Assert.Equal(BookCopyStatus.Available, updatedCopy!.Status); + } + + [Fact] + public async Task ReturnByIsbnAsync_Returns_Null_When_Book_Not_Found() + { + // Arrange + var user = await CreateTestUserAsync(); + + // Act + var result = await _service.ReturnByIsbnAsync("9999999999", user.Id); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task ReturnByIsbnAsync_Returns_Null_When_No_Active_Rental() + { + // Arrange + var user = await CreateTestUserAsync(); + var book = await CreateTestBookAsync(isbn: "1234567890"); + await CreateTestBookCopyAsync(book.Id); + + // Act + var result = await _service.ReturnByIsbnAsync("1234567890", user.Id); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetOverdueRentalsAsync_Returns_Only_Overdue_Rentals() + { + // Arrange + var user = await CreateTestUserAsync(); + var book1 = await CreateTestBookAsync(isbn: "1111111111"); + var book2 = await CreateTestBookAsync(isbn: "2222222222"); + var copy1 = await CreateTestBookCopyAsync(book1.Id); + var copy2 = await CreateTestBookCopyAsync(book2.Id); + + // Create overdue rental (loan period 0 days means due immediately) + var overdueRental = await _service.BorrowAsync(user.Id, copy1.Id, loanPeriodDays: 0); + // Manually set due date to past + overdueRental.DueDate = DateTime.UtcNow.AddDays(-5); + await _dbContext.SaveChangesAsync(); + + // Create normal rental + var normalRental = await _service.BorrowAsync(user.Id, copy2.Id, loanPeriodDays: 14); + + // Act + var overdueRentals = await _service.GetOverdueRentalsAsync(); + + // Assert + Assert.Single(overdueRentals); + Assert.Equal(overdueRental.Id, overdueRentals[0].Id); + } + + [Fact] + public async Task UpdateOverdueStatusAsync_Updates_Status_To_Overdue() + { + // Arrange + var user = await CreateTestUserAsync(); + var book = await CreateTestBookAsync(); + var copy = await CreateTestBookCopyAsync(book.Id); + + var rental = await _service.BorrowAsync(user.Id, copy.Id, loanPeriodDays: 0); + rental.DueDate = DateTime.UtcNow.AddDays(-1); + await _dbContext.SaveChangesAsync(); + + // Act + var count = await _service.UpdateOverdueStatusAsync(); + + // Assert + Assert.Equal(1, count); + + var updatedRental = await _dbContext.Rentals.FindAsync(rental.Id); + Assert.Equal(RentalStatus.Overdue, updatedRental!.Status); + } + + #region Helper Methods + + private async Task CreateTestUserAsync() + { + var user = new ApplicationUser + { + Id = Guid.NewGuid(), + UserName = $"user_{Guid.NewGuid():N}", + Email = $"test_{Guid.NewGuid():N}@example.com", + TenantId = _tenantId + }; + + _dbContext.Users.Add(user); + await _dbContext.SaveChangesAsync(); + return user; + } + + private async Task CreateTestBookAsync(string? isbn = null) + { + var book = new Book + { + Id = Guid.NewGuid(), + Isbn = isbn ?? $"isbn_{Guid.NewGuid():N}", + Title = $"Test Book {Guid.NewGuid():N}", + TenantId = _tenantId, + TotalCopies = 1, + AvailableCopies = 1 + }; + + _dbContext.Books.Add(book); + await _dbContext.SaveChangesAsync(); + return book; + } + + private async Task CreateTestBookCopyAsync(Guid bookId, BookCopyStatus status = BookCopyStatus.Available) + { + var copy = new BookCopy + { + Id = Guid.NewGuid(), + BookId = bookId, + InventoryCode = $"INV-{Guid.NewGuid():N}", + Status = status, + TenantId = _tenantId + }; + + _dbContext.BookCopies.Add(copy); + await _dbContext.SaveChangesAsync(); + return copy; + } + + #endregion +} + +/// +/// Mock implementation of IMultiTenantContextAccessor for testing +/// +internal class MockMultiTenantContextAccessor : Finbuckle.MultiTenant.Abstractions.IMultiTenantContextAccessor +{ + public Finbuckle.MultiTenant.Abstractions.IMultiTenantContext MultiTenantContext { get; set; } + + public MockMultiTenantContextAccessor(string tenantId) + { + var tenantInfo = new ApplicationTenantInfo + { + Id = tenantId, + Identifier = tenantId, + Name = $"Tenant {tenantId}" + }; + + MultiTenantContext = new Finbuckle.MultiTenant.MultiTenantContext + { + TenantInfo = tenantInfo + }; + } + + public void SetTenantContext(Finbuckle.MultiTenant.Abstractions.IMultiTenantContext context) + { + MultiTenantContext = context; + } +} diff --git a/OpenLibraryRent.slnx b/OpenLibraryRent.slnx index b75cbd2..1d09a37 100644 --- a/OpenLibraryRent.slnx +++ b/OpenLibraryRent.slnx @@ -1,5 +1,6 @@ + From 23d3039a5f777c15db1a13cfd1704544d0519911 Mon Sep 17 00:00:00 2001 From: actbit <57023457+actbit@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:30:13 +0900 Subject: [PATCH 2/3] [add] pr check --- .github/workflows/pr-check.yml | 95 ++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/pr-check.yml diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..3280650 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,95 @@ +name: PR Check + +on: + pull_request: + branches: [develop, master] + workflow_dispatch: + +jobs: + dotnet-build: + name: .NET Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + dotnet-test: + name: .NET Test + runs-on: ubuntu-latest + needs: dotnet-build + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test OpenLibraryRent.Tests --configuration Release --no-build --verbosity normal + + npm-build: + name: npm Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: OpenLibraryRent/OpenLibraryRent.Client/package-lock.json + + - name: Install dependencies + working-directory: OpenLibraryRent/OpenLibraryRent.Client + run: npm ci + + - name: Build + working-directory: OpenLibraryRent/OpenLibraryRent.Client + run: npm run build + + npm-check: + name: npm Check + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: OpenLibraryRent/OpenLibraryRent.Client/package-lock.json + + - name: Install dependencies + working-directory: OpenLibraryRent/OpenLibraryRent.Client + run: npm ci + + - name: Svelte Check + working-directory: OpenLibraryRent/OpenLibraryRent.Client + run: npm run check From 730df9e8a0aa50bddc06c12ec2132f814c531085 Mon Sep 17 00:00:00 2001 From: actbit <57023457+actbit@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:32:42 +0900 Subject: [PATCH 3/3] [add] publish workflow --- .github/workflows/release.yml | 79 +++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..928c9a3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-and-release: + name: Build and Release + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: OpenLibraryRent/OpenLibraryRent.Client/package-lock.json + + - name: Install npm dependencies + working-directory: OpenLibraryRent/OpenLibraryRent.Client + run: npm ci + + - name: Build frontend + working-directory: OpenLibraryRent/OpenLibraryRent.Client + run: npm run build + + - name: Restore .NET dependencies + run: dotnet restore + + - name: Build .NET + run: dotnet build --configuration Release --no-restore + + - name: Publish + run: dotnet publish OpenLibraryRent/OpenLibraryRent.csproj --configuration Release --no-build --output ./publish + + - name: Create version tag + id: version + run: | + VERSION=$(date +'%Y.%m.%d')-${{ github.run_number }} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Create ZIP archive + run: | + cd publish + zip -r ../OpenLibraryRent-${{ steps.version.outputs.version }}.zip . + cd .. + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: Release v${{ steps.version.outputs.version }} + body: | + ## OpenLibraryRent Release v${{ steps.version.outputs.version }} + + **Changes:** + ${{ github.event.head_commit.message }} + + **Commit:** ${{ github.sha }} + files: | + OpenLibraryRent-${{ steps.version.outputs.version }}.zip + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}