diff --git a/GerbilManager.Tests/GerbilProfilePhotoTests.cs b/GerbilManager.Tests/GerbilProfilePhotoTests.cs
new file mode 100644
index 0000000..7a0dfa7
--- /dev/null
+++ b/GerbilManager.Tests/GerbilProfilePhotoTests.cs
@@ -0,0 +1,83 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using GerbilManagerWebAPI.Models;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace GerbilManager.Tests;
+
+/// profilePhotoUrl: computed field on GerbilDto — first photo by sortOrder, or null.
+public class GerbilProfilePhotoTests : IClassFixture
+{
+ private readonly HttpClient _client;
+ private readonly ApiFactory _factory;
+
+ public GerbilProfilePhotoTests(ApiFactory factory)
+ {
+ _factory = factory;
+ _client = factory.CreateClient();
+ }
+
+ private static Guid ExtractId(string json) =>
+ Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
+
+ private static string? GetStr(JsonElement el, string prop) =>
+ el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
+ ? v.GetString() : null;
+
+ private async Task CreateGerbil(string name)
+ {
+ var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name, gender = "female" }));
+ Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
+ return ExtractId(await resp.Content.ReadAsStringAsync());
+ }
+
+ private void InsertPhotos(Guid gerbilId, params (string fileName, int sortOrder)[] photos)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ foreach (var (fn, so) in photos)
+ db.GerbilPhotos.Add(new GerbilPhoto
+ {
+ Id = Guid.NewGuid(), GerbilId = gerbilId,
+ FileName = fn, SortOrder = so, CreatedAt = DateTimeOffset.UtcNow,
+ });
+ db.SaveChanges();
+ }
+
+ [Fact]
+ public async Task GetById_without_photos_returns_null_profilePhotoUrl()
+ {
+ var id = await CreateGerbil("NoPhoto");
+ var json = await _client.GetStringAsync($"/gerbils/{id}");
+ var el = JsonDocument.Parse(json).RootElement;
+ Assert.True(el.TryGetProperty("profilePhotoUrl", out var v));
+ Assert.Equal(JsonValueKind.Null, v.ValueKind);
+ }
+
+ [Fact]
+ public async Task GetById_with_photos_returns_lowest_sortOrder_url()
+ {
+ var id = await CreateGerbil("PhotoGerbil");
+ InsertPhotos(id, ("second.jpg", 1), ("first.jpg", 0));
+
+ var json = await _client.GetStringAsync($"/gerbils/{id}");
+ var el = JsonDocument.Parse(json).RootElement;
+ Assert.Equal("/photos/files/first.jpg", GetStr(el, "profilePhotoUrl"));
+ }
+
+ [Fact]
+ public async Task GetList_returns_profilePhotoUrl_without_n_plus_1()
+ {
+ var id = await CreateGerbil("ListPhoto");
+ InsertPhotos(id, ("list-photo.jpg", 0));
+
+ var json = await _client.GetStringAsync("/gerbils");
+ var root = JsonDocument.Parse(json).RootElement;
+ var items = root.GetProperty("items").EnumerateArray().ToList();
+ var gerbil = items.FirstOrDefault(x => GetStr(x, "id") == id.ToString());
+ Assert.NotEqual(default, gerbil);
+ Assert.Equal("/photos/files/list-photo.jpg", GetStr(gerbil, "profilePhotoUrl"));
+ }
+}
diff --git a/GerbilManagerWebAPI/Dtos/ApiDtos.cs b/GerbilManagerWebAPI/Dtos/ApiDtos.cs
index 983583c..ccabf05 100644
--- a/GerbilManagerWebAPI/Dtos/ApiDtos.cs
+++ b/GerbilManagerWebAPI/Dtos/ApiDtos.cs
@@ -29,7 +29,8 @@ namespace GerbilManagerWebAPI.Dtos
List CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
- bool IsResident);
+ bool IsResident,
+ string? ProfilePhotoUrl);
public record LitterDto(
Guid Id,
diff --git a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs
index b62dd8e..285e8c2 100644
--- a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs
+++ b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs
@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
+using Gridify.EntityFramework;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -16,8 +17,21 @@ namespace GerbilManagerWebAPI.Endpoints
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
- TypedResults.Ok(await db.Gerbils.AsNoTracking()
- .ToPagedResultAsync(query, ToDto)));
+ {
+ var q = query.ToQuery();
+ var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q);
+ var ids = paging.Data.Select(g => g.Id).ToList();
+ var photoMap = ids.Count == 0 ? new Dictionary() :
+ await db.GerbilPhotos.AsNoTracking()
+ .Where(p => ids.Contains(p.GerbilId))
+ .GroupBy(p => p.GerbilId)
+ .Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName })
+ .ToDictionaryAsync(x => x.GerbilId, x => x.FileName);
+ var items = paging.Data
+ .Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null))
+ .ToList();
+ return TypedResults.Ok(new PagedResult(items, paging.Count, q.Page, q.PageSize));
+ });
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
group.MapGet("/breeders", async (ApplicationContext db) =>
@@ -30,7 +44,14 @@ namespace GerbilManagerWebAPI.Endpoints
group.MapGet("/{id:guid}", async Task, NotFound>> (Guid id, ApplicationContext db) =>
{
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
- return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g));
+ if (g is null) return TypedResults.NotFound();
+ var photoFileName = await db.GerbilPhotos.AsNoTracking()
+ .Where(p => p.GerbilId == id)
+ .OrderBy(p => p.SortOrder)
+ .Select(p => p.FileName)
+ .FirstOrDefaultAsync();
+ var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null;
+ return TypedResults.Ok(ToDto(g, profilePhotoUrl));
});
// POST /gerbils
@@ -107,10 +128,10 @@ namespace GerbilManagerWebAPI.Endpoints
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
}
- internal static GerbilDto ToDto(Gerbil g) => new(
+ internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
- g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
+ g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl);
}
}