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
4 changes: 2 additions & 2 deletions .github/workflows/ci-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ jobs:
run: dotnet restore
working-directory: backend

- name: Build
run: dotnet build --no-restore -c Release
- name: Build and test
run: dotnet test -c Release
working-directory: backend
4 changes: 4 additions & 0 deletions .github/workflows/ci-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ jobs:
run: npm run lint
working-directory: frontend

- name: Test
run: npm test
working-directory: frontend

- name: Build
run: npm run build
working-directory: frontend
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,24 @@ npm run dev

Фронт: `http://localhost:5173`.

## Тесты

```bash
# Backend (xUnit)
cd backend
dotnet test

# Frontend (Vitest)
cd frontend
npm test
```

## Что сейчас сделано

- **Фронтенд**
- SPA на React + TypeScript + Vite.
- Экран курса и урока, редактор кода, локальное хранение прогресса.
- Дизайн и базовая геймификация (XP, уровни, достижения, фракции, магазин) — пока в основном на клиенте.
- Экран курса и урока, редактор кода, JWT-авторизация.
- Проверка Python-кода выполняется на бэкенде; геймификация (XP, прогресс, достижения, магазин) синхронизируется с API.

- **Бэкенд**
- ASP.NET Core 8 API, PostgreSQL (EF Core), JWT‑аутентификация.
Expand Down
69 changes: 69 additions & 0 deletions backend/CodeFlow.Api.Tests/AuthServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using CodeFlow.Api.DTOs;
using CodeFlow.Api.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;

namespace CodeFlow.Api.Tests;

public class AuthServiceTests
{
private static IConfiguration CreateConfig() =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Key"] = "CodeFlow-Test-SecretKey-32CharsMin!!",
["Jwt:Issuer"] = "CodeFlow.Test",
["Jwt:ExpirationMinutes"] = "60",
["App:BaseUrl"] = "http://localhost:5173"
})
.Build();

[Fact]
public async Task RegisterAsync_ValidUser_ReturnsToken()
{
var db = TestDbContextFactory.Create();
var service = new AuthService(db, CreateConfig(), new EmailStubService(NullLogger<EmailStubService>.Instance));

var result = await service.RegisterAsync(new RegisterRequest("new@codeflow.io", "secret12", "Agent"));

Assert.NotNull(result);
Assert.False(string.IsNullOrWhiteSpace(result!.AccessToken));
Assert.Equal("new@codeflow.io", result.User.Email);
Assert.True(await db.Users.AnyAsync(u => u.Email == "new@codeflow.io"));
}

[Fact]
public async Task RegisterAsync_DuplicateEmail_ReturnsNull()
{
var (db, user, _, _) = await TestDbContextFactory.SeedBasicAsync();
var service = new AuthService(db, CreateConfig(), new EmailStubService(NullLogger<EmailStubService>.Instance));

var result = await service.RegisterAsync(new RegisterRequest(user.Email, "secret12", "Dup"));

Assert.Null(result);
}

[Fact]
public async Task LoginAsync_ValidCredentials_ReturnsToken()
{
var (db, user, _, _) = await TestDbContextFactory.SeedBasicAsync();
var service = new AuthService(db, CreateConfig(), new EmailStubService(NullLogger<EmailStubService>.Instance));

var result = await service.LoginAsync(new LoginRequest(user.Email, "password123"));

Assert.NotNull(result);
Assert.False(string.IsNullOrWhiteSpace(result!.AccessToken));
}

[Fact]
public async Task LoginAsync_WrongPassword_ReturnsNull()
{
var (db, user, _, _) = await TestDbContextFactory.SeedBasicAsync();
var service = new AuthService(db, CreateConfig(), new EmailStubService(NullLogger<EmailStubService>.Instance));

var result = await service.LoginAsync(new LoginRequest(user.Email, "wrong"));

Assert.Null(result);
}
}
29 changes: 29 additions & 0 deletions backend/CodeFlow.Api.Tests/CodeFlow.Api.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<RootNamespace>CodeFlow.Api.Tests</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CodeFlow.Api\CodeFlow.Api.csproj" />
</ItemGroup>

</Project>
1 change: 1 addition & 0 deletions backend/CodeFlow.Api.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using Xunit;
31 changes: 31 additions & 0 deletions backend/CodeFlow.Api.Tests/LessonSubmitLogicTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace CodeFlow.Api.Tests;

/// <summary>
/// Проверка нормализации вывода при сравнении с эталоном (как в LessonsController).
/// </summary>
public class LessonSubmitLogicTests
{
[Theory]
[InlineData("hello\r\nworld", "hello\nworld")]
[InlineData("line\r\n", "line")]
[InlineData("", "")]
public void NormalizeOutput_MatchesControllerBehavior(string input, string expected)
{
var normalized = NormalizeOutput(input);
Assert.Equal(expected, normalized);
}

[Fact]
public void CompareOutputs_TrimmedAndUnifiedLineEndings()
{
const string actual = "OK\r\n";
const string expected = "OK";
Assert.Equal(NormalizeOutput(expected), NormalizeOutput(actual));
}

private static string NormalizeOutput(string s)
{
if (string.IsNullOrEmpty(s)) return "";
return s.TrimEnd().Replace("\r\n", "\n").Replace("\r", "\n");
}
}
37 changes: 37 additions & 0 deletions backend/CodeFlow.Api.Tests/MoralChoiceConfigTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using CodeFlow.Api.Config;

