Refactor LibraryDebugCollector and TelegramApi for improved data handling and clarity
Updated the LibraryDebugCollector to introduce a new CompositionEntry record for better organization of group composition data. Refactored the composition logic to utilize this new structure, enhancing readability and maintainability. In the TelegramApi, replaced inline allowed_updates array with a static readonly field to reduce redundancy and improve clarity in API call configurations. Adjusted related tests to ensure proper handling of message assertions, enhancing overall test reliability.
This commit is contained in:
+35
-14
@@ -430,6 +430,24 @@ public sealed class LibraryDebugCollector(
|
|||||||
/// <summary>Группа в дампе: готовые факты и числа, по которым сверяется её кэш статистики.</summary>
|
/// <summary>Группа в дампе: готовые факты и числа, по которым сверяется её кэш статистики.</summary>
|
||||||
private sealed record GroupSnapshot(Group Group, object Facts, int Units, int PlayableUnits);
|
private sealed record GroupSnapshot(Group Group, object Facts, int Units, int PlayableUnits);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строка вычисленного состава группы. Именованная запись, а не анонимный тип: сведения об
|
||||||
|
/// элементе берутся один раз, а не по разу на каждое поле.
|
||||||
|
/// </summary>
|
||||||
|
private sealed record CompositionEntry(
|
||||||
|
GroupElementKind Kind,
|
||||||
|
Guid Id,
|
||||||
|
string? Name,
|
||||||
|
int Position,
|
||||||
|
int Weight,
|
||||||
|
bool Pinned,
|
||||||
|
int? UnitCount,
|
||||||
|
int PlayableUnits,
|
||||||
|
double? Minutes,
|
||||||
|
ShowKind? ShowKind,
|
||||||
|
ShowAudience? Audience
|
||||||
|
);
|
||||||
|
|
||||||
private async Task<IReadOnlyList<GroupSnapshot>> GroupSnapshotsAsync(
|
private async Task<IReadOnlyList<GroupSnapshot>> GroupSnapshotsAsync(
|
||||||
IReadOnlyList<Group> groups,
|
IReadOnlyList<Group> groups,
|
||||||
IReadOnlyDictionary<Guid, string> showNames,
|
IReadOnlyDictionary<Guid, string> showNames,
|
||||||
@@ -486,21 +504,24 @@ public sealed class LibraryDebugCollector(
|
|||||||
item.Weight,
|
item.Weight,
|
||||||
item.Role,
|
item.Role,
|
||||||
}),
|
}),
|
||||||
Composition = resolved.Select(element => new
|
Composition = resolved.Select(element =>
|
||||||
{
|
{
|
||||||
element.Kind,
|
var resolvedInfo = info.GetValueOrDefault((element.Kind, element.Id));
|
||||||
element.Id,
|
return new CompositionEntry(
|
||||||
Name = Name(element.Kind, element.Id),
|
element.Kind,
|
||||||
element.Position,
|
element.Id,
|
||||||
element.Weight,
|
Name(element.Kind, element.Id),
|
||||||
element.Pinned,
|
element.Position,
|
||||||
UnitCount = info.GetValueOrDefault((element.Kind, element.Id))?.UnitCount,
|
element.Weight,
|
||||||
PlayableUnits = PlayableOf(element.Kind, element.Id),
|
element.Pinned,
|
||||||
Minutes = info.GetValueOrDefault((element.Kind, element.Id)) is { } resolvedInfo
|
resolvedInfo?.UnitCount,
|
||||||
? Math.Round(resolvedInfo.TotalDuration.TotalMinutes, 1)
|
PlayableOf(element.Kind, element.Id),
|
||||||
: (double?)null,
|
resolvedInfo is null
|
||||||
ShowKind = info.GetValueOrDefault((element.Kind, element.Id))?.ShowKind,
|
? null
|
||||||
Audience = info.GetValueOrDefault((element.Kind, element.Id))?.Audience,
|
: Math.Round(resolvedInfo.TotalDuration.TotalMinutes, 1),
|
||||||
|
resolvedInfo?.ShowKind,
|
||||||
|
resolvedInfo?.Audience
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ public sealed class TelegramApi(ILogger<TelegramApi> logger) : ITelegramApi
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private const int LongPollSeconds = 25;
|
private const int LongPollSeconds = 25;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Что просим у Telegram. Разговор ведут только команды и нажатия кнопок; всё остальное
|
||||||
|
/// (правки сообщений, участники чата) боту не нужно, а лишний тип обновления — лишний трафик.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] AllowedUpdates = ["message", "callback_query"];
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions Json = new()
|
private static readonly JsonSerializerOptions Json = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
@@ -51,7 +57,7 @@ public sealed class TelegramApi(ILogger<TelegramApi> logger) : ITelegramApi
|
|||||||
{
|
{
|
||||||
offset,
|
offset,
|
||||||
timeout = LongPollSeconds,
|
timeout = LongPollSeconds,
|
||||||
allowed_updates = new[] { "message", "callback_query" },
|
allowed_updates = AllowedUpdates,
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await CallAsync<UpdatesResult>(
|
var result = await CallAsync<UpdatesResult>(
|
||||||
@@ -178,7 +184,7 @@ public sealed class TelegramApi(ILogger<TelegramApi> logger) : ITelegramApi
|
|||||||
{
|
{
|
||||||
url = settings.WebhookUrl,
|
url = settings.WebhookUrl,
|
||||||
secret_token = settings.WebhookSecret,
|
secret_token = settings.WebhookSecret,
|
||||||
allowed_updates = new[] { "message", "callback_query" },
|
allowed_updates = AllowedUpdates,
|
||||||
},
|
},
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Notifications.Link;
|
||||||
|
using TeleWave.Application.Notifications.Settings;
|
||||||
|
using TeleWave.Application.Notifications.Subscribers;
|
||||||
|
using TeleWave.Application.Tests.Support;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Tests.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Что видит и делает админ вокруг бота: выдача ссылки зрителю и список подписчиков. Ссылка —
|
||||||
|
/// единственная дверь в подписку, поэтому проверяем, что старые коды при выдаче новой гаснут.
|
||||||
|
/// </summary>
|
||||||
|
public class TelegramAdminTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset T0 = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Link_BurnsPreviousCodesOfTheSameUser()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var stale = TelegramLinkCode.Issue("old", userId, T0);
|
||||||
|
var foreign = TelegramLinkCode.Issue("alien", Guid.NewGuid(), T0);
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.TelegramSettings.Add(Configured());
|
||||||
|
seed.TelegramLinkCodes.AddRange(stale, foreign);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await new IssueTelegramLinkCommandHandler(
|
||||||
|
db,
|
||||||
|
new TelegramSettingsService(db),
|
||||||
|
CurrentUser(userId)
|
||||||
|
).Handle(new IssueTelegramLinkCommand(), CancellationToken.None);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.StartsWith("https://t.me/telewave_bot?start=", result.Value.Url);
|
||||||
|
|
||||||
|
await using var check = fixture.New();
|
||||||
|
var codes = check.TelegramLinkCodes.ToList();
|
||||||
|
// Своя старая ссылка гасится — две живые двери в один аккаунт не нужны; чужая не трогается.
|
||||||
|
Assert.NotNull(codes.Single(c => c.Code == "old").UsedAt);
|
||||||
|
Assert.Null(codes.Single(c => c.Code == "alien").UsedAt);
|
||||||
|
var issued = codes.Single(c => c.UserId == userId && c.UsedAt is null);
|
||||||
|
Assert.EndsWith(issued.Code, result.Value.Url);
|
||||||
|
Assert.Equal(TelegramLinkCode.Lifetime, issued.ExpiresAt - issued.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Link_WithoutConfiguredBot_IsRefused()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await new IssueTelegramLinkCommandHandler(
|
||||||
|
db,
|
||||||
|
new TelegramSettingsService(db),
|
||||||
|
CurrentUser(Guid.NewGuid())
|
||||||
|
).Handle(new IssueTelegramLinkCommand(), CancellationToken.None);
|
||||||
|
|
||||||
|
// Ссылку не из чего собрать: имя бота приходит только после успешной проверки токена.
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal("Telegram.NotConfigured", result.Error.Code);
|
||||||
|
Assert.Empty(db.TelegramLinkCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Link_WithoutSignedInViewer_IsRefused()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.TelegramSettings.Add(Configured());
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var anonymous = Substitute.For<ICurrentUser>();
|
||||||
|
anonymous.UserId.Returns((Guid?)null);
|
||||||
|
|
||||||
|
var result = await new IssueTelegramLinkCommandHandler(
|
||||||
|
db,
|
||||||
|
new TelegramSettingsService(db),
|
||||||
|
anonymous
|
||||||
|
).Handle(new IssueTelegramLinkCommand(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Empty(db.TelegramLinkCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Subscribers_ShowChannelsAndUserNamesNewestFirst()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channel = Channel.Create("Мультреалити", "mult", T0);
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var active = TelegramSubscriber.Create(100, userId, "viewer", T0);
|
||||||
|
active.Touch(T0.AddHours(2));
|
||||||
|
active.Toggle(channel.Id, TelegramNotificationKind.ShowStart);
|
||||||
|
active.Toggle(channel.Id, TelegramNotificationKind.UpNext);
|
||||||
|
// Канал удалили, а подписка осталась висеть — это должно быть видно, а не пропасть.
|
||||||
|
active.Toggle(Guid.NewGuid(), TelegramNotificationKind.ShowStart);
|
||||||
|
|
||||||
|
var older = TelegramSubscriber.Create(101, Guid.NewGuid(), "gone", T0.AddHours(-1));
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.Channels.Add(channel);
|
||||||
|
seed.TelegramSubscribers.AddRange(active, older);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = Substitute.For<IIdentityService>();
|
||||||
|
identity
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new CurrentUserProfile(userId, "leonid", "user", false));
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await new ListTelegramSubscribersQueryHandler(db, identity).Handle(
|
||||||
|
new ListTelegramSubscribersQuery(),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
var subscribers = result.Value;
|
||||||
|
Assert.Equal([100L, 101L], subscribers.Select(s => s.ChatId).ToArray());
|
||||||
|
Assert.Equal("leonid", subscribers[0].UserName);
|
||||||
|
// Учётку без профиля показываем как есть: чат жив, а пользователя уже нет.
|
||||||
|
Assert.Null(subscribers[1].UserName);
|
||||||
|
|
||||||
|
var live = subscribers[0].Channels.Single(c => c.ChannelId == channel.Id);
|
||||||
|
Assert.Equal("Мультреалити", live.ChannelName);
|
||||||
|
Assert.Equal(["ShowStart", "UpNext"], live.Kinds);
|
||||||
|
Assert.Equal(
|
||||||
|
"—",
|
||||||
|
subscribers[0].Channels.Single(c => c.ChannelId != channel.Id).ChannelName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TelegramSettings Configured()
|
||||||
|
{
|
||||||
|
var settings = TelegramSettings.CreateDefault();
|
||||||
|
settings.Update(
|
||||||
|
new TelegramSettingsUpdate(
|
||||||
|
true,
|
||||||
|
TelegramTransport.Polling,
|
||||||
|
"123:ABC",
|
||||||
|
null,
|
||||||
|
TelegramProxyKind.None,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
settings.SetBotUsername("telewave_bot");
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ICurrentUser CurrentUser(Guid userId)
|
||||||
|
{
|
||||||
|
var currentUser = Substitute.For<ICurrentUser>();
|
||||||
|
currentUser.UserId.Returns(userId);
|
||||||
|
return currentUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,7 +77,9 @@ public class TelegramBotTests
|
|||||||
.SendMessageAsync(
|
.SendMessageAsync(
|
||||||
Arg.Any<TelegramSettings>(),
|
Arg.Any<TelegramSettings>(),
|
||||||
100,
|
100,
|
||||||
Arg.Is<string>(s => s.Contains("код")),
|
// Предикат NSubstitute принимает аргумент как допускающий null — само сообщение
|
||||||
|
// объявлено обязательным, потому и разыменовываем без проверки.
|
||||||
|
Arg.Is<string>(s => s!.Contains("код")),
|
||||||
null,
|
null,
|
||||||
Arg.Any<CancellationToken>()
|
Arg.Any<CancellationToken>()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -136,7 +136,9 @@ public class TelegramNotifierTests
|
|||||||
.SendMessageAsync(
|
.SendMessageAsync(
|
||||||
Arg.Any<TelegramSettings>(),
|
Arg.Any<TelegramSettings>(),
|
||||||
100,
|
100,
|
||||||
Arg.Is<string>(s => s.Contains("Футурама")),
|
// Предикат NSubstitute принимает аргумент как допускающий null — само сообщение
|
||||||
|
// объявлено обязательным, потому и разыменовываем без проверки.
|
||||||
|
Arg.Is<string>(s => s!.Contains("Футурама")),
|
||||||
null,
|
null,
|
||||||
Arg.Any<CancellationToken>()
|
Arg.Any<CancellationToken>()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Notifications;
|
||||||
|
using TeleWave.Application.Notifications.Settings;
|
||||||
|
using TeleWave.Application.Tests.Support;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
using TeleWave.Infrastructure.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Tests.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Настройки бота: сохранение проверяет связь делом и приводит режим доставки в соответствие.
|
||||||
|
/// Главное свойство — админка никогда не видит секретов, только признак «задан».
|
||||||
|
/// </summary>
|
||||||
|
public class TelegramSettingsTests
|
||||||
|
{
|
||||||
|
private const string Token = "123:ABC";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_WithPolling_RemovesWebhookAndRemembersBotName()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var api = Api("telewave_bot");
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await Handler(db, api).Handle(Command(token: Token), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("telewave_bot", result.Value.BotUsername);
|
||||||
|
Assert.Equal("https://t.me/telewave_bot", result.Value.BotLink);
|
||||||
|
Assert.True(result.Value.HasToken);
|
||||||
|
Assert.NotNull(result.Value.LastContactAt);
|
||||||
|
Assert.Null(result.Value.LastError);
|
||||||
|
// Пока стоит вебхук, Telegram не отдаёт обновления опросом — снимаем его явно.
|
||||||
|
await api.Received(1)
|
||||||
|
.DeleteWebhookAsync(Arg.Any<TelegramSettings>(), Arg.Any<CancellationToken>());
|
||||||
|
await api.DidNotReceive()
|
||||||
|
.SetWebhookAsync(Arg.Any<TelegramSettings>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_WithWebhook_GeneratesSecretOnce()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var api = Api("telewave_bot");
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var handler = Handler(db, api);
|
||||||
|
var command = Command(
|
||||||
|
token: Token,
|
||||||
|
transport: TelegramTransport.Webhook,
|
||||||
|
webhookUrl: "https://tv.example.com/api/telegram/webhook"
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True((await handler.Handle(command, CancellationToken.None)).IsSuccess);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
var secret = db.TelegramSettings.Single().WebhookSecret;
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(secret));
|
||||||
|
|
||||||
|
// Повторное сохранение секрет не меняет: он уже стоит на стороне Telegram, и смена
|
||||||
|
// на каждое нажатие «Сохранить» рвала бы приём обновлений до следующего setWebhook.
|
||||||
|
Assert.True((await handler.Handle(command, CancellationToken.None)).IsSuccess);
|
||||||
|
Assert.Equal(secret, db.TelegramSettings.Single().WebhookSecret);
|
||||||
|
await api.Received(2)
|
||||||
|
.SetWebhookAsync(Arg.Any<TelegramSettings>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_WhenTelegramFails_KeepsWhatAdminTypedAndReportsWhy()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var api = Substitute.For<ITelegramApi>();
|
||||||
|
api.GetMeAsync(Arg.Any<TelegramSettings>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns<TelegramBotInfo?>(_ => throw new InvalidOperationException("Unauthorized"));
|
||||||
|
|
||||||
|
await using (var db = fixture.New())
|
||||||
|
{
|
||||||
|
var result = await Handler(db, api)
|
||||||
|
.Handle(Command(token: Token, proxyHost: "127.0.0.1"), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal("Telegram.Unreachable", result.Error.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настройки при этом сохранены: иначе админ набирал бы форму заново из-за одной опечатки.
|
||||||
|
await using var check = fixture.New();
|
||||||
|
var stored = check.TelegramSettings.Single();
|
||||||
|
Assert.Equal("127.0.0.1", stored.ProxyHost);
|
||||||
|
Assert.Equal("Unauthorized", stored.LastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_WithoutToken_DoesNotEnableTheBot()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var api = Api("telewave_bot");
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await Handler(db, api).Handle(Command(token: null), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal("Telegram.NotConfigured", result.Error.Code);
|
||||||
|
await api.DidNotReceive()
|
||||||
|
.GetMeAsync(Arg.Any<TelegramSettings>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(
|
||||||
|
TelegramTransport.Webhook,
|
||||||
|
null,
|
||||||
|
TelegramProxyKind.None,
|
||||||
|
null,
|
||||||
|
"Telegram.WebhookUrlRequired"
|
||||||
|
)]
|
||||||
|
[InlineData(
|
||||||
|
TelegramTransport.Polling,
|
||||||
|
null,
|
||||||
|
TelegramProxyKind.Socks5,
|
||||||
|
null,
|
||||||
|
"Telegram.ProxyHostRequired"
|
||||||
|
)]
|
||||||
|
public async Task Update_WithHalfFilledMode_IsRejectedBeforeAnyCall(
|
||||||
|
TelegramTransport transport,
|
||||||
|
string? webhookUrl,
|
||||||
|
TelegramProxyKind proxyKind,
|
||||||
|
string? proxyHost,
|
||||||
|
string expected
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var api = Api("telewave_bot");
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await Handler(db, api)
|
||||||
|
.Handle(
|
||||||
|
Command(
|
||||||
|
token: Token,
|
||||||
|
transport: transport,
|
||||||
|
webhookUrl: webhookUrl,
|
||||||
|
proxyKind: proxyKind,
|
||||||
|
proxyHost: proxyHost
|
||||||
|
),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(expected, result.Error.Code);
|
||||||
|
await api.DidNotReceive()
|
||||||
|
.GetMeAsync(Arg.Any<TelegramSettings>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_WhileDisabled_SavesWithoutTouchingTelegram()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var api = Api("telewave_bot");
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await Handler(db, api)
|
||||||
|
.Handle(Command(token: Token, isEnabled: false), CancellationToken.None);
|
||||||
|
|
||||||
|
// Выключенный бот в сеть не ходит: проверять нечего, а лишний запрос через прокси
|
||||||
|
// ждал бы таймаута на каждом сохранении.
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(result.Value.IsEnabled);
|
||||||
|
Assert.Null(result.Value.BotLink);
|
||||||
|
await api.DidNotReceive()
|
||||||
|
.GetMeAsync(Arg.Any<TelegramSettings>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_WithoutRow_ReturnsDefaultsAndCreatesNothing()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await new GetTelegramSettingsQueryHandler(
|
||||||
|
new TelegramSettingsService(db)
|
||||||
|
).Handle(new GetTelegramSettingsQuery(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(result.Value.IsEnabled);
|
||||||
|
Assert.False(result.Value.HasToken);
|
||||||
|
Assert.Equal(TelegramTransport.Polling, result.Value.Transport);
|
||||||
|
Assert.Equal(0, result.Value.Subscribers);
|
||||||
|
// Чтение не должно заводить строку: GET в базу не пишет.
|
||||||
|
Assert.Empty(db.TelegramSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_CountsLiveSubscribersAndHidesSecrets()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var settings = TelegramSettings.CreateDefault();
|
||||||
|
settings.Update(
|
||||||
|
new TelegramSettingsUpdate(
|
||||||
|
true,
|
||||||
|
TelegramTransport.Polling,
|
||||||
|
Token,
|
||||||
|
null,
|
||||||
|
TelegramProxyKind.Socks5,
|
||||||
|
"127.0.0.1",
|
||||||
|
1080,
|
||||||
|
"user",
|
||||||
|
"secret"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
settings.SetBotUsername("telewave_bot");
|
||||||
|
|
||||||
|
var live = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", DateTimeOffset.UtcNow);
|
||||||
|
var stopped = TelegramSubscriber.Create(101, Guid.NewGuid(), "gone", DateTimeOffset.UtcNow);
|
||||||
|
stopped.Stop();
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.TelegramSettings.Add(settings);
|
||||||
|
seed.TelegramSubscribers.AddRange(live, stopped);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await new GetTelegramSettingsQueryHandler(
|
||||||
|
new TelegramSettingsService(db)
|
||||||
|
).Handle(new GetTelegramSettingsQuery(), CancellationToken.None);
|
||||||
|
|
||||||
|
var dto = result.Value;
|
||||||
|
// Ушедший в «стоп» в счётчик не идёт: админ смотрит, скольким реально уходит рассылка.
|
||||||
|
Assert.Equal(1, dto.Subscribers);
|
||||||
|
Assert.True(dto.HasToken);
|
||||||
|
Assert.True(dto.HasProxyPassword);
|
||||||
|
Assert.Equal("user", dto.ProxyUsername);
|
||||||
|
Assert.Equal(1080, dto.ProxyPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BotLink_NeedsBothNameAndEnabledFlag()
|
||||||
|
{
|
||||||
|
var settings = TelegramSettings.CreateDefault();
|
||||||
|
Assert.Null(TelegramSettingsService.BotLink(settings));
|
||||||
|
|
||||||
|
settings.SetBotUsername("telewave_bot");
|
||||||
|
// Имя есть, но бот выключен — кнопки у зрителя быть не должно.
|
||||||
|
Assert.Null(TelegramSettingsService.BotLink(settings));
|
||||||
|
|
||||||
|
settings.Update(
|
||||||
|
new TelegramSettingsUpdate(
|
||||||
|
true,
|
||||||
|
TelegramTransport.Polling,
|
||||||
|
Token,
|
||||||
|
null,
|
||||||
|
TelegramProxyKind.None,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Assert.Equal("https://t.me/telewave_bot", TelegramSettingsService.BotLink(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ITelegramApi Api(string username)
|
||||||
|
{
|
||||||
|
var api = Substitute.For<ITelegramApi>();
|
||||||
|
api.GetMeAsync(Arg.Any<TelegramSettings>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new TelegramBotInfo(1, username));
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UpdateTelegramSettingsCommandHandler Handler(
|
||||||
|
AppDbContext db,
|
||||||
|
ITelegramApi api
|
||||||
|
) => new(db, new TelegramSettingsService(db), api);
|
||||||
|
|
||||||
|
private static UpdateTelegramSettingsCommand Command(
|
||||||
|
string? token,
|
||||||
|
bool isEnabled = true,
|
||||||
|
TelegramTransport transport = TelegramTransport.Polling,
|
||||||
|
string? webhookUrl = null,
|
||||||
|
TelegramProxyKind proxyKind = TelegramProxyKind.None,
|
||||||
|
string? proxyHost = null
|
||||||
|
) =>
|
||||||
|
new(
|
||||||
|
isEnabled,
|
||||||
|
transport,
|
||||||
|
token,
|
||||||
|
webhookUrl,
|
||||||
|
proxyKind,
|
||||||
|
proxyHost,
|
||||||
|
proxyHost is null ? null : 1080,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using TeleWave.Application.Broadcast.Scheduling;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Programming.Groups;
|
||||||
|
using TeleWave.Application.Programming.Planning;
|
||||||
|
using TeleWave.Application.Programming.Planning.DebugExport;
|
||||||
|
using TeleWave.Application.Programming.Templates;
|
||||||
|
using TeleWave.Application.Streaming;
|
||||||
|
using TeleWave.Application.Tests.Support;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Domain.Broadcast.Scheduling;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
|
using TeleWave.Domain.Programming;
|
||||||
|
using TeleWave.Domain.Programming.Planning;
|
||||||
|
using TeleWave.Infrastructure.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Tests.Programming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отладочный дамп канала. Его собирают, когда эфир выглядит не так, как ждали, — значит он сам
|
||||||
|
/// обязан работать при любой библиотеке, включая сломанную: упавший ассет, серия без файла,
|
||||||
|
/// пустая группа. Ровно это здесь и проверяется, вместе с самодиагностикой в issues.json.
|
||||||
|
/// </summary>
|
||||||
|
public class DebugExportTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset T0 = new(2026, 4, 6, 6, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
private sealed class FirstAlways : IRandomSource
|
||||||
|
{
|
||||||
|
public int Next(int maxExclusive) => 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Export_PacksChannelAndLibraryIntoOneArchive()
|
||||||
|
{
|
||||||
|
var fixture = await SeedAsync();
|
||||||
|
var store = Substitute.For<IDebugExportStore>();
|
||||||
|
store
|
||||||
|
.SaveAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns("/debug/dump.zip");
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var channelId = db.Channels.Single().Id;
|
||||||
|
var result = await Handler(db, store)
|
||||||
|
.Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.StartsWith("telewave-debug-one-", result.Value.FileName);
|
||||||
|
Assert.Equal("/debug/dump.zip", result.Value.SavedTo);
|
||||||
|
|
||||||
|
var files = Unpack(result.Value.Content);
|
||||||
|
// Дамп отвечает на два разных вопроса: «что построилось» и «из чего было строить».
|
||||||
|
Assert.Contains("README.md", files.Keys);
|
||||||
|
Assert.Contains("summary.json", files.Keys);
|
||||||
|
Assert.Contains("drift.json", files.Keys);
|
||||||
|
Assert.Contains("effective-grid.json", files.Keys);
|
||||||
|
Assert.Contains("plan-preview.json", files.Keys);
|
||||||
|
Assert.Contains("library-shows.json", files.Keys);
|
||||||
|
Assert.Contains("library-interstitials.json", files.Keys);
|
||||||
|
Assert.Contains("groups-composition.json", files.Keys);
|
||||||
|
Assert.Contains("issues.json", files.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Export_IssuesListEverythingThatKeepsShowsOffAir()
|
||||||
|
{
|
||||||
|
var fixture = await SeedAsync();
|
||||||
|
var store = Substitute.For<IDebugExportStore>();
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await Handler(db, store)
|
||||||
|
.Handle(new ExportChannelDebugCommand(db.Channels.Single().Id), CancellationToken.None);
|
||||||
|
|
||||||
|
var issues = JsonDocument.Parse(Unpack(result.Value.Content)["issues.json"]).RootElement;
|
||||||
|
|
||||||
|
var failed = Assert.Single(issues.GetProperty("failedEpisodes").EnumerateArray());
|
||||||
|
Assert.Equal("broken.mkv", failed.GetProperty("file").GetString());
|
||||||
|
Assert.Equal("Таймаут ffmpeg", failed.GetProperty("errorMessage").GetString());
|
||||||
|
|
||||||
|
// Серия есть, ассета нет вовсе — удаление в обход админки: в эфир не идёт и молчит.
|
||||||
|
var orphan = Assert.Single(issues.GetProperty("orphanEpisodes").EnumerateArray());
|
||||||
|
Assert.Equal("Симпсоны", orphan.GetProperty("show").GetString());
|
||||||
|
|
||||||
|
var empty = Assert.Single(issues.GetProperty("emptyGroups").EnumerateArray());
|
||||||
|
Assert.Equal("Пустая", empty.GetProperty("name").GetString());
|
||||||
|
|
||||||
|
// Кэш статистики группы никто не пересчитывал — цифры в её карточке врут, и это видно.
|
||||||
|
var stale = Assert.Single(issues.GetProperty("staleGroupStats").EnumerateArray());
|
||||||
|
Assert.Equal("Мультсериалы", stale.GetProperty("name").GetString());
|
||||||
|
Assert.Equal(0, stale.GetProperty("cachedUnits").GetInt32());
|
||||||
|
Assert.Equal(3, stale.GetProperty("actualUnits").GetInt32());
|
||||||
|
// Играбельных меньше, чем заведено: одна серия упала, ещё одна осталась без файла.
|
||||||
|
Assert.Equal(1, stale.GetProperty("playableUnits").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Export_ShowsPlayableUnitsBesideDeclaredOnes()
|
||||||
|
{
|
||||||
|
var fixture = await SeedAsync();
|
||||||
|
var store = Substitute.For<IDebugExportStore>();
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await Handler(db, store)
|
||||||
|
.Handle(new ExportChannelDebugCommand(db.Channels.Single().Id), CancellationToken.None);
|
||||||
|
|
||||||
|
var groups = JsonDocument
|
||||||
|
.Parse(Unpack(result.Value.Content)["groups-composition.json"])
|
||||||
|
.RootElement;
|
||||||
|
var content = groups
|
||||||
|
.EnumerateArray()
|
||||||
|
.Single(g => g.GetProperty("name").GetString() == "Мультсериалы");
|
||||||
|
var element = Assert.Single(content.GetProperty("composition").EnumerateArray());
|
||||||
|
|
||||||
|
Assert.Equal("Симпсоны", element.GetProperty("name").GetString());
|
||||||
|
Assert.Equal(3, element.GetProperty("unitCount").GetInt32());
|
||||||
|
// Планировщик видит только готовые серии — расхождение с unitCount и есть ответ
|
||||||
|
// на «почему в эфир пошло меньше, чем заведено».
|
||||||
|
Assert.Equal(1, element.GetProperty("playableUnits").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Export_WithoutChannelOrTemplate_Fails()
|
||||||
|
{
|
||||||
|
var fixture = await SeedAsync();
|
||||||
|
var store = Substitute.For<IDebugExportStore>();
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var missing = await Handler(db, store)
|
||||||
|
.Handle(new ExportChannelDebugCommand(Guid.NewGuid()), CancellationToken.None);
|
||||||
|
Assert.False(missing.IsSuccess);
|
||||||
|
|
||||||
|
var channel = db.Channels.Single();
|
||||||
|
channel.SetTemplate(null);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// Канал без шаблона собирать нечего: дамп строится вокруг сетки.
|
||||||
|
var untemplated = await Handler(db, store)
|
||||||
|
.Handle(new ExportChannelDebugCommand(channel.Id), CancellationToken.None);
|
||||||
|
Assert.False(untemplated.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExportChannelDebugCommandHandler Handler(
|
||||||
|
AppDbContext db,
|
||||||
|
IDebugExportStore store
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var expander = new GroupExpander(db, GroupServices.Dynamic(db));
|
||||||
|
var generator = new GridScheduleGenerator(
|
||||||
|
db,
|
||||||
|
expander,
|
||||||
|
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), new FirstAlways()),
|
||||||
|
new PostCheckRunner(db),
|
||||||
|
new FirstAlways(),
|
||||||
|
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
|
||||||
|
Options.Create(new StreamingOptions())
|
||||||
|
);
|
||||||
|
|
||||||
|
return new ExportChannelDebugCommandHandler(
|
||||||
|
db,
|
||||||
|
new ChannelDebugCollector(
|
||||||
|
db,
|
||||||
|
expander,
|
||||||
|
generator,
|
||||||
|
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 })
|
||||||
|
),
|
||||||
|
new LibraryDebugCollector(db, GroupServices.Dynamic(db), new GroupElementResolver(db)),
|
||||||
|
store
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Канал со сеткой и намеренно битой библиотекой: из трёх серий играбельна одна, вторая
|
||||||
|
/// упала на нарезке, у третьей нет файла. Плюс пустая группа — дыра в сетке.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<TestDb> SeedAsync()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channel = Channel.Create("Первый", "one", T0);
|
||||||
|
|
||||||
|
var show = Show.Create("Симпсоны", ShowKind.Series);
|
||||||
|
var ready = ReadyAsset("s01e01.mkv", TimeSpan.FromMinutes(20));
|
||||||
|
var broken = MediaAsset.Register("broken.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
broken.MarkProcessing();
|
||||||
|
broken.MarkFailed("Таймаут ffmpeg");
|
||||||
|
show.AddEpisode(ready.Id);
|
||||||
|
show.AddEpisode(broken.Id);
|
||||||
|
show.AddEpisode(Guid.NewGuid());
|
||||||
|
|
||||||
|
var spot = Show.Create("Ролик", ShowKind.Interstitial);
|
||||||
|
var spotAsset = ReadyAsset("spot.mkv", TimeSpan.FromSeconds(30));
|
||||||
|
spot.AddEpisode(spotAsset.Id);
|
||||||
|
|
||||||
|
var content = Group.Create("Мультсериалы");
|
||||||
|
content.AddElement(GroupElementKind.Show, show.Id);
|
||||||
|
var empty = Group.Create("Пустая");
|
||||||
|
|
||||||
|
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||||
|
var slot = template.Background!.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
|
||||||
|
slot.UpdateTiming(null, new TimeOnly(6, 0), 24 * 60, Daypart.Day, false, 30, null);
|
||||||
|
slot.UpdateContent(
|
||||||
|
new SlotContent(
|
||||||
|
slot.Title,
|
||||||
|
SlotKind.Content,
|
||||||
|
content.Id,
|
||||||
|
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
||||||
|
null,
|
||||||
|
SlotBlockMode.Count,
|
||||||
|
1,
|
||||||
|
OverflowPolicy.ContinueNext,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
channel.SetTemplate(template.Id);
|
||||||
|
|
||||||
|
await using var seed = fixture.New();
|
||||||
|
seed.Channels.Add(channel);
|
||||||
|
seed.Shows.AddRange(show, spot);
|
||||||
|
seed.MediaAssets.AddRange(ready, broken, spotAsset);
|
||||||
|
seed.Groups.AddRange(content, empty);
|
||||||
|
seed.ScheduleTemplates.Add(template);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MediaAsset ReadyAsset(string name, TimeSpan duration)
|
||||||
|
{
|
||||||
|
var asset = MediaAsset.Register(name, ".mkv", MediaSource.Upload);
|
||||||
|
asset.MarkProcessing();
|
||||||
|
asset.MarkReady(
|
||||||
|
new MediaReadyInfo(
|
||||||
|
duration,
|
||||||
|
2,
|
||||||
|
(int)(duration.TotalSeconds / 2),
|
||||||
|
1920,
|
||||||
|
1080,
|
||||||
|
"h264",
|
||||||
|
"aac",
|
||||||
|
"assets/x"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> Unpack(byte[] content)
|
||||||
|
{
|
||||||
|
using var buffer = new MemoryStream(content);
|
||||||
|
using var archive = new ZipArchive(buffer, ZipArchiveMode.Read);
|
||||||
|
return archive.Entries.ToDictionary(
|
||||||
|
entry => entry.FullName,
|
||||||
|
entry =>
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
||||||
|
return reader.ReadToEnd();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
using TeleWave.Application.Streaming.GetIptvGuide;
|
||||||
|
using TeleWave.Application.Tests.Support;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Tests.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Телегид для IPTV: программа всех включённых каналов одной выгрузкой. Проверяем то, чем он
|
||||||
|
/// отличается от списка записей ленты — склейку серий в блок, отбор окна и подстановку описаний.
|
||||||
|
/// </summary>
|
||||||
|
public class IptvGuideTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Guide_MergesConsecutiveEpisodesOfTheSameShow()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channel = Enabled("Первый", "one");
|
||||||
|
var show = Show.Create("Футурама", ShowKind.Series);
|
||||||
|
var first = MediaAsset.Register("s01e01.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
var second = MediaAsset.Register("s01e02.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
show.AddEpisode(first.Id);
|
||||||
|
show.AddEpisode(second.Id);
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.Channels.Add(channel);
|
||||||
|
seed.Shows.Add(show);
|
||||||
|
seed.MediaAssets.AddRange(first, second);
|
||||||
|
seed.ScheduleEntries.AddRange(
|
||||||
|
Program(channel.Id, first.Id, show.Id, T0, 25),
|
||||||
|
Program(channel.Id, second.Id, show.Id, T0.AddMinutes(25), 25)
|
||||||
|
);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var channels = await GuideAsync(fixture, T0.AddHours(-1), T0.AddHours(3));
|
||||||
|
|
||||||
|
// Две серии встык — одна передача в глазах зрителя: гид показывает программу, а не файлы.
|
||||||
|
var programme = Assert.Single(Assert.Single(channels).Programmes);
|
||||||
|
Assert.Equal("Футурама", programme.Title);
|
||||||
|
Assert.Equal(T0, programme.StartsAtUtc);
|
||||||
|
Assert.Equal(T0.AddMinutes(50), programme.EndsAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Guide_KeepsBlocksApartWhenSomethingRanBetween()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channel = Enabled("Первый", "one");
|
||||||
|
var show = Show.Create("Футурама", ShowKind.Series);
|
||||||
|
var first = MediaAsset.Register("s01e01.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
var second = MediaAsset.Register("s01e02.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
show.AddEpisode(first.Id);
|
||||||
|
show.AddEpisode(second.Id);
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.Channels.Add(channel);
|
||||||
|
seed.Shows.Add(show);
|
||||||
|
seed.MediaAssets.AddRange(first, second);
|
||||||
|
seed.ScheduleEntries.AddRange(
|
||||||
|
Program(channel.Id, first.Id, show.Id, T0, 25),
|
||||||
|
// Разрыв в пять минут — между сериями шла реклама, и одной передачей это не назвать.
|
||||||
|
Program(channel.Id, second.Id, show.Id, T0.AddMinutes(30), 25)
|
||||||
|
);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var channels = await GuideAsync(fixture, T0.AddHours(-1), T0.AddHours(3));
|
||||||
|
|
||||||
|
var programmes = Assert.Single(channels).Programmes;
|
||||||
|
Assert.Equal(2, programmes.Count);
|
||||||
|
Assert.Equal(T0.AddMinutes(25), programmes[0].EndsAtUtc);
|
||||||
|
Assert.Equal(T0.AddMinutes(30), programmes[1].StartsAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Guide_TakesTitleAndCoverFromEpisodeThenFallsBackToShow()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channel = Enabled("Первый", "one");
|
||||||
|
var stillId = Guid.NewGuid();
|
||||||
|
var posterId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var titled = Show.Create("Футурама", ShowKind.Series);
|
||||||
|
titled.SetPosterImage(posterId);
|
||||||
|
var withMetadata = MediaAsset.Register("s01e01.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
var bare = MediaAsset.Register("s01e02.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
titled
|
||||||
|
.AddEpisode(withMetadata.Id)
|
||||||
|
.ApplyMetadata("Космический пилот", "Фрай", stillId, null);
|
||||||
|
titled.AddEpisode(bare.Id);
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.Channels.Add(channel);
|
||||||
|
seed.Shows.Add(titled);
|
||||||
|
seed.MediaAssets.AddRange(withMetadata, bare);
|
||||||
|
seed.ScheduleEntries.AddRange(
|
||||||
|
Program(channel.Id, withMetadata.Id, titled.Id, T0, 25),
|
||||||
|
Program(channel.Id, bare.Id, titled.Id, T0.AddMinutes(30), 25)
|
||||||
|
);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var channels = await GuideAsync(fixture, T0.AddHours(-1), T0.AddHours(3));
|
||||||
|
|
||||||
|
var programmes = Assert.Single(channels).Programmes;
|
||||||
|
Assert.Equal("Космический пилот", programmes[0].SubTitle);
|
||||||
|
Assert.Equal("Фрай", programmes[0].Description);
|
||||||
|
Assert.Equal(stillId, programmes[0].ImageId);
|
||||||
|
// Серия без метаданных показывается обложкой шоу — пустая карточка в плеере хуже общей.
|
||||||
|
Assert.Null(programmes[1].SubTitle);
|
||||||
|
Assert.Equal(posterId, programmes[1].ImageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Guide_TakesOnlyEnabledChannelsAndTheRequestedWindow()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var visible = Enabled("Первый", "one");
|
||||||
|
var hidden = Channel.Create("Выключенный", "off", T0);
|
||||||
|
hidden.UpdateSettings("Выключенный", isEnabled: false, null);
|
||||||
|
|
||||||
|
var show = Show.Create("Ворон", ShowKind.Single);
|
||||||
|
var asset = MediaAsset.Register("crow.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
show.AddEpisode(asset.Id);
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.Channels.AddRange(visible, hidden);
|
||||||
|
seed.Shows.Add(show);
|
||||||
|
seed.MediaAssets.Add(asset);
|
||||||
|
seed.ScheduleEntries.AddRange(
|
||||||
|
Program(visible.Id, asset.Id, show.Id, T0, 60),
|
||||||
|
// За окном — не в выгрузке; выключенный канал не показываем вовсе.
|
||||||
|
Program(visible.Id, asset.Id, show.Id, T0.AddHours(10), 60),
|
||||||
|
Program(hidden.Id, asset.Id, show.Id, T0, 60),
|
||||||
|
// Заставки и врезки в гид не идут: у них нет шоу, а значит и передачи.
|
||||||
|
ScheduleEntry.Bumper(
|
||||||
|
visible.Id,
|
||||||
|
asset.Id,
|
||||||
|
T0.AddHours(2),
|
||||||
|
T0.AddHours(2).AddSeconds(10),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var channels = await GuideAsync(fixture, T0.AddHours(-1), T0.AddHours(3));
|
||||||
|
|
||||||
|
var dto = Assert.Single(channels);
|
||||||
|
Assert.Equal("one", dto.Slug);
|
||||||
|
var programme = Assert.Single(dto.Programmes);
|
||||||
|
Assert.Equal("Ворон", programme.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Guide_WithoutChannels_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channels = await GuideAsync(fixture, T0, T0.AddHours(3));
|
||||||
|
|
||||||
|
Assert.Empty(channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<IptvChannelDto>> GuideAsync(
|
||||||
|
TestDb fixture,
|
||||||
|
DateTimeOffset from,
|
||||||
|
DateTimeOffset to
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await using var db = fixture.New();
|
||||||
|
return await new GetIptvGuideQueryHandler(db).Handle(
|
||||||
|
new GetIptvGuideQuery(from, to),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Channel Enabled(string name, string slug)
|
||||||
|
{
|
||||||
|
var channel = Channel.Create(name, slug, T0);
|
||||||
|
channel.UpdateSettings(name, isEnabled: true, null);
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScheduleEntry Program(
|
||||||
|
Guid channelId,
|
||||||
|
Guid assetId,
|
||||||
|
Guid showId,
|
||||||
|
DateTimeOffset startsAt,
|
||||||
|
int minutes
|
||||||
|
) =>
|
||||||
|
ScheduleEntry.Program(
|
||||||
|
channelId,
|
||||||
|
assetId,
|
||||||
|
startsAt,
|
||||||
|
startsAt.AddMinutes(minutes),
|
||||||
|
showId,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user