Skip to content

Commit c71d23c

Browse files
authored
Upcoming divs (#924)
* Expected dividends graph * cleanup * Attempt to generate predicted dividends * Adding some tests * 1 * optimize screen * SQ * Fixing no calculating predicted divs * Fixing tests * SQ * Fixing preditions * brackets
1 parent e3b3284 commit c71d23c

File tree

10 files changed

+1023
-36
lines changed

10 files changed

+1023
-36
lines changed
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
using AwesomeAssertions;
2+
using GhostfolioSidekick.ExternalDataProvider;
3+
using GhostfolioSidekick.MarketDataMaintainer;
4+
using GhostfolioSidekick.Model;
5+
using GhostfolioSidekick.Model.Market;
6+
using GhostfolioSidekick.Model.Symbols;
7+
using Moq.EntityFrameworkCore;
8+
9+
namespace GhostfolioSidekick.UnitTests.MarketDataMaintainer
10+
{
11+
public class GatherDividendsTaskTests
12+
{
13+
private readonly Mock<IDbContextFactory<DatabaseContext>> _mockDbContextFactory;
14+
private readonly Mock<IDividendRepository> _mockDividendRepository;
15+
private readonly GatherDividendsTask _task;
16+
17+
public GatherDividendsTaskTests()
18+
{
19+
_mockDbContextFactory = new Mock<IDbContextFactory<DatabaseContext>>();
20+
_mockDividendRepository = new Mock<IDividendRepository>();
21+
_task = new GatherDividendsTask(_mockDbContextFactory.Object, _mockDividendRepository.Object);
22+
}
23+
24+
[Fact]
25+
public void Priority_ShouldReturnMarketDataDividends()
26+
{
27+
_task.Priority.Should().Be(TaskPriority.MarketDataDividends);
28+
}
29+
30+
[Fact]
31+
public void ExecutionFrequency_ShouldReturnHourly()
32+
{
33+
_task.ExecutionFrequency.Should().Be(TimeSpan.FromHours(1));
34+
}
35+
36+
[Fact]
37+
public void ExceptionsAreFatal_ShouldReturnFalse()
38+
{
39+
_task.ExceptionsAreFatal.Should().BeFalse();
40+
}
41+
42+
[Fact]
43+
public void Name_ShouldReturnCorrectName()
44+
{
45+
_task.Name.Should().Be("Gather Dividends Task");
46+
}
47+
48+
[Fact]
49+
public async Task DoWork_WhenSymbolIsNotSupported_ShouldNotCallGetDividends()
50+
{
51+
// Arrange
52+
var symbolProfile = BuildSymbolProfile("AAPL");
53+
var (_, loggerMock) = SetupDbContext([symbolProfile]);
54+
_mockDividendRepository.Setup(r => r.IsSymbolSupported(It.IsAny<SymbolProfile>())).ReturnsAsync(false);
55+
56+
// Act
57+
await _task.DoWork(loggerMock.Object);
58+
59+
// Assert
60+
_mockDividendRepository.Verify(r => r.GetDividends(It.IsAny<SymbolProfile>()), Times.Never);
61+
}
62+
63+
[Fact]
64+
public async Task DoWork_WhenSymbolIsNotSupported_ShouldLeaveExistingDividendsUnchanged()
65+
{
66+
// Arrange
67+
var existing = BuildDividend();
68+
var symbolProfile = BuildSymbolProfile("AAPL", [existing]);
69+
var (_, loggerMock) = SetupDbContext([symbolProfile]);
70+
_mockDividendRepository.Setup(r => r.IsSymbolSupported(It.IsAny<SymbolProfile>())).ReturnsAsync(false);
71+
72+
// Act
73+
await _task.DoWork(loggerMock.Object);
74+
75+
// Assert: dividends are untouched when the symbol is skipped
76+
symbolProfile.Dividends.Should().HaveCount(1);
77+
}
78+
79+
[Fact]
80+
public async Task DoWork_WhenNewDividendIsGathered_ShouldAddItToSymbol()
81+
{
82+
// Arrange
83+
var symbolProfile = BuildSymbolProfile("AAPL");
84+
var (_, loggerMock) = SetupDbContext([symbolProfile]);
85+
var newDividend = BuildDividend();
86+
SetupSupportedSymbol("AAPL", [newDividend]);
87+
88+
// Act
89+
await _task.DoWork(loggerMock.Object);
90+
91+
// Assert
92+
symbolProfile.Dividends.Should().HaveCount(1).And.Contain(newDividend);
93+
}
94+
95+
[Fact]
96+
public async Task DoWork_WhenGatheredDividendMatchesExistingKey_ShouldUpdateAmountInPlace()
97+
{
98+
// Arrange: same (ExDividendDate, PaymentDate, DividendType, DividendState) key, different amount
99+
var existing = BuildDividend(amount: 1.00m);
100+
var symbolProfile = BuildSymbolProfile("AAPL", [existing]);
101+
var (_, loggerMock) = SetupDbContext([symbolProfile]);
102+
SetupSupportedSymbol("AAPL", [BuildDividend(amount: 1.75m)]);
103+
104+
// Act
105+
await _task.DoWork(loggerMock.Object);
106+
107+
// Assert: count stays at 1, amount is updated
108+
symbolProfile.Dividends.Should().HaveCount(1);
109+
symbolProfile.Dividends.Single().Amount.Amount.Should().Be(1.75m);
110+
}
111+
112+
[Fact]
113+
public async Task DoWork_WhenExistingPaidDividendIsAbsentFromGathered_ShouldRemoveIt()
114+
{
115+
// Arrange
116+
var stale = BuildDividend(state: DividendState.Paid);
117+
var symbolProfile = BuildSymbolProfile("AAPL", [stale]);
118+
var (_, loggerMock) = SetupDbContext([symbolProfile]);
119+
SetupSupportedSymbol("AAPL", []);
120+
121+
// Act
122+
await _task.DoWork(loggerMock.Object);
123+
124+
// Assert: stale non-predicted dividend is removed
125+
symbolProfile.Dividends.Should().BeEmpty();
126+
}
127+
128+
[Fact]
129+
public async Task DoWork_WhenExistingPredictedDividendIsAbsentFromGathered_ShouldPreserveIt()
130+
{
131+
// Arrange
132+
var predicted = BuildDividend(state: DividendState.Predicted);
133+
var symbolProfile = BuildSymbolProfile("AAPL", [predicted]);
134+
var (_, loggerMock) = SetupDbContext([symbolProfile]);
135+
SetupSupportedSymbol("AAPL", []);
136+
137+
// Act
138+
await _task.DoWork(loggerMock.Object);
139+
140+
// Assert: Predicted dividends are never removed regardless of gathered data
141+
symbolProfile.Dividends.Should().HaveCount(1);
142+
}
143+
144+
[Fact]
145+
public async Task DoWork_WhenMultipleSymbolsExist_ShouldOnlyProcessSupportedOnes()
146+
{
147+
// Arrange
148+
var symbolAapl = BuildSymbolProfile("AAPL");
149+
var symbolMsft = BuildSymbolProfile("MSFT");
150+
var (_, loggerMock) = SetupDbContext([symbolAapl, symbolMsft]);
151+
152+
_mockDividendRepository
153+
.Setup(r => r.IsSymbolSupported(It.Is<SymbolProfile>(s => s.Symbol == "AAPL")))
154+
.ReturnsAsync(true);
155+
_mockDividendRepository
156+
.Setup(r => r.IsSymbolSupported(It.Is<SymbolProfile>(s => s.Symbol == "MSFT")))
157+
.ReturnsAsync(false);
158+
_mockDividendRepository
159+
.Setup(r => r.GetDividends(It.Is<SymbolProfile>(s => s.Symbol == "AAPL")))
160+
.ReturnsAsync((IList<Dividend>)[BuildDividend()]);
161+
162+
// Act
163+
await _task.DoWork(loggerMock.Object);
164+
165+
// Assert
166+
symbolAapl.Dividends.Should().HaveCount(1);
167+
symbolMsft.Dividends.Should().BeEmpty();
168+
_mockDividendRepository.Verify(r => r.GetDividends(It.Is<SymbolProfile>(s => s.Symbol == "MSFT")), Times.Never);
169+
}
170+
171+
[Fact]
172+
public async Task DoWork_ShouldSaveChangesExactlyOnceAfterAllSymbolsAreProcessed()
173+
{
174+
// Arrange
175+
var symbolProfile = BuildSymbolProfile("AAPL");
176+
var (dbContextMock, loggerMock) = SetupDbContext([symbolProfile]);
177+
SetupSupportedSymbol("AAPL", []);
178+
179+
// Act
180+
await _task.DoWork(loggerMock.Object);
181+
182+
// Assert
183+
dbContextMock.Verify(db => db.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
184+
}
185+
186+
[Fact]
187+
public async Task DoWork_WhenDividendsAreProcessed_ShouldLogUpsertedCount()
188+
{
189+
// Arrange
190+
var symbolProfile = BuildSymbolProfile("AAPL");
191+
var (_, loggerMock) = SetupDbContext([symbolProfile]);
192+
var gathered = new List<Dividend>
193+
{
194+
BuildDividend(),
195+
BuildDividend(exDividendDate: new DateOnly(2024, 4, 1), paymentDate: new DateOnly(2024, 4, 15)),
196+
};
197+
SetupSupportedSymbol("AAPL", gathered);
198+
199+
// Act
200+
await _task.DoWork(loggerMock.Object);
201+
202+
// Assert
203+
loggerMock.Verify(
204+
x => x.Log(
205+
LogLevel.Debug,
206+
It.IsAny<EventId>(),
207+
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Upserted 2 dividends for symbol AAPL")),
208+
It.IsAny<Exception>(),
209+
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
210+
Times.Once);
211+
}
212+
213+
private (Mock<DatabaseContext> Context, Mock<ILogger> Logger) SetupDbContext(List<SymbolProfile> symbolProfiles)
214+
{
215+
var mockDbContext = new Mock<DatabaseContext>();
216+
mockDbContext.Setup(db => db.SymbolProfiles).ReturnsDbSet(symbolProfiles);
217+
mockDbContext.Setup(db => db.Dividends).ReturnsDbSet(new List<Dividend>());
218+
mockDbContext.Setup(db => db.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(0);
219+
220+
_mockDbContextFactory
221+
.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
222+
.ReturnsAsync(mockDbContext.Object);
223+
224+
return (mockDbContext, new Mock<ILogger>());
225+
}
226+
227+
private void SetupSupportedSymbol(string symbol, IList<Dividend> dividends)
228+
{
229+
_mockDividendRepository
230+
.Setup(r => r.IsSymbolSupported(It.Is<SymbolProfile>(s => s.Symbol == symbol)))
231+
.ReturnsAsync(true);
232+
_mockDividendRepository
233+
.Setup(r => r.GetDividends(It.Is<SymbolProfile>(s => s.Symbol == symbol)))
234+
.ReturnsAsync(dividends);
235+
}
236+
237+
private static SymbolProfile BuildSymbolProfile(string symbol, List<Dividend>? dividends = null)
238+
{
239+
return new SymbolProfile
240+
{
241+
Symbol = symbol,
242+
DataSource = "YAHOO",
243+
Dividends = dividends ?? new List<Dividend>()
244+
};
245+
}
246+
247+
private static Dividend BuildDividend(
248+
DateOnly? exDividendDate = null,
249+
DateOnly? paymentDate = null,
250+
DividendType type = DividendType.Cash,
251+
DividendState state = DividendState.Paid,
252+
decimal amount = 1.0m)
253+
{
254+
return new Dividend
255+
{
256+
ExDividendDate = exDividendDate ?? new DateOnly(2024, 1, 1),
257+
PaymentDate = paymentDate ?? new DateOnly(2024, 1, 15),
258+
DividendType = type,
259+
DividendState = state,
260+
Amount = new Money(Currency.USD, amount)
261+
};
262+
}
263+
}
264+
}

0 commit comments

Comments
 (0)