mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 07:11:06 -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>
156 lines
7.1 KiB
C#
156 lines
7.1 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using OpenNest.Data;
|
|
|
|
namespace OpenNest.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// Client-allocated ids and uploaded nest metadata: conflicts are 409 and invalid
|
|
/// metadata is 400, never a 500, and neither changes stored records.
|
|
/// </summary>
|
|
public sealed class NestUploadValidationTests
|
|
{
|
|
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
|
{ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } };
|
|
|
|
[Fact]
|
|
public async Task Create_WithExistingId_Returns409WithCurrentRecordAndKeepsIt()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
var id = Guid.NewGuid();
|
|
using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Original" }, TestNests.Archive(1)));
|
|
Assert.Equal(HttpStatusCode.OK, created.StatusCode);
|
|
|
|
using var duplicate = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Replacement" }, TestNests.Archive(2)));
|
|
|
|
Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode);
|
|
var current = (await duplicate.Content.ReadFromJsonAsync<NestRecord>(Options))!;
|
|
Assert.Equal(id, current.Id);
|
|
Assert.Equal("Original", current.Name);
|
|
Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{id}/file"));
|
|
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
Assert.Equal(1, reopened.History(id, 0, 100)!.Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WithIdOfDeletedNest_Returns409AndKeepsItRecoverable()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
var id = Guid.NewGuid();
|
|
using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Original" }, TestNests.Archive(1)));
|
|
using var delete = new HttpRequestMessage(HttpMethod.Delete, $"/api/nests/{id}");
|
|
delete.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
using var deleted = await client.SendAsync(delete);
|
|
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
|
|
|
|
using var duplicate = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Replacement" }, TestNests.Archive(2)));
|
|
|
|
Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode);
|
|
var current = (await duplicate.Content.ReadFromJsonAsync<NestRecord>(Options))!;
|
|
Assert.True(current.Deleted);
|
|
Assert.Equal(2, current.Revision);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/nests/{id}")).StatusCode);
|
|
Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/deleted/{id}/file"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("""{"name":null}""")]
|
|
[InlineData("""{"name":"A","customer":null}""")]
|
|
[InlineData("""{"name":"A","material":null}""")]
|
|
[InlineData("""{"name":"A","comments":null}""")]
|
|
[InlineData("""{"name":"A","madeBy":null}""")]
|
|
[InlineData("""{"name":"A","status":99}""")]
|
|
[InlineData("""{"name":"A","status":"cancelled"}""")]
|
|
[InlineData("""{"name":"A","status":4}""")]
|
|
public async Task Create_WithInvalidMetadata_Returns400AndStoresNothing(string metadata)
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
|
|
using var response = await client.PostAsync("/api/nests", RawBody(metadata, TestNests.Archive(1)));
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
Assert.Empty(reopened.List());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("""{"name":"A","status":"toBeCut"}""", NestStatus.ToBeCut)]
|
|
[InlineData("""{"name":"A","status":2}""", NestStatus.HasBeenCut)]
|
|
[InlineData("""{"name":"A","status":"draft"}""", NestStatus.Draft)]
|
|
[InlineData("""{"name":"A","status":3}""", NestStatus.Draft)]
|
|
public async Task Create_WithDefinedStatus_StoresIt(string metadata, NestStatus expected)
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
|
|
using var response = await client.PostAsync("/api/nests", RawBody(metadata, TestNests.Archive(1)));
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal(expected, (await response.Content.ReadFromJsonAsync<NestRecord>(Options))!.Status);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("""{"name":null}""")]
|
|
[InlineData("""{"name":"A","status":99}""")]
|
|
public async Task UpdateFile_WithInvalidMetadata_Returns400AndLeavesRecordUnchanged(string metadata)
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
var id = await Create(client);
|
|
client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
using var response = await client.PutAsync($"/api/nests/{id}/file", RawBody(metadata, TestNests.Archive(2)));
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
await AssertUnchanged(factory, client, id);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("""{"name":null}""")]
|
|
[InlineData("""{"name":"A","comments":null}""")]
|
|
[InlineData("""{"name":"A","status":99}""")]
|
|
public async Task UpdateMetadata_WithInvalidMetadata_Returns400AndLeavesRecordUnchanged(string metadata)
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
var id = await Create(client);
|
|
client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
using var response = await client.PutAsync($"/api/nests/{id}/metadata",
|
|
new StringContent(metadata, Encoding.UTF8, "application/json"));
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
await AssertUnchanged(factory, client, id);
|
|
}
|
|
|
|
private static async Task<Guid> Create(HttpClient client)
|
|
{
|
|
using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Name = "Original" }, TestNests.Archive(1)));
|
|
Assert.Equal(HttpStatusCode.OK, created.StatusCode);
|
|
return (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!.Id;
|
|
}
|
|
|
|
private static async Task AssertUnchanged(ServerFactory factory, HttpClient client, Guid id)
|
|
{
|
|
Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{id}/file"));
|
|
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
var current = reopened.Get(id)!;
|
|
Assert.Equal("Original", current.Name);
|
|
Assert.Equal(1, current.Revision);
|
|
Assert.Equal(1, reopened.History(id, 0, 100)!.Total);
|
|
}
|
|
|
|
private static MultipartFormDataContent RawBody(string metadata, byte[] bytes)
|
|
{
|
|
var body = new MultipartFormDataContent();
|
|
body.Add(new StringContent(metadata), "metadata");
|
|
body.Add(new ByteArrayContent(bytes), "file", "fixture.nest");
|
|
return body;
|
|
}
|
|
}
|