TourMonitor: мониторинг цен туров Level.Travel

This commit is contained in:
kislovdm
2026-08-06 19:47:16 +03:00
commit 98224c7a9f
37 changed files with 3349 additions and 0 deletions
@@ -0,0 +1,118 @@
using TourMonitor.Web;
namespace TourMonitor.Tests;
public class CalendarPageTests
{
[Fact]
public void Hotel_RendersMonthsWithPricesAndEmptyCells()
{
var start = new DateTime(2026, 10, 5);
var html = CalendarPage.Hotel(
"Sunrise Tucana Resort",
9099454,
new[]
{
("2026-10-05", 475_447, (int?)null, "/package_details/offer-1", 410_000, 550_000),
("2026-10-06", 460_000, (int?)null, "/package_details/offer-2", 460_000, 460_000),
},
start,
windowDays: 3,
baseUrl: "http://192.168.1.6:8084");
Assert.Contains("Sunrise Tucana Resort", html);
Assert.Contains("Октябрь", html);
Assert.Contains("475\u00A0447", html);
Assert.Contains("460\u00A0000", html);
Assert.Contains("<div class=\"cell nodata\">", html);
Assert.Contains("Цена из последнего сканирования", html);
Assert.Contains("<span class=\"range\">410\u00A0000–550\u00A0000</span>", html);
}
[Fact]
public void Hotel_ShowsDeltaArrows_WithPercent()
{
var html = CalendarPage.Hotel(
"Sunrise Tucana Resort",
9099454,
new[]
{
("2026-10-05", 475_447, (int?)540_000, "", 0, 0), // дешевле
("2026-10-06", 460_000, (int?)450_000, "", 0, 0), // дороже
("2026-10-07", 500_000, (int?)500_000, "", 0, 0), // без изменений
},
new DateTime(2026, 10, 5),
windowDays: 3,
baseUrl: "");
Assert.Contains("<span class=\"chg down\">↓ 12%</span>", html);
Assert.Contains("<span class=\"chg up\">↑ 2%</span>", html);
Assert.Contains("<span class=\"chg same\">=</span>", html);
}
[Fact]
public void Hotel_NoArrow_WhenNoPreviousScan()
{
var html = CalendarPage.Hotel(
"Sunrise Tucana Resort",
9099454,
new[] { ("2026-10-05", 475_447, (int?)null, "", 0, 0) },
new DateTime(2026, 10, 5),
windowDays: 1,
baseUrl: "");
Assert.DoesNotContain("<span class=\"chg", html);
}
[Fact]
public void Hotel_PriceLinksToBooking_WhenLinkPresent()
{
var html = CalendarPage.Hotel(
"Sunrise Tucana Resort",
9099454,
new[]
{
("2026-10-05", 475_447, (int?)null, "/package_details/offer-1?hotel_id=9099454", 0, 0),
("2026-10-06", 460_000, (int?)null, "", 0, 0),
},
new DateTime(2026, 10, 5),
windowDays: 2,
baseUrl: "");
Assert.Contains("<a class=\"p book\" href=\"https://level.travel/package_details/offer-1?hotel_id=9099454\">475\u00A0447</a>", html);
Assert.Contains("<span class=\"p\">460\u00A0000</span>", html);
}
[Fact]
public void Hotel_ShowsCheapestRoomRange_InCellsAndHint()
{
var html = CalendarPage.Hotel(
"Sunrise Tucana Resort",
9099454,
new[]
{
("2026-10-05", 475_447, (int?)null, "/package_details/offer-1", 290_494, 574_875),
("2026-10-06", 460_000, (int?)null, "", 100_000, 130_000),
},
new DateTime(2026, 10, 5),
windowDays: 2,
baseUrl: "");
Assert.Contains("<span class=\"range\">290\u00A0494–574\u00A0875</span>", html);
Assert.Contains("<span class=\"range\">100\u00A0000–130\u00A0000</span>", html);
}
[Fact]
public void Index_ListsHotelsWithLinks()
{
var html = CalendarPage.Index(
"Мониторинг туров",
new[] { (9099454, "Sunrise Tucana Resort"), (9151153, "Posh Club By Sunrise Tucana Resort") },
"http://192.168.1.6:8084");
Assert.Contains("Sunrise Tucana Resort", html);
Assert.Contains("Posh Club By Sunrise Tucana Resort", html);
Assert.Contains("/prices/9099454", html);
Assert.Contains("/prices/9151153", html);
}
}
+53
View File
@@ -0,0 +1,53 @@
using System.Text.Json;
using TourMonitor.LevelTravel;
namespace TourMonitor.Tests;
public class CryptoTests
{
/// <summary>
/// Реальный зашифрованный ответ API (captured: /tmp/lt_probe/cap_search/069-resp.json).
/// Проверяет secretbox-дешифровку (XSalsa20-Poly1305) и zlib-inflate.
/// </summary>
[Fact]
public void DecryptToJson_Decrypts_CapturedEnqueueResponse()
{
var fixture = File.ReadAllText(Path.Combine("Fixtures", "enqueue_encrypted.txt"));
var json = LevelTravelCrypto.DecryptToJson(fixture, TestData.SecretBoxKeys);
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
var requestId = doc.RootElement.GetProperty("request_id").GetString();
Assert.Equal("MjEzfDIyNXwxMTgxMjN8MTA1Nnx8MjAyNi0wOC0yMCwyMDI2LTA4LTIwfDB8OSw5fDJ8MHx8fDB8MHx8fDY=", requestId);
}
/// <summary>Незашифрованные тела (v3.7 без секрета) возвращаются как есть.</summary>
[Fact]
public void DecryptToJson_PassesThrough_PlainBody()
{
const string plain = "{\"success\":true}";
var result = LevelTravelCrypto.DecryptToJson(plain, TestData.SecretBoxKeys);
Assert.Equal(plain, result);
}
/// <summary>Мусор после префикса "!/" должен давать осмысленную ошибку, а не исключение-дамп.</summary>
[Fact]
public void DecryptToJson_Throws_LevelTravelException_OnGarbage()
{
Assert.Throws<LevelTravelException>(() => LevelTravelCrypto.DecryptToJson("!/not-base64!!", TestData.SecretBoxKeys));
}
/// <summary>Неизвестный индекс ключа (не 1..7) отклоняется.</summary>
[Fact]
public void DecryptToJson_Throws_OnUnknownKeyIndex()
{
// первый байт выбирает индекс: (payload[0] ^ 23) - '0' должен быть вне 1..7
var body = "!/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
var ex = Assert.Throws<LevelTravelException>(() => LevelTravelCrypto.DecryptToJson(body, TestData.SecretBoxKeys));
Assert.Contains("индекс ключа", ex.Message);
}
}
@@ -0,0 +1 @@
!/JVgjbNqIjUXS695mQj1bxLs2kSyLaK-F6ezZ89YKmB8gT6utv3_tGUpgpJm34UJEKVB7vGSAvQRAcWrNOGV9sDAU0ixFwTm0JfZKlPy5DXDYW0my_CFhx-4_MRmT_6Q_B6QsWlX4xA9w0wXJ8iqiEOSSygHOaErwTYFsaIFTnBENFzEZivIiCGSgSU2uSTmKMEFHchOrArrkd_YeXp0G63Nb8WGlZ7ccrDEioq8HhOsngiclTzt4ZQZJU4nauinzu3tUlOU860b17r9Fq7ewIlnZa0iTTWIWD8BPJjOtKTU6jH7lUcq1r1sNct6WbD98oDHCob1gb2UdTc_YvCu9MVcRZYkQlgHrc64KlVHo8ldImajKCIrNVDOE_Q-A7ecqAE8YpSRSlgYtq9duV2kU7C97AMySPO-QyvPqwhWoBawbIHfXWoqgjDxakRxt1RTwA1k5p2rxAK9L11qbT77eF06JV2_aI25Vi6Mwo_Fn3ML5rPlgGqijcXo1EOWiqTlNfxjUemE4kqpBpdy4OX4VC6EQrTzlTqdrMZrERpHLtZDfuZFt7-x9NhW8dW1P-pTC-BuR0Vdk2IseoA3Mio_Lu_FE8_oMjHwiBnArgrFh9PX6Ppewdma9ND_AYrTB5Td1tH1nFGfWJUnMegsdmvYe38btVD1j7hJzTnw-TMA8QvO_hms-byzLcTrZpib84JZ4xgNt65soPYkmhZdGq2vR6RbA0PtqCfRHoN54jI8ZfjV88WtSY-J7WdxpgGWn68fIaoIUdptuW5GT90Y6YOo9kr3DmsoKz8Dk-3vER7VfNhChDf32qBFCdHKUErBJbFoKOHK23yKjP38OI29BN8FMM8fXbNP1h_MbwAdyvuvRkUCjuCLYmVyGlu0OpxsOsF--zLoinmHFIgOw73zYD3pxVHev5hOQDfEnaPw-SvUBGFZ2DFZhjztgBECUdKBkGPY0nuGGsokmXiSR71O2Gk4fP3b3tAsVhrhmVXX40HdnU0dWAlW7ng6x9s7YPe3iOgL4ybpBcZwqaydivYOIMM9dxkFxVO4TInBwFUkkorgGytbv_Qz0KQIxcb4TDBQL1eBFH6TXJX4BcxbJ_cmsaXjtrzegw_o77JC5qyYUgU0KIWAEqYt6ZRJRjXir7faqk4chLnc=
@@ -0,0 +1,146 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using TourMonitor.Camoufox;
using TourMonitor.Jobs;
using TourMonitor.LevelTravel;
using TourMonitor.Notifications;
using TourMonitor.Storage;
namespace TourMonitor.Tests;
/// <summary>
/// Живые проверки публичного API Level.Travel через браузер Camoufox (единственный транспорт).
/// Включаются только явно:
/// LT_LIVE_TESTS=1 dotnet test
/// Без переменной окружения тесты «проходят» без выполнения запросов.
/// </summary>
public class LevelTravelIntegrationTests
{
private const string SunriseTucana = "9099454";
private const string PoshClub = "9151153";
private static bool LiveEnabled => Environment.GetEnvironmentVariable("LT_LIVE_TESTS") == "1";
private static LevelTravelOptions BuildOptions() =>
new() { SecretBoxKeys = TestData.SecretBoxKeys.ToList() };
private static async Task<CamoufoxBrowserSession> BuildSessionAsync()
{
var installDir = Path.Combine(Path.GetTempPath(), $"camoufox_test_{Guid.NewGuid():N}");
var session = new CamoufoxBrowserSession(
new CamoufoxInstaller(new CamoufoxReleaseResolver(), NullLogger<CamoufoxInstaller>.Instance),
Options.Create(new CamoufoxOptions { InstallDirectory = installDir, Headless = true }),
NullLogger<CamoufoxBrowserSession>.Instance);
await session.GetPageAsync();
return session;
}
private static LevelTravelClient BuildClient(CamoufoxBrowserSession session)
{
var transport = new CamoufoxApiTransport(session, Options.Create(BuildOptions()), NullLogger<CamoufoxApiTransport>.Instance);
return new LevelTravelClient(transport, Options.Create(BuildOptions()), NullLogger<LevelTravelClient>.Instance);
}
private static List<SearchParam> BuildParams(string date)
{
var hotels = new[] { SunriseTucana, PoshClub };
return hotels.Select(h => new SearchParam
{
HotelIds = int.Parse(h),
FromCity = "Moscow",
FromCountry = "RU",
ToCity = "Makadi Bay",
ToCountry = "EG",
StartDate = date,
Nights = "9",
Adults = 2,
Kids = 1,
KidsAges = new[] { 5 },
FlexDates = false,
SearchType = "package",
}).ToList();
}
[Fact]
public async Task Live_Enqueue_Status_RoomRates_Flow()
{
if (!LiveEnabled)
return;
var session = await BuildSessionAsync();
try
{
var client = BuildClient(session);
var date = DateTime.Today.AddDays(3).ToString("yyyy-MM-dd");
var enqueue = await client.MultiEnqueueAsync(BuildParams(date));
Assert.True(enqueue.Success);
Assert.Equal(2, enqueue.SearchRequests.Count);
var hotelIds = new[] { SunriseTucana, PoshClub };
var totalOffers = 0;
for (var i = 0; i < enqueue.SearchRequests.Count; i++)
{
var status = await client.WaitForSearchAsync(enqueue.SearchRequests[i].RequestId, 4, 240);
Assert.True(status.Completeness >= 100 || status.Status?.Count > 0);
var rates = await client.GetRoomRatesAsync(hotelIds[i], enqueue.SearchRequests[i].RequestId);
if (rates.Success)
totalOffers += rates.Result.Sum(r => r.Offers.Values.Sum(o => o.Count));
}
Assert.True(totalOffers > 0, "API вернуло пустой результат по обоим отелям.");
}
finally
{
await session.CloseAsync();
}
}
[Fact]
public async Task Live_DailyScan_Persists_Offers()
{
if (!LiveEnabled)
return;
var dbPath = Path.Combine(Path.GetTempPath(), $"tour_monitor_scan_{Guid.NewGuid():N}.db");
var session = await BuildSessionAsync();
try
{
var store = new PriceStore(dbPath);
await store.EnsureCreatedAsync();
var monitor = new MonitorOptions
{
Hotels =
{
new() { Id = int.Parse(SunriseTucana), Name = "Sunrise Tucana Resort", ToCity = "Makadi Bay", ToCountry = "EG", Nights = 9 },
new() { Id = int.Parse(PoshClub), Name = "Posh Club By Sunrise Tucana Resort", ToCity = "Makadi Bay", ToCountry = "EG", Nights = 9 },
},
DateRangeDays = 1,
KidsAges = { 5 },
MaxParallelDates = 2,
SearchTimeoutSeconds = 240,
};
var job = new DailyScanJob(
BuildClient(session),
store,
new TelegramNotifier(new HttpClient(), Options.Create(new TelegramOptions()), NullLogger<TelegramNotifier>.Instance),
session,
Options.Create(monitor),
NullLogger<DailyScanJob>.Instance);
await job.ExecuteAsync();
var bests = await store.GetTodayBestsAsync(DateTime.Today.ToString("yyyy-MM-dd"));
Assert.NotEmpty(bests);
}
finally
{
await session.CloseAsync();
if (File.Exists(dbPath))
File.Delete(dbPath);
}
}
}
+162
View File
@@ -0,0 +1,162 @@
using TourMonitor.Storage;
namespace TourMonitor.Tests;
public class PriceStoreTests : IDisposable
{
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"tour_monitor_test_{Guid.NewGuid():N}.db");
public void Dispose()
{
if (File.Exists(_dbPath))
File.Delete(_dbPath);
}
private static StoredOffer Offer(int price, string room = "Полулюкс", string operatorName = "Level.Travel", string? link = null, string dateFrom = "2026-08-20") =>
new(9099454, dateFrom, "AI", 12, room, 77, operatorName, price, price / 9, "offer-1", link ?? $"/package_details/offer-1?hotel_id=9099454");
private PriceStore NewStore() => new(_dbPath);
[Fact]
public async Task SaveScan_WritesHistory_AndDailyBest()
{
var store = NewStore();
await store.EnsureCreatedAsync();
var offers = new List<StoredOffer> { Offer(500_000), Offer(475_447) };
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-06", offers);
var bests = await store.GetTodayBestsAsync("2026-08-06");
var best = Assert.Single(bests);
Assert.Equal(475_447, best.BestPrice);
Assert.Equal("offer-1", best.OfferId);
var offer = await store.GetBestOfferAsync(9099454, "2026-08-20", "2026-08-06");
Assert.NotNull(offer);
Assert.Equal(475_447, offer!.Price);
Assert.Equal("Полулюкс", offer.RoomName);
Assert.Equal("AI", offer.Meal);
}
[Fact]
public async Task SaveScan_EmptyOffers_DoesNotCreateDailyBest()
{
var store = NewStore();
await store.EnsureCreatedAsync();
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-06", new List<StoredOffer>());
Assert.Empty(await store.GetTodayBestsAsync("2026-08-06"));
}
[Fact]
public async Task SecondScan_UpsertsDailyBest_AndKeepsHistory()
{
var store = NewStore();
await store.EnsureCreatedAsync();
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-06", new List<StoredOffer> { Offer(500_000) });
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-07", new List<StoredOffer> { Offer(450_000, "Люкс") });
var todayBests = await store.GetTodayBestsAsync("2026-08-07");
var best = Assert.Single(todayBests);
Assert.Equal(450_000, best.BestPrice);
var previous = await store.GetBestBeforeAsync(9099454, "2026-08-20", "2026-08-07");
Assert.NotNull(previous);
Assert.Equal(500_000, previous!.BestPrice);
Assert.Equal("2026-08-06", previous.Date);
var historical = await store.GetHistoricalMinBeforeAsync(9099454, "2026-08-20", "2026-08-07");
Assert.Equal(500_000, historical!.BestPrice);
}
[Fact]
public async Task HistoricalMin_TakesLowestOfAllPreviousDays()
{
var store = NewStore();
await store.EnsureCreatedAsync();
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-04", new List<StoredOffer> { Offer(500_000) });
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-05", new List<StoredOffer> { Offer(470_000) });
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-06", new List<StoredOffer> { Offer(490_000) });
var historical = await store.GetHistoricalMinBeforeAsync(9099454, "2026-08-20", "2026-08-07");
Assert.Equal(470_000, historical!.BestPrice);
}
[Fact]
public async Task GetCalendar_ReturnsLastScanPrice_AndPreviousForDelta()
{
var store = NewStore();
await store.EnsureCreatedAsync();
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-05", new List<StoredOffer> { Offer(500_000) });
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-06", new List<StoredOffer> { Offer(440_000) });
await store.SaveScanAsync(9099454, "2026-10-06", "2026-08-06", new List<StoredOffer> { Offer(470_000) });
var entries = await store.GetCalendarAsync();
var first = entries.Single(e => e.DateFrom == "2026-10-05");
Assert.Equal(440_000, first.BestPrice); // цена из последнего скана, а не накопленный минимум
Assert.Equal(500_000, first.PreviousPrice);
var second = entries.Single(e => e.DateFrom == "2026-10-06");
Assert.Equal(470_000, second.BestPrice);
Assert.Null(second.PreviousPrice); // первого скана не было — дельты нет
}
[Fact]
public async Task GetCalendar_Range_IsMinMaxOfBestPrice_OverScanDates()
{
var store = NewStore();
await store.EnsureCreatedAsync();
// одна и та же дата тура, три разные даты сканирования
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-05", new List<StoredOffer> { Offer(100_000, dateFrom: "2026-10-05") });
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-06", new List<StoredOffer> { Offer(130_000, dateFrom: "2026-10-05") });
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-07", new List<StoredOffer> { Offer(120_000, dateFrom: "2026-10-05") });
var entry = (await store.GetCalendarAsync()).Single(e => e.DateFrom == "2026-10-05");
Assert.Equal(120_000, entry.BestPrice); // из последнего скана
Assert.Equal(100_000, entry.MinPrice); // мин по датам сканирования
Assert.Equal(130_000, entry.MaxPrice); // макс по датам сканирования
}
[Fact]
public async Task GetCalendar_ReturnsBookingLink_ForBestOfferOfLastScan()
{
var store = NewStore();
await store.EnsureCreatedAsync();
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-05",
new List<StoredOffer>
{
Offer(500_000, "Стандарт", "ANEX Tour", link: "/package_details/offer-old", dateFrom: "2026-10-05"),
Offer(475_000, "Делюкс", "Pegas", link: "/package_details/offer-best", dateFrom: "2026-10-05"),
});
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-06",
new List<StoredOffer>
{
Offer(440_000, "Люкс", "Level.Travel", link: "/package_details/offer-latest", dateFrom: "2026-10-05"),
Offer(620_000, "Стандарт", "Pegas", link: "/package_details/offer-expensive", dateFrom: "2026-10-05"),
});
var entry = (await store.GetCalendarAsync()).Single(e => e.DateFrom == "2026-10-05");
Assert.Equal(440_000, entry.BestPrice);
Assert.Equal("/package_details/offer-latest", entry.Link); // ссылка из последнего скана, а не старого
}
[Fact]
public async Task GetBestOffer_ReturnsCheapestOfLatestScan()
{
var store = NewStore();
await store.EnsureCreatedAsync();
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-06", new List<StoredOffer> { Offer(500_000), Offer(460_000, "Стандарт", "ANEX Tour") });
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-07", new List<StoredOffer> { Offer(510_000), Offer(475_000, "Делюкс", "Pegas") });
var offer = await store.GetBestOfferAsync(9099454, "2026-08-20", "2026-08-07");
Assert.Equal(475_000, offer!.Price);
Assert.Equal("Делюкс", offer.RoomName);
Assert.Equal("Pegas", offer.OperatorName);
}
}
@@ -0,0 +1,244 @@
using TourMonitor.Notifications;
namespace TourMonitor.Tests;
public class ReportBuilderTests
{
private static ReportLine Line(
int hotelId = 9099454,
string dateFrom = "2026-08-20",
int price = 475_447,
string meal = "AI",
string roomName = "Полулюкс",
string operatorName = "Level.Travel",
string link = "/package_details/offer-1",
int? previous = null,
int? historicalMin = null) =>
new(hotelId, dateFrom, price, meal, roomName, operatorName, link, previous, historicalMin);
[Fact]
public void BuildHotelSection_GroupsByMonth_ShowsTopPricesPerMonth()
{
var lines = new[]
{
Line(dateFrom: "2026-08-30", price: 520_000),
Line(dateFrom: "2026-08-20", price: 475_447),
Line(dateFrom: "2026-09-02", price: 501_000),
Line(dateFrom: "2026-09-03", price: 490_000),
};
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 2, lines);
Assert.NotNull(section);
Assert.Contains("Sunrise Tucana Resort", section);
Assert.Contains("<b>Август</b>", section);
Assert.Contains("<b>Сентябрь</b>", section);
Assert.Contains("20.08", section);
Assert.Contains("30.08", section);
Assert.Contains("03.09", section);
Assert.Contains("02.09", section);
var months = section.Split("\n📅 <b>");
Assert.Equal(3, months.Length); // отель + Август + Сентябрь
Assert.Equal(2, months[1].Split("\n 📅 ").Length - 1); // Август — 2 дешёвых
Assert.Equal(2, months[2].Split("\n 📅 ").Length - 1); // Сентябрь — 2 дешёвых
}
[Fact]
public void BuildHotelSection_TakesNoMoreThanPricesPerMonth_WithinEachMonth()
{
var lines = new[]
{
Line(dateFrom: "2026-08-10", price: 500_000),
Line(dateFrom: "2026-08-11", price: 510_000),
Line(dateFrom: "2026-08-12", price: 520_000),
Line(dateFrom: "2026-08-13", price: 530_000),
};
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 2, lines);
Assert.NotNull(section);
Assert.Contains("<b>Август</b>", section);
Assert.Contains("10.08", section);
Assert.Contains("11.08", section);
Assert.DoesNotContain("12.08", section);
Assert.DoesNotContain("13.08", section);
}
[Fact]
public void DescribeDelta_ShowsDrop_WithPercentage()
{
var line = Line(price: 475_447, previous: 540_000);
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { line });
Assert.Contains("🔻 −12% (было 540\u00A0000 ₽)", section);
}
[Fact]
public void DescribeDelta_ShowsNoChange_WhenPricesEqual()
{
var line = Line(price: 475_447, previous: 475_447);
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { line });
Assert.Contains("= (без изменений)", section);
}
[Fact]
public void DescribeDelta_MarksNewMinimum()
{
var line = Line(price: 410_000, previous: 420_000, historicalMin: 415_000);
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { line });
Assert.Contains("✨ новая минималка", section);
}
[Fact]
public void DescribeDelta_OmitsDelta_WhenNoPrevious()
{
var line = Line(previous: null);
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { line });
Assert.DoesNotContain("было", section);
Assert.DoesNotContain("🔻", section);
Assert.DoesNotContain("🔺", section);
}
[Fact]
public void BuildHotelSection_ReturnsNull_WhenNoData()
{
Assert.Null(ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, Array.Empty<ReportLine>()));
}
[Fact]
public void Build_IncludesHeader_AndOnlyNonEmptySections()
{
var sections = ReportBuilder.Build(
"📊 <b>Мониторинг туров</b> · 9 ночей, 2+1 чел",
5,
new[] { (9099454, "Sunrise Tucana Resort"), (9151153, "Posh Club By Sunrise Tucana Resort") },
new[] { Line(hotelId: 9099454) });
Assert.NotNull(sections);
Assert.Contains("Мониторинг туров", sections);
Assert.Contains("Sunrise Tucana Resort", sections);
Assert.DoesNotContain("Posh Club", sections);
}
[Fact]
public void Build_ReturnsNull_WhenNoLines()
{
var result = ReportBuilder.Build(
"header", 5,
new[] { (9099454, "Sunrise Tucana Resort") },
Array.Empty<ReportLine>());
Assert.Null(result);
}
[Fact]
public void Html_SpecialCharacters_AreEscaped()
{
var line = Line(roomName: "Стандарт <b>x</b>", operatorName: "ANEX & Co");
var section = ReportBuilder.BuildHotelSection("Sunrise <i>Tucana</i>", 5, new[] { line });
Assert.Contains("Sunrise &lt;i&gt;Tucana&lt;/i&gt;", section);
Assert.Contains("Стандарт &lt;b&gt;x&lt;/b&gt;", section);
Assert.Contains("ANEX &amp; Co", section);
}
[Fact]
public void RoomName_IsLinked_InsteadOfReserveLink()
{
var line = Line(link: "/package_details/offer-1?hotel_id=9099454", roomName: "Делюкс с видом на сад");
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { line });
Assert.Contains("https://level.travel/package_details/offer-1?hotel_id=9099454", section);
Assert.Contains("<a href=\"https://level.travel/package_details/offer-1?hotel_id=9099454\">Делюкс с видом на сад</a>", section);
Assert.DoesNotContain("забронировать", section);
Assert.DoesNotContain("🔗", section);
}
[Fact]
public void RoomName_IsNotLinked_WhenLinkEmpty()
{
var line = Line(link: "", roomName: "Делюкс с видом на сад");
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { line });
Assert.Contains("🍽 AI · Делюкс с видом на сад · Level.Travel", section);
Assert.DoesNotContain("<a href=", section);
}
[Fact]
public void BuildMessages_SplitsHotelsIntoSeparateMessages()
{
var messages = ReportBuilder.BuildMessages(
"📊 <b>Мониторинг туров</b>",
10,
new[] { (9099454, "Sunrise Tucana Resort"), (9151153, "Posh Club By Sunrise Tucana Resort") },
new[]
{
Line(hotelId: 9099454, price: 400000),
Line(hotelId: 9151153, price: 500000),
});
Assert.NotNull(messages);
Assert.Equal(2, messages.Count);
Assert.Contains("Мониторинг туров", messages[0]);
Assert.Contains("Sunrise Tucana Resort", messages[0]);
Assert.DoesNotContain("Posh Club", messages[0]);
Assert.Contains("Posh Club By Sunrise Tucana Resort", messages[1]);
Assert.DoesNotContain("Мониторинг туров", messages[1]);
}
[Fact]
public void BuildMessages_ReturnsNull_WhenNoLines()
{
var messages = ReportBuilder.BuildMessages(
"header", 10,
new[] { (9099454, "Sunrise Tucana Resort") },
Array.Empty<ReportLine>());
Assert.Null(messages);
}
[Fact]
public void BuildHotelSection_WithCalendarUrl_AddsCalendarLink_RightAfterHotelName()
{
var section = ReportBuilder.BuildHotelSection(
"Sunrise Tucana Resort", 5, new[] { Line(price: 400000) },
calendarUrl: "http://192.168.1.6:8084/prices/9099454");
Assert.Contains("календарь цен", section);
Assert.Contains("http://192.168.1.6:8084/prices/9099454", section);
Assert.Contains("Sunrise Tucana Resort</b>\n🗓 <a href=", section);
Assert.True(section.IndexOf("🗓") < section.IndexOf("📅 <b>"));
}
[Fact]
public void BuildHotelSection_WithoutCalendarUrl_HasNoCalendarLink()
{
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { Line(price: 400000) });
Assert.DoesNotContain("календарь цен", section);
}
[Fact]
public void SplitMessage_ChunksOversizedTextAtLineBoundaries()
{
var lines = Enumerable.Range(0, 50).Select(i => $"📅 10.10 — 475 447 ₽\n 🍽 AI · Полулюкс · Level.Travel").ToList();
var text = string.Join("\n", lines);
var chunks = ReportBuilder.SplitMessage(text, maxLength: 500).ToList();
Assert.True(chunks.Count > 1);
Assert.All(chunks, c => Assert.True(c.Length <= 500));
Assert.Equal(text, string.Join("\n", chunks));
}
}
+110
View File
@@ -0,0 +1,110 @@
using TourMonitor.LevelTravel;
namespace TourMonitor.Tests;
public class SignTests
{
/// <summary>
/// Эталонный знак, снятый с реального запроса веб-клиента level.travel
/// (GET /search/enqueue, зафиксирован в /tmp/lt_probe/cap_search/040-req.json).
/// </summary>
[Fact]
public void GetEnqueueSign_Matches_CapturedRequest()
{
var parameters = new Dictionary<string, object?>
{
["start_date"] = "2026-08-20",
["nights"] = 9,
["adults"] = 2,
["kids"] = 0,
["kids_ages"] = "",
["from_city"] = "Moscow",
["from_country"] = "RU",
["to_city"] = "Hurghada",
["to_country"] = "EG",
["search_type"] = "package",
["flex_dates"] = 0,
};
var sign = SignHelper.ComputeGet("/search/enqueue", parameters, TestData.ApiKey, TestData.SignSalt, TestData.ApiVersion);
Assert.Equal("60367bd68251236a1e0820c00a99cb92", sign);
}
/// <summary>
/// Золотой знак для POST /search/multi_enqueue — снят с реального запроса сайта
/// (captured: /tmp/lt_probe/cap_hotel, подпись в теле afaac79c2e0c81be5ce10fd584cc3d44).
/// Проверяет полный путь: сериализация тела + флаттен + подпись.
/// </summary>
[Fact]
public void PostMultiEnqueueSign_Matches_CapturedRequest()
{
var body = new MultiEnqueueRequest
{
SearchParams =
{
new SearchParam
{
HotelIds = 9099454,
FromCity = "Moscow",
FromCountry = "RU",
ToCity = "Makadi Bay",
ToCountry = "EG",
StartDate = "2026-08-20",
Nights = "9",
Adults = 2,
Kids = 1,
FlexDates = false,
KidsAges = new[] { 5 },
SearchType = "package",
},
new SearchParam
{
HotelIds = 9099454,
FromCity = "Moscow",
FromCountry = "RU",
ToCity = "Makadi Bay",
ToCountry = "EG",
StartDate = "2026-08-20",
Nights = "7..11",
Adults = 2,
Kids = 1,
FlexDates = true,
KidsAges = new[] { 5 },
SearchType = "package",
},
},
};
var sign = SignHelper.Compute(
"/search/multi_enqueue",
LevelTravelClient.GetSignValues(body),
TestData.ApiKey,
TestData.SignSalt);
Assert.Equal("afaac79c2e0c81be5ce10fd584cc3d44", sign);
}
/// <summary>Знак не зависит от порядка параметров (JS-клиент сортирует значения).</summary>
[Fact]
public void Compute_IsOrderIndependent()
{
var values = new List<object?> { "2026-08-20", 9, 2, 0, "", "Moscow", "RU", "Hurghada", "EG", "package", 0 };
var reversed = Enumerable.Reverse(values).ToList();
var a = SignHelper.Compute("/search/enqueue", values, TestData.ApiKey, TestData.SignSalt);
var b = SignHelper.Compute("/search/enqueue", reversed, TestData.ApiKey, TestData.SignSalt);
Assert.Equal(a, b);
}
/// <summary>Строки обрезаются по краям, как в JS (value.trim()).</summary>
[Fact]
public void Flatten_TrimsStrings()
{
var signA = SignHelper.Compute("/search/status", new object?[] { " abc ", "1" }, TestData.ApiKey, TestData.SignSalt);
var signB = SignHelper.Compute("/search/status", new object?[] { "abc", "1" }, TestData.ApiKey, TestData.SignSalt);
Assert.Equal(signA, signB);
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace TourMonitor.Tests;
public static class TestData
{
public const string ApiKey = "0fe9fb2ff35679322db5429b18a53aee";
public const string SignSalt = "2qqRS1f8TyuF";
public const string ApiVersion = "3.14";
public static readonly string[] SecretBoxKeys =
[
"B1stAQVPJzJ015iJU8A1DP+i+d/5AbgIZavVPYKInrQ=",
"6+uSlOycGhU6LmgIz1sXsnK6VuZHpPtVaiRbgF7WCg0=",
"fve2pJpiBkJ5raA6EQCk2A21NDsFoP102jbDljzKDyc=",
"1IsckNgz0hwlqpVT0ZsLvjAdd3VhANj9+770p+aKuns=",
"DZllQsSt/82U/LdrlmoT+KNR578sK0y8E5709wjPPJQ=",
"XRoiUKs7d0dXVjcvvhsYUZ6Oo/ekYXZR9p+21aT6DOI=",
"3GV0SKSSEpNHxkwyU3VrCLgYJWrodLZJIZfpvJ3MVQY=",
];
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\TourMonitor\TourMonitor.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+10
View File
@@ -0,0 +1,10 @@
namespace TourMonitor.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}