mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-05 04:42: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
|
||||
- name: Run OpenNest.IO.Tests
|
||||
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.' }
|
||||
- name: Run all test projects on Windows
|
||||
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
|
||||
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]
|
||||
?? Environment.GetEnvironmentVariable("OPENNEST_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.
|
||||
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("/api/nests", (NestDatabase db) => Results.Ok(db.List()));
|
||||
@@ -127,3 +130,6 @@ internal static class MultipartJson
|
||||
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
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server", "OpenNest.Server\OpenNest.Server.csproj", "{A2FC654E-8F13-4AD7-BED2-455F2B690961}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server.Tests", "OpenNest.Server.Tests\OpenNest.Server.Tests.csproj", "{91DCA585-CEBF-4B80-B239-591CD0C919FA}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
+17
-1
@@ -62,7 +62,23 @@ outside the shop network without adding one.
|
||||
`OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with
|
||||
`journal_mode=WAL`. Each row holds the metadata columns plus the `.nest`
|
||||
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
|
||||
|
||||
|
||||
Reference in new issue
Block a user