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); } public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); } } /// Records requests and replies with canned responses. internal sealed class StubHandler : HttpMessageHandler { private readonly Func _responder; public List Requests { get; } = new(); public List RequestBodies { get; } = new(); public StubHandler(Func responder) => _responder = responder; protected override async Task 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(() => new RemoteNestRepository("not a url")); Assert.Throws(() => 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_PutsMultipartToIdFileRoute() { 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 repo.UpdateFileAsync(id, new byte[] { 9 }, record); var request = handler.Requests[0]; Assert.Equal(HttpMethod.Put, request.Method); Assert.EndsWith($"/api/nests/{id}/file", request.RequestUri!.ToString()); } [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_PutsJsonToMetadataRoute() { var id = Guid.NewGuid(); var record = SampleRecord(id); var handler = new StubHandler(_ => StubHandler.Json(record)); using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s"); await repo.UpdateMetadataAsync(id, record); Assert.Equal(HttpMethod.Put, handler.Requests[0].Method); Assert.EndsWith($"/api/nests/{id}/metadata", handler.Requests[0].RequestUri!.ToString()); Assert.Equal("application/json", handler.Requests[0].Content!.Headers.ContentType!.MediaType); } [Fact] public async Task DeleteAsync_ThrowsWithStatusDetailOnError() { 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(() => repo.DeleteAsync(Guid.NewGuid())); Assert.Contains("500", ex.Message); Assert.Contains("disk on fire", ex.Message); } [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 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" }, }; [Fact] public async Task QueryAsync_SendsSortColumnAndDirection() { var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty() })); 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() })); using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s"); await Assert.ThrowsAsync(() => 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(() => 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(() => repo.QueryAsync(new NestQuery { Limit = 2 })); } }