mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-05 16:01:37 -05:00
A release that merges into an existing draft needs the drafts of the same material group. The nest query can now filter exactly on status, material, grade and thickness. Material and grade compare like the shared material lists (case and spacing ignored) through a SQLite function, so no key columns or backfill are needed; a blank grade asks for nests without one, and thickness matches at the four decimals drawings are stored with. The client sends the filters only when set, so plain browsing keeps working against servers that do not know them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
342 lines
13 KiB
C#
342 lines
13 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using OpenNest.Data;
|
|
|
|
namespace OpenNest.Tests.Data;
|
|
|
|
public class NestStorageSettingsTests : IDisposable
|
|
{
|
|
private readonly string _directory = Path.Combine(
|
|
Path.GetTempPath(), $"opennest-storage-{Guid.NewGuid():N}");
|
|
|
|
private string FilePath => Path.Combine(_directory, "storage.json");
|
|
|
|
[Fact]
|
|
public void Load_MissingFile_DefaultsToFileMode()
|
|
{
|
|
var settings = NestStorageSettings.Load(FilePath);
|
|
|
|
Assert.Equal(NestStorageMode.File, settings.Mode);
|
|
Assert.Equal("", settings.ServerUrl);
|
|
Assert.False(settings.IsDatabaseMode);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_CorruptFile_DefaultsToFileModeWithoutThrowing()
|
|
{
|
|
Directory.CreateDirectory(_directory);
|
|
File.WriteAllText(FilePath, "{ not json");
|
|
|
|
var settings = NestStorageSettings.Load(FilePath);
|
|
|
|
Assert.Equal(NestStorageMode.File, settings.Mode);
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveThenLoad_DatabaseMode_RoundTrips()
|
|
{
|
|
NestStorageSettings.Load(FilePath)
|
|
.Save(FilePath); // ensure directory exists pattern
|
|
var settings = new NestStorageSettings
|
|
{
|
|
Mode = NestStorageMode.Database,
|
|
ServerUrl = "http://barge.lan:8090/",
|
|
};
|
|
|
|
settings.Save(FilePath);
|
|
var loaded = NestStorageSettings.Load(FilePath);
|
|
|
|
Assert.Equal(NestStorageMode.Database, loaded.Mode);
|
|
Assert.Equal("http://barge.lan:8090", loaded.ServerUrl);
|
|
Assert.True(loaded.IsDatabaseMode);
|
|
}
|
|
|
|
[Fact]
|
|
public void DatabaseMode_WithoutServerUrl_IsNotActive()
|
|
{
|
|
var settings = new NestStorageSettings { Mode = NestStorageMode.Database };
|
|
Assert.False(settings.IsDatabaseMode);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryNormalizeServerUrl_AcceptsHttpsWithoutChangingMode()
|
|
{
|
|
var settings = new NestStorageSettings { Mode = NestStorageMode.File };
|
|
|
|
Assert.True(NestStorageSettings.TryNormalizeServerUrl(" https://shop.example:8090/ ", out var url));
|
|
settings.ServerUrl = url;
|
|
settings.Save(FilePath);
|
|
|
|
var loaded = NestStorageSettings.Load(FilePath);
|
|
Assert.Equal("https://shop.example:8090", loaded.ServerUrl);
|
|
Assert.Equal(NestStorageMode.File, loaded.Mode);
|
|
Assert.False(loaded.IsDatabaseMode);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("ftp://shop.example")]
|
|
[InlineData("not-a-url")]
|
|
public void TryNormalizeServerUrl_RejectsInvalidAddress(string input)
|
|
{
|
|
Assert.False(NestStorageSettings.TryNormalizeServerUrl(input, out var url));
|
|
Assert.Equal("", url);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_directory))
|
|
Directory.Delete(_directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
/// <summary>Records requests and replies with canned responses.</summary>
|
|
internal sealed class StubHandler : HttpMessageHandler
|
|
{
|
|
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
|
|
|
public List<HttpRequestMessage> Requests { get; } = new();
|
|
public List<string> RequestBodies { get; } = new();
|
|
|
|
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) =>
|
|
_responder = responder;
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
Requests.Add(request);
|
|
RequestBodies.Add(request.Content is null
|
|
? ""
|
|
: await request.Content.ReadAsStringAsync(cancellationToken));
|
|
return _responder(request);
|
|
}
|
|
|
|
public static HttpResponseMessage Json(object payload, HttpStatusCode code = HttpStatusCode.OK) =>
|
|
new(code)
|
|
{
|
|
Content = new StringContent(
|
|
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
|
|
};
|
|
}
|
|
|
|
public class RemoteNestRepositoryTests
|
|
{
|
|
private static NestRecord SampleRecord(Guid? id = null) => new()
|
|
{
|
|
Id = id ?? Guid.NewGuid(),
|
|
Name = "Job 1",
|
|
Customer = "Cozy Cat",
|
|
Material = "Aluminum",
|
|
Thickness = 0.25,
|
|
Status = NestStatus.ToBeCut,
|
|
PlateCount = 2,
|
|
PartCount = 17,
|
|
Comments = "rush",
|
|
MadeBy = "AJ",
|
|
FileSize = 1234,
|
|
SavedAt = new DateTime(2026, 9, 29, 12, 0, 0),
|
|
};
|
|
|
|
[Fact]
|
|
public void Constructor_RejectsInvalidUrl()
|
|
{
|
|
Assert.Throws<ArgumentException>(() => new RemoteNestRepository("not a url"));
|
|
Assert.Throws<ArgumentException>(() => new RemoteNestRepository("ftp://host"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListAsync_DeserializesCamelCaseRecords()
|
|
{
|
|
var handler = new StubHandler(_ => StubHandler.Json(new[] { SampleRecord() }));
|
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090/");
|
|
|
|
var records = await repo.ListAsync();
|
|
|
|
Assert.Single(records);
|
|
Assert.Equal("Cozy Cat", records[0].Customer);
|
|
Assert.Equal(NestStatus.ToBeCut, records[0].Status);
|
|
Assert.Contains("/api/nests", handler.Requests[0].RequestUri!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UploadAsync_PostsMultipartWithMetadataAndFile()
|
|
{
|
|
var record = SampleRecord();
|
|
var handler = new StubHandler(_ => StubHandler.Json(record));
|
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090");
|
|
|
|
var result = await repo.UploadAsync(new byte[] { 1, 2, 3 }, record);
|
|
|
|
Assert.Equal(record.Id, result.Id);
|
|
var request = handler.Requests[0];
|
|
Assert.Equal(HttpMethod.Post, request.Method);
|
|
Assert.EndsWith("/api/nests", request.RequestUri!.ToString());
|
|
Assert.StartsWith("multipart/form-data", request.Content!.Headers.ContentType!.ToString());
|
|
Assert.Contains("metadata", handler.RequestBodies[0]);
|
|
Assert.Contains("toBeCut", handler.RequestBodies[0]);
|
|
Assert.Contains("filename=\"Job 1.nest\"", handler.RequestBodies[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateFileAsync_RefusesLegacyRecordBeforeHttp()
|
|
{
|
|
var id = Guid.NewGuid();
|
|
var record = SampleRecord(id);
|
|
var handler = new StubHandler(_ => StubHandler.Json(record));
|
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090");
|
|
|
|
await Assert.ThrowsAsync<IOException>(() => repo.UpdateFileAsync(id, new byte[] { 9 }, record));
|
|
Assert.Empty(handler.Requests); // A legacy record has no revision precondition.
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetFileAsync_NullOnNotFound_BytesOtherwise()
|
|
{
|
|
var missing = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
|
|
using var missingRepo = new RemoteNestRepository(new HttpClient(missing), "http://s");
|
|
Assert.Null(await missingRepo.GetFileAsync(Guid.NewGuid()));
|
|
|
|
var found = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new ByteArrayContent(new byte[] { 4, 5, 6 }),
|
|
});
|
|
using var foundRepo = new RemoteNestRepository(new HttpClient(found), "http://s");
|
|
Assert.Equal(new byte[] { 4, 5, 6 }, await foundRepo.GetFileAsync(Guid.NewGuid()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMetadataAsync_NullOnNotFound()
|
|
{
|
|
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
|
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
|
|
|
|
Assert.Null(await repo.GetMetadataAsync(Guid.NewGuid()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateMetadataAsync_RefusesLegacyServerBeforeMutation()
|
|
{
|
|
var id = Guid.NewGuid();
|
|
var record = SampleRecord(id);
|
|
record.Revision = 1;
|
|
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
|
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
|
|
|
|
await Assert.ThrowsAsync<IOException>(() => repo.UpdateMetadataAsync(id, record));
|
|
Assert.Equal(HttpMethod.Get, Assert.Single(handler.Requests).Method);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteAsync_RefusesUntilGuardedSoftDeleteIsAvailable()
|
|
{
|
|
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
|
{
|
|
Content = new StringContent("disk on fire"),
|
|
});
|
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
|
|
|
|
var ex = await Assert.ThrowsAsync<IOException>(() => repo.DeleteAsync(Guid.NewGuid()));
|
|
Assert.Contains("soft-delete", ex.Message);
|
|
Assert.Empty(handler.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryAsync_SendsEscapedBoundedQueryAndReadsPage()
|
|
{
|
|
var record = SampleRecord();
|
|
var handler = new StubHandler(_ => StubHandler.Json(new { items = new[] { record }, total = 41, offset = 20, limit = 20 }));
|
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090/base/");
|
|
|
|
var page = await repo.QueryAsync(new NestQuery { Search = " a&b=c %_\\ ", Offset = 20, Limit = 20 });
|
|
|
|
Assert.Equal(HttpMethod.Get, handler.Requests[0].Method);
|
|
Assert.Equal(
|
|
"http://server:8090/base/api/nests/query?search=a%26b%3Dc%20%25_%5C&sort=savedAt&order=desc&offset=20&limit=20",
|
|
handler.Requests[0].RequestUri!.AbsoluteUri);
|
|
Assert.Equal(record.Id, Assert.Single(page.Items).Id);
|
|
Assert.Equal(41, page.Total);
|
|
Assert.Equal(20, page.Offset);
|
|
Assert.Equal(20, page.Limit);
|
|
}
|
|
|
|
public static TheoryData<NestQuery> OutOfBoundsQueries() => new()
|
|
{
|
|
new NestQuery { Offset = -1 },
|
|
new NestQuery { Limit = 0 },
|
|
new NestQuery { Limit = NestQuery.MaxLimit + 1 },
|
|
new NestQuery { Search = new string('x', NestQuery.MaxSearchLength + 1) },
|
|
new NestQuery { Sort = (NestSortField)99 },
|
|
new NestQuery { Search = "Alpha\0not-present" },
|
|
new NestQuery { Status = (NestStatus)99 },
|
|
new NestQuery { Thickness = -0.1 },
|
|
new NestQuery { Thickness = double.NaN },
|
|
new NestQuery { Material = new string('m', 201) },
|
|
new NestQuery { MaterialGrade = "A\0" },
|
|
};
|
|
|
|
[Fact]
|
|
public async Task QueryAsync_SendsDraftFilters_IncludingABlankGrade()
|
|
{
|
|
var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty<NestRecord>() }));
|
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
|
|
|
|
await repo.QueryAsync(new NestQuery
|
|
{
|
|
Status = NestStatus.Draft,
|
|
Material = "Carbon Steel",
|
|
MaterialGrade = "",
|
|
Thickness = 0.1875,
|
|
});
|
|
|
|
Assert.EndsWith(
|
|
"/api/nests/query?search=&sort=savedAt&order=desc&offset=0&limit=100"
|
|
+ "&status=draft&material=Carbon%20Steel&materialGrade=&thickness=0.1875",
|
|
handler.Requests[0].RequestUri!.AbsoluteUri);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryAsync_SendsSortColumnAndDirection()
|
|
{
|
|
var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty<NestRecord>() }));
|
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
|
|
|
|
await repo.QueryAsync(new NestQuery { Sort = NestSortField.PlateCount, Descending = false });
|
|
|
|
Assert.EndsWith(
|
|
"/api/nests/query?search=&sort=plateCount&order=asc&offset=0&limit=100",
|
|
handler.Requests[0].RequestUri!.AbsoluteUri);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(OutOfBoundsQueries))]
|
|
public async Task QueryAsync_OutOfBounds_ThrowsWithoutSending(NestQuery query)
|
|
{
|
|
var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty<NestRecord>() }));
|
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
|
|
|
|
await Assert.ThrowsAsync<ArgumentException>(() => repo.QueryAsync(query));
|
|
Assert.Empty(handler.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryAsync_OldServerWithoutRoute_ReportsServerUpdateNeeded()
|
|
{
|
|
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
|
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
|
|
|
|
var ex = await Assert.ThrowsAsync<IOException>(() => repo.QueryAsync(new NestQuery()));
|
|
Assert.Contains("Update the nest server", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryAsync_MoreItemsThanRequested_Throws()
|
|
{
|
|
var items = new[] { SampleRecord(), SampleRecord(), SampleRecord() };
|
|
var handler = new StubHandler(_ => StubHandler.Json(new { items, total = 3, offset = 0, limit = 2 }));
|
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
|
|
|
|
await Assert.ThrowsAsync<IOException>(() => repo.QueryAsync(new NestQuery { Limit = 2 }));
|
|
}
|
|
}
|