TourMonitor: мониторинг цен туров Level.Travel
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <i>Tucana</i>", section);
|
||||
Assert.Contains("Стандарт <b>x</b>", section);
|
||||
Assert.Contains("ANEX & 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TourMonitor.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user