diff --git a/DatabaseProjectAPI.sln b/DatabaseProjectAPI.sln index 4b67481..6710ed8 100644 --- a/DatabaseProjectAPI.sln +++ b/DatabaseProjectAPI.sln @@ -7,6 +7,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabaseProjectAPI", "Datab EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KubsConnect", "KubsConnect\KubsConnect.csproj", "{3ED8AE85-9161-47E3-95AC-B4A224D49AAA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XUnitTest", "XUnitTest\XUnitTest.csproj", "{63016646-DDEC-41FA-92A1-4F33E9EAD8CE}" + ProjectSection(ProjectDependencies) = postProject + {BCFC56D8-061F-4C1C-AA30-41E784E4AE13} = {BCFC56D8-061F-4C1C-AA30-41E784E4AE13} + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +26,10 @@ Global {3ED8AE85-9161-47E3-95AC-B4A224D49AAA}.Debug|Any CPU.Build.0 = Debug|Any CPU {3ED8AE85-9161-47E3-95AC-B4A224D49AAA}.Release|Any CPU.ActiveCfg = Release|Any CPU {3ED8AE85-9161-47E3-95AC-B4A224D49AAA}.Release|Any CPU.Build.0 = Release|Any CPU + {63016646-DDEC-41FA-92A1-4F33E9EAD8CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {63016646-DDEC-41FA-92A1-4F33E9EAD8CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {63016646-DDEC-41FA-92A1-4F33E9EAD8CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {63016646-DDEC-41FA-92A1-4F33E9EAD8CE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DatabaseProjectAPI/Actions/AutoDeleteAction.cs b/DatabaseProjectAPI/Actions/AutoDeleteAction.cs index 8677d6d..8f31a33 100644 --- a/DatabaseProjectAPI/Actions/AutoDeleteAction.cs +++ b/DatabaseProjectAPI/Actions/AutoDeleteAction.cs @@ -1,91 +1,88 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using DatabaseProjectAPI.DataContext; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; +using DatabaseProjectAPI.DataContext; -namespace DatabaseProjectAPI.Actions +namespace DatabaseProjectAPI.Actions; + +public interface IAutoDeleteService +{ + Task DeleteOldStockHistoryAsync(CancellationToken cancellationToken); + Task DeleteOldApiCallLogsAsync(CancellationToken cancellationToken); +} + +public class AutoDeleteAction : IAutoDeleteService { - public interface IAutoDeleteService + private readonly DpapiDbContext _dbContext; + private readonly ILogger _logger; + + public AutoDeleteAction(DpapiDbContext dbContext, ILogger logger) { - Task DeleteOldStockHistoryAsync(CancellationToken cancellationToken); - Task DeleteOldApiCallLogsAsync(CancellationToken cancellationToken); + _dbContext = dbContext; + _logger = logger; } - public class AutoDeleteAction : IAutoDeleteService + public async Task DeleteOldStockHistoryAsync(CancellationToken cancellationToken) { - private readonly DpapiDbContext _dbContext; - private readonly ILogger _logger; + var ninetyDaysAgo = DateTime.UtcNow.AddDays(-90); - public AutoDeleteAction(DpapiDbContext dbContext, ILogger logger) + try { - _dbContext = dbContext; - _logger = logger; - } + + var oldHistories = await _dbContext.StockHistories + .Where(sh => sh.Timestamp < ninetyDaysAgo) + .ToListAsync(cancellationToken); - public async Task DeleteOldStockHistoryAsync(CancellationToken cancellationToken) - { - var ninetyDaysAgo = DateTime.UtcNow.AddDays(-90); - - try - { - - int deletedCount = await _dbContext.StockHistories - .Where(sh => sh.Timestamp < ninetyDaysAgo) - .ExecuteDeleteAsync(cancellationToken); - - if (deletedCount > 0) - { - _logger.LogInformation("{Count} old stock history records deleted.", deletedCount); - } - else - { - _logger.LogInformation("No stock history records found to delete."); - } - } - catch (OperationCanceledException) + if (oldHistories.Any()) { - _logger.LogInformation("Deletion of old stock history was cancelled."); - throw; + _dbContext.StockHistories.RemoveRange(oldHistories); + await _dbContext.SaveChangesAsync(cancellationToken); + _logger.LogInformation("{Count} old stock history records deleted.", oldHistories.Count); } - catch (Exception ex) + else { - _logger.LogError(ex, "An error occurred while deleting old stock history records."); - throw; + _logger.LogInformation("No stock history records found to delete."); } } - - public async Task DeleteOldApiCallLogsAsync(CancellationToken cancellationToken) + catch (OperationCanceledException) { - var ninetyDaysAgo = DateTime.UtcNow.AddDays(-90); + _logger.LogInformation("Deletion of old stock history was cancelled."); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while deleting old stock history records."); + throw; + } + } - try - { - // Use ExecuteDeleteAsync for efficient bulk deletion (EF Core 7.0+) - int deletedCount = await _dbContext.ApiCallLog - .Where(log => log.CallDate < ninetyDaysAgo) - .ExecuteDeleteAsync(cancellationToken); + public async Task DeleteOldApiCallLogsAsync(CancellationToken cancellationToken) + { + var ninetyDaysAgo = DateTime.UtcNow.AddDays(-90); - if (deletedCount > 0) - { - _logger.LogInformation("{Count} old API call log records deleted.", deletedCount); - } - else - { - _logger.LogInformation("No API call log records found to delete."); - } - } - catch (OperationCanceledException) + try + { + var oldLogs = await _dbContext.ApiCallLog + .Where(log => log.CallDate < ninetyDaysAgo) + .ToListAsync(cancellationToken); + + if (oldLogs.Any()) { - _logger.LogInformation("Deletion of old API call logs was cancelled."); - throw; + _dbContext.ApiCallLog.RemoveRange(oldLogs); + await _dbContext.SaveChangesAsync(cancellationToken); + _logger.LogInformation("{Count} old API call log records deleted.", oldLogs.Count); } - catch (Exception ex) + else { - _logger.LogError(ex, "An error occurred while deleting old API call log records."); - throw; + _logger.LogInformation("No API call log records found to delete."); } } + catch (OperationCanceledException) + { + _logger.LogInformation("Deletion of old API call logs was cancelled."); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while deleting old API call log records."); + throw; + } } } diff --git a/DatabaseProjectAPI/DatabaseProjectAPI.csproj b/DatabaseProjectAPI/DatabaseProjectAPI.csproj index 5ca2d53..73dba2c 100644 --- a/DatabaseProjectAPI/DatabaseProjectAPI.csproj +++ b/DatabaseProjectAPI/DatabaseProjectAPI.csproj @@ -15,6 +15,7 @@ + diff --git a/DatabaseProjectAPI/Services/StockQuoteBackgroundService.cs b/DatabaseProjectAPI/Services/StockQuoteBackgroundService.cs index 55505d1..2581dfe 100644 --- a/DatabaseProjectAPI/Services/StockQuoteBackgroundService.cs +++ b/DatabaseProjectAPI/Services/StockQuoteBackgroundService.cs @@ -64,7 +64,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); } } - private async Task FetchAndSaveStockDataAsync(DpapiDbContext dbContext, IApiRequestLogger apiRequestLogger, string callType, TrackedStock trackedStock, CancellationToken cancellationToken) + public async Task FetchAndSaveStockDataAsync(DpapiDbContext dbContext, IApiRequestLogger apiRequestLogger, string callType, TrackedStock trackedStock, CancellationToken cancellationToken) { var symbol = trackedStock.Symbol; _logger.LogInformation("FetchAndSaveStockDataAsync started for symbol {Symbol} with call type {CallType}", symbol, callType); diff --git a/XUnitTest/APIrequestloggerTests.cs b/XUnitTest/APIrequestloggerTests.cs new file mode 100644 index 0000000..f17ac80 --- /dev/null +++ b/XUnitTest/APIrequestloggerTests.cs @@ -0,0 +1,54 @@ +namespace XUnitTests; +public class ApiRequestLoggerTests +{ + private readonly DpapiDbContext _dbContext; + private readonly Mock> _loggerMock; + private readonly ApiRequestLogger _logger; + + public ApiRequestLoggerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _dbContext = new DpapiDbContext(options); + _loggerMock = new Mock>(); + _logger = new ApiRequestLogger(_dbContext, _loggerMock.Object); + } + + [Fact] + public async Task HasMadeApiCallTodayAsync_ReturnsTrue_WhenLogExists() + { + var today = DateTime.UtcNow.Date; + await _dbContext.ApiCallLog.AddAsync(new ApiCallLog + { + CallDate = today, + CallType = "Quote", + Symbol = "AAPL" + }); + await _dbContext.SaveChangesAsync(); + + var result = await _logger.HasMadeApiCallTodayAsync("Quote", "AAPL", CancellationToken.None); + Assert.True(result); + } + + [Fact] + public async Task HasMadeApiCallTodayAsync_ReturnsFalse_WhenNoLogExists() + { + var result = await _logger.HasMadeApiCallTodayAsync("Quote", "MSFT", CancellationToken.None); + Assert.False(result); + } + + [Fact] + public async Task LogApiCallAsync_AddsLogSuccessfully() + { + var symbol = "GOOG"; + var callType = "News"; + + await _logger.LogApiCallAsync(callType, symbol, CancellationToken.None); + + var entry = await _dbContext.ApiCallLog.FirstOrDefaultAsync(a => a.Symbol == symbol && a.CallType == callType); + Assert.NotNull(entry); + Assert.Equal(DateTime.UtcNow.Date, entry.CallDate); + } +} diff --git a/XUnitTest/AlphaVantageServiceTests.cs b/XUnitTest/AlphaVantageServiceTests.cs new file mode 100644 index 0000000..b8996b5 --- /dev/null +++ b/XUnitTest/AlphaVantageServiceTests.cs @@ -0,0 +1,100 @@ +namespace XUnitTests; +public class AlphaVantageServiceTests +{ + private readonly Mock _handlerMock; + private readonly Mock> _loggerMock; + private readonly HttpClient _httpClient; + private readonly AlphaVantageService _service; + + public AlphaVantageServiceTests() + { + _handlerMock = new Mock(); + _loggerMock = new Mock>(); + + _httpClient = new HttpClient(_handlerMock.Object); + var settings = new AlphaVantageSettings { ApiKey = "demo" }; + + _service = new AlphaVantageService(_httpClient, settings, _loggerMock.Object); + } + + [Fact] + public async Task GetStockQuoteAsync_ReturnsValidStockQuote() + { + var today = DateTime.UtcNow.Date; + + var responseObject = new Dictionary + { + ["Global Quote"] = new Dictionary + { + ["01. symbol"] = "AAPL", + ["02. open"] = "170.5", + ["03. high"] = "173.0", + ["04. low"] = "169.5", + ["05. price"] = "172.1", + ["06. volume"] = "5000000", + ["07. latest trading day"] = today.ToString("yyyy-MM-dd"), + ["08. previous close"] = "171.0", + ["10. change percent"] = "0.64%" + } + }; + + var responseContent = JsonConvert.SerializeObject(responseObject); + + _handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }); + + var result = await _service.GetStockQuoteAsync("AAPL"); + + Assert.NotNull(result); + Assert.Equal("AAPL", result.Symbol); + Assert.Equal(170.5m, result.Open); + Assert.Equal(172.1m, result.Price); + Assert.Equal(5000000, result.Volume); + Assert.Equal(today, result.LatestTradingDay.Date); + } + + + [Fact] + public async Task GetStockQuoteAsync_ThrowsOnRateLimit() + { + var rateLimitResponse = JsonConvert.SerializeObject(new { Note = "Rate limit exceeded" }); + + _handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(rateLimitResponse) + }); + + await Assert.ThrowsAsync(() => _service.GetStockQuoteAsync("AAPL")); + } + + [Fact] + public async Task GetStockQuoteAsync_ThrowsOnErrorMessage() + { + var errorResponse = JsonConvert.SerializeObject(new { ErrorMessage = "Invalid API call" }); + + _handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(errorResponse) + }); + + await Assert.ThrowsAsync(() => _service.GetStockQuoteAsync("INVALID")); + } +} diff --git a/XUnitTest/AutoDeleteActionTest.cs b/XUnitTest/AutoDeleteActionTest.cs new file mode 100644 index 0000000..f61dc21 --- /dev/null +++ b/XUnitTest/AutoDeleteActionTest.cs @@ -0,0 +1,89 @@ +namespace XUnitTests; +public class AutoDeleteActionTests +{ + private DpapiDbContext _dbContext; + private AutoDeleteAction _autoDeleteAction; + private Mock> _loggerMock; + + public AutoDeleteActionTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _dbContext = new DpapiDbContext(options); + _loggerMock = new Mock>(); + _autoDeleteAction = new AutoDeleteAction(_dbContext, _loggerMock.Object); + } + + [Fact] + public async Task DeleteOldStockHistoryAsync_DeletesOnlyRecordsOlderThan90Days() + { + var now = DateTime.UtcNow; + _dbContext.StockHistories.AddRange(new List + { + new StockHistory { HistoryId = 1, Timestamp = now.AddDays(-91), OpenedValue = 100, ClosedValue = 105, Volume = 1000, StockId = 1, Stock = new Stock { StockId = 1, Symbol = "AAPL" } }, + new StockHistory { HistoryId = 2, Timestamp = now.AddDays(-5), OpenedValue = 110, ClosedValue = 115, Volume = 2000, StockId = 2, Stock = new Stock { StockId = 2, Symbol = "MSFT" } } + }); + + await _dbContext.SaveChangesAsync(); + await _autoDeleteAction.DeleteOldStockHistoryAsync(CancellationToken.None); + + var remaining = await _dbContext.StockHistories.ToListAsync(); + Assert.Single(remaining); + Assert.Equal(2, remaining.First().HistoryId); + } + + [Fact] + public async Task DeleteOldApiCallLogsAsync_DeletesOnlyRecordsOlderThan90Days() + { + var now = DateTime.UtcNow; + _dbContext.ApiCallLog.AddRange(new List + { + new ApiCallLog { Id = 1, Symbol = "AAPL", CallType = "Quote", CallDate = now.AddDays(-91) }, + new ApiCallLog { Id = 2, Symbol = "MSFT", CallType = "Quote", CallDate = now } + }); + + await _dbContext.SaveChangesAsync(); + await _autoDeleteAction.DeleteOldApiCallLogsAsync(CancellationToken.None); + + var remaining = await _dbContext.ApiCallLog.ToListAsync(); + Assert.Single(remaining); + Assert.Equal(2, remaining.First().Id); + } + + [Fact] + public async Task DeleteOldStockHistoryAsync_DoesNotThrow_WhenNoneToDelete() + { + _dbContext.StockHistories.Add(new StockHistory + { + HistoryId = 3, + Timestamp = DateTime.UtcNow, + OpenedValue = 120, + ClosedValue = 125, + Volume = 1500, + StockId = 3, + Stock = new Stock { StockId = 3, Symbol = "GOOG" } + }); + + await _dbContext.SaveChangesAsync(); + var exception = await Record.ExceptionAsync(() => _autoDeleteAction.DeleteOldStockHistoryAsync(CancellationToken.None)); + Assert.Null(exception); + } + + [Fact] + public async Task DeleteOldApiCallLogsAsync_DoesNotThrow_WhenNoneToDelete() + { + _dbContext.ApiCallLog.Add(new ApiCallLog + { + Id = 3, + Symbol = "TSLA", + CallType = "News", + CallDate = DateTime.UtcNow + }); + + await _dbContext.SaveChangesAsync(); + var exception = await Record.ExceptionAsync(() => _autoDeleteAction.DeleteOldApiCallLogsAsync(CancellationToken.None)); + Assert.Null(exception); + } +} diff --git a/XUnitTest/DataCleanupBackgroundServiceTests.cs b/XUnitTest/DataCleanupBackgroundServiceTests.cs new file mode 100644 index 0000000..3ec55f4 --- /dev/null +++ b/XUnitTest/DataCleanupBackgroundServiceTests.cs @@ -0,0 +1,38 @@ +namespace XUnitTests; + +public class DataCleanupBackgroundServiceTests +{ + [Fact] + public async Task ExecuteAsync_CallsAutoDeleteMethodsOnce() + { + // Arrange + var autoDeleteServiceMock = new Mock(); + autoDeleteServiceMock.Setup(x => x.DeleteOldStockHistoryAsync(It.IsAny())).Returns(Task.CompletedTask); + autoDeleteServiceMock.Setup(x => x.DeleteOldApiCallLogsAsync(It.IsAny())).Returns(Task.CompletedTask); + + var scopedServiceProviderMock = new Mock(); + scopedServiceProviderMock.Setup(x => x.GetService(typeof(IAutoDeleteService))).Returns(autoDeleteServiceMock.Object); + + var scopeMock = new Mock(); + scopeMock.Setup(x => x.ServiceProvider).Returns(scopedServiceProviderMock.Object); + + var scopeFactoryMock = new Mock(); + scopeFactoryMock.Setup(x => x.CreateScope()).Returns(scopeMock.Object); + + var rootServiceProviderMock = new Mock(); + rootServiceProviderMock.Setup(x => x.GetService(typeof(IServiceScopeFactory))).Returns(scopeFactoryMock.Object); + + var loggerMock = new Mock>(); + + var service = new DataCleanupBackgroundService(rootServiceProviderMock.Object, loggerMock.Object); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); // short delay + + // Act + await service.StartAsync(cts.Token); + + // Assert + autoDeleteServiceMock.Verify(x => x.DeleteOldStockHistoryAsync(It.IsAny()), Times.AtLeastOnce); + autoDeleteServiceMock.Verify(x => x.DeleteOldApiCallLogsAsync(It.IsAny()), Times.AtLeastOnce); + } +} diff --git a/XUnitTest/EventActionTests.cs b/XUnitTest/EventActionTests.cs new file mode 100644 index 0000000..c0fe57c --- /dev/null +++ b/XUnitTest/EventActionTests.cs @@ -0,0 +1,77 @@ +namespace XUnitTests; +public class EventsActionTests +{ + private readonly DpapiDbContext _dbContext; + private readonly EventsAction _eventsAction; + + public EventsActionTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _dbContext = new DpapiDbContext(options); + + _dbContext.Events.AddRange(new List + { + new Event + { + Datetime = new DateTime(2024, 10, 1), + CreatedAt = new DateTime(2024, 10, 2), + FederalInterestRate = 5.25m, + UnemploymentRate = 3.8m, + Inflation = 3.2m, + CPI = 302.1m, + }, + new Event + { + Datetime = new DateTime(2024, 11, 1), + CreatedAt = new DateTime(2024, 11, 2), + FederalInterestRate = 5.5m, + UnemploymentRate = 3.6m, + Inflation = 3.5m, + CPI = 305.3m, + } + }); + + _dbContext.SaveChanges(); + + _eventsAction = new EventsAction(_dbContext); + } + + [Fact] + public async Task GetLatestEvent_ReturnsMostRecent() + { + var latest = await _eventsAction.GetLatestEvent(); + Assert.NotNull(latest); + Assert.Equal(new DateTime(2024, 11, 1), latest.Datetime); + } + + [Fact] + public async Task GetFederalInterestRate_ReturnsLatestValue() + { + var rate = await _eventsAction.GetFederalInterestRate(); + Assert.Equal(5.5m, rate); + } + + [Fact] + public async Task GetUnemploymentRate_ReturnsLatestValue() + { + var rate = await _eventsAction.GetUnemploymentRate(); + Assert.Equal(3.6m, rate); + } + + [Fact] + public async Task GetInflation_ReturnsLatestPositiveValue() + { + var inflation = await _eventsAction.GetInflation(); + Assert.Equal(3.5m, inflation); + } + + [Fact] + public async Task GetCPI_ReturnsLatestValue() + { + var cpi = await _eventsAction.GetCPI(); + Assert.Equal(305.3m, cpi); + } +} \ No newline at end of file diff --git a/XUnitTest/EventsBackgroundServiceTests.cs b/XUnitTest/EventsBackgroundServiceTests.cs new file mode 100644 index 0000000..aae84cd --- /dev/null +++ b/XUnitTest/EventsBackgroundServiceTests.cs @@ -0,0 +1,63 @@ +namespace XUnitTests; +public class EventsBackgroundServiceTests +{ + private readonly DpapiDbContext _dbContext; + private readonly EventsBackgroundService _service; + private readonly Mock _alphaVantageServiceMock; + private readonly Mock> _loggerMock; + + public EventsBackgroundServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _dbContext = new DpapiDbContext(options); + _alphaVantageServiceMock = new Mock(); + _loggerMock = new Mock>(); + + // ServiceProvider inside scope + var scopedProviderMock = new Mock(); + scopedProviderMock.Setup(x => x.GetService(typeof(DpapiDbContext))).Returns(_dbContext); + scopedProviderMock.Setup(x => x.GetService(typeof(IAlphaVantageService))).Returns(_alphaVantageServiceMock.Object); + + var scopeMock = new Mock(); + scopeMock.Setup(x => x.ServiceProvider).Returns(scopedProviderMock.Object); + + var scopeFactoryMock = new Mock(); + scopeFactoryMock.Setup(x => x.CreateScope()).Returns(scopeMock.Object); + + var rootProviderMock = new Mock(); + rootProviderMock.Setup(x => x.GetService(typeof(IServiceScopeFactory))).Returns(scopeFactoryMock.Object); + + _service = new EventsBackgroundService(rootProviderMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task ExecuteAsync_CallsAllFetchMethods() + { + // Arrange + var today = DateTime.UtcNow.ToString("yyyy-MM-dd"); + _alphaVantageServiceMock.Setup(a => a.GetInflationAsync()).ReturnsAsync( + new Inflation { Data = new List { new() { Date = today, Value = "3.5" } } }); + _alphaVantageServiceMock.Setup(a => a.GetFederalInterestRateAsync()).ReturnsAsync( + new FederalInterestRate { Data = new List { new() { Date = today, Value = "4.5" } } }); + _alphaVantageServiceMock.Setup(a => a.GetUnemploymentRateAsync()).ReturnsAsync( + new UnemploymentRate { Data = new List { new() { Date = today, Value = "5.1" } } }); + _alphaVantageServiceMock.Setup(a => a.GetCPIdataAsync()).ReturnsAsync( + new CPIdata { Data = new List { new() { Date = today, Value = "260.7" } } }); + + var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + + // Act + await _service.StartAsync(cts.Token); + + // Assert + var @event = await _dbContext.Events.FirstOrDefaultAsync(); + Assert.NotNull(@event); + Assert.Equal(3.5m, @event.Inflation); + Assert.Equal(4.5m, @event.FederalInterestRate); + Assert.Equal(5.1m, @event.UnemploymentRate); + Assert.Equal(260.7m, @event.CPI); + } +} \ No newline at end of file diff --git a/XUnitTest/FinnhubServiceTests.cs b/XUnitTest/FinnhubServiceTests.cs new file mode 100644 index 0000000..666c8f4 --- /dev/null +++ b/XUnitTest/FinnhubServiceTests.cs @@ -0,0 +1,77 @@ +namespace XUnitTests; +public class FinnhubServiceTests +{ + private readonly Mock _handlerMock; + private readonly HttpClient _httpClient; + private readonly FinnhubService _service; + + public FinnhubServiceTests() + { + _handlerMock = new Mock(); + _httpClient = new HttpClient(_handlerMock.Object); + + var settings = new FinnhubSettings + { + ApiKey = "test-api-key" + }; + + _service = new FinnhubService(_httpClient, settings); + } + + [Fact] + public async Task GetStockDataAsync_ReturnsParsedJObject() + { + var responseContent = new + { + c = new[] { 150.1, 151.2 }, + t = new[] { 1617753600, 1617840000 } + }; + + var json = JsonConvert.SerializeObject(responseContent); + + _handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + + var result = await _service.GetStockDataAsync("AAPL", DateTime.UtcNow.AddDays(-2), DateTime.UtcNow); + + Assert.NotNull(result); + Assert.True(result.ContainsKey("c")); + } + + [Fact] + public async Task MarkStatusAsync_ReturnsDeserializedStatus() + { + var marketStatus = new FinnhubMarketStatus + { + isOpen = false, + exchange = "US" + }; + + var json = JsonConvert.SerializeObject(marketStatus); + + _handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + + var result = await _service.MarkStatusAsync(); + + Assert.NotNull(result); + Assert.False(result.isOpen); + Assert.Equal("US", result.exchange); + } +} \ No newline at end of file diff --git a/XUnitTest/GlobalUsingsXUnit.cs b/XUnitTest/GlobalUsingsXUnit.cs new file mode 100644 index 0000000..188d51a --- /dev/null +++ b/XUnitTest/GlobalUsingsXUnit.cs @@ -0,0 +1,15 @@ +global using DatabaseProjectAPI.DataContext; +global using DatabaseProjectAPI.Entities; +global using DatabaseProjectAPI.Services; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using Moq; +global using DatabaseProjectAPI.Helpers; +global using DatabaseProjectAPI.Actions; +global using Moq.Protected; +global using Newtonsoft.Json; +global using System.Net; +global using System.Text; +global using KubsConnect.Settings; +global using DatabaseProjectAPI.Entities.Settings; \ No newline at end of file diff --git a/XUnitTest/MarketNewsActionTests.cs b/XUnitTest/MarketNewsActionTests.cs new file mode 100644 index 0000000..653e31f --- /dev/null +++ b/XUnitTest/MarketNewsActionTests.cs @@ -0,0 +1,82 @@ +namespace XUnitTests; +public class MarketNewsActionTests +{ + private DpapiDbContext GetInMemoryDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + var dbContext = new DpapiDbContext(options); + + dbContext.MarketNews.AddRange(new List + { + new MarketNews + { + NewsId = 1, + StockId = 101, + Datetime = new DateTime(2025, 4, 1, 10, 0, 0), + Headline = "Stock 101 rises", + SourceUrl = "http://example.com/1" + }, + new MarketNews + { + NewsId = 2, + StockId = 101, + Datetime = new DateTime(2025, 4, 1, 15, 0, 0), + Headline = "Stock 101 jumps again", + SourceUrl = "http://example.com/2" + }, + new MarketNews + { + NewsId = 3, + StockId = 102, + Datetime = new DateTime(2025, 4, 1), + Headline = "Stock 102 drops", + SourceUrl = "http://example.com/3" + } + }); + + dbContext.SaveChanges(); + return dbContext; + } + + [Fact] + public async Task GetMarketNewsByDateAndStockId_ReturnsMatchingNews() + { + // Arrange + var dbContext = GetInMemoryDbContext(); + var action = new MarketNewsAction(dbContext); + + // Act + var result = await action.GetMarketNewsByDateAndStockId(new DateTime(2025, 4, 1), 101); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, r => Assert.Equal(101, r.StockId)); + Assert.True(result[0].Datetime > result[1].Datetime); // check ordering + } + + [Fact] + public async Task GetMarketNewsByDateAndStockId_ReturnsEmpty_WhenNoMatch() + { + var dbContext = GetInMemoryDbContext(); + var action = new MarketNewsAction(dbContext); + + var result = await action.GetMarketNewsByDateAndStockId(new DateTime(2025, 3, 31), 101); + + Assert.Empty(result); + } + + [Fact] + public async Task GetMarketNewsByDateAndStockId_FiltersByStockId() + { + var dbContext = GetInMemoryDbContext(); + var action = new MarketNewsAction(dbContext); + + var result = await action.GetMarketNewsByDateAndStockId(new DateTime(2025, 4, 1), 102); + + Assert.Single(result); + Assert.Equal("Stock 102 drops", result[0].Headline); + } +} diff --git a/XUnitTest/NewsAPIServiceTests.cs b/XUnitTest/NewsAPIServiceTests.cs new file mode 100644 index 0000000..9c8810e --- /dev/null +++ b/XUnitTest/NewsAPIServiceTests.cs @@ -0,0 +1,63 @@ +namespace XUnitTests; + +public class NewsAPIServiceTests +{ + private readonly Mock _handlerMock; + private readonly HttpClient _httpClient; + private readonly NewsAPIService _service; + + public NewsAPIServiceTests() + { + _handlerMock = new Mock(MockBehavior.Strict); + _httpClient = new HttpClient(_handlerMock.Object); + var settings = new NewsSettings { ApiKey = "demo" }; + + _service = new NewsAPIService(_httpClient, settings); + } + + [Fact] + public async Task GetNewsDataAsync_ReturnsArticles() + { + var articles = new List
+ { + new Article { Title = "Test Article", Url = "http://example.com", PublishedAt = DateTime.UtcNow } + }; + + var response = new NewsApiResponse { Articles = articles }; + var json = JsonConvert.SerializeObject(response); + + _handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + + var result = await _service.GetNewsDataAsync("apple", DateTime.UtcNow.AddDays(-1), DateTime.UtcNow); + + Assert.Single(result); + Assert.Equal("Test Article", result[0].Title); + } + + [Fact] + public async Task GetNewsDataAsync_ThrowsOnError() + { + _handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.BadRequest, + Content = new StringContent("Invalid request") + }); + + var ex = await Assert.ThrowsAsync(() => + _service.GetNewsDataAsync("apple", DateTime.UtcNow.AddDays(-1), DateTime.UtcNow)); + + Assert.Contains("status code", ex.Message); + } +} \ No newline at end of file diff --git a/XUnitTest/NewsBackgroundServiceTests.cs b/XUnitTest/NewsBackgroundServiceTests.cs new file mode 100644 index 0000000..825236e --- /dev/null +++ b/XUnitTest/NewsBackgroundServiceTests.cs @@ -0,0 +1,54 @@ +namespace XUnitTests; +public class NewsBackgroundServiceTests +{ + [Fact] + public async Task FetchAndSaveNewsAsync_SavesNews_WhenValidResponse() + { + // Arrange + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + await using var dbContext = new DpapiDbContext(options); + + var stock = new Stock { StockId = 1, Symbol = "AAPL", Name = "Apple Inc." }; + var trackedStock = new TrackedStock { Id = 1, Symbol = "AAPL", StockName = "Apple Inc." }; + + dbContext.Stocks.Add(stock); + dbContext.TrackedStocks.Add(trackedStock); + await dbContext.SaveChangesAsync(); + + var mockNewsService = new Mock(); + mockNewsService.Setup(x => x.GetNewsDataAsync("Apple Inc.", It.IsAny(), It.IsAny())) + .ReturnsAsync(new List
+ { + new Article + { + Title = "Apple releases new iPhone", + Url = "https://example.com/apple-news", + PublishedAt = DateTime.UtcNow + } + }); + + var serviceProviderMock = new Mock(); + var scopeFactoryMock = new Mock(); + var scopeMock = new Mock(); + var scopedServiceProviderMock = new Mock(); + + scopedServiceProviderMock.Setup(x => x.GetService(typeof(DpapiDbContext))).Returns(dbContext); + scopeMock.Setup(x => x.ServiceProvider).Returns(scopedServiceProviderMock.Object); + scopeFactoryMock.Setup(x => x.CreateScope()).Returns(scopeMock.Object); + serviceProviderMock.Setup(x => x.GetService(typeof(IServiceScopeFactory))).Returns(scopeFactoryMock.Object); + + var loggerMock = new Mock>(); + var service = new NewsBackgroundService(serviceProviderMock.Object, loggerMock.Object, mockNewsService.Object); + + // Act + await service.FetchAndSaveNewsAsync(CancellationToken.None); + + // Assert + Assert.Single(dbContext.MarketNews); + var savedNews = dbContext.MarketNews.First(); + Assert.Equal("Apple releases new iPhone", savedNews.Headline); + } +} diff --git a/XUnitTest/StockActionTests.cs b/XUnitTest/StockActionTests.cs new file mode 100644 index 0000000..c0a031d --- /dev/null +++ b/XUnitTest/StockActionTests.cs @@ -0,0 +1,69 @@ +namespace XUnitTests; +public class StockActionTests +{ + private DpapiDbContext GetDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + var context = new DpapiDbContext(options); + + context.Stocks.AddRange(new List + { + new Stock { StockId = 1, TrackedStockId = 101, Symbol = "AAPL", Name = "Apple" }, + new Stock { StockId = 2, TrackedStockId = 102, Symbol = "GOOGL", Name = "Google" }, + new Stock { StockId = 3, TrackedStockId = 103, Symbol = "AAPL", Name = "Apple Duplicate" } + }); + + context.SaveChanges(); + return context; + } + + [Fact] + public async Task GetStocksById_ReturnsCorrectStock() + { + var context = GetDbContext(); + var action = new StockAction(context); + + var stock = await action.GetStocksById(101); + + Assert.NotNull(stock); + Assert.Equal("AAPL", stock.Symbol); + Assert.Equal("Apple", stock.Name); + } + + [Fact] + public async Task GetAllStocks_ReturnsAllStocks() + { + var context = GetDbContext(); + var action = new StockAction(context); + + var stocks = await action.GetAllStocks(); + + Assert.Equal(3, stocks.Count); + } + + [Fact] + public async Task GetStocksBySymbol_ReturnsMatchingStocks() + { + var context = GetDbContext(); + var action = new StockAction(context); + + var stocks = await action.GetStocksBySymbol("AAPL"); + + Assert.Equal(2, stocks.Count); + Assert.All(stocks, s => Assert.Equal("AAPL", s.Symbol)); + } + + [Fact] + public async Task GetStocksBySymbol_ReturnsEmptyList_IfNoMatch() + { + var context = GetDbContext(); + var action = new StockAction(context); + + var stocks = await action.GetStocksBySymbol("MSFT"); + + Assert.Empty(stocks); + } +} diff --git a/XUnitTest/StockHistoryActionTests.cs b/XUnitTest/StockHistoryActionTests.cs new file mode 100644 index 0000000..5d3a1ae --- /dev/null +++ b/XUnitTest/StockHistoryActionTests.cs @@ -0,0 +1,62 @@ +namespace XUnitTests; +public class StockHistoryActionTests +{ + private DpapiDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var context = new DpapiDbContext(options); + + var aaplStock = new Stock { StockId = 1, Symbol = "AAPL", Name = "Apple Inc." }; + var googlStock = new Stock { StockId = 2, Symbol = "GOOGL", Name = "Alphabet Inc." }; + + context.Stocks.AddRange(aaplStock, googlStock); + context.StockHistories.AddRange( + new StockHistory { HistoryId = 1, Stock = aaplStock, Timestamp = new DateTime(2025, 3, 15), OpenedValue = 150, ClosedValue = 155, Volume = 100000 }, + new StockHistory { HistoryId = 2, Stock = aaplStock, Timestamp = new DateTime(2025, 3, 10), OpenedValue = 148, ClosedValue = 153, Volume = 90000 }, + new StockHistory { HistoryId = 3, Stock = googlStock, Timestamp = new DateTime(2025, 3, 15), OpenedValue = 2800, ClosedValue = 2820, Volume = 110000 } + ); + + context.SaveChanges(); + return context; + } + + [Fact] + public void GetStockHistory_ReturnsAllForSymbol() + { + var dbContext = CreateDbContext(); + var action = new StockHistoryAction(dbContext); + + var result = action.GetStockHistory("AAPL"); + + Assert.Equal(2, result.Count); + Assert.All(result, sh => Assert.Equal("AAPL", sh.Stock.Symbol)); + } + + [Fact] + public void GetStockHistory_WithDateRange_FiltersCorrectly() + { + var dbContext = CreateDbContext(); + var action = new StockHistoryAction(dbContext); + var from = new DateTime(2025, 3, 12); + var to = new DateTime(2025, 3, 16); + + var result = action.GetStockHistory("AAPL", from, to); + + Assert.Single(result); + Assert.Equal(new DateTime(2025, 3, 15), result.First().Timestamp); + } + + [Fact] + public void GetStockHistory_ReturnsEmpty_WhenNoMatch() + { + var dbContext = CreateDbContext(); + var action = new StockHistoryAction(dbContext); + + var result = action.GetStockHistory("MSFT"); + + Assert.Empty(result); + } +} diff --git a/XUnitTest/StockQuoteBackgroundServiceTests.cs b/XUnitTest/StockQuoteBackgroundServiceTests.cs new file mode 100644 index 0000000..ef0f7e8 --- /dev/null +++ b/XUnitTest/StockQuoteBackgroundServiceTests.cs @@ -0,0 +1,63 @@ +namespace XUnitTests; +public class StockQuoteBackgroundServiceTests +{ + [Fact] + public async Task FetchAndSaveStockDataAsync_SavesStockAndHistory_WhenCalled() + { + // Arrange + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + var dbContext = new DpapiDbContext(options); + var trackedStock = new TrackedStock { Id = 1, Symbol = "AAPL", StockName = "Apple Inc." }; + dbContext.TrackedStocks.Add(trackedStock); + await dbContext.SaveChangesAsync(); + + var fakeQuote = new StockQuote + { + Symbol = "AAPL", + Open = 180.00m, + Price = 185.50m, + Volume = 2000000, + LatestTradingDay = DateTime.UtcNow.Date + }; + + var mockAlphaVantage = new Mock(); + mockAlphaVantage.Setup(x => x.GetStockQuoteAsync("AAPL")).ReturnsAsync(fakeQuote); + + var mockApiLogger = new Mock(); + + var mockLogger = new Mock>(); + var mockFinnhubService = new Mock(); + + var mockServiceProvider = new Mock(); + mockServiceProvider.Setup(sp => sp.GetService(typeof(IAlphaVantageService))) + .Returns(mockAlphaVantage.Object); + + var service = new StockQuoteBackgroundService( + mockServiceProvider.Object, + mockLogger.Object, + mockFinnhubService.Object + ); + + var cancellationToken = new CancellationToken(); + + // Act + await service.FetchAndSaveStockDataAsync(dbContext, mockApiLogger.Object, "MarketClose", trackedStock, cancellationToken); + + // Assert + var savedStock = await dbContext.Stocks.FirstOrDefaultAsync(); + Assert.NotNull(savedStock); + Assert.Equal(185.50m, savedStock.ClosingValue); + + var savedHistory = await dbContext.StockHistories.FirstOrDefaultAsync(); + Assert.NotNull(savedHistory); + Assert.Equal(180.00m, savedHistory.OpenedValue); + Assert.Equal(185.50m, savedHistory.ClosedValue); + Assert.Equal(savedStock.StockId, savedHistory.Stock.StockId); + + mockApiLogger.Verify(x => + x.LogApiCallAsync("MarketClose", "AAPL", cancellationToken), Times.Once); + } +} \ No newline at end of file diff --git a/XUnitTest/TrackedStockActionTests.cs b/XUnitTest/TrackedStockActionTests.cs new file mode 100644 index 0000000..236395b --- /dev/null +++ b/XUnitTest/TrackedStockActionTests.cs @@ -0,0 +1,64 @@ +namespace XUnitTests; +public class TrackedStockActionTests +{ + private DpapiDbContext _dbContext; + private TrackedStockAction _trackedStockAction; + + public TrackedStockActionTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _dbContext = new DpapiDbContext(options); + _dbContext.TrackedStocks.AddRange(new List + { + new TrackedStock { Id = 1, Symbol = "AAPL", StockName = "Apple Inc." }, + new TrackedStock { Id = 2, Symbol = "AMZN", StockName = "Amazon Inc." }, + new TrackedStock { Id = 3, Symbol = "GOOGL", StockName = "Alphabet Inc." } + }); + _dbContext.SaveChanges(); + + _trackedStockAction = new TrackedStockAction(_dbContext); + } + + [Fact] + public void GetTrackedStocks_ReturnsAll_WhenNoSymbolProvided() + { + var result = _trackedStockAction.GetTrackedStocks(); + Assert.Equal(3, result.Count); + } + + [Fact] + public void GetTrackedStocks_ReturnsFilteredList_WhenSymbolProvided() + { + var result = _trackedStockAction.GetTrackedStocks("A"); + + Assert.Equal(2, result.Count); // AAPL and AMZN + Assert.All(result, s => Assert.StartsWith("A", s.Symbol)); + } + + [Fact] + public void AddTrackedStock_AddsNewStock_WhenNotExists() + { + var newStock = new TrackedStock { Symbol = "MSFT", StockName = "Microsoft" }; + + _trackedStockAction.AddTrackedStock(newStock); + + var all = _trackedStockAction.GetTrackedStocks(); + Assert.Equal(4, all.Count); + Assert.Contains(all, s => s.Symbol == "MSFT"); + } + + [Fact] + public void AddTrackedStock_DoesNotAddDuplicate() + { + var duplicateStock = new TrackedStock { Symbol = "AAPL", StockName = "Apple Duplicate" }; + + _trackedStockAction.AddTrackedStock(duplicateStock); + + var result = _trackedStockAction.GetTrackedStocks("AAPL"); + Assert.Single(result); + Assert.Equal("Apple Inc.", result[0].StockName); + } +} diff --git a/XUnitTest/XUnitTest.csproj b/XUnitTest/XUnitTest.csproj new file mode 100644 index 0000000..a54c07f --- /dev/null +++ b/XUnitTest/XUnitTest.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/XUnitTest/test_times.csv b/XUnitTest/test_times.csv new file mode 100644 index 0000000..f79074b --- /dev/null +++ b/XUnitTest/test_times.csv @@ -0,0 +1,101 @@ +"Run","Time" +"1","1130" +"2","1128" +"3","1126" +"4","1143" +"5","1152" +"6","1154" +"7","1119" +"8","1120" +"9","1149" +"10","1146" +"11","1122" +"12","1105" +"13","1119" +"14","1109" +"15","1120" +"16","1116" +"17","1124" +"18","1119" +"19","1133" +"20","1133" +"21","1121" +"22","1128" +"23","1125" +"24","1121" +"25","1123" +"26","1112" +"27","1133" +"28","1119" +"29","1123" +"30","1107" +"31","1116" +"32","1108" +"33","1128" +"34","1119" +"35","1135" +"36","1113" +"37","1122" +"38","1118" +"39","1129" +"40","1127" +"41","1125" +"42","1111" +"43","1117" +"44","1132" +"45","1128" +"46","1128" +"47","1114" +"48","1143" +"49","1128" +"50","1126" +"51","1120" +"52","1120" +"53","1119" +"54","1123" +"55","1126" +"56","1107" +"57","1119" +"58","1121" +"59","1125" +"60","1123" +"61","1123" +"62","1124" +"63","1106" +"64","1125" +"65","1105" +"66","1128" +"67","1113" +"68","1114" +"69","1127" +"70","1129" +"71","1107" +"72","1111" +"73","1118" +"74","1121" +"75","1115" +"76","1122" +"77","1113" +"78","1111" +"79","1116" +"80","1115" +"81","1131" +"82","1117" +"83","1119" +"84","1131" +"85","1119" +"86","1114" +"87","1124" +"88","1104" +"89","1123" +"90","1119" +"91","1123" +"92","1115" +"93","1117" +"94","1113" +"95","1118" +"96","1131" +"97","1154" +"98","1162" +"99","1131" +"100","1128"