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