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,70 @@
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Api.Tests;
|
||||
|
||||
public sealed class AttachmentStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoreAsync_GefolgtVonOpenRead_LiefertByteidentischeDatei()
|
||||
{
|
||||
using var temp = new TempDataPath();
|
||||
var store = new AttachmentStore(temp.Path);
|
||||
byte[] original = [1, 2, 3, 4, 5, 255, 0, 42];
|
||||
|
||||
await store.StoreAsync("user-1", "abc123", new MemoryStream(original));
|
||||
|
||||
using var read = store.OpenRead("user-1", "abc123");
|
||||
Assert.NotNull(read);
|
||||
using var ms = new MemoryStream();
|
||||
await read!.CopyToAsync(ms);
|
||||
Assert.Equal(original, ms.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenRead_UnbekannteStorageId_GibtNullZurueck()
|
||||
{
|
||||
using var temp = new TempDataPath();
|
||||
var store = new AttachmentStore(temp.Path);
|
||||
|
||||
Assert.Null(store.OpenRead("user-1", "unbekannt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_TrenntAnhaengeVerschiedenerNutzer()
|
||||
{
|
||||
using var temp = new TempDataPath();
|
||||
var store = new AttachmentStore(temp.Path);
|
||||
|
||||
await store.StoreAsync("user-1", "shared-id", new MemoryStream([1]));
|
||||
|
||||
Assert.Null(store.OpenRead("user-2", "shared-id"));
|
||||
Assert.NotNull(store.OpenRead("user-1", "shared-id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_BereinigtStorageIdMitPathTraversalZeichen()
|
||||
{
|
||||
using var temp = new TempDataPath();
|
||||
var store = new AttachmentStore(temp.Path);
|
||||
|
||||
// Darf keinesfalls außerhalb von <root>/attachments/<user> landen.
|
||||
await store.StoreAsync("user-1", "../../evil", new MemoryStream([1, 2, 3]));
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(temp.Path, "evil")));
|
||||
var withinRoot = Directory.EnumerateFiles(Path.Combine(temp.Path, "attachments"), "*", SearchOption.AllDirectories);
|
||||
Assert.Contains(withinRoot, f => Path.GetFileName(f) == "evil");
|
||||
}
|
||||
|
||||
private sealed class TempDataPath : IDisposable
|
||||
{
|
||||
public string Path { get; } = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-api-tests-attachments-{Guid.NewGuid():N}");
|
||||
|
||||
public TempDataPath() => Directory.CreateDirectory(Path);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Rohdaten-Ablage für verschlüsselte Datei-Anhänge, getrennt vom Ereignis-/Snapshot-Speicher.
|
||||
/// Server sieht nur verschlüsselte Bytes, kein LiteDB nötig - einfache Dateien pro Nutzer/Id.
|
||||
/// </summary>
|
||||
public class AttachmentStore(string dataPath)
|
||||
{
|
||||
private readonly string _root = Path.Combine(dataPath, "attachments");
|
||||
|
||||
public async Task StoreAsync(string userId, string storageId, Stream content)
|
||||
{
|
||||
var dir = Path.Combine(_root, Safe(userId));
|
||||
Directory.CreateDirectory(dir);
|
||||
await using var file = File.Create(Path.Combine(dir, Safe(storageId)));
|
||||
await content.CopyToAsync(file);
|
||||
}
|
||||
|
||||
public Stream? OpenRead(string userId, string storageId)
|
||||
{
|
||||
var path = Path.Combine(_root, Safe(userId), Safe(storageId));
|
||||
return File.Exists(path) ? File.OpenRead(path) : null;
|
||||
}
|
||||
|
||||
// storageId kommt als Routen-Parameter vom Client - nie ungeprüft in einen Dateipfad
|
||||
// übernehmen (Path-Traversal).
|
||||
private static string Safe(string value) =>
|
||||
string.Concat(value.Where(c => char.IsLetterOrDigit(c) || c == '-'));
|
||||
}
|
||||
@@ -48,6 +48,30 @@ public static class Endpoints
|
||||
});
|
||||
}
|
||||
|
||||
// ── Anhänge (eigener Binärkanal, getrennt vom JSON-Ereigniskanal) ──────────
|
||||
|
||||
public static void MapAttachmentEndpoints(this WebApplication app)
|
||||
{
|
||||
var g = app.MapGroup("/api/sync/attachments").RequireAuthorization();
|
||||
g.MapPost("/{storageId}", async (string storageId, HttpRequest req,
|
||||
ClaimsPrincipal user, AttachmentStore store) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
if (req.ContentLength is null or > LehrerApp.Core.Interfaces.IAttachmentStorage.MaxSizeBytes)
|
||||
return Results.BadRequest("Datei zu groß oder Content-Length fehlt.");
|
||||
await store.StoreAsync(uid, storageId, req.Body);
|
||||
return Results.Ok();
|
||||
});
|
||||
g.MapGet("/{storageId}", (string storageId, ClaimsPrincipal user, AttachmentStore store) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
var stream = store.OpenRead(uid, storageId);
|
||||
return stream is null ? Results.NotFound() : Results.Stream(stream, "application/octet-stream");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Snapshot (Device-Pairing) ─────────────────────────────────────────────
|
||||
|
||||
public static void MapSnapshotEndpoints(this WebApplication app)
|
||||
|
||||
@@ -30,6 +30,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddSingleton<UserStore>(_ => new UserStore(data));
|
||||
builder.Services.AddSingleton<AttachmentStore>(_ => new AttachmentStore(data));
|
||||
builder.Services.AddSingleton<EventStore>(_ => new EventStore(data));
|
||||
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
|
||||
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
|
||||
@@ -41,6 +42,7 @@ app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapAuthEndpoints(secret);
|
||||
app.MapSyncEndpoints();
|
||||
app.MapAttachmentEndpoints();
|
||||
app.MapSnapshotEndpoints();
|
||||
app.MapReadableSnapshotEndpoints();
|
||||
app.MapPlainSyncEndpoints();
|
||||
|
||||
@@ -179,14 +179,19 @@ public static class AppBootstrapper
|
||||
if (!string.IsNullOrEmpty(serverUrl))
|
||||
{
|
||||
services.AddSingleton(sp => new EventApplier(
|
||||
sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<byte[]>()));
|
||||
sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<byte[]>(),
|
||||
BuildHttp(serverUrl, appData)));
|
||||
services.AddSingleton(sp => new SyncEventPublisher(
|
||||
sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>()));
|
||||
services.AddSingleton(sp => new AttachmentSyncer(
|
||||
sp.GetRequiredService<LiteDbContext>(), BuildHttp(serverUrl, appData),
|
||||
sp.GetRequiredService<byte[]>()));
|
||||
|
||||
services.AddSingleton<SyncEngine>(sp => new SyncEngine(
|
||||
sp.GetRequiredService<EventQueue>(),
|
||||
sp.GetRequiredService<ConflictResolver>(),
|
||||
sp.GetRequiredService<EventApplier>(),
|
||||
sp.GetRequiredService<AttachmentSyncer>(),
|
||||
BuildHttp(serverUrl, appData),
|
||||
new SyncConfig
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Lädt ausstehende Datei-Anhänge (siehe <see cref="EventQueue.GetPendingAttachmentUploads"/>)
|
||||
/// als eigenen, verschlüsselten Binärtransfer hoch — getrennt vom JSON-Ereigniskanal, damit
|
||||
/// Fotos/Scans ihn nicht aufblähen. Gegenstück zum Download in <see cref="EventApplier"/>.
|
||||
/// </summary>
|
||||
public class AttachmentSyncer(LiteDbContext db, HttpClient http, byte[] syncKey)
|
||||
{
|
||||
public async Task UploadPendingAsync(EventQueue queue)
|
||||
{
|
||||
foreach (var storageId in queue.GetPendingAttachmentUploads())
|
||||
{
|
||||
if (!db.Attachments.Exists(storageId))
|
||||
{
|
||||
// Lokal inzwischen wieder gelöscht (z.B. HardDelete vor dem eigentlichen Upload) -
|
||||
// nichts hochzuladen, Warteliste trotzdem bereinigen.
|
||||
queue.MarkAttachmentUploaded(storageId);
|
||||
continue;
|
||||
}
|
||||
|
||||
using var raw = db.Attachments.OpenRead(storageId);
|
||||
using var buffer = new MemoryStream();
|
||||
await raw.CopyToAsync(buffer);
|
||||
var encrypted = SyncCrypto.Encrypt(buffer.ToArray(), syncKey);
|
||||
|
||||
using var content = new ByteArrayContent(encrypted);
|
||||
var resp = await http.PostAsync($"/api/sync/attachments/{storageId}", content);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
queue.MarkAttachmentUploaded(storageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,17 +23,19 @@ namespace LehrerApp.Sync;
|
||||
/// Pfad bewusst NICHT geprüft (v1-Einschränkung, siehe TODO.md 10.3) — nur harte LiteDB-Unique-
|
||||
/// Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses.
|
||||
/// </summary>
|
||||
public class EventApplier(LiteDbContext db, byte[] syncKey)
|
||||
public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = null)
|
||||
{
|
||||
private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers();
|
||||
|
||||
public void Apply(SyncEvent evt)
|
||||
public async Task ApplyAsync(SyncEvent evt)
|
||||
{
|
||||
if (!Handlers.TryGetValue(evt.EntityType, out var handler)) return;
|
||||
try
|
||||
{
|
||||
var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload);
|
||||
handler(db, evt.Operation, evt.EntityId, json);
|
||||
if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null)
|
||||
await DownloadMissingAttachmentsAsync(json);
|
||||
}
|
||||
catch (LiteException)
|
||||
{
|
||||
@@ -42,6 +44,27 @@ public class EventApplier(LiteDbContext db, byte[] syncKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Anhang-Bytes reisen nicht im JSON-Ereignis mit (siehe SyncEventPublisher) - nach dem
|
||||
// Anwenden der Documentation-Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln
|
||||
// nachladen. Gegenstück zum Upload in AttachmentSyncer.
|
||||
private async Task DownloadMissingAttachmentsAsync(string json)
|
||||
{
|
||||
var doc = JsonSerializer.Deserialize<Documentation>(json);
|
||||
if (doc is null) return;
|
||||
foreach (var attachment in doc.Attachments)
|
||||
{
|
||||
if (db.Attachments.Exists(attachment.StorageId)) continue;
|
||||
var resp = await http!.GetAsync($"/api/sync/attachments/{attachment.StorageId}");
|
||||
if (!resp.IsSuccessStatusCode) continue;
|
||||
var encrypted = await resp.Content.ReadAsByteArrayAsync();
|
||||
var decrypted = SyncCrypto.Decrypt(encrypted, syncKey);
|
||||
using var stream = new MemoryStream(decrypted);
|
||||
// Über die rohe Collection statt IAttachmentStorage.Upload, da dieses immer eine
|
||||
// neue Id vergibt - hier muss die Original-StorageId erhalten bleiben.
|
||||
db.Attachments.Upload(attachment.StorageId, attachment.FileName, stream);
|
||||
}
|
||||
}
|
||||
|
||||
private string Decrypt(string payloadBase64) =>
|
||||
Encoding.UTF8.GetString(SyncCrypto.Decrypt(Convert.FromBase64String(payloadBase64), syncKey));
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ public class EventQueue : IDisposable
|
||||
private readonly ILiteCollection<SyncEvent> _queue;
|
||||
private readonly ILiteCollection<SyncMeta> _meta;
|
||||
private readonly ILiteCollection<ConflictEntry> _conflicts;
|
||||
private readonly ILiteCollection<PendingAttachmentUpload> _attachmentUploads;
|
||||
private long _currentSeq;
|
||||
|
||||
public EventQueue(string path)
|
||||
@@ -21,6 +22,8 @@ public class EventQueue : IDisposable
|
||||
_queue = _db.GetCollection<SyncEvent>("queue");
|
||||
_meta = _db.GetCollection<SyncMeta>("meta");
|
||||
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
|
||||
_attachmentUploads = _db.GetCollection<PendingAttachmentUpload>("attachment_uploads");
|
||||
_attachmentUploads.EnsureIndex(x => x.StorageId, unique: true);
|
||||
_queue.EnsureIndex(x => x.SequenceNr);
|
||||
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
|
||||
}
|
||||
@@ -56,6 +59,18 @@ public class EventQueue : IDisposable
|
||||
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
|
||||
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
|
||||
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed);
|
||||
|
||||
// ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ────
|
||||
public void QueueAttachmentUpload(string storageId)
|
||||
{
|
||||
if (!_attachmentUploads.Exists(a => a.StorageId == storageId))
|
||||
_attachmentUploads.Insert(new PendingAttachmentUpload { StorageId = storageId });
|
||||
}
|
||||
public List<string> GetPendingAttachmentUploads() =>
|
||||
_attachmentUploads.FindAll().Select(a => a.StorageId).ToList();
|
||||
public void MarkAttachmentUploaded(string storageId) =>
|
||||
_attachmentUploads.DeleteMany(a => a.StorageId == storageId);
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
|
||||
@@ -75,3 +90,9 @@ internal class SyncMeta
|
||||
public long Value { get; set; }
|
||||
public DateTime? Timestamp { get; set; }
|
||||
}
|
||||
|
||||
internal class PendingAttachmentUpload
|
||||
{
|
||||
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
|
||||
public string StorageId { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public class SyncEngine : IDisposable
|
||||
private readonly EventQueue _queue;
|
||||
private readonly ConflictResolver _resolver;
|
||||
private readonly EventApplier _applier;
|
||||
private readonly AttachmentSyncer _attachments;
|
||||
private readonly HttpClient _http;
|
||||
private readonly SyncConfig _config;
|
||||
private readonly Timer _timer;
|
||||
@@ -20,13 +21,14 @@ public class SyncEngine : IDisposable
|
||||
public event Action<SyncStatus>? StatusChanged;
|
||||
|
||||
public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier,
|
||||
HttpClient http, SyncConfig config)
|
||||
AttachmentSyncer attachments, HttpClient http, SyncConfig config)
|
||||
{
|
||||
_queue = queue;
|
||||
_resolver = resolver;
|
||||
_applier = applier;
|
||||
_http = http;
|
||||
_config = config;
|
||||
_queue = queue;
|
||||
_resolver = resolver;
|
||||
_applier = applier;
|
||||
_attachments = attachments;
|
||||
_http = http;
|
||||
_config = config;
|
||||
_timer = new Timer(
|
||||
async _ => await SyncNowAsync(true), null,
|
||||
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes),
|
||||
@@ -42,6 +44,7 @@ public class SyncEngine : IDisposable
|
||||
try
|
||||
{
|
||||
var (pushed, _) = await PushAsync();
|
||||
await _attachments.UploadPendingAsync(_queue);
|
||||
var (pulled, conflicts) = await PullAsync();
|
||||
_queue.SetLastSyncAt(DateTime.UtcNow);
|
||||
SetState(SyncState.Idle);
|
||||
@@ -77,10 +80,10 @@ public class SyncEngine : IDisposable
|
||||
foreach (var evt in resp.Events)
|
||||
{
|
||||
var c = _resolver.TryResolve(evt, _config.DeviceId);
|
||||
if (c is null) { _applier.Apply(evt); continue; }
|
||||
if (c is null) { await _applier.ApplyAsync(evt); continue; }
|
||||
_queue.AddConflict(c);
|
||||
conflicts++;
|
||||
if (c.Resolution == "RemoteWon") _applier.Apply(evt);
|
||||
if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt);
|
||||
}
|
||||
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
||||
return (resp.Events.Count, conflicts);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
@@ -15,5 +16,11 @@ public class SyncEventPublisher(EventQueue queue, string deviceId, byte[] syncKe
|
||||
{
|
||||
var encrypted = payload is null ? "" : SyncCrypto.EncryptObject(payload, syncKey);
|
||||
queue.Enqueue(deviceId, DeviceType.Desktop, entityType, entityId, operation, encrypted);
|
||||
|
||||
// Anhänge reisen nicht im JSON-Ereignis mit (würde den Kanal für Fotos/Scans aufblähen),
|
||||
// sondern als eigener Binärtransfer über AttachmentSyncer — hier nur zur Warteliste hinzufügen.
|
||||
if (payload is Documentation doc)
|
||||
foreach (var attachment in doc.Attachments)
|
||||
queue.QueueAttachmentUpload(attachment.StorageId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user