Skip to content

Commit 20eb185

Browse files
Improve job resilience, HTTP retry handling, and Telegram integration
1 parent 95e42f6 commit 20eb185

21 files changed

+892
-172
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<IsPackable>false</IsPackable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
12+
<PackageReference Include="xunit" Version="2.9.3" />
13+
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
14+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
15+
<PrivateAssets>all</PrivateAssets>
16+
</PackageReference>
17+
</ItemGroup>
18+
19+
<ItemGroup>
20+
<ProjectReference Include="..\AniWorldReminder_API\AniWorldReminder_API.csproj" />
21+
</ItemGroup>
22+
23+
</Project>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using AniWorldReminder_API.Models;
2+
using Xunit;
3+
4+
namespace AniWorldReminder_API.Tests.Models
5+
{
6+
public class AppSettingsModelTests
7+
{
8+
[Fact]
9+
public void AppSettingsModel_HasConservativeEpisodeReminderDelayDefaults()
10+
{
11+
AppSettingsModel settings = new();
12+
13+
Assert.Equal(1500, settings.EpisodeReminderSeriesDelayMs);
14+
Assert.Equal(500, settings.EpisodeReminderNotificationDelayMs);
15+
}
16+
}
17+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
using AniWorldReminder_API.Interfaces;
2+
using AniWorldReminder_API.Services;
3+
using Microsoft.Extensions.Logging.Abstractions;
4+
using System.Net;
5+
using Telegram.Bot.Types;
6+
using Telegram.Bot.Types.Enums;
7+
using Telegram.Bot.Types.ReplyMarkups;
8+
using Xunit;
9+
10+
namespace AniWorldReminder_API.Tests.Services
11+
{
12+
public class AdminHttpFailureNotificationHandlerTests
13+
{
14+
[Fact]
15+
public async Task SendAsync_SendsAdminTelegramMessage_OnTransientHttpFailureResponse()
16+
{
17+
using CurrentDirectoryScope _ = CurrentDirectoryScope.CreateWithSettingsFile(adminChat: "123456");
18+
FakeTelegramBotService telegramBotService = new();
19+
using HttpMessageInvoker invoker = CreateInvoker(
20+
telegramBotService,
21+
new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)));
22+
23+
HttpResponseMessage response = await invoker.SendAsync(
24+
new HttpRequestMessage(HttpMethod.Get, "https://example.com/episodes"),
25+
CancellationToken.None);
26+
27+
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
28+
Assert.Single(telegramBotService.Messages);
29+
Assert.Equal(123456L, telegramBotService.Messages[0].ChatId);
30+
Assert.Contains("HTTP request failed after all retries", telegramBotService.Messages[0].Text);
31+
Assert.Contains("GET https://example.com/episodes", telegramBotService.Messages[0].Text);
32+
Assert.Contains("HTTP 500 InternalServerError", telegramBotService.Messages[0].Text);
33+
}
34+
35+
[Fact]
36+
public async Task SendAsync_DoesNotSendAdminTelegramMessage_OnSuccessfulResponse()
37+
{
38+
using CurrentDirectoryScope _ = CurrentDirectoryScope.CreateWithSettingsFile(adminChat: "123456");
39+
FakeTelegramBotService telegramBotService = new();
40+
using HttpMessageInvoker invoker = CreateInvoker(
41+
telegramBotService,
42+
new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)));
43+
44+
HttpResponseMessage response = await invoker.SendAsync(
45+
new HttpRequestMessage(HttpMethod.Get, "https://example.com/episodes"),
46+
CancellationToken.None);
47+
48+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
49+
Assert.Empty(telegramBotService.Messages);
50+
}
51+
52+
[Fact]
53+
public async Task SendAsync_SendsAdminTelegramMessage_OnTransientHttpException()
54+
{
55+
using CurrentDirectoryScope _ = CurrentDirectoryScope.CreateWithSettingsFile(adminChat: "123456");
56+
FakeTelegramBotService telegramBotService = new();
57+
using HttpMessageInvoker invoker = CreateInvoker(
58+
telegramBotService,
59+
new StubHttpMessageHandler(_ => throw new HttpRequestException("network down")));
60+
61+
HttpRequestException exception = await Assert.ThrowsAsync<HttpRequestException>(() => invoker.SendAsync(
62+
new HttpRequestMessage(HttpMethod.Get, "https://example.com/episodes"),
63+
CancellationToken.None));
64+
65+
Assert.Equal("network down", exception.Message);
66+
Assert.Single(telegramBotService.Messages);
67+
Assert.Contains("network down", telegramBotService.Messages[0].Text);
68+
}
69+
70+
private static HttpMessageInvoker CreateInvoker(ITelegramBotService telegramBotService, HttpMessageHandler innerHandler)
71+
{
72+
AdminHttpFailureNotificationHandler handler = new(NullLogger<AdminHttpFailureNotificationHandler>.Instance, telegramBotService)
73+
{
74+
InnerHandler = innerHandler
75+
};
76+
77+
return new HttpMessageInvoker(handler);
78+
}
79+
80+
private sealed record SentTelegramMessage(long ChatId, string Text);
81+
82+
private sealed class FakeTelegramBotService : ITelegramBotService
83+
{
84+
public List<SentTelegramMessage> Messages { get; } = [];
85+
86+
public Task<bool> Init()
87+
{
88+
return Task.FromResult(true);
89+
}
90+
91+
public Task SendChatAction(long chatId, ChatAction chatAction)
92+
{
93+
return Task.CompletedTask;
94+
}
95+
96+
public Task<Message?> SendMessageAsync(long chatId, string text, int? replyId = null, bool showLinkPreview = true, ParseMode parseMode = ParseMode.Html, bool silentMessage = false, ReplyKeyboardMarkup? rkm = null)
97+
{
98+
Messages.Add(new SentTelegramMessage(chatId, text));
99+
return Task.FromResult<Message?>(null);
100+
}
101+
102+
public Task<Message?> SendPhotoAsync(long chatId, string photoUrl, string? text = null, ParseMode parseMode = ParseMode.Html, bool silentMessage = false)
103+
{
104+
return Task.FromResult<Message?>(null);
105+
}
106+
}
107+
108+
private sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory) : HttpMessageHandler
109+
{
110+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
111+
{
112+
return Task.FromResult(responseFactory(request));
113+
}
114+
}
115+
116+
private sealed class CurrentDirectoryScope : IDisposable
117+
{
118+
private readonly string originalDirectory;
119+
private readonly string tempDirectory;
120+
121+
private CurrentDirectoryScope(string originalDirectory, string tempDirectory)
122+
{
123+
this.originalDirectory = originalDirectory;
124+
this.tempDirectory = tempDirectory;
125+
}
126+
127+
public static CurrentDirectoryScope CreateWithSettingsFile(string adminChat)
128+
{
129+
string originalDirectory = Directory.GetCurrentDirectory();
130+
string tempDirectory = Path.Combine(Path.GetTempPath(), $"AniWorldReminder_API.Tests.{Guid.NewGuid():N}");
131+
Directory.CreateDirectory(tempDirectory);
132+
133+
string settingsJson =
134+
$$"""
135+
{
136+
"TelegramBot": {
137+
"Token": "test-token",
138+
"AdminChat": "{{adminChat}}"
139+
},
140+
"Database": {
141+
"IP": "127.0.0.1",
142+
"Database": "test",
143+
"Username": "test",
144+
"Password": "test"
145+
},
146+
"Proxy": {
147+
"URI": "",
148+
"Username": "",
149+
"Password": ""
150+
},
151+
"AppSettings": {
152+
"AddSwagger": false,
153+
"EnableEpisodeReminderJob": true,
154+
"EpisodeReminderCron": "*/15 * * * *",
155+
"EnableHangfireDashboard": false,
156+
"HangfireDashboardPath": "/hangfire",
157+
"EpisodeReminderSeriesDelayMs": 1500,
158+
"EpisodeReminderNotificationDelayMs": 500
159+
},
160+
"Jwt": {
161+
"Key": "test-key",
162+
"Issuer": "test-issuer"
163+
},
164+
"TMDB": {
165+
"AccessToken": "test-token"
166+
}
167+
}
168+
""";
169+
170+
File.WriteAllText(Path.Combine(tempDirectory, "settings.json"), settingsJson);
171+
Directory.SetCurrentDirectory(tempDirectory);
172+
173+
return new CurrentDirectoryScope(originalDirectory, tempDirectory);
174+
}
175+
176+
public void Dispose()
177+
{
178+
Directory.SetCurrentDirectory(originalDirectory);
179+
180+
if (Directory.Exists(tempDirectory))
181+
{
182+
Directory.Delete(tempDirectory, true);
183+
}
184+
}
185+
}
186+
}
187+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using AniWorldReminder_API.Interfaces;
2+
using AniWorldReminder_API.Models;
3+
using AniWorldReminder_API.Services;
4+
using Microsoft.Extensions.Logging.Abstractions;
5+
using Xunit;
6+
7+
namespace AniWorldReminder_API.Tests.Services
8+
{
9+
public class EpisodeReminderDelayServiceTests
10+
{
11+
[Fact]
12+
public async Task DelayAfterSeriesScanAsync_UsesDefaultDelay_WhenSettingsAreMissing()
13+
{
14+
FakeDelayExecutor delayExecutor = new();
15+
EpisodeReminderDelayService service = new(NullLogger<EpisodeReminderDelayService>.Instance, delayExecutor);
16+
17+
await service.DelayAfterSeriesScanAsync(null);
18+
19+
Assert.Single(delayExecutor.Delays);
20+
Assert.InRange(delayExecutor.Delays[0], TimeSpan.FromMilliseconds(1000), TimeSpan.FromMilliseconds(1500));
21+
}
22+
23+
[Fact]
24+
public async Task DelayAfterSeriesScanAsync_UsesDefaultDelay_WhenConfiguredDelayIsInvalid()
25+
{
26+
FakeDelayExecutor delayExecutor = new();
27+
EpisodeReminderDelayService service = new(NullLogger<EpisodeReminderDelayService>.Instance, delayExecutor);
28+
AppSettingsModel appSettings = new()
29+
{
30+
EpisodeReminderSeriesDelayMs = 0
31+
};
32+
33+
await service.DelayAfterSeriesScanAsync(appSettings);
34+
35+
Assert.Single(delayExecutor.Delays);
36+
Assert.InRange(delayExecutor.Delays[0], TimeSpan.FromMilliseconds(1000), TimeSpan.FromMilliseconds(1500));
37+
}
38+
39+
[Fact]
40+
public async Task DelayAfterNotificationAsync_UsesConfiguredDelay()
41+
{
42+
FakeDelayExecutor delayExecutor = new();
43+
EpisodeReminderDelayService service = new(NullLogger<EpisodeReminderDelayService>.Instance, delayExecutor);
44+
AppSettingsModel appSettings = new()
45+
{
46+
EpisodeReminderNotificationDelayMs = 750
47+
};
48+
49+
await service.DelayAfterNotificationAsync(appSettings);
50+
51+
Assert.Single(delayExecutor.Delays);
52+
Assert.Equal(TimeSpan.FromMilliseconds(750), delayExecutor.Delays[0]);
53+
}
54+
55+
[Fact]
56+
public async Task DelayAfterNotificationAsync_UsesConfiguredRange_WhenDelayExceedsMinimum()
57+
{
58+
FakeDelayExecutor delayExecutor = new();
59+
EpisodeReminderDelayService service = new(NullLogger<EpisodeReminderDelayService>.Instance, delayExecutor);
60+
AppSettingsModel appSettings = new()
61+
{
62+
EpisodeReminderNotificationDelayMs = 2000
63+
};
64+
65+
await service.DelayAfterNotificationAsync(appSettings);
66+
67+
Assert.Single(delayExecutor.Delays);
68+
Assert.InRange(delayExecutor.Delays[0], TimeSpan.FromMilliseconds(1000), TimeSpan.FromMilliseconds(2000));
69+
}
70+
71+
private sealed class FakeDelayExecutor : IDelayExecutor
72+
{
73+
public List<TimeSpan> Delays { get; } = [];
74+
75+
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default)
76+
{
77+
Delays.Add(delay);
78+
return Task.CompletedTask;
79+
}
80+
}
81+
}
82+
}

