mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-05 22:22:12 -04:00
feat(data): storage-mode settings and remote nest repository
NestStorageSettings persists the file/database toggle and server URL at %APPDATA%\OpenNest\storage.json following the EngineSelectionSettings never-throwing pattern; missing config means File mode. NestRecord carries the shareable job metadata, INestRepository defines the backend contract, and RemoteNestRepository implements it against the nest server with multipart uploads (metadata JSON part + .nest file part).
This commit is contained in:
1 parent
ef2f9aa714
commit
10ed3d3a30
5 files changed
+538
No files matched your search
@@ -0,0 +1,31 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Storage backend for saved nests. <see cref="RemoteNestRepository"/> talks to the
|
||||
/// central nest server; File mode in the desktop app keeps using SaveFileDialog and
|
||||
/// does not go through this interface.
|
||||
/// </summary>
|
||||
public interface INestRepository
|
||||
{
|
||||
/// <summary>All stored nests, newest saved first.</summary>
|
||||
Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Downloaded .nest archive bytes, or null when the id does not exist.</summary>
|
||||
Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Stores a new nest; returns the server-assigned record.</summary>
|
||||
Task<NestRecord> UploadAsync(
|
||||
byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Replaces the archive and metadata of an existing id, keeping the id.</summary>
|
||||
Task<NestRecord> UpdateFileAsync(
|
||||
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Replaces metadata only (status, made-by, comments...), leaving the archive.</summary>
|
||||
Task<NestRecord> UpdateMetadataAsync(
|
||||
Guid id, NestRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using OpenNest;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side metadata for one saved nest. The .nest archive itself is stored
|
||||
/// alongside; this record is what lists, filters and status tracking read.
|
||||
/// Counts are captured by the client at save time.
|
||||
/// </summary>
|
||||
public sealed class NestRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string Customer { get; set; } = "";
|
||||
|
||||
public DateTime DateCreated { get; set; }
|
||||
|
||||
public DateTime DateModified { get; set; }
|
||||
|
||||
public string Material { get; set; } = "";
|
||||
|
||||
public double Thickness { get; set; }
|
||||
|
||||
/// <summary>quote | toBeCut | hasBeenCut (camelCase on the wire, case-insensitive readers).</summary>
|
||||
public NestStatus Status { get; set; } = NestStatus.Quote;
|
||||
|
||||
public int PlateCount { get; set; }
|
||||
|
||||
public int PartCount { get; set; }
|
||||
|
||||
public string Comments { get; set; } = "";
|
||||
|
||||
public string MadeBy { get; set; } = "";
|
||||
|
||||
/// <summary>Size of the stored .nest archive in bytes.</summary>
|
||||
public long FileSize { get; set; }
|
||||
|
||||
/// <summary>When the server last stored the nest contents or metadata.</summary>
|
||||
public DateTime SavedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>Where the desktop app persists nests: local .nest files or the shared nest server.</summary>
|
||||
public enum NestStorageMode
|
||||
{
|
||||
File,
|
||||
Database,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage-mode toggle and nest-server address, stored at %APPDATA%\OpenNest\storage.json.
|
||||
/// Loading never throws; a missing or corrupt file means File mode so existing installs
|
||||
/// keep the original behavior until the operator opts in.
|
||||
/// </summary>
|
||||
public sealed class NestStorageSettings
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public NestStorageMode Mode { get; set; } = NestStorageMode.File;
|
||||
|
||||
/// <summary>Base URL of the nest server, e.g. http://barge.lan:8090. No trailing slash required.</summary>
|
||||
public string ServerUrl { get; set; } = "";
|
||||
|
||||
/// <summary>True when Database mode is active and a server URL is configured.</summary>
|
||||
public bool IsDatabaseMode =>
|
||||
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
|
||||
|
||||
/// <summary>%APPDATA%\OpenNest\storage.json.</summary>
|
||||
public static string DefaultPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenNest", "storage.json");
|
||||
|
||||
public static NestStorageSettings Load(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return new();
|
||||
|
||||
try
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<NestStorageSettings>(
|
||||
File.ReadAllText(path), JsonOptions) ?? new();
|
||||
settings.ServerUrl = NormalizeUrl(settings.ServerUrl);
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes camelCase JSON, creating the parent directory and retrying IO collisions.</summary>
|
||||
public void Save(string path)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new NestStorageSettings { Mode = Mode, ServerUrl = NormalizeUrl(ServerUrl) },
|
||||
JsonOptions);
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
|
||||
for (var attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < 2)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeUrl(string? url) => (url ?? "").Trim().TrimEnd('/');
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Talks to the central OpenNest nest server over HTTP. Uploads are multipart:
|
||||
/// a "metadata" JSON part and a "file" part holding the .nest archive.
|
||||
/// Connection and HTTP failures surface as HttpRequestException/IOException to the caller.
|
||||
/// </summary>
|
||||
public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsClient;
|
||||
private readonly Uri _baseUri;
|
||||
|
||||
public RemoteNestRepository(string baseUrl)
|
||||
: this(new HttpClient(), baseUrl)
|
||||
{
|
||||
_ownsClient = true;
|
||||
}
|
||||
|
||||
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
|
||||
public RemoteNestRepository(HttpClient httpClient, string baseUrl)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsClient = false;
|
||||
var url = (baseUrl ?? "").Trim().TrimEnd('/');
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var baseUri)
|
||||
|| (baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new ArgumentException($"Invalid nest server URL: '{baseUrl}'", nameof(baseUrl));
|
||||
}
|
||||
// Keep the trailing slash so relative combines replace no path segment.
|
||||
_baseUri = new Uri(baseUri.AbsoluteUri.TrimEnd('/') + "/");
|
||||
}
|
||||
|
||||
private Uri Url(string relative) => new(_baseUri, relative);
|
||||
|
||||
public async Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(Url("api/nests"), cancellationToken);
|
||||
await EnsureSuccess(response, "list nests", cancellationToken);
|
||||
var items = await response.Content
|
||||
.ReadFromJsonAsync<List<NestRecord>>(JsonOptions, cancellationToken);
|
||||
return items ?? new List<NestRecord>();
|
||||
}
|
||||
|
||||
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
await EnsureSuccess(response, $"get nest {id}", cancellationToken);
|
||||
return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/file"), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
await EnsureSuccess(response, $"download nest {id}", cancellationToken);
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NestRecord> UploadAsync(
|
||||
byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = BuildMultipart(nestFile, record);
|
||||
using var response = await _httpClient.PostAsync(Url("api/nests"), content, cancellationToken);
|
||||
await EnsureSuccess(response, $"upload nest '{record.Name}'", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NestRecord> UpdateFileAsync(
|
||||
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = BuildMultipart(nestFile, record);
|
||||
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/file"), content, cancellationToken);
|
||||
await EnsureSuccess(response, $"update nest {id}", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NestRecord> UpdateMetadataAsync(
|
||||
Guid id, NestRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
|
||||
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/metadata"), content, cancellationToken);
|
||||
await EnsureSuccess(response, $"update nest metadata {id}", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _httpClient.DeleteAsync(Url($"api/nests/{id}"), cancellationToken);
|
||||
await EnsureSuccess(response, $"delete nest {id}", cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsClient)
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
|
||||
private static MultipartFormDataContent BuildMultipart(byte[] nestFile, NestRecord record)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
var metadata = new StringContent(
|
||||
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
|
||||
content.Add(metadata, "metadata");
|
||||
var file = new ByteArrayContent(nestFile);
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
|
||||
content.Add(file, "file", record.Name.Length > 0 ? $"{record.Name}.nest" : "nest.nest");
|
||||
return content;
|
||||
}
|
||||
|
||||
private static async Task<NestRecord> ReadRecord(
|
||||
HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var record = await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
|
||||
return record ?? throw new IOException("Nest server returned an empty record.");
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccess(
|
||||
HttpResponseMessage response, string action, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
|
||||
var detail = "";
|
||||
try
|
||||
{
|
||||
detail = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidOperationException)
|
||||
{
|
||||
// The status code is the useful part; the body is best-effort.
|
||||
}
|
||||
|
||||
if (detail.Length > 200)
|
||||
detail = detail[..200] + "…";
|
||||
throw new IOException(
|
||||
$"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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_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<IOException>(() => repo.DeleteAsync(Guid.NewGuid()));
|
||||
Assert.Contains("500", ex.Message);
|
||||
Assert.Contains("disk on fire", ex.Message);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user