FEAT-13: SaleContract + BreederSettings entities (additive migration, join table justified in code docs), /contracts + /settings/breeder-profile Minimal-API endpoints w/ Abgabe-completion transaction + SQLite-backed endpoint round-trip tests (21/21)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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(), "<[^>]+>", "");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<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" />
|
||||
|
||||
@@ -15,6 +15,8 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
||||
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
||||
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
||||
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
||||
public DbSet<BreederSettings> BreederSettings => Set<BreederSettings>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -59,6 +61,31 @@ public class ApplicationContext : DbContext
|
||||
e.HasOne<Gerbil>().WithMany()
|
||||
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
||||
|
||||
// FEAT-13: Abgabeverträge + Zuchtprofil.
|
||||
modelBuilder.Entity<SaleContract>(e =>
|
||||
{
|
||||
e.Property(c => c.Price).HasPrecision(10, 2);
|
||||
// Restrict: ein Kontakt mit Verträgen ist ein Dokumentenbestand,
|
||||
// kein versehentlich löschbarer Datensatz.
|
||||
e.HasOne(c => c.Contact).WithMany()
|
||||
.HasForeignKey(c => c.ContactId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SaleContractAnimal>(e =>
|
||||
{
|
||||
e.HasKey(a => new { a.SaleContractId, a.GerbilId });
|
||||
e.HasOne<SaleContract>().WithMany(c => c.Animals)
|
||||
.HasForeignKey(a => a.SaleContractId).OnDelete(DeleteBehavior.Cascade);
|
||||
// Cascade: wird ein Tier gelöscht, verschwindet nur die Verknüpfung —
|
||||
// der Vertrag (und seine .docx als Beleg) bleibt bestehen.
|
||||
e.HasOne(a => a.Gerbil).WithMany()
|
||||
.HasForeignKey(a => a.GerbilId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Zuchtprofil: genau eine (leere) Zeile mit fixer Id.
|
||||
modelBuilder.Entity<BreederSettings>()
|
||||
.HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId });
|
||||
|
||||
SeedColorVarieties(modelBuilder);
|
||||
}
|
||||
|
||||
|
||||
33
GerbilManagerWebAPI/Dtos/ContractDtos.cs
Normal file
33
GerbilManagerWebAPI/Dtos/ContractDtos.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
// FEAT-13: Abgabeverträge + Zuchtprofil (eigene Datei, hält ApiDtos.cs konfliktfrei).
|
||||
|
||||
public record SaleContractDto(
|
||||
Guid Id,
|
||||
Guid ContactId,
|
||||
decimal Price,
|
||||
DateOnly HandoverDate,
|
||||
DateOnly ContractDate,
|
||||
string FileName,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<Guid> GerbilIds,
|
||||
// Download-URL der .docx ("/contracts/{id}/file").
|
||||
string Url);
|
||||
|
||||
public record SaleContractInput(
|
||||
Guid ContactId,
|
||||
List<Guid> GerbilIds,
|
||||
decimal Price,
|
||||
DateOnly HandoverDate,
|
||||
DateOnly? ContractDate);
|
||||
|
||||
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
|
||||
public record BreederProfileDto(
|
||||
string ZuchtName,
|
||||
string Name,
|
||||
string Address,
|
||||
string Phone,
|
||||
string Email,
|
||||
string Homepage,
|
||||
string City);
|
||||
}
|
||||
201
GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs
Normal file
201
GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Contracts;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-13: Abgabeverträge.
|
||||
/// POST /contracts -> erzeugt .docx (phase-A-Generator), speichert sie im
|
||||
/// Vertrags-Dateiroot, legt die Vertragszeile an und stellt
|
||||
/// die Tiere in DERSELBEN Transaktion auf Abgegeben
|
||||
/// (ReceiverContactId, GoHomeDate, Status).
|
||||
/// GET /contracts -> Gridify-paged (z. B. filter=contactId==…)
|
||||
/// GET /contracts/{id} -> Metadaten
|
||||
/// GET /contracts/{id}/file -> .docx-Download (deutscher Dateiname)
|
||||
/// DELETE /contracts/{id} -> entfernt Zeile + Datei (Tier-Status bleibt unberührt)
|
||||
/// </summary>
|
||||
public static class ContractEndpoints
|
||||
{
|
||||
private const string DocxContentType =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/contracts").WithTags("Contracts");
|
||||
|
||||
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.SaleContracts.AsNoTracking()
|
||||
.Include(c => c.Animals)
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
// GET /contracts/{id}
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var c = await db.SaleContracts.AsNoTracking()
|
||||
.Include(x => x.Animals)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
|
||||
});
|
||||
|
||||
// POST /contracts — der Abgabe-Abschluss.
|
||||
group.MapPost("/", async Task<Results<Created<SaleContractDto>, ValidationProblem>> (
|
||||
SaleContractInput input, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
|
||||
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
|
||||
if (gerbilIds.Count == 0)
|
||||
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
|
||||
if (input.Price < 0)
|
||||
errors["price"] = ["Der Kaufpreis darf nicht negativ sein."];
|
||||
|
||||
var contact = await db.Contacts.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == input.ContactId);
|
||||
if (contact is null)
|
||||
errors["contactId"] = ["Der Abnehmer wurde nicht gefunden."];
|
||||
|
||||
var gerbils = await db.Gerbils
|
||||
.Include(g => g.ColorVariety)
|
||||
.Where(g => gerbilIds.Contains(g.Id))
|
||||
.ToListAsync();
|
||||
if (gerbils.Count != gerbilIds.Count)
|
||||
errors["gerbilIds"] = ["Mindestens ein ausgewähltes Tier wurde nicht gefunden."];
|
||||
|
||||
if (errors.Count > 0) return TypedResults.ValidationProblem(errors);
|
||||
|
||||
var settings = await db.BreederSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
|
||||
?? new BreederSettings();
|
||||
|
||||
var contractDate = input.ContractDate ?? input.HandoverDate;
|
||||
var data = new ContractData(
|
||||
Seller: ToSeller(settings),
|
||||
Buyer: new ContractBuyer(contact!.Name, contact.Address ?? "", contact.Phone, contact.Email),
|
||||
Animals: gerbils
|
||||
.Select(g => new ContractAnimal(g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name))
|
||||
.ToList(),
|
||||
Price: input.Price,
|
||||
HandoverDate: input.HandoverDate,
|
||||
ContractDate: contractDate);
|
||||
|
||||
var bytes = ContractGenerator.Generate(data);
|
||||
|
||||
var fileName = $"{Guid.NewGuid():N}.docx";
|
||||
var root = ContractRoot(config, env);
|
||||
Directory.CreateDirectory(root);
|
||||
await File.WriteAllBytesAsync(Path.Combine(root, fileName), bytes);
|
||||
|
||||
var entity = new SaleContract
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContactId = contact.Id,
|
||||
Price = input.Price,
|
||||
HandoverDate = input.HandoverDate,
|
||||
ContractDate = contractDate,
|
||||
FileName = fileName,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Animals = gerbilIds.Select(id => new SaleContractAnimal { GerbilId = id }).ToList(),
|
||||
};
|
||||
db.SaleContracts.Add(entity);
|
||||
|
||||
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
|
||||
foreach (var g in gerbils)
|
||||
{
|
||||
g.ReceiverContactId = contact.Id;
|
||||
g.GoHomeDate = input.HandoverDate;
|
||||
g.Status = GerbilStatus.GivenAway;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// DB fehlgeschlagen -> verwaiste Datei wieder aufräumen.
|
||||
var orphan = Path.Combine(root, fileName);
|
||||
if (File.Exists(orphan)) File.Delete(orphan);
|
||||
throw;
|
||||
}
|
||||
|
||||
return TypedResults.Created($"/contracts/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
// GET /contracts/{id}/file — Download mit sprechendem deutschen Dateinamen.
|
||||
group.MapGet("/{id:guid}/file", async Task<Results<PhysicalFileHttpResult, NotFound>> (
|
||||
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var c = await db.SaleContracts.AsNoTracking()
|
||||
.Include(x => x.Contact)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
|
||||
var path = Path.Combine(ContractRoot(config, env), c.FileName);
|
||||
if (!File.Exists(path)) return TypedResults.NotFound();
|
||||
|
||||
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact?.Name)}.docx";
|
||||
return TypedResults.PhysicalFile(path, DocxContentType, download);
|
||||
});
|
||||
|
||||
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
|
||||
// (das wäre Magie — Status korrigiert man am Tier selbst).
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var c = await db.SaleContracts.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
|
||||
var path = Path.Combine(ContractRoot(config, env), c.FileName);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
|
||||
db.SaleContracts.Remove(c); // Joins kaskadieren
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
|
||||
|
||||
private static BreederProfile ToSeller(BreederSettings s) => new()
|
||||
{
|
||||
ZuchtName = s.ZuchtName,
|
||||
Name = s.Name,
|
||||
Address = s.Address,
|
||||
Phone = s.Phone,
|
||||
Email = s.Email,
|
||||
Homepage = s.Homepage,
|
||||
City = s.City,
|
||||
};
|
||||
|
||||
/// <summary>Deutscher Anzeigetext fürs Geschlecht (Vertragsdokument).</summary>
|
||||
internal static string GeschlechtText(Gender gender) => gender switch
|
||||
{
|
||||
Gender.male => "Männlich",
|
||||
Gender.female => "Weiblich",
|
||||
_ => "Unbekannt",
|
||||
};
|
||||
|
||||
/// <summary>Kontaktname → Dateinamens-tauglich (Umlaute bleiben, Trenner -> '-').</summary>
|
||||
private static string Sanitize(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return "Abnehmer";
|
||||
var cleaned = new string(name.Trim()
|
||||
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')
|
||||
.ToArray());
|
||||
return cleaned.Trim('-');
|
||||
}
|
||||
|
||||
internal static SaleContractDto ToDto(SaleContract c) => new(
|
||||
c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt,
|
||||
c.Animals.Select(a => a.GerbilId).ToList(),
|
||||
$"/contracts/{c.Id}/file");
|
||||
}
|
||||
}
|
||||
60
GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs
Normal file
60
GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-13: Zuchtprofil (Verkäufer-Block der Abgabeverträge) — Einzelzeile,
|
||||
/// per Migration leer geseedet, von der Züchterin unter /einstellungen gepflegt.
|
||||
/// GET /settings/breeder-profile -> BreederProfileDto
|
||||
/// PUT /settings/breeder-profile -> 204
|
||||
/// </summary>
|
||||
public static class SettingsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/settings").WithTags("Settings");
|
||||
|
||||
group.MapGet("/breeder-profile", async Task<Ok<BreederProfileDto>> (ApplicationContext db) =>
|
||||
{
|
||||
var s = await LoadAsync(db, track: false);
|
||||
return TypedResults.Ok(ToDto(s));
|
||||
});
|
||||
|
||||
group.MapPut("/breeder-profile", async Task<NoContent> (BreederProfileDto input, ApplicationContext db) =>
|
||||
{
|
||||
var s = await LoadAsync(db, track: true);
|
||||
s.ZuchtName = input.ZuchtName ?? "";
|
||||
s.Name = input.Name ?? "";
|
||||
s.Address = input.Address ?? "";
|
||||
s.Phone = input.Phone ?? "";
|
||||
s.Email = input.Email ?? "";
|
||||
s.Homepage = input.Homepage ?? "";
|
||||
s.City = input.City ?? "";
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>Die Singleton-Zeile; defensiv neu anlegen, falls sie fehlt.</summary>
|
||||
private static async Task<BreederSettings> LoadAsync(ApplicationContext db, bool track)
|
||||
{
|
||||
var query = track ? db.BreederSettings : db.BreederSettings.AsNoTracking();
|
||||
var s = await query.FirstOrDefaultAsync(x => x.Id == BreederSettings.SingletonId);
|
||||
if (s is null)
|
||||
{
|
||||
s = new BreederSettings { Id = BreederSettings.SingletonId };
|
||||
db.BreederSettings.Add(s);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
internal static BreederProfileDto ToDto(BreederSettings s) =>
|
||||
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City);
|
||||
}
|
||||
}
|
||||
1024
GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs
generated
Normal file
1024
GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SaleContractsAndBreederSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BreederSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ZuchtName = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Address = table.Column<string>(type: "text", nullable: false),
|
||||
Phone = table.Column<string>(type: "text", nullable: false),
|
||||
Email = table.Column<string>(type: "text", nullable: false),
|
||||
Homepage = table.Column<string>(type: "text", nullable: false),
|
||||
City = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BreederSettings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SaleContracts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ContactId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Price = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
|
||||
HandoverDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ContractDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
FileName = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SaleContracts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SaleContracts_Contacts_ContactId",
|
||||
column: x => x.ContactId,
|
||||
principalTable: "Contacts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SaleContractAnimal",
|
||||
columns: table => new
|
||||
{
|
||||
SaleContractId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GerbilId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SaleContractAnimal", x => new { x.SaleContractId, x.GerbilId });
|
||||
table.ForeignKey(
|
||||
name: "FK_SaleContractAnimal_Gerbils_GerbilId",
|
||||
column: x => x.GerbilId,
|
||||
principalTable: "Gerbils",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_SaleContractAnimal_SaleContracts_SaleContractId",
|
||||
column: x => x.SaleContractId,
|
||||
principalTable: "SaleContracts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "BreederSettings",
|
||||
columns: new[] { "Id", "Address", "City", "Email", "Homepage", "Name", "Phone", "ZuchtName" },
|
||||
values: new object[] { new Guid("11111111-1111-1111-1111-000000000001"), "", "", "", "", "", "", "" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SaleContractAnimal_GerbilId",
|
||||
table: "SaleContractAnimal",
|
||||
column: "GerbilId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SaleContracts_ContactId",
|
||||
table: "SaleContracts",
|
||||
column: "ContactId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BreederSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SaleContractAnimal");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SaleContracts");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,58 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Address")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("City")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Homepage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ZuchtName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("BreederSettings");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = new Guid("11111111-1111-1111-1111-000000000001"),
|
||||
Address = "",
|
||||
City = "",
|
||||
Email = "",
|
||||
Homepage = "",
|
||||
Name = "",
|
||||
Phone = "",
|
||||
ZuchtName = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -771,6 +823,54 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("Litters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateOnly>("ContractDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateOnly>("HandoverDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<decimal>("Price")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("SaleContracts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b =>
|
||||
{
|
||||
b.Property<Guid>("SaleContractId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("SaleContractId", "GerbilId");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("SaleContractAnimal");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -869,6 +969,34 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Navigation("Mother");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Contact", "Contact")
|
||||
.WithMany()
|
||||
.HasForeignKey("ContactId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Contact");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Gerbil")
|
||||
.WithMany()
|
||||
.HasForeignKey("GerbilId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.SaleContract", null)
|
||||
.WithMany("Animals")
|
||||
.HasForeignKey("SaleContractId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Gerbil");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
|
||||
@@ -882,6 +1010,11 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
b.Navigation("Gerbils");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
|
||||
{
|
||||
b.Navigation("Animals");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
36
GerbilManagerWebAPI/Models/BreederSettings.cs
Normal file
36
GerbilManagerWebAPI/Models/BreederSettings.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Zuchtprofil (Verkäufer-Block der Abgabeverträge) — Einzelzeilen-Entity
|
||||
/// (Singleton, fixe Id, leer geseedet). Die Züchterin pflegt ihre Daten in
|
||||
/// der App unter /einstellungen statt in einer JSON-Datei; FEAT-13 phase B
|
||||
/// ersetzt damit die appsettings-Variante aus phase A.
|
||||
/// </summary>
|
||||
public class BreederSettings
|
||||
{
|
||||
/// <summary>Fixe Id der einzigen Zeile (per HasData geseedet).</summary>
|
||||
public static readonly Guid SingletonId = new("11111111-1111-1111-1111-000000000001");
|
||||
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string ZuchtName { get; set; } = "";
|
||||
|
||||
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>Anschrift einzeilig: „Straße Nr, PLZ Ort“.</summary>
|
||||
public string Address { get; set; } = "";
|
||||
|
||||
public string Phone { get; set; } = "";
|
||||
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
public string Homepage { get; set; } = "";
|
||||
|
||||
/// <summary>Ort für die Unterschriftszeile („{Ort}, den {Datum}“).</summary>
|
||||
public string City { get; set; } = "";
|
||||
}
|
||||
}
|
||||
45
GerbilManagerWebAPI/Models/SaleContract.cs
Normal file
45
GerbilManagerWebAPI/Models/SaleContract.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Ein erzeugter Abgabevertrag (FEAT-13). Die .docx liegt wie die Fotos im
|
||||
/// Datei-Root (Contracts:RootPath bzw. contract-storage/), in der DB steht
|
||||
/// nur der Dateiname. Tiere hängen über <see cref="SaleContractAnimal"/>
|
||||
/// (Join-Entity statt uuid[]-Spalte: referenzielle Integrität, „Verträge
|
||||
/// eines Tieres“ bleibt abfragbar, und pro Tier-Zeile ist später Platz für
|
||||
/// Zusatzdaten wie den Einzelpreis).
|
||||
/// </summary>
|
||||
public class SaleContract
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Abnehmer (Käufer-Block des Vertrags).</summary>
|
||||
public Guid ContactId { get; set; }
|
||||
public Contact? Contact { get; set; }
|
||||
|
||||
/// <summary>Kaufpreis in Euro (gesamt).</summary>
|
||||
public decimal Price { get; set; }
|
||||
|
||||
public DateOnly HandoverDate { get; set; }
|
||||
|
||||
/// <summary>Datum der Unterschriftszeile (Standard: Übergabedatum).</summary>
|
||||
public DateOnly ContractDate { get; set; }
|
||||
|
||||
/// <summary>Dateiname der erzeugten .docx im Vertrags-Dateiroot.</summary>
|
||||
public required string FileName { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public List<SaleContractAnimal> Animals { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Join-Zeile Vertrag ↔ Tier (zusammengesetzter Schlüssel).</summary>
|
||||
public class SaleContractAnimal
|
||||
{
|
||||
public Guid SaleContractId { get; set; }
|
||||
public Guid GerbilId { get; set; }
|
||||
public Gerbil? Gerbil { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -62,5 +62,10 @@ app.MapHealthRecordEndpoints();
|
||||
app.MapWeightRecordEndpoints();
|
||||
app.MapInbreedingEndpoints();
|
||||
app.MapPhotoEndpoints();
|
||||
app.MapContractEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
// Sichtbarer Programmtyp für WebApplicationFactory<Program> (Endpoint-Tests).
|
||||
public partial class Program { }
|
||||
|
||||
Reference in New Issue
Block a user