AniWorldReminder_API.sln

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,42 @@ VisualStudioVersion = 17.7.34024.191
55
MinimumVisualStudioVersion = 10.0.40219.1
66
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AniWorldReminder_API", "AniWorldReminder_API\AniWorldReminder_API.csproj", "{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}"
77
EndProject
8+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AniWorldReminder_API.Tests", "AniWorldReminder_API.Tests\AniWorldReminder_API.Tests.csproj", "{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}"
9+
EndProject
810
Global
911
GlobalSection(SolutionConfigurationPlatforms) = preSolution
1012
Debug|Any CPU = Debug|Any CPU
13+
Debug|x64 = Debug|x64
14+
Debug|x86 = Debug|x86
1115
Release|Any CPU = Release|Any CPU
16+
Release|x64 = Release|x64
17+
Release|x86 = Release|x86
1218
EndGlobalSection
1319
GlobalSection(ProjectConfigurationPlatforms) = postSolution
1420
{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
1521
{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}.Debug|Any CPU.Build.0 = Debug|Any CPU
22+
{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}.Debug|x64.ActiveCfg = Debug|Any CPU
23+
{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}.Debug|x64.Build.0 = Debug|Any CPU
24+
{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}.Debug|x86.ActiveCfg = Debug|Any CPU
25+
{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}.Debug|x86.Build.0 = Debug|Any CPU
1626
{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}.Release|Any CPU.ActiveCfg = Release|Any CPU
1727
{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}.Release|Any CPU.Build.0 = Release|Any CPU
28+
{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}.Release|x64.ActiveCfg = Release|Any CPU
29+
{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}.Release|x64.Build.0 = Release|Any CPU
30+
{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}.Release|x86.ActiveCfg = Release|Any CPU
31+
{2ED6ED35-82DE-4F83-A7E5-EAA30B07F68C}.Release|x86.Build.0 = Release|Any CPU
32+
{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
33+
{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}.Debug|Any CPU.Build.0 = Debug|Any CPU
34+
{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}.Debug|x64.ActiveCfg = Debug|Any CPU
35+
{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}.Debug|x64.Build.0 = Debug|Any CPU
36+
{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}.Debug|x86.ActiveCfg = Debug|Any CPU
37+
{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}.Debug|x86.Build.0 = Debug|Any CPU
38+
{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}.Release|Any CPU.ActiveCfg = Release|Any CPU
39+
{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}.Release|Any CPU.Build.0 = Release|Any CPU
40+
{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}.Release|x64.ActiveCfg = Release|Any CPU
41+
{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}.Release|x64.Build.0 = Release|Any CPU
42+
{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}.Release|x86.ActiveCfg = Release|Any CPU
43+
{F6800C11-CB84-4EB8-A8EA-4E7BDCE7383D}.Release|x86.Build.0 = Release|Any CPU
1844
EndGlobalSection
1945
GlobalSection(SolutionProperties) = preSolution
2046
HideSolutionNode = FALSE

AniWorldReminder_API/AniWorldReminder_API.csproj

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.23" />
1414
<PackageReference Include="Hangfire.MemoryStorage" Version="1.8.1.2" />
1515
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
16+
<PackageReference Include="MethodTimer.Fody" Version="3.2.3" />
1617
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
1718
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
19+
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.4.0" />
1820
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
1921
<PackageReference Include="MySql.Data" Version="9.6.0" />
20-
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
22+
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
2123
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
2224
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" />
2325
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" />

0 commit comments

Comments
 (0)