Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
**/bin/
**/obj/
.git/
.vs/
.github/
**/*.local.csv
BiStudent.Server/App_Data/
BiStudent/
BiStudent.Tests/
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI

on:
push:
branches: [main, 'release/**']
pull_request:
branches: [main]
workflow_dispatch:

jobs:
build-test:
runs-on: windows-2025
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- run: dotnet restore BiStudent.slnx
- run: dotnet build BiStudent.slnx -c Release --no-restore -warnaserror
- run: dotnet test --solution BiStudent.slnx -c Release --no-build
- uses: actions/setup-node@v5
with:
node-version: '24'
- name: Registration site tests
run: node --test tests/site/register.test.cjs
- name: Desktop self-test
shell: pwsh
run: |
$exe = Get-ChildItem BiStudent/bin/Release -Recurse -Filter BiStudent.exe | Select-Object -First 1
$p = Start-Process $exe.FullName -ArgumentList '--self-test' -Wait -PassThru
$result = Join-Path $exe.DirectoryName 'diagnostics/result.txt'
if ($p.ExitCode -ne 0 -or (Get-Content $result) -ne 'OK') { Get-Content (Join-Path $exe.DirectoryName 'self-test-error.txt') -ErrorAction SilentlyContinue; exit 1 }

docker:
runs-on: ubuntu-24.04
needs: build-test
steps:
- uses: actions/checkout@v5
- run: docker build -f BiStudent.Server/Dockerfile -t bistudent-server .
- name: Smoke test
run: |
docker run -d --name bistudent -p 5080:8080 bistudent-server
for i in $(seq 1 30); do curl -fsS http://localhost:5080/health && exit 0; sleep 2; done
docker logs bistudent
exit 1

publish:
runs-on: windows-2025
needs: build-test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- run: dotnet publish BiStudent/BiStudent.csproj -c Release -r win-x64 --self-contained true -o out/BiStudent-Client
- run: dotnet publish BiStudent.Server/BiStudent.Server.csproj -c Release -r win-x64 --self-contained true -o out/BiStudent-Server
- uses: actions/upload-artifact@v5
with:
name: BiStudent-win-x64
path: out/
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,13 @@
**/obj/
dist/
*.csproj.user
*.user
BiStudent.Server/seed/*.local.csv
BiStudent.Server/seed/*.local.xlsx
BiStudent.Server/seed/*.old.csv
BiStudent.Server/App_Data*/

