mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 05:01:05 -04:00
The server stored whatever plate count, part count, material and thickness the client claimed, so a record could disagree with its own archive, and a bulk importer had to read each written .nest back in full just to describe it (about a quarter of the PEP archive import's time). Uploads (POST and PUT /file) now read only the archive's nest.json - never programs or geometry, and without referencing OpenNest.IO - and overwrite those fields, following NestReader's fallbacks for thickness, material, parts with a missing drawing and the trailing new-plate slot. A file without a readable nest.json is 400 and stores nothing, so the server can guarantee the record describes the bytes; nest.json decompression stops at 64 MB. A metadata-only PUT keeps the stored counts because the archive is unchanged; material and thickness stay editable there, since the desktop applies the record's values on open. Server tests that uploaded placeholder bytes now upload small deterministic archives, and the container smoke's near-limit probe is a padded real archive. New tests pin the server's values to a full NestReader read of the same archive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
8.8 KiB
C#
172 lines
8.8 KiB
C#
using System.Net;
|
|
using OpenNest.Data;
|
|
|
|
namespace OpenNest.Server.Tests;
|
|
|
|
public sealed class NestHistoryClientTests
|
|
{
|
|
[Fact]
|
|
public async Task LostCreateReply_BlocksReplayAndReconcilesSameId()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var http = factory.CreateDefaultClient(new LostReplyHandler());
|
|
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
|
|
var session = new NestSaveSession();
|
|
var nest = NestApiTests.SyntheticNest();
|
|
var archive = NestApiTests.Archive(nest);
|
|
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
|
|
var pendingId = session.PendingCreateId;
|
|
Assert.NotEqual(Guid.Empty, pendingId);
|
|
Assert.True(session.RequiresReload);
|
|
Assert.Single(await repository.ListAsync());
|
|
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
|
|
Assert.Single(await repository.ListAsync());
|
|
var reconciled = await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString());
|
|
Assert.Equal(pendingId, reconciled!.Id);
|
|
Assert.Equal(pendingId, session.RemoteId);
|
|
Assert.Equal(Guid.Empty, session.PendingCreateId);
|
|
Assert.Single(await repository.ListAsync());
|
|
Assert.Equal(archive, await repository.GetRevisionFileAsync(pendingId, 1));
|
|
}
|
|
|
|
private sealed class LostReplyHandler : DelegatingHandler
|
|
{
|
|
private bool lost;
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
|
{
|
|
var response = await base.SendAsync(request, ct);
|
|
if (request.Method == HttpMethod.Post && !lost)
|
|
{
|
|
lost = true;
|
|
response.Dispose();
|
|
throw new IOException("Response lost after commit.");
|
|
}
|
|
return response;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LostCreateReply_ChangedServerMetadataCannotBindWithoutReload()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var http = factory.CreateDefaultClient(new LostReplyHandler());
|
|
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
|
|
var session = new NestSaveSession();
|
|
var nest = NestApiTests.SyntheticNest();
|
|
var archive = NestApiTests.Archive(nest);
|
|
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
|
|
var id = session.PendingCreateId;
|
|
var current = (await repository.GetMetadataAsync(id))!;
|
|
current.Comments = "Changed by another editor";
|
|
await repository.UpdateMetadataAsync(id, current, current.Revision);
|
|
await Assert.ThrowsAsync<IOException>(() => session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString()));
|
|
Assert.Equal(Guid.Empty, session.RemoteId);
|
|
Assert.True(session.RequiresReload);
|
|
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
|
|
Assert.Single(await repository.ListAsync());
|
|
}
|
|
|
|
private sealed class FirstPostFailsBeforeSend : DelegatingHandler
|
|
{
|
|
private bool failed;
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
|
{
|
|
if (request.Method == HttpMethod.Post && !failed)
|
|
{
|
|
failed = true;
|
|
throw new IOException("Upload was not sent.");
|
|
}
|
|
return base.SendAsync(request, ct);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AbsentPendingUpload_RetriesSameIdOnlyAfterExplicitReconciliation()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var http = factory.CreateDefaultClient(new FirstPostFailsBeforeSend());
|
|
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
|
|
var session = new NestSaveSession();
|
|
var nest = NestApiTests.SyntheticNest();
|
|
var archive = NestApiTests.Archive(nest);
|
|
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
|
|
var pending = session.PendingCreateId;
|
|
Assert.Empty(await repository.ListAsync());
|
|
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
|
|
Assert.Null(await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString()));
|
|
Assert.False(session.RequiresReload);
|
|
var created = await session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive);
|
|
Assert.Equal(pending, created.Id);
|
|
Assert.Equal(1, created.Revision);
|
|
Assert.Single(await repository.ListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AbsentPendingSaveAs_NeverUpdatesBoundOriginalOnRetry()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var seedHttp = factory.CreateClient();
|
|
using var seed = new RemoteNestRepository(seedHttp, seedHttp.BaseAddress!.ToString());
|
|
var nest = NestApiTests.SyntheticNest();
|
|
var originalBytes = NestApiTests.Archive(nest);
|
|
var original = await seed.UploadAsync(originalBytes, new NestRecord { Name = "Original" });
|
|
using var http = factory.CreateDefaultClient(new FirstPostFailsBeforeSend());
|
|
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
|
|
var session = new NestSaveSession();
|
|
session.Bind(original.Id, http.BaseAddress.ToString(), original.Revision);
|
|
nest.Name = "Copied nest";
|
|
var copyBytes = NestApiTests.Archive(nest);
|
|
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes, saveCopy: true));
|
|
var pending = session.PendingCreateId;
|
|
Assert.Null(await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString()));
|
|
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes));
|
|
var copy = await session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes, saveCopy: true);
|
|
Assert.Equal(pending, copy.Id);
|
|
Assert.NotEqual(original.Id, copy.Id);
|
|
Assert.Equal(1, (await repository.GetMetadataAsync(original.Id))!.Revision);
|
|
Assert.Equal(originalBytes, await repository.GetFileAsync(original.Id));
|
|
Assert.Equal(copyBytes, await repository.GetFileAsync(copy.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RealClient_CreatesAndUpdatesOneNestWithExactHistoricalArchives()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var http = factory.CreateClient();
|
|
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
|
|
var id = Guid.NewGuid();
|
|
var first = TestNests.Archive(80, 75, 0, 1, 255);
|
|
var second = TestNests.Archive(80, 75, 0, 2, 254);
|
|
var created = await repository.UploadAsync(first, new NestRecord { Id = id, Name = "Synthetic" });
|
|
Assert.Equal(1, created.Revision);
|
|
|
|
var updated = await repository.UpdateFileAsync(id, second, new NestRecord { Id = id, Name = "Synthetic two" }, 1);
|
|
Assert.Equal(2, updated.Revision);
|
|
var page = await repository.GetRevisionsAsync(id, limit: 1);
|
|
Assert.Equal(2, page.Total);
|
|
Assert.Equal(2, Assert.Single(page.Items).Revision);
|
|
Assert.Equal("Synthetic", Assert.Single((await repository.GetRevisionsAsync(id, offset: 1, limit: 1)).Items).Name);
|
|
Assert.Equal(first, await repository.GetRevisionFileAsync(id, 1));
|
|
Assert.Equal(second, await repository.GetRevisionFileAsync(id, 2));
|
|
Assert.Equal(second, await repository.GetFileAsync(id));
|
|
|
|
var conflict = await Assert.ThrowsAsync<IOException>(() =>
|
|
repository.UpdateFileAsync(id, first, created, 1));
|
|
Assert.Contains("409", conflict.Message);
|
|
Assert.Equal(2, (await repository.GetRevisionsAsync(id, limit: 1)).Total);
|
|
Assert.Equal(second, await repository.GetFileAsync(id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LegacyUnguardedDelete_IsRefusedWithoutMutation()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var http = factory.CreateClient();
|
|
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
|
|
var created = await repository.UploadAsync(TestNests.Archive(1, 2, 3), new NestRecord { Name = "Synthetic" });
|
|
using var response = await http.DeleteAsync($"/api/nests/{created.Id}");
|
|
Assert.Equal((HttpStatusCode)428, response.StatusCode);
|
|
Assert.Equal(TestNests.Archive(1, 2, 3), await repository.GetRevisionFileAsync(created.Id, 1));
|
|
}
|
|
}
|