тесты: автотесты десктопа (xUnit) + CI
Some checks failed
Desktop tests / test (push) Has been cancelled

- проект 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 <noreply@anthropic.com>
This commit is contained in:
Exest 2026-08-17 11:51:50 +03:00
parent 60ab620608
commit 3d89fac6e0
18 changed files with 572 additions and 5 deletions

30
.github/workflows/desktop-tests.yml vendored Normal file
View File

@ -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"

View File

@ -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

View File

@ -14,6 +14,10 @@
<SatelliteResourceLanguages>ru;en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Elfisa.Tests" />
</ItemGroup>
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />

View File

@ -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();

View File

@ -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();
/// <summary>Логин. Возвращает (token, role) либо бросает с понятным сообщением.</summary>
public async Task<LoginResponse> LoginAsync(string username, string password)

View File

@ -6,4 +6,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Elfisa.Tests" />
</ItemGroup>
</Project>

View File

@ -47,7 +47,7 @@ public sealed class Session
}
/// <summary>Проверяет, истёк ли JWT (по claim exp), с запасом в 1 минуту.</summary>
private static bool IsJwtExpired(string token)
internal static bool IsJwtExpired(string token)
{
try
{

View File

@ -124,7 +124,7 @@ public static class UpdateService
}
/// <summary>Разбирает тег вида v2.0.4 / 2.0.4 / 2.0.4-beta в Version.</summary>
private static Version? ParseVersion(string? tag)
internal static Version? ParseVersion(string? tag)
{
if (string.IsNullOrWhiteSpace(tag)) return null;
tag = tag.Trim();

View File

@ -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<ApiException>(() => 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<ApiException>(() => 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<ApiException>(() => 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<ApiException>(() => c.GetPriceSummaryAsync("асп"));
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<PriceItem>(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<LoginResponse>(
"{\"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<AppSettings>(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);
}
}

View File

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<AssemblyName>Elfisa.Tests</AssemblyName>
<RootNamespace>Elfisa.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Elfisa.Core\Elfisa.Core.csproj" />
<ProjectReference Include="..\..\src\Elfisa.Avalonia\Elfisa.Avalonia.csproj" />
</ItemGroup>
</Project>

View File

@ -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, ""));
}

View File

@ -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.

View File

@ -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()));
}

View File

@ -0,0 +1,41 @@
using System.Net;
using System.Net.Http;
using System.Text;
namespace Elfisa.Tests;
/// <summary>Мок HttpMessageHandler: отдаёт заданные ответы или бросает ошибки.</summary>
internal sealed class FakeHttpHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, int, HttpResponseMessage> _responder;
public int Calls { get; private set; }
public HttpRequestMessage? LastRequest { get; private set; }
public FakeHttpHandler(Func<HttpRequestMessage, int, HttpResponseMessage> responder) => _responder = responder;
public FakeHttpHandler(HttpStatusCode code, string body)
: this((_, __) => Json(code, body)) { }
protected override Task<HttpResponseMessage> 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") };
}
/// <summary>Генератор JWT для тестов срока действия сессии.</summary>
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";
}

View File

@ -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);
}