Merge feature/feat-13: Abgabevertrag phase B — SaleContract+wizard, /einstellungen Zuchtprofil, contract endpoints w/ transactional Abgabe, Kontakt structured fields [god-QA: 40/40+47/47+e2e 56/56]
# Conflicts: # GerbilManager.Tests/GerbilManager.Tests.csproj # GerbilManagerWebAPI/Program.cs
This commit is contained in:
61
GerbilManager.Tests/ApiFactory.cs
Normal file
61
GerbilManager.Tests/ApiFactory.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13: In-Memory-Test-Host für Endpoint-Round-Trips. Ersetzt die
|
||||
/// Aspire/Npgsql-Registrierung durch SQLite in-memory (eine offene Verbindung
|
||||
/// hält die DB am Leben), Umgebung "Testing" überspringt Database.Migrate()
|
||||
/// (Npgsql-Migrationen laufen nicht auf SQLite) — Schema via EnsureCreated,
|
||||
/// inklusive der HasData-Seeds (73 Farbschläge + Zuchtprofil-Singleton).
|
||||
/// Vertrags-Dateien landen in einem Temp-Ordner, der mit dem Host stirbt.
|
||||
/// </summary>
|
||||
public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly SqliteConnection _connection = new("DataSource=:memory:");
|
||||
|
||||
public string ContractRoot { get; } =
|
||||
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}");
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
// Dummy, damit Aspires AddNpgsqlDbContext beim Host-Aufbau zufrieden ist;
|
||||
// die eigentliche Registrierung wird unten durch SQLite ersetzt.
|
||||
builder.UseSetting("ConnectionStrings:gerbilmanager",
|
||||
"Host=localhost;Database=test;Username=test;Password=test");
|
||||
builder.UseSetting("Contracts:RootPath", ContractRoot);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// EF 9+: AddDbContext registriert die Provider-Konfiguration als
|
||||
// IDbContextOptionsConfiguration<T> — ohne deren Entfernung blieben
|
||||
// Npgsql UND SQLite registriert (ein Provider pro ServiceProvider).
|
||||
services.RemoveAll<Microsoft.EntityFrameworkCore.Infrastructure.IDbContextOptionsConfiguration<ApplicationContext>>();
|
||||
services.RemoveAll<DbContextOptions<ApplicationContext>>();
|
||||
services.RemoveAll<ApplicationContext>();
|
||||
|
||||
_connection.Open();
|
||||
services.AddDbContext<ApplicationContext>(o => o.UseSqlite(_connection));
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated();
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing)
|
||||
{
|
||||
_connection.Dispose();
|
||||
if (Directory.Exists(ContractRoot)) Directory.Delete(ContractRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
198
GerbilManager.Tests/ContractEndpointTests.cs
Normal file
198
GerbilManager.Tests/ContractEndpointTests.cs
Normal file
@@ -0,0 +1,198 @@
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13 phase B: Endpoint-Round-Trips für /contracts + /settings/breeder-profile
|
||||
/// gegen den In-Memory-Host (ApiFactory). Deckt den Abgabe-Abschluss ab:
|
||||
/// Vertrag erzeugen -> Tiere stehen auf Abgegeben -> .docx ist herunterladbar.
|
||||
/// </summary>
|
||||
public class ContractEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = CreateJsonOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var o = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
o.Converters.Add(new JsonStringEnumConverter());
|
||||
return o;
|
||||
}
|
||||
|
||||
private readonly ApiFactory _factory;
|
||||
|
||||
public ContractEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
private sealed record IdDto(Guid Id);
|
||||
|
||||
private sealed record GerbilView(Guid Id, string Status, Guid? ReceiverContactId, DateOnly? GoHomeDate);
|
||||
|
||||
private sealed record ContractView(
|
||||
Guid Id, Guid ContactId, decimal Price, DateOnly HandoverDate, DateOnly ContractDate,
|
||||
string FileName, List<Guid> GerbilIds, string Url);
|
||||
|
||||
private sealed record Paged<T>(List<T> Items, int TotalCount, int Page, int PageSize);
|
||||
|
||||
private async Task<Guid> CreateContactAsync(HttpClient client, string name = "Herr Max Beispiel")
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/contacts", new
|
||||
{
|
||||
name,
|
||||
email = "max@example.com",
|
||||
phone = "0987 654321",
|
||||
address = "Beispielallee 7, 54321 Beispielstadt",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
return (await response.Content.ReadFromJsonAsync<IdDto>(Json))!.Id;
|
||||
}
|
||||
|
||||
private async Task<Guid> CreateGerbilAsync(HttpClient client, string name)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/gerbils", new
|
||||
{
|
||||
name,
|
||||
gender = "female",
|
||||
dateOfBirth = "2025-03-09",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
return (await response.Content.ReadFromJsonAsync<IdDto>(Json))!.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZuchtprofilRoundTrip_PutDannGet()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var put = await client.PutAsJsonAsync("/settings/breeder-profile", new
|
||||
{
|
||||
zuchtName = "Zucht Testhausen",
|
||||
name = "Frau Erika Muster",
|
||||
address = "Musterweg 1, 12345 Testhausen",
|
||||
phone = "0123 456789",
|
||||
email = "zucht@example.org",
|
||||
homepage = "https://zucht.example.org/",
|
||||
city = "Testhausen",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var profile = await client.GetFromJsonAsync<Dictionary<string, string>>("/settings/breeder-profile", Json);
|
||||
Assert.Equal("Zucht Testhausen", profile!["zuchtName"]);
|
||||
Assert.Equal("Testhausen", profile["city"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VertragErzeugen_SchließtDieAbgabeAb()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Zuchtprofil füllen, damit der Verkäufer-Block im Dokument landet.
|
||||
await client.PutAsJsonAsync("/settings/breeder-profile", new
|
||||
{
|
||||
zuchtName = "Zucht Testhausen",
|
||||
name = "Frau Erika Muster",
|
||||
address = "Musterweg 1, 12345 Testhausen",
|
||||
phone = "0123 456789",
|
||||
email = "zucht@example.org",
|
||||
homepage = "https://zucht.example.org/",
|
||||
city = "Testhausen",
|
||||
}, Json);
|
||||
|
||||
var contactId = await CreateContactAsync(client);
|
||||
var krümel = await CreateGerbilAsync(client, "Krümel");
|
||||
var luna = await CreateGerbilAsync(client, "Luna");
|
||||
|
||||
// POST /contracts — der Abschluss.
|
||||
var post = await client.PostAsJsonAsync("/contracts", new
|
||||
{
|
||||
contactId,
|
||||
gerbilIds = new[] { krümel, luna },
|
||||
price = 72.0m,
|
||||
handoverDate = "2026-06-05",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.Created, post.StatusCode);
|
||||
var contract = (await post.Content.ReadFromJsonAsync<ContractView>(Json))!;
|
||||
|
||||
Assert.Equal(contactId, contract.ContactId);
|
||||
Assert.Equal(2, contract.GerbilIds.Count);
|
||||
Assert.Equal(new DateOnly(2026, 6, 5), contract.ContractDate); // Standard = Übergabedatum
|
||||
Assert.Equal($"/contracts/{contract.Id}/file", contract.Url);
|
||||
|
||||
// Tiere stehen jetzt auf Abgegeben — mit Abnehmer und Abgabedatum.
|
||||
foreach (var id in new[] { krümel, luna })
|
||||
{
|
||||
var gerbil = await client.GetFromJsonAsync<GerbilView>($"/gerbils/{id}", Json);
|
||||
Assert.Equal("GivenAway", gerbil!.Status);
|
||||
Assert.Equal(contactId, gerbil.ReceiverContactId);
|
||||
Assert.Equal(new DateOnly(2026, 6, 5), gerbil.GoHomeDate);
|
||||
}
|
||||
|
||||
// Download: echtes .docx mit beiden Tieren, Käufer, de-DE-Preis.
|
||||
var file = await client.GetAsync(contract.Url);
|
||||
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
|
||||
Assert.Equal(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
file.Content.Headers.ContentType!.MediaType);
|
||||
Assert.Contains("Abgabevertrag_2026-06-05_Herr-Max-Beispiel.docx",
|
||||
file.Content.Headers.ContentDisposition!.FileNameStar ?? file.Content.Headers.ContentDisposition.FileName);
|
||||
|
||||
var text = DocumentText(await file.Content.ReadAsByteArrayAsync());
|
||||
Assert.Contains("Krümel", text);
|
||||
Assert.Contains("Luna", text);
|
||||
Assert.Contains("Herr Max Beispiel", text);
|
||||
Assert.Contains("Kaufpreis von 72,00 €", text);
|
||||
Assert.Contains("Testhausen, den 05.06.2026", text);
|
||||
Assert.DoesNotContain("{{", text);
|
||||
|
||||
// Liste: nach Abnehmer filterbar (Gridify).
|
||||
var list = await client.GetFromJsonAsync<Paged<ContractView>>(
|
||||
$"/contracts?filter=contactId=={contactId}", Json);
|
||||
Assert.Equal(1, list!.TotalCount);
|
||||
Assert.Equal(contract.Id, list.Items[0].Id);
|
||||
|
||||
// DELETE räumt Zeile und Datei ab; Tier-Status bleibt unangetastet.
|
||||
var delete = await client.DeleteAsync($"/contracts/{contract.Id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(contract.Url)).StatusCode);
|
||||
var still = await client.GetFromJsonAsync<GerbilView>($"/gerbils/{krümel}", Json);
|
||||
Assert.Equal("GivenAway", still!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VertragOhneTiereOderMitUnbekanntemTier_Validierungsfehler()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var contactId = await CreateContactAsync(client, "Frau Lisa Test");
|
||||
|
||||
var empty = await client.PostAsJsonAsync("/contracts", new
|
||||
{
|
||||
contactId,
|
||||
gerbilIds = Array.Empty<Guid>(),
|
||||
price = 10m,
|
||||
handoverDate = "2026-06-05",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
|
||||
|
||||
var unknown = await client.PostAsJsonAsync("/contracts", new
|
||||
{
|
||||
contactId,
|
||||
gerbilIds = new[] { Guid.NewGuid() },
|
||||
price = 10m,
|
||||
handoverDate = "2026-06-05",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Sichtbarer Text aus word/document.xml (Tags entfernt).</summary>
|
||||
private static string DocumentText(byte[] docx)
|
||||
{
|
||||
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
|
||||
var entry = Assert.Single(zip.Entries, e => e.FullName == "word/document.xml");
|
||||
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
||||
return Regex.Replace(reader.ReadToEnd(), "<[^>]+>", "");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
|
||||
Reference in New Issue
Block a user