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>
77 lines
3.1 KiB
C#
77 lines
3.1 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|