From 3d89fac6e0a6d9b131ee32c1af28d27c48a2d48b Mon Sep 17 00:00:00 2001 From: Exest Date: Mon, 17 Aug 2026 11:51:50 +0300 Subject: [PATCH] =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B:=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=BF=D0=B0=20(xUnit)=20+=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - проект tests/Elfisa.Tests: 56 юнит-тестов на Elfisa.Core + Elfisa.Avalonia: версии апдейтера, срок JWT-сессии, ApiClient (URL/ошибки/ретраи/парсинг), DTO-контракты, черновики, корзина, дрилдаун прайса (МГ/базовое имя) - тест-швы: internal-методы через InternalsVisibleTo, ApiClient(HttpMessageHandler) - CI .github/workflows/desktop-tests.yml — dotnet test при пуше (GitHub/Gitea Actions) - README с описанием покрытия Co-Authored-By: Claude Opus 4.8 --- .github/workflows/desktop-tests.yml | 30 ++++++ Elfisa.sln | 47 ++++++++ src/Elfisa.Avalonia/Elfisa.Avalonia.csproj | 4 + .../ViewModels/PriceListViewModel.cs | 4 +- src/Elfisa.Core/ApiClient.cs | 9 +- src/Elfisa.Core/Elfisa.Core.csproj | 4 + src/Elfisa.Core/Session.cs | 2 +- src/Elfisa.Core/UpdateService.cs | 2 +- tests/Elfisa.Tests/ApiClientTests.cs | 100 ++++++++++++++++++ tests/Elfisa.Tests/CartServiceTests.cs | 69 ++++++++++++ tests/Elfisa.Tests/DraftTests.cs | 36 +++++++ tests/Elfisa.Tests/DtoTests.cs | 60 +++++++++++ tests/Elfisa.Tests/Elfisa.Tests.csproj | 23 ++++ tests/Elfisa.Tests/PriceLogicTests.cs | 36 +++++++ tests/Elfisa.Tests/README.md | 39 +++++++ tests/Elfisa.Tests/SessionTests.cs | 32 ++++++ tests/Elfisa.Tests/TestHelpers.cs | 41 +++++++ tests/Elfisa.Tests/UpdateServiceTests.cs | 39 +++++++ 18 files changed, 572 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/desktop-tests.yml create mode 100644 tests/Elfisa.Tests/ApiClientTests.cs create mode 100644 tests/Elfisa.Tests/CartServiceTests.cs create mode 100644 tests/Elfisa.Tests/DraftTests.cs create mode 100644 tests/Elfisa.Tests/DtoTests.cs create mode 100644 tests/Elfisa.Tests/Elfisa.Tests.csproj create mode 100644 tests/Elfisa.Tests/PriceLogicTests.cs create mode 100644 tests/Elfisa.Tests/README.md create mode 100644 tests/Elfisa.Tests/SessionTests.cs create mode 100644 tests/Elfisa.Tests/TestHelpers.cs create mode 100644 tests/Elfisa.Tests/UpdateServiceTests.cs diff --git a/.github/workflows/desktop-tests.yml b/.github/workflows/desktop-tests.yml new file mode 100644 index 0000000..2b23557 --- /dev/null +++ b/.github/workflows/desktop-tests.yml @@ -0,0 +1,30 @@ +name: Desktop tests + +# Автозапуск юнит-тестов десктопа при изменениях кода. +# Работает и в GitHub Actions, и в Gitea Actions (если настроены раннеры). +on: + push: + paths: + - 'src/Elfisa.Core/**' + - 'src/Elfisa.Avalonia/**' + - 'tests/**' + - '.github/workflows/desktop-tests.yml' + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore tests/Elfisa.Tests/Elfisa.Tests.csproj + + - name: Test + run: dotnet test tests/Elfisa.Tests/Elfisa.Tests.csproj -c Release --no-restore --logger "console;verbosity=minimal" diff --git a/Elfisa.sln b/Elfisa.sln index fc6d9da..dc4add0 100644 --- a/Elfisa.sln +++ b/Elfisa.sln @@ -13,6 +13,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.ButtonSkillDemo", "s EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.GradientButtonDemo", "src\Elfisa.GradientButtonDemo\Elfisa.GradientButtonDemo.csproj", "{19457083-89B8-4C5B-BCA1-BFB66C273502}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.Tests", "tests\Elfisa.Tests\Elfisa.Tests.csproj", "{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.Core", "src\Elfisa.Core\Elfisa.Core.csproj", "{40529B23-A30E-4BCF-B706-254DB547A643}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.Avalonia", "src\Elfisa.Avalonia\Elfisa.Avalonia.csproj", "{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -83,6 +91,42 @@ Global {19457083-89B8-4C5B-BCA1-BFB66C273502}.Release|x64.Build.0 = Release|Any CPU {19457083-89B8-4C5B-BCA1-BFB66C273502}.Release|x86.ActiveCfg = Release|Any CPU {19457083-89B8-4C5B-BCA1-BFB66C273502}.Release|x86.Build.0 = Release|Any CPU + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|x64.ActiveCfg = Debug|Any CPU + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|x64.Build.0 = Debug|Any CPU + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|x86.ActiveCfg = Debug|Any CPU + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|x86.Build.0 = Debug|Any CPU + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|Any CPU.Build.0 = Release|Any CPU + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|x64.ActiveCfg = Release|Any CPU + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|x64.Build.0 = Release|Any CPU + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|x86.ActiveCfg = Release|Any CPU + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|x86.Build.0 = Release|Any CPU + {40529B23-A30E-4BCF-B706-254DB547A643}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {40529B23-A30E-4BCF-B706-254DB547A643}.Debug|Any CPU.Build.0 = Debug|Any CPU + {40529B23-A30E-4BCF-B706-254DB547A643}.Debug|x64.ActiveCfg = Debug|Any CPU + {40529B23-A30E-4BCF-B706-254DB547A643}.Debug|x64.Build.0 = Debug|Any CPU + {40529B23-A30E-4BCF-B706-254DB547A643}.Debug|x86.ActiveCfg = Debug|Any CPU + {40529B23-A30E-4BCF-B706-254DB547A643}.Debug|x86.Build.0 = Debug|Any CPU + {40529B23-A30E-4BCF-B706-254DB547A643}.Release|Any CPU.ActiveCfg = Release|Any CPU + {40529B23-A30E-4BCF-B706-254DB547A643}.Release|Any CPU.Build.0 = Release|Any CPU + {40529B23-A30E-4BCF-B706-254DB547A643}.Release|x64.ActiveCfg = Release|Any CPU + {40529B23-A30E-4BCF-B706-254DB547A643}.Release|x64.Build.0 = Release|Any CPU + {40529B23-A30E-4BCF-B706-254DB547A643}.Release|x86.ActiveCfg = Release|Any CPU + {40529B23-A30E-4BCF-B706-254DB547A643}.Release|x86.Build.0 = Release|Any CPU + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|Any CPU.Build.0 = Debug|Any CPU + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|x64.ActiveCfg = Debug|Any CPU + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|x64.Build.0 = Debug|Any CPU + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|x86.ActiveCfg = Debug|Any CPU + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|x86.Build.0 = Debug|Any CPU + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|Any CPU.ActiveCfg = Release|Any CPU + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|Any CPU.Build.0 = Release|Any CPU + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|x64.ActiveCfg = Release|Any CPU + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|x64.Build.0 = Release|Any CPU + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|x86.ActiveCfg = Release|Any CPU + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -91,5 +135,8 @@ Global {7974F646-2F23-46E3-A614-A3D0C5D8C844} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {1A28299F-5E9B-4E2E-AEC1-EB35FB911F85} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {19457083-89B8-4C5B-BCA1-BFB66C273502} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {40529B23-A30E-4BCF-B706-254DB547A643} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj index baf0393..c812fad 100644 --- a/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj +++ b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj @@ -14,6 +14,10 @@ ru;en + + + + diff --git a/src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs b/src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs index 8f20957..22a0b31 100644 --- a/src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs +++ b/src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs @@ -208,14 +208,14 @@ public partial class PriceListViewModel : ViewModelBase SelectedOffer = Offers.FirstOrDefault(); } - private static string ExtractDosage(string? drugName) + internal static string ExtractDosage(string? drugName) { if (string.IsNullOrWhiteSpace(drugName)) return string.Empty; var m = DosageRegex.Match(drugName); return m.Success ? m.Value.Trim() : string.Empty; } - private static string BuildBaseName(string? drugName, string? tradeName, string dosage) + internal static string BuildBaseName(string? drugName, string? tradeName, string dosage) { if (!string.IsNullOrWhiteSpace(tradeName)) return tradeName.Trim(); diff --git a/src/Elfisa.Core/ApiClient.cs b/src/Elfisa.Core/ApiClient.cs index c810fe6..b74936e 100644 --- a/src/Elfisa.Core/ApiClient.cs +++ b/src/Elfisa.Core/ApiClient.cs @@ -25,13 +25,20 @@ public sealed class ApiClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; } + // Тест-конструктор: подмена HTTP-обработчика (мок) для юнит-тестов. + internal ApiClient(HttpMessageHandler handler, string baseUrl) + { + _baseUrl = baseUrl.TrimEnd('/'); + _http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(120) }; + } + public string BaseUrl => _baseUrl; public string? Token => _token; public bool IsAuthenticated => !string.IsNullOrEmpty(_token); public void SetToken(string? token) => _token = string.IsNullOrWhiteSpace(token) ? null : token; - private string Url(string path) => new Uri(new Uri(_baseUrl + "/"), path.TrimStart('/')).ToString(); + internal string Url(string path) => new Uri(new Uri(_baseUrl + "/"), path.TrimStart('/')).ToString(); /// Логин. Возвращает (token, role) либо бросает с понятным сообщением. public async Task LoginAsync(string username, string password) diff --git a/src/Elfisa.Core/Elfisa.Core.csproj b/src/Elfisa.Core/Elfisa.Core.csproj index fa71b7a..47b4598 100644 --- a/src/Elfisa.Core/Elfisa.Core.csproj +++ b/src/Elfisa.Core/Elfisa.Core.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/Elfisa.Core/Session.cs b/src/Elfisa.Core/Session.cs index 4c09845..31ae125 100644 --- a/src/Elfisa.Core/Session.cs +++ b/src/Elfisa.Core/Session.cs @@ -47,7 +47,7 @@ public sealed class Session } /// Проверяет, истёк ли JWT (по claim exp), с запасом в 1 минуту. - private static bool IsJwtExpired(string token) + internal static bool IsJwtExpired(string token) { try { diff --git a/src/Elfisa.Core/UpdateService.cs b/src/Elfisa.Core/UpdateService.cs index 2401054..83f31a8 100644 --- a/src/Elfisa.Core/UpdateService.cs +++ b/src/Elfisa.Core/UpdateService.cs @@ -124,7 +124,7 @@ public static class UpdateService } /// Разбирает тег вида v2.0.4 / 2.0.4 / 2.0.4-beta в Version. - private static Version? ParseVersion(string? tag) + internal static Version? ParseVersion(string? tag) { if (string.IsNullOrWhiteSpace(tag)) return null; tag = tag.Trim(); diff --git a/tests/Elfisa.Tests/ApiClientTests.cs b/tests/Elfisa.Tests/ApiClientTests.cs new file mode 100644 index 0000000..19af307 --- /dev/null +++ b/tests/Elfisa.Tests/ApiClientTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using System.Net.Http; +using Elfisa.Core; +using Xunit; + +namespace Elfisa.Tests; + +public class ApiClientTests +{ + private static ApiClient Client(HttpMessageHandler h) => new(h, "https://api.test"); + + [Fact] + public void Url_builds_absolute_paths() + { + var c = new ApiClient("https://api.test/"); + Assert.Equal("https://api.test/auth/login", c.Url("/auth/login")); + Assert.Equal("https://api.test/x/y", c.Url("x/y")); + } + + [Fact] + public async Task Login_success_returns_token_and_role() + { + var h = new FakeHttpHandler(HttpStatusCode.OK, "{\"token\":\"abc\",\"role\":\"buyer\"}"); + var resp = await Client(h).LoginAsync("u", "p"); + Assert.Equal("abc", resp.Token); + Assert.Equal("buyer", resp.Role); + } + + [Fact] + public async Task Login_401_maps_to_friendly_message() + { + var h = new FakeHttpHandler(HttpStatusCode.Unauthorized, "{\"error\":\"x\"}"); + var ex = await Assert.ThrowsAsync(() => Client(h).LoginAsync("u", "p")); + Assert.Contains("Неверный логин", ex.Message); + } + + [Fact] + public async Task Login_429_maps_to_ratelimit_message() + { + var h = new FakeHttpHandler((HttpStatusCode)429, "{}"); + var ex = await Assert.ThrowsAsync(() => Client(h).LoginAsync("u", "p")); + Assert.Contains("Слишком много", ex.Message); + } + + [Fact] + public async Task Login_retries_transient_then_succeeds() + { + var h = new FakeHttpHandler((req, i) => + i < 2 ? throw new HttpRequestException("transient") + : FakeHttpHandler.Json(HttpStatusCode.OK, "{\"token\":\"ok\"}")); + var resp = await Client(h).LoginAsync("u", "p"); + Assert.Equal("ok", resp.Token); + Assert.Equal(3, h.Calls); // 2 сбоя + 1 успех + } + + [Fact] + public async Task Login_gives_up_after_retries() + { + var h = new FakeHttpHandler((req, i) => throw new HttpRequestException("down")); + await Assert.ThrowsAsync(() => Client(h).LoginAsync("u", "p")); + Assert.Equal(3, h.Calls); // 1 попытка + 2 повтора + } + + [Fact] + public async Task Summary_parses_response_and_builds_query() + { + var body = "{\"total_drugs\":2,\"summary\":[{\"supplier_price_id\":\"1\",\"drug_name\":\"Аспирин\",\"price\":10.5}]}"; + var h = new FakeHttpHandler(HttpStatusCode.OK, body); + var c = Client(h); + c.SetToken("t"); + var resp = await c.GetPriceSummaryAsync("асп", limit: 50, offset: 20); + Assert.Equal(2, resp.TotalDrugs); + Assert.Single(resp.Summary!); + Assert.Equal("Аспирин", resp.Summary![0].DrugName); + Assert.Equal(10.5m, resp.Summary![0].Price); + + var url = h.LastRequest!.RequestUri!.ToString(); + Assert.Contains("limit=50", url); + Assert.Contains("offset=20", url); + Assert.Contains("q=", url); + } + + [Fact] + public async Task Summary_short_query_omits_q() + { + var h = new FakeHttpHandler(HttpStatusCode.OK, "{\"total_drugs\":0,\"summary\":[]}"); + var c = Client(h); + c.SetToken("t"); + await c.GetPriceSummaryAsync("a", limit: 10, offset: 0); // 1 символ < 2 → без q + Assert.DoesNotContain("q=", h.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task Authorized_call_without_token_throws() + { + var h = new FakeHttpHandler(HttpStatusCode.OK, "{}"); + var c = Client(h); // токен не задан + await Assert.ThrowsAsync(() => c.GetPriceSummaryAsync("асп")); + } +} diff --git a/tests/Elfisa.Tests/CartServiceTests.cs b/tests/Elfisa.Tests/CartServiceTests.cs new file mode 100644 index 0000000..2d9d83d --- /dev/null +++ b/tests/Elfisa.Tests/CartServiceTests.cs @@ -0,0 +1,69 @@ +using Elfisa.Avalonia.ViewModels; +using Elfisa.Core; +using Xunit; + +namespace Elfisa.Tests; + +// Все тесты корзины в одном классе → xUnit гонит их последовательно (синглтон CartService). +public class CartServiceTests +{ + private static PriceItem Item(string id, decimal price, string name = "X") + => new() { SupplierPriceId = id, Price = price, DrugName = name, SupplierName = "S" }; + + [Fact] + public void Add_items_and_total() + { + var cart = CartService.Instance; + cart.Clear(); + cart.Add(Item("1", 10m), 2); + cart.Add(Item("2", 5m), 3); + Assert.Equal(2, cart.Count); + Assert.Equal(35m, cart.Total); // 20 + 15 + cart.Clear(); + } + + [Fact] + public void Add_same_id_merges_quantity() + { + var cart = CartService.Instance; + cart.Clear(); + cart.Add(Item("1", 10m), 2); + cart.Add(Item("1", 10m), 3); // тот же supplier_price_id → qty += 3 + Assert.Equal(1, cart.Count); + Assert.Equal(50m, cart.Total); // qty 5 * 10 + cart.Clear(); + } + + [Fact] + public void Add_ignores_empty_id() + { + var cart = CartService.Instance; + cart.Clear(); + cart.Add(Item("", 10m), 1); + Assert.Equal(0, cart.Count); + } + + [Fact] + public void Remove_and_clear() + { + var cart = CartService.Instance; + cart.Clear(); + cart.Add(Item("1", 10m), 1); + cart.Remove(cart.Items[0]); + Assert.Equal(0, cart.Count); + } + + [Fact] + public void Changed_event_fires_on_add() + { + var cart = CartService.Instance; + cart.Clear(); + int fired = 0; + Action handler = () => fired++; + cart.Changed += handler; + cart.Add(Item("1", 10m), 1); + cart.Changed -= handler; + cart.Clear(); + Assert.True(fired >= 1); + } +} diff --git a/tests/Elfisa.Tests/DraftTests.cs b/tests/Elfisa.Tests/DraftTests.cs new file mode 100644 index 0000000..3c37fbe --- /dev/null +++ b/tests/Elfisa.Tests/DraftTests.cs @@ -0,0 +1,36 @@ +using Elfisa.Core; +using Xunit; + +namespace Elfisa.Tests; + +public class DraftTests +{ + [Fact] + public void DraftItem_line_sum_multiplies() + { + var it = new DraftItem { Price = 10.5m, Qty = 3 }; + Assert.Equal(31.5m, it.LineSum); + } + + [Fact] + public void DraftOrder_total_and_count() + { + var o = new DraftOrder(); + o.Items.Add(new DraftItem { Price = 10m, Qty = 2 }); // 20 + o.Items.Add(new DraftItem { Price = 5.5m, Qty = 4 }); // 22 + Assert.Equal(42m, o.Total); + Assert.Equal(2, o.ItemsCount); + } + + [Fact] + public void DraftOrder_generates_id() + => Assert.False(string.IsNullOrWhiteSpace(new DraftOrder().Id)); + + [Fact] + public void Empty_order_totals_zero() + { + var o = new DraftOrder(); + Assert.Equal(0m, o.Total); + Assert.Equal(0, o.ItemsCount); + } +} diff --git a/tests/Elfisa.Tests/DtoTests.cs b/tests/Elfisa.Tests/DtoTests.cs new file mode 100644 index 0000000..d9fc920 --- /dev/null +++ b/tests/Elfisa.Tests/DtoTests.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using Elfisa.Core; +using Xunit; + +namespace Elfisa.Tests; + +public class DtoTests +{ + [Fact] + public void PriceItem_deserializes_snake_case() + { + var json = "{\"supplier_price_id\":\"sp1\",\"drug_name\":\"Аспирин\",\"barcode\":\"123\"," + + "\"price\":12.30,\"quantity\":5,\"es_code\":777,\"trade_name\":\"Асп\"}"; + var it = JsonSerializer.Deserialize(json)!; + Assert.Equal("sp1", it.SupplierPriceId); + Assert.Equal("Аспирин", it.DrugName); + Assert.Equal("123", it.Barcode); + Assert.Equal(12.30m, it.Price); + Assert.Equal(5m, it.Quantity); + Assert.Equal(777, it.EsCode); + Assert.Equal("Асп", it.TradeName); + } + + [Fact] + public void BuyerOrderCreateRequest_serializes_snake_case() + { + var req = new BuyerOrderCreateRequest + { + LocationId = "loc1", + GlobalSign = "EX-1", + Items = { new BuyerOrderItemReq { SupplierPriceId = "sp1", Qty = 2, ItemName = "Товар", Barcode = "111" } } + }; + var json = JsonSerializer.Serialize(req); + Assert.Contains("\"location_id\":\"loc1\"", json); + Assert.Contains("\"supplier_price_id\":\"sp1\"", json); + Assert.Contains("\"qty\":2", json); + } + + [Fact] + public void LoginResponse_round_trip() + { + var r = JsonSerializer.Deserialize( + "{\"token\":\"t\",\"role\":\"manager\",\"redirect_url\":\"/x\"}")!; + Assert.Equal("t", r.Token); + Assert.Equal("manager", r.Role); + Assert.Equal("/x", r.RedirectUrl); + } + + [Fact] + public void AppSettings_round_trip_preserves_fields() + { + var s = new AppSettings { ApiBaseUrl = "https://x", Theme = "Dark", FontFamily = "Arial", UiScale = 1.3, Username = "u" }; + var back = JsonSerializer.Deserialize(JsonSerializer.Serialize(s))!; + Assert.Equal("https://x", back.ApiBaseUrl); + Assert.Equal("Dark", back.Theme); + Assert.Equal("Arial", back.FontFamily); + Assert.Equal(1.3, back.UiScale); + Assert.Equal("u", back.Username); + } +} diff --git a/tests/Elfisa.Tests/Elfisa.Tests.csproj b/tests/Elfisa.Tests/Elfisa.Tests.csproj new file mode 100644 index 0000000..a4d256f --- /dev/null +++ b/tests/Elfisa.Tests/Elfisa.Tests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + false + Elfisa.Tests + Elfisa.Tests + + + + + + + + + + + + + + diff --git a/tests/Elfisa.Tests/PriceLogicTests.cs b/tests/Elfisa.Tests/PriceLogicTests.cs new file mode 100644 index 0000000..55a6208 --- /dev/null +++ b/tests/Elfisa.Tests/PriceLogicTests.cs @@ -0,0 +1,36 @@ +using Elfisa.Avalonia.ViewModels; +using Xunit; + +namespace Elfisa.Tests; + +// Логика дрилдаула прайса: извлечение дозировки (МГ) и базового наименования. +public class PriceLogicTests +{ + [Theory] + [InlineData("Нурофен таб. 200мг №10", "200мг")] + [InlineData("Парацетамол 500 мг таб", "500 мг")] + [InlineData("Витамин D3 2000 МЕ", "2000 МЕ")] + [InlineData("Настойка 25мл", "25мл")] + [InlineData("Метформин 1000мг", "1000мг")] + [InlineData("Бинт стерильный", "")] + [InlineData("", "")] + [InlineData(null, "")] + public void ExtractDosage_finds_unit(string? name, string expected) + => Assert.Equal(expected, PriceListViewModel.ExtractDosage(name)); + + [Fact] + public void BuildBaseName_prefers_trade_name() + => Assert.Equal("Нурофен", PriceListViewModel.BuildBaseName("Нурофен таб 200мг", "Нурофен", "200мг")); + + [Fact] + public void BuildBaseName_strips_dosage() + => Assert.Equal("Аспирин", PriceListViewModel.BuildBaseName("Аспирин 500мг", null, "500мг")); + + [Fact] + public void BuildBaseName_keeps_name_without_dosage() + => Assert.Equal("Бинт стерильный", PriceListViewModel.BuildBaseName("Бинт стерильный", null, "")); + + [Fact] + public void BuildBaseName_empty_returns_empty() + => Assert.Equal("", PriceListViewModel.BuildBaseName("", null, "")); +} diff --git a/tests/Elfisa.Tests/README.md b/tests/Elfisa.Tests/README.md new file mode 100644 index 0000000..220f4aa --- /dev/null +++ b/tests/Elfisa.Tests/README.md @@ -0,0 +1,39 @@ +# Тесты десктопа (Elfisa.Tests) + +Автотесты для кросс-платформенного клиента ЭльФиСА (`Elfisa.Core` + `Elfisa.Avalonia`). + +## Запуск + +```bash +dotnet test tests/Elfisa.Tests/Elfisa.Tests.csproj +``` + +или всё решение: + +```bash +dotnet test Elfisa.sln +``` + +CI (`.github/workflows/desktop-tests.yml`) гоняет их автоматически при пуше в `src/**` или `tests/**`. + +## Что покрыто (56 тестов) + +| Файл | Область | +|---|---| +| `UpdateServiceTests` | разбор версий тегов (`v2.0.4`→`2.0.4.0`), сравнение «новее», URL установщика | +| `SessionTests` | срок JWT-сессии (`IsJwtExpired`): будущий/просроченный/битый токен, запас 60с | +| `ApiClientTests` | сборка URL, маппинг ошибок (401/429), ретраи на транзиентных сбоях, парсинг сводного прайса, требование токена | +| `DtoTests` | JSON-контракты (snake_case) DTO: `PriceItem`, `BuyerOrderCreateRequest`, `LoginResponse`, `AppSettings` | +| `DraftTests` | суммы черновиков заказа (`LineSum`, `Total`, `ItemsCount`) | +| `CartServiceTests` | корзина: добавление, слияние по `supplier_price_id`, удаление, очистка, событие Changed | +| `PriceLogicTests` | дрилдаун прайса: извлечение дозировки (МГ) и базового наименования | + +## Как устроены моки + +- `FakeHttpHandler` (в `TestHelpers.cs`) — подменяет HTTP у `ApiClient` через internal-конструктор `ApiClient(HttpMessageHandler, baseUrl)`. Позволяет тестировать ретраи/ошибки/парсинг без сети. +- `Jwt.Make(exp)` — генерирует JWT с нужным `exp` для тестов сессии. +- Тестируемые внутренние методы открыты через `InternalsVisibleTo("Elfisa.Tests")` (в csproj `Elfisa.Core` и `Elfisa.Avalonia`). + +## Задел (не сделано) + +Headless UI-тесты (рендер вьюх без экрана) — `Avalonia.Headless.XUnit` под Avalonia 12 требует **xUnit v3**, что конфликтует с текущим v2. Добавить после миграции тест-проекта на xUnit v3. diff --git a/tests/Elfisa.Tests/SessionTests.cs b/tests/Elfisa.Tests/SessionTests.cs new file mode 100644 index 0000000..b8cc2b9 --- /dev/null +++ b/tests/Elfisa.Tests/SessionTests.cs @@ -0,0 +1,32 @@ +using Elfisa.Core; +using Xunit; + +namespace Elfisa.Tests; + +public class SessionTests +{ + [Fact] + public void Future_token_is_not_expired() + => Assert.False(Session.IsJwtExpired(Jwt.Make(Jwt.Now + 3600))); + + [Fact] + public void Past_token_is_expired() + => Assert.True(Session.IsJwtExpired(Jwt.Make(Jwt.Now - 10))); + + [Fact] + public void Within_60s_skew_is_treated_expired() + => Assert.True(Session.IsJwtExpired(Jwt.Make(Jwt.Now + 30))); + + [Theory] + [InlineData("")] + [InlineData("onlyonepart")] + [InlineData("has.two")] + [InlineData("too.many.parts.here")] + [InlineData("a.!!!notbase64!!!.c")] + public void Malformed_token_is_treated_expired(string token) + => Assert.True(Session.IsJwtExpired(token)); + + [Fact] + public void No_exp_claim_is_not_expired() + => Assert.False(Session.IsJwtExpired(Jwt.MakeNoExp())); +} diff --git a/tests/Elfisa.Tests/TestHelpers.cs b/tests/Elfisa.Tests/TestHelpers.cs new file mode 100644 index 0000000..2a53035 --- /dev/null +++ b/tests/Elfisa.Tests/TestHelpers.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Net.Http; +using System.Text; + +namespace Elfisa.Tests; + +/// Мок HttpMessageHandler: отдаёт заданные ответы или бросает ошибки. +internal sealed class FakeHttpHandler : HttpMessageHandler +{ + private readonly Func _responder; + public int Calls { get; private set; } + public HttpRequestMessage? LastRequest { get; private set; } + + public FakeHttpHandler(Func responder) => _responder = responder; + + public FakeHttpHandler(HttpStatusCode code, string body) + : this((_, __) => Json(code, body)) { } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + var i = Calls++; + return Task.FromResult(_responder(request, i)); + } + + public static HttpResponseMessage Json(HttpStatusCode code, string body) => + new(code) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; +} + +/// Генератор JWT для тестов срока действия сессии. +internal static class Jwt +{ + public static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + private static string B64(string s) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(s)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + public static string Make(long exp) => $"{B64("{\"alg\":\"HS256\"}")}.{B64($"{{\"exp\":{exp}}}")}.sig"; + + public static string MakeNoExp() => $"{B64("{\"alg\":\"HS256\"}")}.{B64("{\"sub\":\"x\"}")}.sig"; +} diff --git a/tests/Elfisa.Tests/UpdateServiceTests.cs b/tests/Elfisa.Tests/UpdateServiceTests.cs new file mode 100644 index 0000000..b6fa550 --- /dev/null +++ b/tests/Elfisa.Tests/UpdateServiceTests.cs @@ -0,0 +1,39 @@ +using Elfisa.Core; +using Xunit; + +namespace Elfisa.Tests; + +public class UpdateServiceTests +{ + [Theory] + [InlineData("v2.0.4", "2.0.4.0")] + [InlineData("2.0.4", "2.0.4.0")] + [InlineData("2.0", "2.0.0.0")] + [InlineData("2", "2.0.0.0")] + [InlineData("2.0.4-beta", "2.0.4.0")] + [InlineData("V10.2.1", "10.2.1.0")] + [InlineData(" 2.0.9 ", "2.0.9.0")] + public void ParseVersion_valid(string tag, string expected) + => Assert.Equal(Version.Parse(expected), UpdateService.ParseVersion(tag)); + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData("garbage")] + [InlineData("v.x.y")] + public void ParseVersion_invalid_returns_null(string? tag) + => Assert.Null(UpdateService.ParseVersion(tag)); + + [Fact] + public void Newer_version_compares_greater() + { + Assert.True(UpdateService.ParseVersion("v2.0.5") > UpdateService.ParseVersion("v2.0.4")); + Assert.True(UpdateService.ParseVersion("2.1.0") > UpdateService.ParseVersion("2.0.9")); + Assert.False(UpdateService.ParseVersion("2.0.4") > UpdateService.ParseVersion("2.0.4")); + Assert.True(UpdateService.ParseVersion("v3") > UpdateService.ParseVersion("2.9.9")); + } + + [Fact] + public void SetupUrl_points_to_cdn() + => Assert.Equal("https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe", UpdateService.SetupUrl); +}