# Модель SFace скачивается при сборке (см. BiStudent.Core.csproj)
BiStudent.Core/Models/face_recognition_sface_2021dec.onnx
BiStudent.Core/Models/*.download
TestResults/
317 changes: 317 additions & 0 deletions BiStudent.Client.Tests/ApiClientTests.cs

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions BiStudent.Client.Tests/AuditClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System.Net;
using BiStudent.Contracts;

namespace BiStudent.Client.Tests;

public sealed class AuditClientTests
{
[Fact]
public async Task ChangePasswordRefreshesExpiredAccessTokenAndRetries()
{
var server = new FakeServer();
var attempts = 0;
server.On(HttpMethod.Post, "api/auth/login", _ => FakeServer.Json(new TokenResponse("old", 60, "old-r")));
server.On(HttpMethod.Post, "api/auth/refresh", _ => FakeServer.Json(new TokenResponse("new", 60, "new-r")));
server.On(HttpMethod.Post, "api/auth/change-password", _ =>
++attempts == 1
? FakeServer.Error(HttpStatusCode.Unauthorized, "Истёк")
: FakeServer.Json(new TokenResponse("changed", 60, "changed-r")));
server.On(HttpMethod.Get, "api/me", _ => FakeServer.Json(new TeacherDto(7, "Преподаватель", "teacher")));
using var api = new ApiClient(new ClientSettings { ServerUrl = "http://test/" }, server);

await api.LoginAsync("teacher", "secret");
await api.ChangePasswordAsync("secret", "new password");

var requests = server.Requests;
var changes = requests.Where(request => request.Path == "api/auth/change-password").ToList();
Assert.Equal(2, changes.Count);
Assert.Equal("Bearer old", changes[0].Authorization);
Assert.Equal("Bearer new", changes[1].Authorization);
Assert.Equal(changes[0].Body, changes[1].Body);
Assert.Single(requests, request => request.Path == "api/auth/refresh");
Assert.Equal("changed-r", api.RefreshToken);
}
}
21 changes: 21 additions & 0 deletions BiStudent.Client.Tests/BiStudent.Client.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);xUnit1051</NoWarn>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="4.0.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BiStudent\BiStudent.csproj" />
<Using Include="Xunit" />
</ItemGroup>
</Project>
88 changes: 88 additions & 0 deletions BiStudent.Client.Tests/ClientSettingsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
namespace BiStudent.Client.Tests;

[CollectionDefinition("settings", DisableParallelization = true)]
public sealed class SettingsCollection { }

[Collection("settings")]
public sealed class ClientSettingsTests : IDisposable
{
private readonly string previousPath = ClientSettings.FilePath;
private readonly string directory = Path.Combine(Path.GetTempPath(), "BiStudent.Tests", Guid.NewGuid().ToString("N"));

public ClientSettingsTests()
{
ClientSettings.FilePath = Path.Combine(directory, "client.json");
}

public void Dispose()
{
ClientSettings.FilePath = previousPath;
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}

[Fact]
public void MissingFileLoadsDefaults()
{
var settings = ClientSettings.Load();
Assert.Equal("http://localhost:5080", settings.ServerUrl);
Assert.Equal("", settings.LastLogin);
Assert.False(settings.RememberMe);
}

[Fact]
public void SaveAndLoadPreservePreferences()
{
new ClientSettings
{
ServerUrl = "https://school.example",
LastLogin = "teacher",
RememberMe = true,
CameraId = "camera-id",
CameraName = "Камера",
CameraIndex = 2
}.Save();
var loaded = ClientSettings.Load();
Assert.Equal("https://school.example", loaded.ServerUrl);
Assert.Equal("teacher", loaded.LastLogin);
Assert.True(loaded.RememberMe);
Assert.Equal("camera-id", loaded.CameraId);
Assert.Equal("Камера", loaded.CameraName);
Assert.Equal(2, loaded.CameraIndex);
}

[Fact]
public void DamagedJsonLoadsDefaults()
{
Directory.CreateDirectory(directory);
File.WriteAllText(ClientSettings.FilePath, "{bad json");
Assert.Equal("http://localhost:5080", ClientSettings.Load().ServerUrl);
}

[Fact]
public void RefreshTokenIsProtectedAndBoundToServer()
{
var settings = new ClientSettings { ServerUrl = "https://school.example" };
settings.StoreRefreshToken("abc");
settings.Save();
Assert.DoesNotContain("abc", File.ReadAllText(ClientSettings.FilePath));
var loaded = ClientSettings.Load();
Assert.Equal("abc", loaded.LoadRefreshToken());
loaded.ServerUrl = "HTTPS://SCHOOL.EXAMPLE/";
Assert.Equal("abc", loaded.LoadRefreshToken());
loaded.ServerUrl = "https://other.example";
Assert.Null(loaded.LoadRefreshToken());
}

[Fact]
public void ClearingOrCorruptingTokenReturnsNull()
{
var settings = new ClientSettings();
settings.StoreRefreshToken("abc");
settings.StoreRefreshToken(null);
Assert.Null(settings.LoadRefreshToken());
Assert.Null(settings.ProtectedRefreshToken);
settings.ProtectedRefreshToken = "not base64";
settings.RefreshTokenServer = settings.ServerUrl;
Assert.Null(settings.LoadRefreshToken());
}
}
121 changes: 121 additions & 0 deletions BiStudent.Client.Tests/DialogsSmokeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System.Drawing.Imaging;
using System.Net;
using System.Net.Http.Headers;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using BiStudent.Contracts;

namespace BiStudent.Client.Tests;

// Диалоги открываются поверх скрытого окна, загружают данные с фейкового сервера и закрываются без ошибок.
[Collection("settings")]
public sealed class DialogsSmokeTests
{
[Fact]
public void DialogsLoadAndClose()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var server = PagesSmokeTests.DemoServer();
server.On(HttpMethod.Get, "api/groups/1/enrollment-code", _ => FakeServer.Json(new EnrollmentCodeDto(1, "23-ПО-103-4р-1", "K7PM-Q2XA",
DateTime.Now, DateTime.Now.AddDays(1), ["http://192.168.0.10:5080/register/"])));
server.On(HttpMethod.Get, "api/admin/backups", _ => FakeServer.Json(new List<BackupFileDto>
{ new("bistudent-2026-10-16.zip", DateTime.Now, 1_234_567, true) }));
for (var index = 0; index < 3; index++)
server.On(HttpMethod.Get, $"api/face-submissions/7/images/{index}", _ => JpegResponse());

var slot = new SlotDto(10, 1, "23-ПО-103-4р-1", "Базы данных", "305", today.DayOfWeek,
new TimeOnly(8, 30), new TimeOnly(9, 50), WeekParity.Every, new DateOnly(2026, 9, 1), null, null);
var lesson = new LessonDto(slot, today, today.ToDateTime(slot.Start), today.ToDateTime(slot.End), false);
var groups = new List<GroupDto> { new(1, "23-ПО-103-4р-1", 3, 1) };
var opened = new List<string>();

RunSta(() =>
{
var previousPath = ClientSettings.FilePath;
var directory = Path.Combine(Path.GetTempPath(), "BiStudent.Tests", Guid.NewGuid().ToString("N"));
ClientSettings.FilePath = Path.Combine(directory, "client.json");
ExceptionDispatchInfo? failure = null;
try
{
using var api = new ApiClient(new ClientSettings { ServerUrl = "http://test/" }, server);
using var host = new Form { StartPosition = FormStartPosition.Manual, Location = new Point(-4000, -4000), ShowInTaskbar = false };
host.Shown += async (_, _) =>
{
try
{
await api.LoginAsync("teacher", "secret");
await api.LoadMeAsync();
var dialogs = new (string Name, Func<Form> Create)[]
{
("lesson", () => new LessonDialog(api, lesson)),
("enrollment-code", () => new EnrollmentCodeDialog(api, 1, "23-ПО-103-4р-1")),
("face-submissions", () => new FaceSubmissionsDialog(api)),
("backups", () => new BackupDialog(api)),
("slot-new", () => new SlotDialog(api, null, today, ["Базы данных"])),
("slot-edit", () => new SlotDialog(api, slot, today, ["Базы данных"])),
("student-new", () => new StudentDialog(api, groups, null, 1)),
("student-edit", () => new StudentDialog(api, groups, new StudentDto(101, "Абдиев Артур", 1, true), 1))
};
foreach (var (name, create) in dialogs)
{
using var dialog = create();
dialog.StartPosition = FormStartPosition.Manual;
dialog.Location = new Point(-4000, -4000);
dialog.ShowInTaskbar = false;
dialog.Show(host);
await Task.Delay(500);
using var bitmap = new Bitmap(Math.Max(1, dialog.ClientSize.Width), Math.Max(1, dialog.ClientSize.Height));
dialog.DrawToBitmap(bitmap, new Rectangle(Point.Empty, dialog.ClientSize));
dialog.Close();
opened.Add(name);
}
var column = new JournalColumnDto(JournalColumnKind.Lesson, 10, today, new TimeOnly(8, 30), "Базы данных");
using (new GradeEditor(api, column, new JournalCellDto(AttendanceStatus.Excused, ExcuseReason.Illness, "Справка", 80), 101, "Абдиев Артур")) { }
using (new GradeEditor(api, column, new JournalCellDto(null, null, null, null), 101, "Абдиев Артур", attendanceOnly: true)) { }
using (new GradeEditor(api, 1, "Базы данных", 101, "Абдиев Артур", 88)) { }
opened.Add("grade-editor");
}
catch (Exception ex) { failure = ExceptionDispatchInfo.Capture(ex); }
finally { host.Close(); }
};
Application.Run(host);
}
finally
{
ClientSettings.FilePath = previousPath;
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
failure?.Throw();
});

Assert.Equal(["lesson", "enrollment-code", "face-submissions", "backups", "slot-new", "slot-edit", "student-new", "student-edit", "grade-editor"], opened);
var missing = server.Requests.Where(r => !server.Handles(r)).Select(r => $"{r.Method} {r.Path}").Distinct().ToList();
Assert.True(missing.Count == 0, "Запросы без ответа фейкового сервера: " + string.Join("; ", missing));
Assert.Contains(server.Requests, r => r.Path.StartsWith("api/face-submissions/7/images/0", StringComparison.Ordinal));
}

private static HttpResponseMessage JpegResponse()
{
using var bitmap = new Bitmap(112, 112);
using (var graphics = Graphics.FromImage(bitmap)) graphics.Clear(Color.SlateGray);
using var stream = new MemoryStream();
bitmap.Save(stream, ImageFormat.Jpeg);
var content = new ByteArrayContent(stream.ToArray());
content.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
}

private static void RunSta(Action action)
{
ExceptionDispatchInfo? failure = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { failure = ExceptionDispatchInfo.Capture(ex); }
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
failure?.Throw();
}
}
45 changes: 45 additions & 0 deletions BiStudent.Client.Tests/FakeServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace BiStudent.Client.Tests;
internal sealed class FakeServer : HttpMessageHandler
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } };
private readonly object _gate = new();
private readonly Dictionary<(HttpMethod, string), Func<HttpRequestMessage, Task<HttpResponseMessage>>> _routes = new();
private readonly List<RequestEntry> _requests = new();
internal sealed record RequestEntry(HttpMethod Method, string Path, string? Authorization, string? Body);
public IReadOnlyList<RequestEntry> Requests { get { lock (_gate) return _requests.ToArray(); } }
public void On(HttpMethod method, string path, Func<HttpRequestMessage, Task<HttpResponseMessage>> handler)
{
lock (_gate) _routes[(method, path)] = handler;
}
public void On(HttpMethod method, string path, Func<HttpRequestMessage, HttpResponseMessage> handler) =>
On(method, path, request => Task.FromResult(handler(request)));
public static HttpResponseMessage Json(object? value, HttpStatusCode statusCode = HttpStatusCode.OK)
{
return new HttpResponseMessage(statusCode)
{
Content = new StringContent(JsonSerializer.Serialize(value, JsonOptions), System.Text.Encoding.UTF8, "application/json")
};
}
public static HttpResponseMessage Error(HttpStatusCode statusCode, string message) => Json(new { message }, statusCode);
public bool Handles(RequestEntry entry)
{
var path = entry.Path.Split('?')[0];
lock (_gate) return _routes.ContainsKey((entry.Method, path));
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri!.AbsolutePath.TrimStart('/');
var entry = new RequestEntry(request.Method, request.RequestUri.PathAndQuery.TrimStart('/'),
request.Headers.Authorization?.ToString(), request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken));
Func<HttpRequestMessage, Task<HttpResponseMessage>>? handler;
lock (_gate)
{
_requests.Add(entry);
_routes.TryGetValue((request.Method, path), out handler);
}
return handler is null ? new HttpResponseMessage(HttpStatusCode.NotFound) : await handler(request);
}
}
Loading
Loading