namespace CodeFlow.Api.Tests;

public class MoralChoiceConfigTests
{
[Theory]
[InlineData("Глава 1: Сигнал", "data_brokers", 500, 50)]
[InlineData("Глава 2: Логика", "ai_ethicists", 400, 50)]
[InlineData("Глава 5: Функции", "ghost_protocol", 400, 50)]
public void TryGetRewards_ReturnsChapterValues(string chapter, string faction, int expectedXp, int expectedRep)
{
var ok = MoralChoiceConfig.TryGetRewards(chapter, faction, out var xp, out var rep);

Assert.True(ok);
Assert.Equal(expectedXp, xp);
Assert.Equal(expectedRep, rep);
}

[Fact]
public void TryGetRewards_UnknownFaction_ReturnsFalse()
{
var ok = MoralChoiceConfig.TryGetRewards("Глава 1: Сигнал", "unknown_faction", out _, out _);

Assert.False(ok);
}

[Fact]
public void TryGetRewards_UnknownChapter_UsesFactionDefaults()
{
var ok = MoralChoiceConfig.TryGetRewards("Неизвестная глава", "ghost_protocol", out var xp, out var rep);

Assert.True(ok);
Assert.Equal(100, xp);
Assert.Equal(50, rep);
}
}
101 changes: 101 additions & 0 deletions backend/CodeFlow.Api.Tests/ProgressServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using CodeFlow.Api.DTOs;
using CodeFlow.Api.Models;
using CodeFlow.Api.Services;
using Microsoft.EntityFrameworkCore;

namespace CodeFlow.Api.Tests;

public class ProgressServiceTests
{
[Fact]
public async Task CompleteLessonAsync_AddsXpAndProgress()
{
var (db, user, lesson, _) = await TestDbContextFactory.SeedBasicAsync();
var service = new ProgressService(db);

var result = await service.CompleteLessonAsync(user.Id, new CompleteLessonRequest(lesson.Id, true));

Assert.NotNull(result);
Assert.Equal(100, result!.XpEarned);
var updated = await db.Users.Include(u => u.Progress).FirstAsync(u => u.Id == user.Id);
Assert.Equal(600, updated.TotalXp);
Assert.Single(updated.Progress);
}

[Fact]
public async Task CompleteLessonAsync_AlreadyCompleted_ReturnsNull()
{
var (db, user, lesson, _) = await TestDbContextFactory.SeedBasicAsync();
var service = new ProgressService(db);
await service.CompleteLessonAsync(user.Id, new CompleteLessonRequest(lesson.Id, true));

var second = await service.CompleteLessonAsync(user.Id, new CompleteLessonRequest(lesson.Id, true));

Assert.Null(second);
}

[Fact]
public async Task PurchaseHintAsync_Level1_Deducts50Xp()
{
var (db, user, lesson, _) = await TestDbContextFactory.SeedBasicAsync();
var service = new ProgressService(db);

var result = await service.PurchaseHintAsync(user.Id, new PurchaseHintRequest(lesson.Id, 1));

Assert.NotNull(result);
Assert.Equal(450, result!.TotalXp);
Assert.Equal(1, result.HintLevel);
Assert.Equal("Hint 1", result.HintText);
}

[Fact]
public async Task PurchaseHintAsync_InsufficientXp_ReturnsNull()
{
var (db, user, lesson, _) = await TestDbContextFactory.SeedBasicAsync();
user.TotalXp = 10;
await db.SaveChangesAsync();
var service = new ProgressService(db);

var result = await service.PurchaseHintAsync(user.Id, new PurchaseHintRequest(lesson.Id, 2));

Assert.Null(result);
}

[Fact]
public async Task ApplyMoralChoiceAsync_BossLesson_GrantsXpAndReputation()
{
var (db, user, _, faction) = await TestDbContextFactory.SeedBasicAsync();
var service = new ProgressService(db);

var result = await service.ApplyMoralChoiceAsync(user.Id, new MoralChoiceRequest(faction.Id, 4));

Assert.NotNull(result);
Assert.Equal(1000, result!.TotalXp);
var rep = await db.UserReputations.FirstOrDefaultAsync(r => r.UserId == user.Id && r.FactionId == faction.Id);
Assert.NotNull(rep);
Assert.Equal(50, rep!.Reputation);
}

[Fact]
public async Task ApplyMoralChoiceAsync_NonBossLesson_ReturnsNull()
{
var (db, user, lesson, faction) = await TestDbContextFactory.SeedBasicAsync();
var service = new ProgressService(db);

var result = await service.ApplyMoralChoiceAsync(user.Id, new MoralChoiceRequest(faction.Id, lesson.Id));

Assert.Null(result);
}

[Fact]
public async Task ApplyMoralChoiceAsync_DuplicateChoice_ReturnsNull()
{
var (db, user, _, faction) = await TestDbContextFactory.SeedBasicAsync();
var service = new ProgressService(db);
await service.ApplyMoralChoiceAsync(user.Id, new MoralChoiceRequest(faction.Id, 4));

var second = await service.ApplyMoralChoiceAsync(user.Id, new MoralChoiceRequest(faction.Id, 4));

Assert.Null(second);
}
}
Loading
Loading