Baustein 6: Datei-Anhaenge synchronisieren (Kapitel 10)

Anhaenge laufen bewusst NICHT ueber den JSON-Ereigniskanal (wuerde ihn
fuer Fotos/Scans stark aufblaehen), sondern ueber einen eigenen
verschluesselten Binaerkanal - analog zum bereits bestehenden Muster
in SnapshotService.

- Neue Endpunkte POST/GET /api/sync/attachments/{storageId} in
  LehrerApp.Api (AttachmentStore, dateibasiert je Nutzer)
- EventQueue: neue, vom JSON-Ereignis getrennte Warteliste fuer
  ausstehende Uploads (SyncEventPublisher traegt Anhaenge einer
  gespeicherten Documentation dort ein)
- AttachmentSyncer laedt ausstehende Anhaenge hoch (in
  SyncEngine.SyncNowAsync nach dem Event-Push)
- EventApplier laedt fehlende Anhaenge nach dem Anwenden eines
  Documentation-Ereignisses nach - ueber die rohe Collection statt
  IAttachmentStorage.Upload, da dieses immer eine neue Id vergaebe und
  hier die Original-StorageId erhalten bleiben muss

Round-Trip-Tests belegen byteidentische Uebertragung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 11:30:04 +02:00
co-authored by Claude Sonnet 5
parent ce4dfb0197
commit f0f8fa25e5
14 changed files with 391 additions and 29 deletions
+64 -18
View File
@@ -12,13 +12,13 @@ public sealed class EventApplierTests
private static readonly byte[] Key = SyncCrypto.GenerateKey();
[Fact]
public void Apply_Save_SchreibtEntitaetDirektInDieCollection()
public async Task ApplyAsync_Save_SchreibtEntitaetDirektInDieCollection()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student));
await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student));
var saved = db.Students.FindById(student.Id);
Assert.NotNull(saved);
@@ -26,32 +26,32 @@ public sealed class EventApplierTests
}
[Fact]
public void Apply_Delete_EntferntDenDatensatz()
public async Task ApplyAsync_Delete_EntferntDenDatensatz()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
db.Students.Insert(student);
applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null));
await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null));
Assert.Null(db.Students.FindById(student.Id));
}
[Fact]
public void Apply_UnbekannterEntityType_TutNichtsUndWirftNicht()
public async Task ApplyAsync_UnbekannterEntityType_TutNichtsUndWirftNicht()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var exception = Record.Exception(() =>
applier.Apply(MakeEvent("UnbekannterTyp", Guid.NewGuid().ToString(), "Save", new { Foo = "Bar" })));
var exception = await Record.ExceptionAsync(() =>
applier.ApplyAsync(MakeEvent("UnbekannterTyp", Guid.NewGuid().ToString(), "Save", new { Foo = "Bar" })));
Assert.Null(exception);
}
[Fact]
public void Apply_GroupDelete_FuehrtDieselbeKaskadeAusWieDasRepository()
public async Task ApplyAsync_GroupDelete_FuehrtDieselbeKaskadeAusWieDasRepository()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
@@ -60,14 +60,14 @@ public sealed class EventApplierTests
var gradeId = Guid.NewGuid();
db.Grades.Insert(new Grade { Id = gradeId, GroupId = groupId, StudentId = Guid.NewGuid() });
applier.Apply(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null));
await applier.ApplyAsync(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null));
Assert.Null(db.Groups.FindById(groupId));
Assert.Null(db.Grades.FindById(gradeId));
}
[Fact]
public void Apply_VerletztHartenUniqueIndex_WirdUebersprungenOhneAusnahme()
public async Task ApplyAsync_VerletztHartenUniqueIndex_WirdUebersprungenOhneAusnahme()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
@@ -77,8 +77,8 @@ public sealed class EventApplierTests
// Zweite Mitgliedschaft für dasselbe Schüler/Gruppe-Paar verletzt den ux_student_group-Index.
var duplicate = new GroupMembership { Id = Guid.NewGuid(), StudentId = studentId, GroupId = groupId };
var exception = Record.Exception(() =>
applier.Apply(MakeEvent(nameof(GroupMembership), duplicate.Id.ToString(), "Save", duplicate)));
var exception = await Record.ExceptionAsync(() =>
applier.ApplyAsync(MakeEvent(nameof(GroupMembership), duplicate.Id.ToString(), "Save", duplicate)));
Assert.Null(exception);
Assert.Single(db.Memberships.FindAll());
@@ -89,7 +89,7 @@ public sealed class EventApplierTests
// sonst entsteht ein Sync-Ping-Pong zwischen den Geräten (siehe EventApplier-Kommentar).
[Fact]
public void Apply_Save_LoestNIEMALSDenOnChangeHookAus()
public async Task ApplyAsync_Save_LoestNIEMALSDenOnChangeHookAus()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
@@ -97,13 +97,13 @@ public sealed class EventApplierTests
db.OnChange = (_, _, _, _) => onChangeCallCount++;
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student));
await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student));
Assert.Equal(0, onChangeCallCount);
}
[Fact]
public void Apply_Delete_LoestNIEMALSDenOnChangeHookAus()
public async Task ApplyAsync_Delete_LoestNIEMALSDenOnChangeHookAus()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
@@ -112,13 +112,13 @@ public sealed class EventApplierTests
var onChangeCallCount = 0;
db.OnChange = (_, _, _, _) => onChangeCallCount++;
applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null));
await applier.ApplyAsync(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null));
Assert.Equal(0, onChangeCallCount);
}
[Fact]
public void Apply_GroupDeleteKaskade_LoestNIEMALSDenOnChangeHookAus()
public async Task ApplyAsync_GroupDeleteKaskade_LoestNIEMALSDenOnChangeHookAus()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
@@ -129,11 +129,57 @@ public sealed class EventApplierTests
var onChangeCallCount = 0;
db.OnChange = (_, _, _, _) => onChangeCallCount++;
applier.Apply(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null));
await applier.ApplyAsync(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null));
Assert.Equal(0, onChangeCallCount);
}
// ── Fehlende Anhänge nachladen ───────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_DocumentationMitFehlendemAnhang_LaedtIhnUeberHttpNach()
{
using var db = NewInMemoryContext();
var storageId = Guid.NewGuid().ToString("N");
var handler = new FakeHttpMessageHandler(req =>
{
Assert.Equal($"/api/sync/attachments/{storageId}", req.RequestUri!.AbsolutePath);
var encrypted = SyncCrypto.Encrypt([9, 8, 7], Key);
return new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{ Content = new ByteArrayContent(encrypted) };
});
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
var applier = new EventApplier(db, Key, http);
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Elternbrief" };
doc.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "brief.pdf", SizeBytes = 3 });
await applier.ApplyAsync(MakeEvent(nameof(Documentation), doc.Id.ToString(), "Save", doc));
Assert.Single(handler.Requests);
Assert.True(db.Attachments.Exists(storageId));
using var read = db.Attachments.OpenRead(storageId);
using var ms = new MemoryStream();
await read.CopyToAsync(ms);
Assert.Equal([9, 8, 7], ms.ToArray());
}
[Fact]
public async Task ApplyAsync_DocumentationMitBereitsVorhandenemAnhang_LaedtNichtErneut()
{
using var db = NewInMemoryContext();
var storageId = Guid.NewGuid().ToString("N");
db.Attachments.Upload(storageId, "brief.pdf", new MemoryStream([1, 2, 3]));
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK));
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
var applier = new EventApplier(db, Key, http);
var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Elternbrief" };
doc.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "brief.pdf", SizeBytes = 3 });
await applier.ApplyAsync(MakeEvent(nameof(Documentation), doc.Id.ToString(), "Save", doc));
Assert.Empty(handler.Requests);
}
private static SyncEvent MakeEvent(string entityType, string entityId, string operation, object? payload) => new()
{
DeviceId = "companion-1",