mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-06 03:52:10 -04:00
test(server): cover HTTP storage contracts with isolated SQLite
This commit is contained in:
1 parent
7999e3cdde
commit
b17fe1a318
8 files changed
+534
-3
No files matched your search
@@ -30,3 +30,5 @@ jobs:
|
|||||||
python3 scripts/check-synthetic-nests.py --parallel 2
|
python3 scripts/check-synthetic-nests.py --parallel 2
|
||||||
- name: Run OpenNest.IO.Tests
|
- name: Run OpenNest.IO.Tests
|
||||||
run: dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
|
run: dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
|
||||||
|
- name: Run OpenNest.Server.Tests
|
||||||
|
run: dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||||
- name: Run all test projects on Windows
|
- name: Run all test projects on Windows
|
||||||
run: |
|
run: |
|
||||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||||
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||||
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using OpenNest.Data;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
using CncProgram = OpenNest.CNC.Program;
|
||||||
|
|
||||||
|
namespace OpenNest.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production HTTP routes + real SQLite, driven through the desktop app's own
|
||||||
|
/// <see cref="RemoteNestRepository"/> and <see cref="NestSaveSession"/>.
|
||||||
|
/// Every test owns a fresh server and database.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NestApiTests
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Host_UsesPrivateDatabase_NotDefaultPath()
|
||||||
|
{
|
||||||
|
var defaultPath = Path.GetFullPath(Path.Combine("data", "nests.db"));
|
||||||
|
Assert.False(File.Exists(defaultPath), "Precondition: no default database in the test directory.");
|
||||||
|
|
||||||
|
using var factory = new ServerFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
using var response = await client.GetAsync("/healthz");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.True(File.Exists(factory.DatabasePath));
|
||||||
|
Assert.False(File.Exists(defaultPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_ThenListAndDownload_ReturnsExactRecordAndArchive()
|
||||||
|
{
|
||||||
|
using var server = new TestServerSession();
|
||||||
|
var nest = SyntheticNest();
|
||||||
|
var archive = Archive(nest);
|
||||||
|
var session = new NestSaveSession();
|
||||||
|
|
||||||
|
var created = await session.SaveAsync(server.Repository, server.Url, nest, archive);
|
||||||
|
|
||||||
|
Assert.NotEqual(Guid.Empty, created.Id);
|
||||||
|
Assert.NotEqual(default, created.SavedAt);
|
||||||
|
Assert.Equal(archive.LongLength, created.FileSize);
|
||||||
|
AssertSameMetadata(Expected(nest, created, archive), created);
|
||||||
|
|
||||||
|
var listed = Assert.Single(await server.Repository.ListAsync());
|
||||||
|
AssertSameMetadata(created, listed);
|
||||||
|
AssertSameMetadata(created, (await server.Repository.GetMetadataAsync(created.Id))!);
|
||||||
|
|
||||||
|
var downloaded = await server.Repository.GetFileAsync(created.Id);
|
||||||
|
Assert.Equal(archive, downloaded);
|
||||||
|
AssertArchive(downloaded!, nest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Save_AfterCreate_UpdatesSameRecordArchiveAndMetadata()
|
||||||
|
{
|
||||||
|
using var server = new TestServerSession();
|
||||||
|
var nest = SyntheticNest();
|
||||||
|
var session = new NestSaveSession();
|
||||||
|
var created = await session.SaveAsync(server.Repository, server.Url, nest, Archive(nest));
|
||||||
|
|
||||||
|
nest.Name = "Synthetic integration nest updated";
|
||||||
|
nest.Status = NestStatus.ToBeCut;
|
||||||
|
nest.Plates[0].Parts[0].Location = new Vector(4, 5);
|
||||||
|
var archive = Archive(nest);
|
||||||
|
var updated = await session.SaveAsync(server.Repository, server.Url, nest, archive);
|
||||||
|
|
||||||
|
Assert.Equal(created.Id, updated.Id);
|
||||||
|
AssertSameMetadata(Expected(nest, updated, archive), updated);
|
||||||
|
var listed = Assert.Single(await server.Repository.ListAsync());
|
||||||
|
AssertSameMetadata(updated, listed);
|
||||||
|
var downloaded = await server.Repository.GetFileAsync(created.Id);
|
||||||
|
Assert.Equal(archive, downloaded);
|
||||||
|
AssertArchive(downloaded!, nest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveCopy_CreatesDistinctRecordAndLeavesOriginal()
|
||||||
|
{
|
||||||
|
using var server = new TestServerSession();
|
||||||
|
var nest = SyntheticNest();
|
||||||
|
var session = new NestSaveSession();
|
||||||
|
var originalArchive = Archive(nest);
|
||||||
|
var original = await session.SaveAsync(server.Repository, server.Url, nest, originalArchive);
|
||||||
|
|
||||||
|
nest.Name = "Synthetic integration nest copy";
|
||||||
|
var copyArchive = Archive(nest);
|
||||||
|
var copy = await session.SaveAsync(server.Repository, server.Url, nest, copyArchive, saveCopy: true);
|
||||||
|
|
||||||
|
Assert.NotEqual(original.Id, copy.Id);
|
||||||
|
Assert.Equal(copy.Id, session.RemoteId);
|
||||||
|
var ids = (await server.Repository.ListAsync()).Select(r => r.Id).Order().ToArray();
|
||||||
|
Assert.Equal(new[] { original.Id, copy.Id }.Order().ToArray(), ids);
|
||||||
|
AssertSameMetadata(original, (await server.Repository.GetMetadataAsync(original.Id))!);
|
||||||
|
Assert.Equal(originalArchive, await server.Repository.GetFileAsync(original.Id));
|
||||||
|
Assert.Equal(copyArchive, await server.Repository.GetFileAsync(copy.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MetadataUpdate_ChangesFieldsWithoutTouchingArchiveOrFileSize()
|
||||||
|
{
|
||||||
|
using var server = new TestServerSession();
|
||||||
|
var nest = SyntheticNest();
|
||||||
|
var archive = Archive(nest);
|
||||||
|
var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, archive);
|
||||||
|
|
||||||
|
var change = Clone(created);
|
||||||
|
change.Name = "Renamed by metadata update";
|
||||||
|
change.Status = NestStatus.HasBeenCut;
|
||||||
|
change.Comments = "Metadata-only change";
|
||||||
|
change.FileSize = 1; // Server-computed; must be ignored.
|
||||||
|
var updated = await server.Repository.UpdateMetadataAsync(created.Id, change);
|
||||||
|
|
||||||
|
Assert.Equal(created.Id, updated.Id);
|
||||||
|
Assert.Equal("Renamed by metadata update", updated.Name);
|
||||||
|
Assert.Equal(NestStatus.HasBeenCut, updated.Status);
|
||||||
|
Assert.Equal("Metadata-only change", updated.Comments);
|
||||||
|
Assert.Equal(archive.LongLength, updated.FileSize);
|
||||||
|
AssertSameMetadata(updated, (await server.Repository.GetMetadataAsync(created.Id))!);
|
||||||
|
Assert.Equal(archive, await server.Repository.GetFileAsync(created.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_RemovesRecordAndArchive()
|
||||||
|
{
|
||||||
|
using var server = new TestServerSession();
|
||||||
|
var nest = SyntheticNest();
|
||||||
|
var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, Archive(nest));
|
||||||
|
|
||||||
|
await server.Repository.DeleteAsync(created.Id);
|
||||||
|
|
||||||
|
Assert.Empty(await server.Repository.ListAsync());
|
||||||
|
Assert.Null(await server.Repository.GetMetadataAsync(created.Id));
|
||||||
|
Assert.Null(await server.Repository.GetFileAsync(created.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<string, string> InvalidUploads()
|
||||||
|
{
|
||||||
|
var data = new TheoryData<string, string>();
|
||||||
|
foreach (var route in new[] { "create", "update" })
|
||||||
|
{
|
||||||
|
foreach (var body in new[]
|
||||||
|
{
|
||||||
|
"not multipart", "missing metadata", "invalid metadata JSON", "null metadata",
|
||||||
|
"missing file", "empty file",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
data.Add(route, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(InvalidUploads))]
|
||||||
|
public async Task Upload_WithMissingOrMalformedParts_Returns400AndLeavesStoreUnchanged(string route, string body)
|
||||||
|
{
|
||||||
|
using var server = new TestServerSession();
|
||||||
|
var existing = await server.SeedAsync();
|
||||||
|
var before = await server.SnapshotAsync();
|
||||||
|
|
||||||
|
var method = route == "create" ? HttpMethod.Post : HttpMethod.Put;
|
||||||
|
var path = route == "create" ? "/api/nests" : $"/api/nests/{existing.Id}/file";
|
||||||
|
using var request = new HttpRequestMessage(method, path) { Content = InvalidUploadContent(body) };
|
||||||
|
using var response = await server.Client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
Assert.Equal(before, await server.SnapshotAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("{not-json")]
|
||||||
|
[InlineData("null")]
|
||||||
|
public async Task MetadataUpdate_WithInvalidJson_Returns400AndLeavesStoreUnchanged(string json)
|
||||||
|
{
|
||||||
|
using var server = new TestServerSession();
|
||||||
|
var existing = await server.SeedAsync();
|
||||||
|
var before = await server.SnapshotAsync();
|
||||||
|
|
||||||
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
using var response = await server.Client.PutAsync($"/api/nests/{existing.Id}/metadata", content);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
Assert.Equal(before, await server.SnapshotAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UnknownId_Returns404OnEveryRouteWithoutCreatingRecords()
|
||||||
|
{
|
||||||
|
using var server = new TestServerSession();
|
||||||
|
var existing = await server.SeedAsync();
|
||||||
|
var before = await server.SnapshotAsync();
|
||||||
|
var unknown = Guid.NewGuid();
|
||||||
|
var record = Clone(existing);
|
||||||
|
record.Id = unknown;
|
||||||
|
|
||||||
|
var requests = new Func<HttpRequestMessage>[]
|
||||||
|
{
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Get, $"/api/nests/{unknown}"),
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Get, $"/api/nests/{unknown}/file"),
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Put, $"/api/nests/{unknown}/file")
|
||||||
|
{
|
||||||
|
Content = Multipart(JsonSerializer.Serialize(record, JsonOptions), new byte[] { 1, 2, 3 }),
|
||||||
|
},
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Put, $"/api/nests/{unknown}/metadata")
|
||||||
|
{
|
||||||
|
Content = new StringContent(JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8,
|
||||||
|
"application/json"),
|
||||||
|
},
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Delete, $"/api/nests/{unknown}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var create in requests)
|
||||||
|
{
|
||||||
|
using var request = create();
|
||||||
|
using var response = await server.Client.SendAsync(request);
|
||||||
|
Assert.True(response.StatusCode == HttpStatusCode.NotFound,
|
||||||
|
$"{request.Method} {request.RequestUri} returned {(int)response.StatusCode}, expected 404.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(before, await server.SnapshotAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Startup_WithUnusableDatabasePath_Fails()
|
||||||
|
{
|
||||||
|
var directory = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A regular file where the data directory should be: the database cannot be created.
|
||||||
|
var blocker = Path.Combine(directory, "not-a-directory");
|
||||||
|
File.WriteAllText(blocker, "");
|
||||||
|
using var factory = new ServerFactory(Path.Combine(blocker, "nests.db"));
|
||||||
|
|
||||||
|
var error = Record.Exception(() => factory.CreateClient());
|
||||||
|
|
||||||
|
Assert.NotNull(error);
|
||||||
|
Assert.False(File.Exists(Path.Combine(blocker, "nests.db")));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_RemovesTemporaryDatabaseDirectory()
|
||||||
|
{
|
||||||
|
string directory;
|
||||||
|
using (var server = new TestServerSession())
|
||||||
|
{
|
||||||
|
await server.SeedAsync();
|
||||||
|
directory = server.Factory.DirectoryPath;
|
||||||
|
Assert.True(File.Exists(server.Factory.DatabasePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.False(Directory.Exists(directory));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpContent InvalidUploadContent(string body)
|
||||||
|
{
|
||||||
|
var metadata = JsonSerializer.Serialize(new NestRecord { Name = "Synthetic rejected upload" }, JsonOptions);
|
||||||
|
switch (body)
|
||||||
|
{
|
||||||
|
case "not multipart":
|
||||||
|
return new StringContent(metadata, Encoding.UTF8, "application/json");
|
||||||
|
case "missing metadata":
|
||||||
|
return Multipart(null, new byte[] { 1 });
|
||||||
|
case "invalid metadata JSON":
|
||||||
|
return Multipart("{not-json", new byte[] { 1 });
|
||||||
|
case "null metadata":
|
||||||
|
return Multipart("null", new byte[] { 1 });
|
||||||
|
case "missing file":
|
||||||
|
return Multipart(metadata, null);
|
||||||
|
case "empty file":
|
||||||
|
return Multipart(metadata, Array.Empty<byte>());
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(body), body, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MultipartFormDataContent Multipart(string? metadata, byte[]? file)
|
||||||
|
{
|
||||||
|
var content = new MultipartFormDataContent();
|
||||||
|
if (metadata is not null)
|
||||||
|
content.Add(new StringContent(metadata, Encoding.UTF8, "application/json"), "metadata");
|
||||||
|
if (file is not null)
|
||||||
|
content.Add(new ByteArrayContent(file), "file", "synthetic.nest");
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestRecord Expected(Nest nest, NestRecord saved, byte[] archive)
|
||||||
|
{
|
||||||
|
var expected = NestRecordFactory.FromNest(nest, saved.Id, archive.LongLength);
|
||||||
|
expected.SavedAt = saved.SavedAt;
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestRecord Clone(NestRecord record) =>
|
||||||
|
JsonSerializer.Deserialize<NestRecord>(JsonSerializer.Serialize(record, JsonOptions), JsonOptions)!;
|
||||||
|
|
||||||
|
private static void AssertSameMetadata(NestRecord expected, NestRecord actual) =>
|
||||||
|
Assert.Equal(JsonSerializer.Serialize(expected, JsonOptions), JsonSerializer.Serialize(actual, JsonOptions));
|
||||||
|
|
||||||
|
private static void AssertArchive(byte[] archive, Nest expected)
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(archive, writable: false);
|
||||||
|
var reader = new NestReader(stream);
|
||||||
|
var nest = reader.Read();
|
||||||
|
|
||||||
|
Assert.Empty(reader.Warnings);
|
||||||
|
Assert.Equal(expected.Name, nest.Name);
|
||||||
|
Assert.Equal(expected.Status, nest.Status);
|
||||||
|
var part = Assert.Single(Assert.Single(nest.Plates).Parts);
|
||||||
|
Assert.Equal(expected.Plates[0].Parts[0].Location.X, part.Location.X);
|
||||||
|
Assert.Equal(expected.Plates[0].Parts[0].Location.Y, part.Location.Y);
|
||||||
|
Assert.Equal(18, Assert.Single(nest.Drawings).Area);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Nest SyntheticNest()
|
||||||
|
{
|
||||||
|
var program = new CncProgram();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(6, 0);
|
||||||
|
program.LineTo(6, 3);
|
||||||
|
program.LineTo(0, 3);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
var drawing = new Drawing("Synthetic rectangle", program);
|
||||||
|
drawing.Quantity.Required = 1;
|
||||||
|
var nest = new Nest("Synthetic integration nest")
|
||||||
|
{
|
||||||
|
Customer = "Synthetic test customer",
|
||||||
|
DateCreated = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Unspecified),
|
||||||
|
Material = new Material("Synthetic alloy", "test-grade", 0.25),
|
||||||
|
Thickness = 0.25,
|
||||||
|
Status = NestStatus.Quote,
|
||||||
|
Notes = "Neutral generated fixture; no customer data",
|
||||||
|
MadeBy = "Synthetic test author",
|
||||||
|
Units = Units.Inches,
|
||||||
|
};
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
var plate = new Plate(new Size(20, 30)) { Quantity = 1 };
|
||||||
|
plate.Parts.Add(new Part(drawing, new Vector(2, 3)));
|
||||||
|
nest.Plates.Add(plate);
|
||||||
|
return nest;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static byte[] Archive(Nest nest)
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
Assert.True(new NestWriter(nest).Write(stream));
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One in-memory server, its HTTP client, and the real storage client over it.</summary>
|
||||||
|
private sealed class TestServerSession : IDisposable
|
||||||
|
{
|
||||||
|
public TestServerSession()
|
||||||
|
{
|
||||||
|
Factory = new ServerFactory();
|
||||||
|
Client = Factory.CreateClient();
|
||||||
|
Url = Client.BaseAddress!.ToString();
|
||||||
|
Repository = new RemoteNestRepository(Client, Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ServerFactory Factory { get; }
|
||||||
|
public HttpClient Client { get; }
|
||||||
|
public string Url { get; }
|
||||||
|
public RemoteNestRepository Repository { get; }
|
||||||
|
|
||||||
|
public async Task<NestRecord> SeedAsync()
|
||||||
|
{
|
||||||
|
var nest = SyntheticNest();
|
||||||
|
return await new NestSaveSession().SaveAsync(Repository, Url, nest, Archive(nest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every record's metadata plus its archive hash, in id order.</summary>
|
||||||
|
public async Task<string> SnapshotAsync()
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
foreach (var record in (await Repository.ListAsync()).OrderBy(r => r.Id))
|
||||||
|
{
|
||||||
|
var file = await Repository.GetFileAsync(record.Id);
|
||||||
|
Assert.NotNull(file);
|
||||||
|
lines.Add(JsonSerializer.Serialize(record, JsonOptions) + " " + Convert.ToHexString(SHA256.HashData(file!)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Repository.Dispose();
|
||||||
|
Client.Dispose();
|
||||||
|
Factory.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\OpenNest.Server\OpenNest.Server.csproj" />
|
||||||
|
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
|
||||||
|
<!-- IO only writes/reads synthetic .nest archives in tests; the server never references it. -->
|
||||||
|
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
|
||||||
|
namespace OpenNest.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hosts the production routes in memory against a private, disposable SQLite file.
|
||||||
|
/// The production <see cref="NestDatabase"/> registration is replaced before anything
|
||||||
|
/// resolves it, so tests never open the default data/nests.db or an OPENNEST_DB path.
|
||||||
|
/// Disposing the factory disposes the host (and its database) before deleting the directory.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
||||||
|
{
|
||||||
|
private bool _cleanedUp;
|
||||||
|
|
||||||
|
public ServerFactory(string? databasePath = null)
|
||||||
|
{
|
||||||
|
DirectoryPath = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(DirectoryPath);
|
||||||
|
DatabasePath = databasePath ?? Path.Combine(DirectoryPath, "nests.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Private temporary directory owned by this factory.</summary>
|
||||||
|
public string DirectoryPath { get; }
|
||||||
|
|
||||||
|
public string DatabasePath { get; }
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
services.RemoveAll<NestDatabase>();
|
||||||
|
services.AddSingleton(_ => new NestDatabase(DatabasePath));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
base.Dispose(disposing);
|
||||||
|
if (!disposing || _cleanedUp)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_cleanedUp = true;
|
||||||
|
// Pooled connections would keep the file open on Windows after the host disposed it.
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
if (Directory.Exists(DirectoryPath))
|
||||||
|
Directory.Delete(DirectoryPath, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,11 +25,14 @@ var databasePath =
|
|||||||
args.FirstOrDefault(a => a.StartsWith("--database=", StringComparison.Ordinal))?.Split('=', 2)[1]
|
args.FirstOrDefault(a => a.StartsWith("--database=", StringComparison.Ordinal))?.Split('=', 2)[1]
|
||||||
?? Environment.GetEnvironmentVariable("OPENNEST_DB")
|
?? Environment.GetEnvironmentVariable("OPENNEST_DB")
|
||||||
?? Path.Combine("data", "nests.db");
|
?? Path.Combine("data", "nests.db");
|
||||||
builder.Services.AddSingleton(new NestDatabase(databasePath));
|
builder.Services.AddSingleton(_ => new NestDatabase(databasePath));
|
||||||
|
|
||||||
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
|
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Open the database now so an unusable data path fails startup, not the first request.
|
||||||
|
app.Services.GetRequiredService<NestDatabase>();
|
||||||
|
|
||||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||||
|
|
||||||
app.MapGet("/api/nests", (NestDatabase db) => Results.Ok(db.List()));
|
app.MapGet("/api/nests", (NestDatabase db) => Results.Ok(db.List()));
|
||||||
@@ -127,3 +130,6 @@ internal static class MultipartJson
|
|||||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Entry-point type for in-memory integration tests (WebApplicationFactory<Program>).
|
||||||
|
public partial class Program { }
|
||||||
@@ -50,6 +50,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Reporting", "OpenN
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server", "OpenNest.Server\OpenNest.Server.csproj", "{A2FC654E-8F13-4AD7-BED2-455F2B690961}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server", "OpenNest.Server\OpenNest.Server.csproj", "{A2FC654E-8F13-4AD7-BED2-455F2B690961}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server.Tests", "OpenNest.Server.Tests\OpenNest.Server.Tests.csproj", "{91DCA585-CEBF-4B80-B239-591CD0C919FA}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -312,6 +314,18 @@ Global
|
|||||||
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x64.Build.0 = Release|Any CPU
|
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.ActiveCfg = Release|Any CPU
|
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.Build.0 = Release|Any CPU
|
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
+17
-1
@@ -62,7 +62,23 @@ outside the shop network without adding one.
|
|||||||
`OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with
|
`OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with
|
||||||
`journal_mode=WAL`. Each row holds the metadata columns plus the `.nest`
|
`journal_mode=WAL`. Each row holds the metadata columns plus the `.nest`
|
||||||
archive as a `BLOB`. The database file path comes from `--database=<path>`,
|
archive as a `BLOB`. The database file path comes from `--database=<path>`,
|
||||||
then `OPENNEST_DB`, defaulting to `./data/nests.db`.
|
then `OPENNEST_DB`, defaulting to `./data/nests.db`. The server opens the
|
||||||
|
database at startup, so an unusable path fails startup rather than the first request.
|
||||||
|
|
||||||
|
## Server integration tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-platform; runs in both CI workflows. Each test hosts the production routes in
|
||||||
|
memory (`WebApplicationFactory`) against its own temporary SQLite file and drives
|
||||||
|
them through the real `RemoteNestRepository` and `NestSaveSession`: create, list,
|
||||||
|
download, same-record update, copy, metadata-only update (archive and `fileSize`
|
||||||
|
unchanged), and delete. Missing/invalid upload parts must return 400 and unknown ids
|
||||||
|
404, both leaving every stored record and archive hash unchanged. Tests never open
|
||||||
|
`data/nests.db` or `OPENNEST_DB`; the temporary directory is removed when the host is
|
||||||
|
disposed. The [container smoke](#isolated-container-smoke) remains the image-level check.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user