module SomhairlesDream.Server.Tests.ApiTests open System open System.IO open System.Net open System.Net.Http open System.Text open System.Text.Json open System.Threading open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Hosting open Microsoft.AspNetCore.TestHost open Xunit open SomhairlesDream.Modeling open SomhairlesDream.Server open SomhairlesDream.Shared let private ids : RunIds = { ProjectId = "heritage-001" RunId = "run-api-001" BaseId = "base-000" TargetId = "target-003" } let private withTempDirectory action = let path = Path.Combine(Path.GetTempPath(), "somhairles-dream-api-" + Guid.NewGuid().ToString("N")) Directory.CreateDirectory(path) |> ignore try action path finally if Directory.Exists(path) then Directory.Delete(path, true) type private TestBridge() = interface IArtifactBridge with member _.Export(request, _) = File.WriteAllBytes(request.GlbPath, [| 0x67uy; 0x6Cuy; 0x54uy; byte request.Step.Index |]) Directory.CreateDirectory(Path.GetDirectoryName(request.LogPath)) |> ignore File.WriteAllText(request.LogPath, $"bridge log for {request.Step.StepId}") Ok { BlenderVersion = "5.0.1" ExportedAt = DateTimeOffset.Parse("2026-09-20T12:00:05Z") ObjectIds = request.Step.ObjectIds VerifiedObjectIds = request.Step.ObjectIds GlbBytes = 4L RenderPath = None RenderedAt = None } type private BlockingBridge(gate: ManualResetEventSlim) = interface IArtifactBridge with member _.Export(request, _) = gate.Wait() (TestBridge() :> IArtifactBridge).Export(request, fun () -> ()) type private StepGatedBridge(gate: ManualResetEventSlim) = interface IArtifactBridge with member _.Export(request, _) = if request.Step.Index > 0 then gate.Wait() (TestBridge() :> IArtifactBridge).Export(request, fun () -> ()) let private requestBody runId = $"{{\"projectId\":\"{ids.ProjectId}\",\"runId\":\"{runId}\",\"baseId\":\"{ids.BaseId}\",\"targetId\":\"{ids.TargetId}\"}}" let private withAppUsing (bridgeFactory: unit -> IArtifactBridge) action = withTempDirectory (fun root -> let clock () = DateTimeOffset.Parse("2026-09-20T12:00:00Z") let coordinator = ArtifactRunCoordinator( root, bridgeFactory, clock, TimeSpan.FromSeconds 15. ) let builder = WebApplication.CreateBuilder([||]) builder.WebHost.UseTestServer() |> ignore let app = builder.Build() ArtifactRunApi.register app { Coordinator = coordinator Clock = clock JsonOptions = JsonSerializerOptions(JsonSerializerDefaults.Web) } app.StartAsync().GetAwaiter().GetResult() try action (app.GetTestClient()) coordinator finally app.StopAsync().GetAwaiter().GetResult() app.DisposeAsync().AsTask().GetAwaiter().GetResult()) let private withApp action = withAppUsing (fun () -> TestBridge() :> IArtifactBridge) action let private waitFor predicate = let deadline = DateTime.UtcNow.AddSeconds(5.) let mutable matched = predicate () while not matched && DateTime.UtcNow < deadline do Thread.Sleep(10) matched <- predicate () matched [] let ``start endpoint accepts a run and conflicts on its second start`` () = withApp (fun client _ -> let first = use content = new StringContent(requestBody ids.RunId, Encoding.UTF8, "application/json") client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.Accepted, first.StatusCode) let firstSnapshot = first.Content.ReadAsStringAsync().GetAwaiter().GetResult() |> fun body -> JsonSerializer.Deserialize(body, JsonSerializerOptions(JsonSerializerDefaults.Web)) Assert.Equal(ids.ProjectId, firstSnapshot.ProjectId) Assert.Equal(ids.RunId, firstSnapshot.RunId) use duplicateContent = new StringContent(requestBody ids.RunId, Encoding.UTF8, "application/json") let duplicate = client.PostAsync("/api/runs/start", duplicateContent).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode)) [] let ``start endpoint rejects invalid resource ids before creating a run`` () = withApp (fun client coordinator -> let body = requestBody "../outside" use content = new StringContent(body, Encoding.UTF8, "application/json") let response = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode) Assert.True(coordinator.TryFind(ids.ProjectId, "../outside").IsNone)) [] let ``manifest and artifact endpoints expose a completed run and reject traversal`` () = withApp (fun client coordinator -> let runId = "run-api-complete" use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) let completed = waitFor (fun () -> coordinator.TryFind(ids.ProjectId, runId) |> Option.exists (fun store -> store.Snapshot().Status = "complete")) Assert.True(completed) let manifest = client .GetAsync($"/api/artifacts/manifest?projectId={ids.ProjectId}&runId={runId}") .GetAwaiter() .GetResult() Assert.Equal(HttpStatusCode.OK, manifest.StatusCode) let manifestBody = manifest.Content.ReadAsStringAsync().GetAwaiter().GetResult() Assert.Contains("\"schemaVersion\":1", manifestBody) let glbPath = Uri.EscapeDataString("steps/01-foundation.glb") let artifact = client .GetAsync($"/api/artifacts/file?projectId={ids.ProjectId}&runId={runId}&path={glbPath}") .GetAwaiter() .GetResult() Assert.Equal(HttpStatusCode.OK, artifact.StatusCode) let traversalPath = Uri.EscapeDataString("../manifest.json") let traversal = client .GetAsync($"/api/artifacts/file?projectId={ids.ProjectId}&runId={runId}&path={traversalPath}") .GetAwaiter() .GetResult() Assert.Equal(HttpStatusCode.BadRequest, traversal.StatusCode)) [] let ``manifest endpoint reports active runs as conflicts`` () = let gate = new ManualResetEventSlim(false) try withAppUsing (fun () -> BlockingBridge(gate) :> IArtifactBridge) (fun client coordinator -> let runId = "run-api-active" use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) let manifest = client .GetAsync($"/api/artifacts/manifest?projectId={ids.ProjectId}&runId={runId}") .GetAwaiter() .GetResult() Assert.Equal(HttpStatusCode.Conflict, manifest.StatusCode) gate.Set() Assert.True( waitFor (fun () -> coordinator.TryFind(ids.ProjectId, runId) |> Option.exists (fun store -> store.Snapshot().Status = "complete"))) ) finally gate.Set() gate.Dispose() [] let ``step artifacts stream while a run is still active`` () = let gate = new ManualResetEventSlim(false) try withAppUsing (fun () -> StepGatedBridge(gate) :> IArtifactBridge) (fun client coordinator -> let runId = "run-api-steps" use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) Assert.True( waitFor (fun () -> coordinator.TryFind(ids.ProjectId, runId) |> Option.exists (fun store -> let snapshot = store.Snapshot() snapshot.Status = "running" && snapshot.CompletedSteps.Length = 1))) let glbPath = Uri.EscapeDataString("steps/01-foundation.glb") let stepsUrl = $"/api/artifacts/steps?projectId={ids.ProjectId}&runId={runId}&path={glbPath}" let stepArtifact = client.GetAsync(stepsUrl).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.OK, stepArtifact.StatusCode) Assert.Equal("model/gltf-binary", stepArtifact.Content.Headers.ContentType.MediaType) let traversalPath = Uri.EscapeDataString("../manifest.json") let traversalUrl = $"/api/artifacts/steps?projectId={ids.ProjectId}&runId={runId}&path={traversalPath}" let traversal = client.GetAsync(traversalUrl).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.BadRequest, traversal.StatusCode) let missingPath = Uri.EscapeDataString("steps/03-cabin.glb") let missingUrl = $"/api/artifacts/steps?projectId={ids.ProjectId}&runId={runId}&path={missingPath}" let missing = client.GetAsync(missingUrl).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode) gate.Set() Assert.True( waitFor (fun () -> coordinator.TryFind(ids.ProjectId, runId) |> Option.exists (fun store -> store.Snapshot().Status = "complete"))) ) finally gate.Set() gate.Dispose() [] let ``events endpoint streams only the selected run`` () = withApp (fun client _ -> let runId = "run-api-events" use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) use response = client .GetAsync( $"/api/runs/events?projectId={ids.ProjectId}&runId={runId}", HttpCompletionOption.ResponseHeadersRead ) .GetAwaiter() .GetResult() Assert.Equal(HttpStatusCode.OK, response.StatusCode) use reader = new StreamReader(response.Content.ReadAsStream()) let line = reader.ReadLine() Assert.StartsWith("data: ", line) Assert.Contains(runId, line) let unknown = client .GetAsync($"/api/runs/events?projectId={ids.ProjectId}&runId=run-api-missing") .GetAwaiter() .GetResult() Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode))