тесты: автотесты десктопа (xUnit) + CI
Some checks failed
Desktop tests / test (push) Has been cancelled
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:
parent
60ab620608
commit
3d89fac6e0
30
.github/workflows/desktop-tests.yml
vendored
Normal file
30
.github/workflows/desktop-tests.yml
vendored
Normal 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"
|
||||
47
Elfisa.sln
47
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
|
||||
|
||||
@ -14,6 +14,10 @@
|
||||
<SatelliteResourceLanguages>ru;en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Elfisa.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -6,4 +6,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Elfisa.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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();
|
||||
|
||||
100
tests/Elfisa.Tests/ApiClientTests.cs
Normal file
100
tests/Elfisa.Tests/ApiClientTests.cs
Normal 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("асп"));
|
||||
}
|
||||
}
|
||||
69
tests/Elfisa.Tests/CartServiceTests.cs
Normal file
69
tests/Elfisa.Tests/CartServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
36
tests/Elfisa.Tests/DraftTests.cs
Normal file
36
tests/Elfisa.Tests/DraftTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
60
tests/Elfisa.Tests/DtoTests.cs
Normal file
60
tests/Elfisa.Tests/DtoTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
23
tests/Elfisa.Tests/Elfisa.Tests.csproj
Normal file
23
tests/Elfisa.Tests/Elfisa.Tests.csproj
Normal 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>
|
||||
36
tests/Elfisa.Tests/PriceLogicTests.cs
Normal file
36
tests/Elfisa.Tests/PriceLogicTests.cs
Normal 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, ""));
|
||||
}
|
||||
39
tests/Elfisa.Tests/README.md
Normal file
39
tests/Elfisa.Tests/README.md
Normal 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.
|
||||
32
tests/Elfisa.Tests/SessionTests.cs
Normal file
32
tests/Elfisa.Tests/SessionTests.cs
Normal 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()));
|
||||
}
|
||||
41
tests/Elfisa.Tests/TestHelpers.cs
Normal file
41
tests/Elfisa.Tests/TestHelpers.cs
Normal 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";
|
||||
}
|
||||
39
tests/Elfisa.Tests/UpdateServiceTests.cs
Normal file
39
tests/Elfisa.Tests/UpdateServiceTests.cs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user