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:
@@ -0,0 +1,6 @@
|
||||
using Xunit;
|
||||
|
||||
// Gleicher Grund wie in LehrerApp.Data.Tests/AssemblyInfo.cs: LiteDBs geteilter, statischer
|
||||
// BsonMapper.Global verträgt keine parallele Erstzuordnung von Typ-Metadaten über mehrere
|
||||
// Testklassen hinweg (EventApplierTests/AttachmentSyncerTests konstruieren beide LiteDbContext).
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
@@ -0,0 +1,76 @@
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Sync.Tests;
|
||||
|
||||
public sealed class AttachmentSyncerTests
|
||||
{
|
||||
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||
private static readonly byte[] Key = SyncCrypto.GenerateKey();
|
||||
|
||||
[Fact]
|
||||
public async Task UploadPendingAsync_LaedtVerschluesselteBytesHochUndMarkiertAlsErledigt()
|
||||
{
|
||||
using var temp = new TempEventQueue();
|
||||
using var db = NewInMemoryContext();
|
||||
var storageId = Guid.NewGuid().ToString("N");
|
||||
db.Attachments.Upload(storageId, "brief.pdf", new MemoryStream([1, 2, 3, 4]));
|
||||
temp.Queue.QueueAttachmentUpload(storageId);
|
||||
// Bytes MÜSSEN synchron innerhalb des Handler-Callbacks gelesen werden: AttachmentSyncer
|
||||
// disposed sein ByteArrayContent direkt nach dem PostAsync-Aufruf (eigenes "using"),
|
||||
// ein Zugriff auf request.Content danach würde ObjectDisposedException werfen.
|
||||
byte[]? uploadedEncrypted = null;
|
||||
var handler = new FakeHttpMessageHandler(req =>
|
||||
{
|
||||
uploadedEncrypted = req.Content!.ReadAsByteArrayAsync().GetAwaiter().GetResult();
|
||||
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
|
||||
});
|
||||
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
|
||||
var syncer = new AttachmentSyncer(db, http, Key);
|
||||
|
||||
await syncer.UploadPendingAsync(temp.Queue);
|
||||
|
||||
var request = Assert.Single(handler.Requests);
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
Assert.Equal($"/api/sync/attachments/{storageId}", request.RequestUri!.AbsolutePath);
|
||||
Assert.NotNull(uploadedEncrypted);
|
||||
Assert.Equal([1, 2, 3, 4], SyncCrypto.Decrypt(uploadedEncrypted!, Key));
|
||||
Assert.Empty(temp.Queue.GetPendingAttachmentUploads());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadPendingAsync_LokalNichtMehrVorhandenerAnhang_WirdOhneUploadAlsErledigtMarkiert()
|
||||
{
|
||||
using var temp = new TempEventQueue();
|
||||
using var db = NewInMemoryContext();
|
||||
temp.Queue.QueueAttachmentUpload("laengst-geloescht");
|
||||
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK));
|
||||
var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
|
||||
var syncer = new AttachmentSyncer(db, http, Key);
|
||||
|
||||
await syncer.UploadPendingAsync(temp.Queue);
|
||||
|
||||
Assert.Empty(handler.Requests);
|
||||
Assert.Empty(temp.Queue.GetPendingAttachmentUploads());
|
||||
}
|
||||
|
||||
private sealed class TempEventQueue : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(
|
||||
Path.GetTempPath(), $"lehrerapp-sync-tests-attachments-{Guid.NewGuid():N}");
|
||||
public EventQueue Queue { get; }
|
||||
|
||||
public TempEventQueue()
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
Queue = new EventQueue(Path.Combine(_directory, "queue.db"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Queue.Dispose();
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace LehrerApp.Sync.Tests;
|
||||
|
||||
/// <summary>Zeichnet Requests auf und beantwortet sie über eine Callback-Funktion, ohne echtes Netzwerk.</summary>
|
||||
public sealed class FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
public List<HttpRequestMessage> Requests { get; } = [];
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user