Project to GitHub

This commit is contained in:
2026-06-24 08:33:23 +02:00
commit ecd89a68f8
121 changed files with 6481 additions and 0 deletions
@@ -0,0 +1,198 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Bsevita.Library.Api.Data.Generated;
using Bsevita.Library.Api.Data.Generated.Entities;
using Bsevita.Library.Models.Students;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace Bsevita.Library.Api.Tests;
public sealed class ApiIntegrationTests
{
[Fact]
public async Task SwaggerUi_AndSwaggerJson_AreAvailableInDevelopment()
{
await using var factory = new ApiFactory();
var client = factory.CreateClient();
var ui = await client.GetAsync("/swagger");
var document = await client.GetFromJsonAsync<JsonObject>("/swagger/v1/swagger.json");
Assert.Equal(HttpStatusCode.OK, ui.StatusCode);
Assert.Equal("Bsevita.Library.Api", document?["info"]?["title"]?.GetValue<string>());
Assert.NotNull(document?["paths"]?["/api/students"]);
Assert.NotNull(document?["paths"]?["/api/books"]);
Assert.NotNull(document?["paths"]?["/api/loans"]);
Assert.NotNull(document?["paths"]?["/api/returns/verify/{bookNumber}"]);
Assert.NotNull(document?["paths"]?["/api/reports/statistics"]);
}
[Fact]
public async Task PostStudent_ReturnsCreatedStudent()
{
await using var factory = new ApiFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/students", new SaveStudentRequest
{
CardNumber = "S-1",
FirstName = "Ada",
LastName = "Lovelace",
ClassName = "4AHIT"
});
var body = await response.Content.ReadFromJsonAsync<StudentDto>();
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.Equal("S-1", body?.CardNumber);
Assert.Equal("Ada Lovelace", body?.FullName);
}
[Fact]
public async Task InvalidBody_ReturnsValidationProblemDetails()
{
await using var factory = new ApiFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/students", new { cardNumber = "" });
var problem = await response.Content.ReadFromJsonAsync<JsonObject>();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
Assert.Equal(400, problem?["status"]?.GetValue<int>());
Assert.NotNull(problem?["errors"]);
}
[Fact]
public async Task MissingResource_ReturnsProblemDetails()
{
await using var factory = new ApiFactory();
var client = factory.CreateClient();
var response = await client.GetAsync($"/api/students/{Guid.NewGuid()}");
var problem = await response.Content.ReadFromJsonAsync<JsonObject>();
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
Assert.Equal(404, problem?["status"]?.GetValue<int>());
Assert.Equal("Nicht gefunden", problem?["title"]?.GetValue<string>());
}
[Fact]
public async Task UnknownRoute_ReturnsNotFound()
{
await using var factory = new ApiFactory();
var client = factory.CreateClient();
var response = await client.GetAsync("/api/does-not-exist");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Health_ReturnsOk()
{
await using var factory = new ApiFactory();
var client = factory.CreateClient();
var response = await client.GetAsync("/health");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task LoanConflict_ReturnsConflictProblemDetails()
{
await using var factory = new ApiFactory();
await factory.SeedBorrowedBookAsync();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/loans", new
{
cardNumber = "S-2",
bookNumber = "B-1"
});
var problem = await response.Content.ReadFromJsonAsync<JsonObject>();
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
Assert.Equal(409, problem?["status"]?.GetValue<int>());
}
private sealed class ApiFactory : WebApplicationFactory<Program>
{
private readonly string _databaseName = $"Bsevita.Library.Api.Tests.{Guid.NewGuid():N}";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.UseSetting("Database:ValidateOnStartup", "false");
builder.ConfigureLogging(logging => logging.ClearProviders());
builder.ConfigureServices(services =>
{
services.RemoveAll<LibraryDbContext>();
services.RemoveAll<DbContextOptions>();
services.RemoveAll<DbContextOptions<LibraryDbContext>>();
services.RemoveAll<IDbContextOptionsConfiguration<LibraryDbContext>>();
services.RemoveAll<TimeProvider>();
services.AddDbContext<LibraryDbContext>(options => options.UseInMemoryDatabase(_databaseName));
services.AddSingleton<TimeProvider>(new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)));
});
}
public async Task SeedBorrowedBookAsync()
{
using var scope = Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LibraryDbContext>();
var now = new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero);
var firstStudent = new Student
{
StudentId = Guid.NewGuid(),
CardNumber = "S-1",
FirstName = "Ada",
LastName = "Lovelace",
ClassName = "4AHIT",
IsActive = true,
CreatedAt = now,
UpdatedAt = now
};
var secondStudent = new Student
{
StudentId = Guid.NewGuid(),
CardNumber = "S-2",
FirstName = "Grace",
LastName = "Hopper",
ClassName = "5BHIT",
IsActive = true,
CreatedAt = now,
UpdatedAt = now
};
var book = new Book
{
BookId = Guid.NewGuid(),
BookNumber = "B-1",
Title = "Borrowed",
Author = "A",
Subject = "S",
IsActive = true,
CreatedAt = now,
UpdatedAt = now
};
dbContext.AddRange(firstStudent, secondStudent, book, new Loan
{
LoanId = Guid.NewGuid(),
StudentId = firstStudent.StudentId,
BookId = book.BookId,
LoanedAt = now,
DueAt = now.AddDays(14)
});
await dbContext.SaveChangesAsync();
}
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Bsevita.Library.Api/Bsevita.Library.Api.csproj" />
<ProjectReference Include="..\..\src\Bsevita.Library.Models\Bsevita.Library.Models.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
namespace Bsevita.Library.Api.Tests;
internal sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}
@@ -0,0 +1,645 @@
using Bsevita.Library.Api.Data.Generated.Entities;
using Bsevita.Library.Api.Infrastructure;
using Bsevita.Library.Api.Services;
using Bsevita.Library.Models.Books;
using Bsevita.Library.Models.Loans;
using Bsevita.Library.Models.Returns;
using Bsevita.Library.Models.Students;
namespace Bsevita.Library.Api.Tests;
public sealed class LibraryWorkflowTests
{
[Fact]
public async Task LoanAndReturn_UpdatesReportsAndAvailability()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero));
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var loans = new LoanService(database.Context, clock);
var returns = new ReturnService(database.Context, clock);
var reports = new ReportService(database.Context, clock);
await students.CreateAsync(new SaveStudentRequest
{
CardNumber = "S-1",
FirstName = "Ada",
LastName = "Lovelace",
ClassName = "4AHIT"
}, CancellationToken.None);
await books.CreateAsync(new SaveBookRequest
{
BookNumber = "B-1",
Title = "Clean Architecture",
Author = "Robert C. Martin",
Subject = "Informatik"
}, CancellationToken.None);
var verification = await loans.VerifyAsync(new VerifyLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None);
Assert.True(verification.CanBorrow);
var loan = await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None);
Assert.Equal(clock.GetUtcNow().AddDays(14), loan.DueAt);
Assert.Single(await reports.GetActiveLoansAsync(CancellationToken.None));
Assert.False((await books.GetByIdAsync(loan.BookId, CancellationToken.None)).IsAvailable);
var returned = await returns.ReturnAsync(new ReturnBookRequest { BookNumber = "B-1" }, CancellationToken.None);
Assert.Equal(loan.Id, returned.LoanId);
Assert.Empty(await reports.GetActiveLoansAsync(CancellationToken.None));
Assert.True((await books.GetByIdAsync(loan.BookId, CancellationToken.None)).IsAvailable);
}
[Fact]
public async Task CreateLoan_UsesCustomDueDate_WhenProvided()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero));
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var loans = new LoanService(database.Context, clock);
await students.CreateAsync(new SaveStudentRequest
{
CardNumber = "S-1",
FirstName = "Ada",
LastName = "Lovelace",
ClassName = "4AHIT"
}, CancellationToken.None);
await books.CreateAsync(new SaveBookRequest
{
BookNumber = "B-1",
Title = "Clean Architecture",
Author = "Robert C. Martin",
Subject = "Informatik"
}, CancellationToken.None);
var dueAt = new DateTimeOffset(2026, 7, 20, 0, 0, 0, TimeSpan.Zero);
var loan = await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1", DueAt = dueAt }, CancellationToken.None);
Assert.Equal(dueAt, loan.DueAt);
}
[Fact]
public async Task DuplicateCardNumber_IsRejected()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var service = new StudentService(database.Context, new FixedTimeProvider(DateTimeOffset.UtcNow));
var request = new SaveStudentRequest { CardNumber = "same", FirstName = "A", LastName = "B", ClassName = "1A" };
await service.CreateAsync(request, CancellationToken.None);
await Assert.ThrowsAsync<Bsevita.Library.Api.Infrastructure.ResourceConflictException>(
() => service.CreateAsync(new SaveStudentRequest { CardNumber = "SAME", FirstName = "C", LastName = "D", ClassName = "1B" }, CancellationToken.None));
}
[Fact]
public async Task Statistics_CountsAvailableActiveBooks()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var now = new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero);
var student = new Student
{
StudentId = Guid.NewGuid(),
CardNumber = "S-1",
FirstName = "Ada",
LastName = "Lovelace",
ClassName = "4AHIT",
IsActive = true,
CreatedAt = now,
UpdatedAt = now
};
var activeBook = new Book
{
BookId = Guid.NewGuid(),
BookNumber = "B-1",
Title = "Active",
Author = "A",
Subject = "S",
IsActive = true,
CreatedAt = now,
UpdatedAt = now
};
var inactiveBook = new Book
{
BookId = Guid.NewGuid(),
BookNumber = "B-2",
Title = "Inactive",
Author = "A",
Subject = "S",
IsActive = false,
CreatedAt = now,
UpdatedAt = now
};
database.Context.AddRange(student, activeBook, inactiveBook, new Loan
{
LoanId = Guid.NewGuid(),
StudentId = student.StudentId,
BookId = inactiveBook.BookId,
LoanedAt = now.AddDays(-1),
DueAt = now.AddDays(13)
});
await database.Context.SaveChangesAsync();
var statistics = await new ReportService(database.Context, new FixedTimeProvider(now)).GetStatisticsAsync(CancellationToken.None);
Assert.Equal(1, statistics.TotalBooks);
Assert.Equal(1, statistics.AvailableBooks);
Assert.Equal(1, statistics.ActiveLoans);
}
[Fact]
public async Task BookList_CanFilterAvailabilityAndLimitRows()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero));
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var loans = new LoanService(database.Context, clock);
await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "Ada", LastName = "Lovelace", ClassName = "4AHIT" }, CancellationToken.None);
await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Available", Author = "A", Subject = "S" }, CancellationToken.None);
await books.CreateAsync(new SaveBookRequest { BookNumber = "B-2", Title = "Borrowed", Author = "A", Subject = "S" }, CancellationToken.None);
await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-2" }, CancellationToken.None);
var available = await books.GetAllAsync(availableOnly: true, skip: 0, take: 1, CancellationToken.None);
var borrowed = await books.GetAllAsync(availableOnly: false, skip: 0, take: 10, CancellationToken.None);
Assert.Single(available);
Assert.Equal("B-1", available[0].BookNumber);
Assert.Collection(borrowed, book => Assert.Equal("B-2", book.BookNumber));
}
[Fact]
public async Task Reports_ReturnsBundledReportData()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero));
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var loans = new LoanService(database.Context, clock);
await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "Ada", LastName = "Lovelace", ClassName = "4AHIT" }, CancellationToken.None);
await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Overdue", Author = "A", Subject = "S" }, CancellationToken.None);
await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1", DueAt = clock.GetUtcNow().AddDays(1) }, CancellationToken.None);
var reports = await new ReportService(database.Context, new FixedTimeProvider(clock.GetUtcNow().AddDays(2))).GetReportsAsync(CancellationToken.None);
Assert.Single(reports.ActiveLoans);
Assert.Single(reports.OverdueLoans);
Assert.Single(reports.ActiveStudents);
Assert.Equal(1, reports.Statistics.ActiveLoans);
Assert.Equal(0, reports.Statistics.AvailableBooks);
}
[Fact]
public async Task Reports_LimitsListsButKeepsFullStatistics()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var now = new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero);
var student = new Student
{
StudentId = Guid.NewGuid(),
CardNumber = "S-1",
FirstName = "Ada",
LastName = "Lovelace",
ClassName = "4AHIT",
IsActive = true,
CreatedAt = now,
UpdatedAt = now
};
database.Context.Students.Add(student);
for (var i = 0; i < 101; i++)
{
var book = new Book
{
BookId = Guid.NewGuid(),
BookNumber = $"B-{i}",
Title = $"Book {i}",
Author = "A",
Subject = "S",
IsActive = true,
CreatedAt = now,
UpdatedAt = now
};
database.Context.Add(book);
database.Context.Loans.Add(new Loan
{
LoanId = Guid.NewGuid(),
StudentId = student.StudentId,
BookId = book.BookId,
LoanedAt = now.AddDays(-1),
DueAt = now.AddDays(13)
});
}
await database.Context.SaveChangesAsync();
var reports = await new ReportService(database.Context, new FixedTimeProvider(now)).GetReportsAsync(CancellationToken.None);
Assert.Equal(100, reports.ActiveLoans.Count);
Assert.Equal(101, reports.Statistics.ActiveLoans);
}
[Fact]
public async Task Search_FindsPartialMatches()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero));
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "Ada", LastName = "Lovelace", ClassName = "4AHIT" }, CancellationToken.None);
await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Clean Architecture", Author = "Robert Martin", Subject = "Informatik" }, CancellationToken.None);
Assert.Single(await students.SearchAsync("love", CancellationToken.None));
Assert.Single(await books.SearchAsync(title: "arch", author: null, subject: null, CancellationToken.None));
}
[Fact]
public async Task Loan_ThrowsNotFound_WhenStudentOrBookIsMissing()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var loans = new LoanService(database.Context, clock);
await Assert.ThrowsAsync<ResourceNotFoundException>(
() => loans.VerifyAsync(new VerifyLoanRequest { CardNumber = "missing", BookNumber = "missing" }, CancellationToken.None));
await students.CreateAsync(Student("S-1"), CancellationToken.None);
await Assert.ThrowsAsync<ResourceNotFoundException>(
() => loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "missing" }, CancellationToken.None));
await books.CreateAsync(Book("B-1"), CancellationToken.None);
await Assert.ThrowsAsync<ResourceNotFoundException>(
() => loans.CreateAsync(new CreateLoanRequest { CardNumber = "missing", BookNumber = "B-1" }, CancellationToken.None));
}
[Fact]
public async Task Loan_ThrowsConflict_WhenBookIsAlreadyBorrowed()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var loans = new LoanService(database.Context, clock);
await students.CreateAsync(Student("S-1"), CancellationToken.None);
await students.CreateAsync(Student("S-2"), CancellationToken.None);
await books.CreateAsync(Book("B-1"), CancellationToken.None);
await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None);
await Assert.ThrowsAsync<ResourceConflictException>(
() => loans.VerifyAsync(new VerifyLoanRequest { CardNumber = "S-2", BookNumber = "B-1" }, CancellationToken.None));
await Assert.ThrowsAsync<ResourceConflictException>(
() => loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-2", BookNumber = "B-1" }, CancellationToken.None));
}
[Fact]
public async Task Loan_ThrowsValidation_WhenDueDateIsInPast()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var loans = new LoanService(database.Context, clock);
await students.CreateAsync(Student("S-1"), CancellationToken.None);
await books.CreateAsync(Book("B-1"), CancellationToken.None);
await Assert.ThrowsAsync<RequestValidationException>(
() => loans.CreateAsync(new CreateLoanRequest
{
CardNumber = "S-1",
BookNumber = "B-1",
DueAt = clock.GetUtcNow().AddSeconds(-1)
}, CancellationToken.None));
}
[Fact]
public async Task Return_ThrowsNotFound_WhenNoActiveLoanExists()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var returns = new ReturnService(database.Context, NewClock());
await Assert.ThrowsAsync<ResourceNotFoundException>(
() => returns.VerifyAsync("missing", CancellationToken.None));
await Assert.ThrowsAsync<ResourceNotFoundException>(
() => returns.ReturnAsync(new ReturnBookRequest { BookNumber = "missing" }, CancellationToken.None));
}
[Fact]
public async Task Return_ReportsOverdueDays()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var start = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero));
var overdueClock = new FixedTimeProvider(start.GetUtcNow().AddDays(3));
var students = new StudentService(database.Context, start);
var books = new BookService(database.Context, start);
var loans = new LoanService(database.Context, start);
await students.CreateAsync(Student("S-1"), CancellationToken.None);
await books.CreateAsync(Book("B-1"), CancellationToken.None);
await loans.CreateAsync(new CreateLoanRequest
{
CardNumber = "S-1",
BookNumber = "B-1",
DueAt = start.GetUtcNow().AddDays(1)
}, CancellationToken.None);
var returns = new ReturnService(database.Context, overdueClock);
var verification = await returns.VerifyAsync("B-1", CancellationToken.None);
var result = await returns.ReturnAsync(new ReturnBookRequest { BookNumber = "B-1" }, CancellationToken.None);
Assert.True(verification.IsOverdue);
Assert.Equal(2, verification.DaysOverdue);
Assert.True(result.WasOverdue);
Assert.Equal(2, result.DaysOverdue);
}
[Fact]
public async Task Delete_ThrowsConflict_WhenActiveLoanExists()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var loans = new LoanService(database.Context, clock);
var student = await students.CreateAsync(Student("S-1"), CancellationToken.None);
var book = await books.CreateAsync(Book("B-1"), CancellationToken.None);
await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None);
await Assert.ThrowsAsync<ResourceConflictException>(() => students.DeleteAsync(student.Id, CancellationToken.None));
await Assert.ThrowsAsync<ResourceConflictException>(() => books.DeleteAsync(book.Id, CancellationToken.None));
}
[Fact]
public async Task Delete_DeactivatesStudentAndBook()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var student = await students.CreateAsync(Student("S-1"), CancellationToken.None);
var book = await books.CreateAsync(Book("B-1"), CancellationToken.None);
await students.DeleteAsync(student.Id, CancellationToken.None);
await books.DeleteAsync(book.Id, CancellationToken.None);
Assert.Empty(await students.GetAllAsync(0, 10, CancellationToken.None));
Assert.Empty(await books.GetAllAsync(null, 0, 10, CancellationToken.None));
await Assert.ThrowsAsync<ResourceNotFoundException>(() => students.GetByIdAsync(student.Id, CancellationToken.None));
await Assert.ThrowsAsync<ResourceNotFoundException>(() => books.GetByIdAsync(book.Id, CancellationToken.None));
}
[Fact]
public async Task BookIdentifiers_AreUnique()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var books = new BookService(database.Context, NewClock());
await books.CreateAsync(Book("B-1", isbn: "978-1"), CancellationToken.None);
await Assert.ThrowsAsync<ResourceConflictException>(
() => books.CreateAsync(Book("b-1"), CancellationToken.None));
await Assert.ThrowsAsync<ResourceConflictException>(
() => books.CreateAsync(Book("B-2", isbn: "978-1"), CancellationToken.None));
}
[Fact]
public async Task Update_ThrowsConflict_WhenIdentifiersAlreadyExist()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
await students.CreateAsync(Student("S-1"), CancellationToken.None);
var secondStudent = await students.CreateAsync(Student("S-2"), CancellationToken.None);
await books.CreateAsync(Book("B-1", isbn: "978-1"), CancellationToken.None);
var secondBook = await books.CreateAsync(Book("B-2", isbn: "978-2"), CancellationToken.None);
await Assert.ThrowsAsync<ResourceConflictException>(
() => students.UpdateAsync(secondStudent.Id, Student("S-1"), CancellationToken.None));
await Assert.ThrowsAsync<ResourceConflictException>(
() => books.UpdateAsync(secondBook.Id, Book("B-1", isbn: "978-2"), CancellationToken.None));
await Assert.ThrowsAsync<ResourceConflictException>(
() => books.UpdateAsync(secondBook.Id, Book("B-2", isbn: "978-1"), CancellationToken.None));
}
[Fact]
public async Task Search_ThrowsValidation_WhenSearchInputIsMissing()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
await Assert.ThrowsAsync<RequestValidationException>(
() => students.SearchAsync(" ", CancellationToken.None));
await Assert.ThrowsAsync<RequestValidationException>(
() => books.SearchAsync(null, null, null, CancellationToken.None));
}
[Fact]
public async Task Reads_ThrowNotFound_WhenResourcesDoNotExist()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
await Assert.ThrowsAsync<ResourceNotFoundException>(
() => students.GetByIdAsync(Guid.NewGuid(), CancellationToken.None));
await Assert.ThrowsAsync<ResourceNotFoundException>(
() => students.GetByCardAsync("missing", CancellationToken.None));
await Assert.ThrowsAsync<ResourceNotFoundException>(
() => books.GetByIdAsync(Guid.NewGuid(), CancellationToken.None));
await Assert.ThrowsAsync<ResourceNotFoundException>(
() => books.GetByNumberAsync("missing", CancellationToken.None));
}
[Fact]
public async Task Paging_ClampsInvalidSkipAndTake()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
await students.CreateAsync(Student("S-1"), CancellationToken.None);
await books.CreateAsync(Book("B-1"), CancellationToken.None);
Assert.Single(await students.GetAllAsync(-10, 0, CancellationToken.None));
Assert.Single(await books.GetAllAsync(null, -10, 0, CancellationToken.None));
}
[Fact]
public async Task Update_ChangesStudentAndBookValues()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var student = await students.CreateAsync(Student("S-1"), CancellationToken.None);
var book = await books.CreateAsync(Book("B-1"), CancellationToken.None);
var updatedStudent = await students.UpdateAsync(student.Id, new SaveStudentRequest
{
CardNumber = "S-2",
FirstName = "Grace",
LastName = "Hopper",
ClassName = "5BHIT",
Email = "grace@example.test"
}, CancellationToken.None);
var updatedBook = await books.UpdateAsync(book.Id, new SaveBookRequest
{
BookNumber = "B-2",
Isbn = "978-2",
Title = "Compiler Design",
Author = "Grace Hopper",
Subject = "Informatik",
Publisher = "ACM",
PublicationYear = 1952
}, CancellationToken.None);
Assert.Equal("Grace Hopper", updatedStudent.FullName);
Assert.Equal("S-2", updatedStudent.CardNumber);
Assert.Equal("grace@example.test", updatedStudent.Email);
Assert.Equal("Compiler Design", updatedBook.Title);
Assert.Equal("978-2", updatedBook.Isbn);
Assert.Equal(1952, updatedBook.PublicationYear);
}
[Fact]
public async Task Search_CoversStudentFullNameAndBookAuthorAndSubject()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "Ada", LastName = "Lovelace", ClassName = "4AHIT" }, CancellationToken.None);
await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Clean Architecture", Author = "Robert Martin", Subject = "Software Engineering" }, CancellationToken.None);
Assert.Single(await students.SearchAsync("ada love", CancellationToken.None));
Assert.Single(await books.SearchAsync(title: null, author: "martin", subject: null, CancellationToken.None));
Assert.Single(await books.SearchAsync(title: null, author: null, subject: "engineering", CancellationToken.None));
Assert.Empty(await books.SearchAsync(title: "missing", author: null, subject: null, CancellationToken.None));
}
[Fact]
public async Task Lists_AreSortedAndPaged()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var loans = new LoanService(database.Context, clock);
var reports = new ReportService(database.Context, clock);
await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "B", LastName = "Beta", ClassName = "1A" }, CancellationToken.None);
await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-2", FirstName = "A", LastName = "Alpha", ClassName = "1A" }, CancellationToken.None);
await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Zulu", Author = "A", Subject = "S" }, CancellationToken.None);
await books.CreateAsync(new SaveBookRequest { BookNumber = "B-2", Title = "Alpha", Author = "A", Subject = "S" }, CancellationToken.None);
await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1", DueAt = clock.GetUtcNow().AddDays(5) }, CancellationToken.None);
await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-2", BookNumber = "B-2", DueAt = clock.GetUtcNow().AddDays(1) }, CancellationToken.None);
var orderedStudents = await students.GetAllAsync(0, 10, CancellationToken.None);
var pagedBooks = await books.GetAllAsync(null, 1, 1, CancellationToken.None);
var orderedLoans = await reports.GetActiveLoansAsync(0, 10, CancellationToken.None);
Assert.Equal(["Alpha", "Beta"], orderedStudents.Select(student => student.LastName).ToArray());
Assert.Equal("Zulu", pagedBooks.Single().Title);
Assert.Equal(["Alpha", "Zulu"], orderedLoans.Select(loan => loan.BookTitle).ToArray());
}
[Fact]
public async Task Return_ThrowsNotFound_WhenBookIsReturnedTwice()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var loans = new LoanService(database.Context, clock);
var returns = new ReturnService(database.Context, clock);
await students.CreateAsync(Student("S-1"), CancellationToken.None);
await books.CreateAsync(Book("B-1"), CancellationToken.None);
await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None);
await returns.ReturnAsync(new ReturnBookRequest { BookNumber = "B-1" }, CancellationToken.None);
await Assert.ThrowsAsync<ResourceNotFoundException>(
() => returns.ReturnAsync(new ReturnBookRequest { BookNumber = "B-1" }, CancellationToken.None));
}
[Fact]
public async Task SoftDeletedResources_AreHiddenFromSearchAndLookup()
{
await using var database = new TestDatabase();
await database.InitializeAsync();
var clock = NewClock();
var students = new StudentService(database.Context, clock);
var books = new BookService(database.Context, clock);
var student = await students.CreateAsync(Student("S-1"), CancellationToken.None);
var book = await books.CreateAsync(Book("B-1"), CancellationToken.None);
await students.DeleteAsync(student.Id, CancellationToken.None);
await books.DeleteAsync(book.Id, CancellationToken.None);
Assert.Empty(await students.SearchAsync("Ada", CancellationToken.None));
Assert.Empty(await books.SearchAsync(title: "Book", author: null, subject: null, CancellationToken.None));
await Assert.ThrowsAsync<ResourceNotFoundException>(() => students.GetByCardAsync("S-1", CancellationToken.None));
await Assert.ThrowsAsync<ResourceNotFoundException>(() => books.GetByNumberAsync("B-1", CancellationToken.None));
}
private static FixedTimeProvider NewClock() =>
new(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero));
private static SaveStudentRequest Student(string cardNumber) =>
new()
{
CardNumber = cardNumber,
FirstName = "Ada",
LastName = "Lovelace",
ClassName = "4AHIT"
};
private static SaveBookRequest Book(string bookNumber, string? isbn = null) =>
new()
{
BookNumber = bookNumber,
Isbn = isbn,
Title = $"Book {bookNumber}",
Author = "Author",
Subject = "Subject"
};
}
@@ -0,0 +1,23 @@
using Bsevita.Library.Api.Data.Generated;
using Microsoft.EntityFrameworkCore;
namespace Bsevita.Library.Api.Tests;
internal sealed class TestDatabase : IAsyncDisposable
{
public LibraryDbContext Context { get; private set; } = null!;
public Task InitializeAsync()
{
var options = new DbContextOptionsBuilder<LibraryDbContext>()
.UseInMemoryDatabase($"Bsevita.Library.Tests.{Guid.NewGuid():N}")
.Options;
Context = new LibraryDbContext(options);
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
await Context.DisposeAsync();
}
}
@@ -0,0 +1 @@
global using Xunit;