feat(tickets): Foto-/Datei-Anhänge an Tickets

- Anhänge im Melde-Fenster (beim Erstellen) und direkt an bestehenden Tickets:
  Vorschaubilder, Öffnen im neuen Tab, Entfernen.
- Bytes liegen in eigener Tabelle (FeedbackAttachment, lose FeedbackId ohne FK →
  übersteht den Ingest-Wipe); GET /feedback liefert nur Metadaten (id/Name/Typ/Größe),
  die Bytes über /feedback/attachments/{id}. Größenlimit 10 MB.
- Endpoints: POST /feedback/{id}/attachments (base64), GET /feedback/attachments/{id}
  (Bytes), DELETE /feedback/attachments/{id}.

Migration FeedbackAttachments. Tests: 262 Backend grün (+Upload/Serve/Delete +Validierung),
e2e Tickets Desktop+Phone grün (+Foto-Upload), vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:24:02 +02:00
parent 48b138bbfd
commit 750619d3d7
15 changed files with 2423 additions and 8 deletions

View File

@@ -148,6 +148,64 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
}
[Fact]
public async Task Attachment_upload_list_serve_delete_lifecycle()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new
{
message = "Foto vom Fellschlag.",
context = "gerbil-detail",
});
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// 1×1-PNG hochladen.
const string pngB64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
{
fileName = "maus.png",
contentType = "image/png",
dataBase64 = pngB64,
});
Assert.Equal(HttpStatusCode.Created, up.StatusCode);
var att = JsonDocument.Parse(await up.Content.ReadAsStringAsync()).RootElement;
var attId = att.GetProperty("id").GetString();
Assert.Equal("maus.png", att.GetProperty("fileName").GetString());
Assert.True(att.GetProperty("size").GetInt32() > 0);
// GET /feedback liefert die Metadaten (ohne Bytes).
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
var row = listed.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
Assert.Equal(1, row.GetProperty("attachments").GetArrayLength());
// Bytes ausliefern.
var bytes = await client.GetByteArrayAsync($"/feedback/attachments/{attId}");
Assert.Equal(Convert.FromBase64String(pngB64).Length, bytes.Length);
// Löschen -> danach keine Anhänge mehr.
var del = await client.DeleteAsync($"/feedback/attachments/{attId}");
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
var after = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
var rowAfter = after.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
Assert.Equal(0, rowAfter.GetProperty("attachments").GetArrayLength());
}
[Fact]
public async Task Attachment_upload_rejects_invalid_base64()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
{
fileName = "x.png",
contentType = "image/png",
dataBase64 = "###nicht base64###",
});
Assert.Equal(HttpStatusCode.BadRequest, up.StatusCode);
}
[Fact]
public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt()
{