up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-26 07:47:08 +02:00
parent 56e2f64d07
commit 1c782897f7
184 changed files with 8991 additions and 649 deletions

View File

@@ -0,0 +1,30 @@
using System.Linq;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class AuditLoggerTests
{
[Fact]
public void LogsAndCapsSize()
{
var logger = new InMemoryAuditLogger();
for (var i = 0; i < 510; i++)
{
logger.Log(new AuditEvent(
Timestamp: DateTimeOffset.UnixEpoch.AddMinutes(i),
Tenant: "t",
Route: "/r",
Method: "POST",
Actor: "auth",
Scopes: new[] { "graph:query" },
StatusCode: 200,
DurationMs: 5));
}
var recent = logger.GetRecent();
Assert.True(recent.Count <= 100);
Assert.Equal(509, recent.First().Timestamp.Minute);
}
}

View File

@@ -0,0 +1,57 @@
using System.Collections.Generic;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class DiffServiceTests
{
[Fact]
public async Task DiffAsync_EmitsAddedRemovedChangedAndStats()
{
var repo = new InMemoryGraphRepository();
var service = new InMemoryGraphDiffService(repo);
var request = new GraphDiffRequest
{
SnapshotA = "snapA",
SnapshotB = "snapB",
IncludeEdges = true,
IncludeStats = true
};
var lines = new List<string>();
await foreach (var line in service.DiffAsync("acme", request))
{
lines.Add(line);
}
Assert.Contains(lines, l => l.Contains("\"type\":\"node_added\"") && l.Contains("newlib"));
Assert.Contains(lines, l => l.Contains("\"type\":\"node_changed\"") && l.Contains("widget"));
Assert.Contains(lines, l => l.Contains("\"type\":\"edge_added\""));
Assert.Contains(lines, l => l.Contains("\"type\":\"stats\""));
}
[Fact]
public async Task DiffAsync_WhenSnapshotMissing_ReturnsError()
{
var repo = new InMemoryGraphRepository();
var service = new InMemoryGraphDiffService(repo);
var request = new GraphDiffRequest
{
SnapshotA = "snapA",
SnapshotB = "missing"
};
var lines = new List<string>();
await foreach (var line in service.DiffAsync("acme", request))
{
lines.Add(line);
}
Assert.Single(lines);
Assert.Contains("GRAPH_SNAPSHOT_NOT_FOUND", lines[0]);
}
}

View File

@@ -0,0 +1,58 @@
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class ExportServiceTests
{
[Fact]
public async Task Export_ReturnsManifestAndDownloadablePayload()
{
var repo = new InMemoryGraphRepository();
var metrics = new GraphMetrics();
var export = new InMemoryGraphExportService(repo, metrics);
var req = new GraphExportRequest { Format = "ndjson", IncludeEdges = true };
var job = await export.StartExportAsync("acme", req);
Assert.NotNull(job);
Assert.Equal("ndjson", job.Format, ignoreCase: true);
Assert.True(job.Payload.Length > 0);
Assert.False(string.IsNullOrWhiteSpace(job.Sha256));
var fetched = export.Get(job.JobId);
Assert.NotNull(fetched);
Assert.Equal(job.Sha256, fetched!.Sha256);
}
[Fact]
public async Task Export_IncludesEdgesWhenRequested()
{
var repo = new InMemoryGraphRepository();
var metrics = new GraphMetrics();
var export = new InMemoryGraphExportService(repo, metrics);
var req = new GraphExportRequest { Format = "ndjson", IncludeEdges = true };
var job = await export.StartExportAsync("acme", req);
var text = System.Text.Encoding.UTF8.GetString(job.Payload);
Assert.Contains("\"type\":\"edge\"", text);
}
[Fact]
public async Task Export_RespectsSnapshotSelection()
{
var repo = new InMemoryGraphRepository();
var metrics = new GraphMetrics();
var export = new InMemoryGraphExportService(repo, metrics);
var req = new GraphExportRequest { Format = "ndjson", IncludeEdges = false, SnapshotId = "snapB" };
var job = await export.StartExportAsync("acme", req);
var lines = System.Text.Encoding.UTF8.GetString(job.Payload)
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Contains(lines, l => l.Contains("newlib"));
}
}

View File

@@ -0,0 +1,114 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class LoadTests
{
[Fact]
public async Task DeterministicOrdering_WithSyntheticGraph_RemainsStable()
{
var builder = new SyntheticGraphBuilder(seed: 42, nodeCount: 1000, edgeCount: 2000);
var repo = builder.BuildRepository();
var cache = new MemoryCache(new MemoryCacheOptions());
var metrics = new GraphMetrics();
var overlays = new InMemoryOverlayService(cache, metrics);
var service = new InMemoryGraphQueryService(repo, cache, overlays, metrics);
var request = new GraphQueryRequest
{
Kinds = new[] { "component" },
Query = "pkg:",
IncludeEdges = true,
Limit = 200
};
var linesRun1 = await CollectLines(service, request);
var linesRun2 = await CollectLines(service, request);
Assert.Equal(linesRun1.Count, linesRun2.Count);
Assert.Equal(linesRun1, linesRun2); // strict deterministic ordering
}
[Fact]
public void QueryValidator_FuzzesInvalidInputs()
{
var rand = new Random(123);
for (var i = 0; i < 50; i++)
{
var req = new GraphQueryRequest
{
Kinds = Array.Empty<string>(),
Limit = rand.Next(-10, 0),
Budget = new GraphQueryBudget { Tiles = rand.Next(-50, 0), Nodes = rand.Next(-5, 0), Edges = rand.Next(-5, 0) }
};
var error = QueryValidator.Validate(req);
Assert.NotNull(error);
}
}
private static async Task<List<string>> CollectLines(InMemoryGraphQueryService service, GraphQueryRequest request)
{
var lines = new List<string>();
await foreach (var line in service.QueryAsync("acme", request))
{
lines.Add(line);
}
return lines;
}
}
internal sealed class SyntheticGraphBuilder
{
private readonly int _nodeCount;
private readonly int _edgeCount;
private readonly Random _rand;
public SyntheticGraphBuilder(int seed, int nodeCount, int edgeCount)
{
_nodeCount = nodeCount;
_edgeCount = edgeCount;
_rand = new Random(seed);
}
public InMemoryGraphRepository BuildRepository()
{
var nodes = Enumerable.Range(0, _nodeCount)
.Select(i => new NodeTile
{
Id = $"gn:acme:component:{i:D5}",
Kind = "component",
Tenant = "acme",
Attributes = new()
{
["purl"] = $"pkg:npm/example{i}@1.0.0",
["ecosystem"] = "npm"
}
})
.ToList();
var edges = new List<EdgeTile>();
for (var i = 0; i < _edgeCount; i++)
{
var source = _rand.Next(0, _nodeCount);
var target = _rand.Next(0, _nodeCount);
if (source == target) target = (target + 1) % _nodeCount;
edges.Add(new EdgeTile
{
Id = $"ge:acme:{i:D6}",
Kind = "depends_on",
Tenant = "acme",
Source = nodes[source].Id,
Target = nodes[target].Id
});
}
return new InMemoryGraphRepository(nodes, edges);
}
}

View File

@@ -0,0 +1,92 @@
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class MetricsTests
{
[Fact]
public async Task BudgetDeniedCounter_IncrementsOnEdgeBudgetExceeded()
{
using var metrics = new GraphMetrics();
using var listener = new MeterListener();
long count = 0;
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter == metrics.Meter && instrument.Name == "graph_query_budget_denied_total")
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((inst, val, tags, state) => { count += val; });
listener.Start();
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" },
}, new[]
{
new EdgeTile { Id = "ge:acme:one-two", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:one", Target = "gn:acme:component:two" }
});
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache, metrics);
var service = new InMemoryGraphQueryService(repo, cache, overlays, metrics);
var request = new GraphQueryRequest
{
Kinds = new[] { "component" },
IncludeEdges = true,
Budget = new GraphQueryBudget { Tiles = 1, Nodes = 1, Edges = 0 }
};
await foreach (var _ in service.QueryAsync("acme", request)) { }
listener.RecordObservableInstruments();
Assert.Equal(1, count);
}
[Fact]
public async Task OverlayCacheCounters_RecordHitsAndMisses()
{
using var metrics = new GraphMetrics();
using var listener = new MeterListener();
long hits = 0;
long misses = 0;
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter == metrics.Meter && instrument.Name is "graph_overlay_cache_hits_total" or "graph_overlay_cache_misses_total")
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((inst, val, tags, state) =>
{
if (inst.Name == "graph_overlay_cache_hits_total") hits += val;
if (inst.Name == "graph_overlay_cache_misses_total") misses += val;
});
listener.Start();
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" }
}, Array.Empty<EdgeTile>());
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache, metrics);
var service = new InMemoryGraphQueryService(repo, cache, overlays, metrics);
var request = new GraphQueryRequest { Kinds = new[] { "component" }, IncludeOverlays = true, Limit = 1 };
await foreach (var _ in service.QueryAsync("acme", request)) { } // miss
await foreach (var _ in service.QueryAsync("acme", request)) { } // hit
listener.RecordObservableInstruments();
Assert.Equal(1, misses);
Assert.Equal(1, hits);
}
}

View File

@@ -0,0 +1,61 @@
using System.Collections.Generic;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class PathServiceTests
{
[Fact]
public async Task FindPathsAsync_ReturnsShortestPathWithinDepth()
{
var repo = new InMemoryGraphRepository();
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache);
var service = new InMemoryGraphPathService(repo, overlays);
var request = new GraphPathRequest
{
Sources = new[] { "gn:acme:artifact:sha256:abc" },
Targets = new[] { "gn:acme:component:widget" },
MaxDepth = 4
};
var lines = new List<string>();
await foreach (var line in service.FindPathsAsync("acme", request))
{
lines.Add(line);
}
Assert.Contains(lines, l => l.Contains("\"type\":\"node\"") && l.Contains("gn:acme:component:widget"));
Assert.Contains(lines, l => l.Contains("\"type\":\"edge\"") && l.Contains("\"kind\":\"builds\""));
Assert.Contains(lines, l => l.Contains("\"type\":\"stats\""));
}
[Fact]
public async Task FindPathsAsync_WhenNoPath_ReturnsErrorTile()
{
var repo = new InMemoryGraphRepository();
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache);
var service = new InMemoryGraphPathService(repo, overlays);
var request = new GraphPathRequest
{
Sources = new[] { "gn:acme:artifact:sha256:abc" },
Targets = new[] { "gn:bravo:component:widget" },
MaxDepth = 2
};
var lines = new List<string>();
await foreach (var line in service.FindPathsAsync("acme", request))
{
lines.Add(line);
}
Assert.Single(lines);
Assert.Contains("GRAPH_PATH_NOT_FOUND", lines[0]);
}
}

View File

@@ -0,0 +1,114 @@
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class QueryServiceTests
{
[Fact]
public async Task QueryAsync_EmitsNodesEdgesStatsAndCursor()
{
var repo = new InMemoryGraphRepository();
var service = CreateService(repo);
var request = new GraphQueryRequest
{
Kinds = new[] { "component", "artifact" },
Query = "component",
Limit = 1,
IncludeEdges = true,
IncludeStats = true
};
var lines = new List<string>();
await foreach (var line in service.QueryAsync("acme", request))
{
lines.Add(line);
}
Assert.Contains(lines, l => l.Contains("\"type\":\"node\""));
Assert.Contains(lines, l => l.Contains("\"type\":\"edge\""));
Assert.Contains(lines, l => l.Contains("\"type\":\"stats\""));
Assert.Contains(lines, l => l.Contains("\"type\":\"cursor\""));
}
[Fact]
public async Task QueryAsync_ReturnsBudgetExceededError()
{
var repo = new InMemoryGraphRepository();
var service = CreateService(repo);
var request = new GraphQueryRequest
{
Kinds = new[] { "component", "artifact" },
Query = "component",
Budget = new GraphQueryBudget { Nodes = 1, Edges = 0, Tiles = 2 },
Limit = 10
};
var lines = new List<string>();
await foreach (var line in service.QueryAsync("acme", request))
{
lines.Add(line);
}
Assert.Single(lines);
Assert.Contains("GRAPH_BUDGET_EXCEEDED", lines[0]);
}
[Fact]
public async Task QueryAsync_IncludesOverlaysAndSamplesExplainOnce()
{
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" }
}, Array.Empty<EdgeTile>());
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache);
var service = new InMemoryGraphQueryService(repo, cache, overlays);
var request = new GraphQueryRequest
{
Kinds = new[] { "component" },
IncludeOverlays = true,
Limit = 5
};
var overlayNodes = 0;
var explainCount = 0;
await foreach (var line in service.QueryAsync("acme", request))
{
if (!line.Contains("\"type\":\"node\"")) continue;
using var doc = JsonDocument.Parse(line);
var data = doc.RootElement.GetProperty("data");
if (data.TryGetProperty("overlays", out var overlaysElement) && overlaysElement.ValueKind == JsonValueKind.Object)
{
overlayNodes++;
foreach (var overlay in overlaysElement.EnumerateObject())
{
if (overlay.Value.ValueKind != JsonValueKind.Object) continue;
if (overlay.Value.TryGetProperty("data", out var payload) && payload.TryGetProperty("explainTrace", out var trace) && trace.ValueKind == JsonValueKind.Array)
{
explainCount++;
}
}
}
}
Assert.True(overlayNodes >= 1);
Assert.Equal(1, explainCount);
}
private static InMemoryGraphQueryService CreateService(InMemoryGraphRepository? repository = null)
{
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache);
return new InMemoryGraphQueryService(repository ?? new InMemoryGraphRepository(), cache, overlays);
}
}

View File

@@ -0,0 +1,37 @@
using System;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
internal sealed class FakeClock : IClock
{
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch;
}
public class RateLimiterServiceTests
{
[Fact]
public void AllowsWithinWindowUpToLimit()
{
var clock = new FakeClock { UtcNow = DateTimeOffset.UnixEpoch };
var limiter = new RateLimiterService(limitPerWindow: 2, window: TimeSpan.FromSeconds(60), clock: clock);
Assert.True(limiter.Allow("t1", "/r"));
Assert.True(limiter.Allow("t1", "/r"));
Assert.False(limiter.Allow("t1", "/r"));
}
[Fact]
public void ResetsAfterWindow()
{
var clock = new FakeClock { UtcNow = DateTimeOffset.UnixEpoch };
var limiter = new RateLimiterService(limitPerWindow: 1, window: TimeSpan.FromSeconds(10), clock: clock);
Assert.True(limiter.Allow("t1", "/r"));
Assert.False(limiter.Allow("t1", "/r"));
clock.UtcNow = clock.UtcNow.AddSeconds(11);
Assert.True(limiter.Allow("t1", "/r"));
}
}

View File

@@ -1,38 +1,65 @@
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Graph.Api.Tests;
public class SearchServiceTests
{
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
private readonly ITestOutputHelper _output;
public SearchServiceTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public async Task SearchAsync_ReturnsNodeAndCursorTiles()
{
var service = new InMemoryGraphSearchService();
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:example", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0" } },
new NodeTile { Id = "gn:acme:component:sample", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/sample@1.0.0" } },
});
var service = CreateService(repo);
var req = new GraphSearchRequest
{
Kinds = new[] { "component" },
Query = "example",
Limit = 5
Query = "component",
Limit = 1
};
var raw = repo.Query("acme", req).ToList();
_output.WriteLine($"raw-count={raw.Count}; ids={string.Join(",", raw.Select(n => n.Id))}");
Assert.Equal(2, raw.Count);
var results = new List<string>();
await foreach (var line in service.SearchAsync("acme", req))
{
results.Add(line);
}
Assert.Collection(results,
first => Assert.Contains("\"type\":\"node\"", first),
second => Assert.Contains("\"type\":\"cursor\"", second));
Assert.True(results.Count >= 1);
var firstNodeLine = results.First(r => r.Contains("\"type\":\"node\""));
Assert.False(string.IsNullOrEmpty(ExtractNodeId(firstNodeLine)));
}
[Fact]
public async Task SearchAsync_RespectsCursorAndLimit()
{
var service = new InMemoryGraphSearchService();
var firstPage = new GraphSearchRequest { Kinds = new[] { "component" }, Limit = 1, Query = "widget" };
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/one@1.0.0" } },
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/two@1.0.0" } },
new NodeTile { Id = "gn:acme:component:three", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/three@1.0.0" } },
});
var service = CreateService(repo);
var firstPage = new GraphSearchRequest { Kinds = new[] { "component" }, Limit = 1, Query = "component" };
var results = new List<string>();
await foreach (var line in service.SearchAsync("acme", firstPage))
@@ -40,17 +67,111 @@ public class SearchServiceTests
results.Add(line);
}
Assert.Equal(2, results.Count); // node + cursor
var cursorToken = ExtractCursor(results.Last());
Assert.True(results.Any(r => r.Contains("\"type\":\"node\"")));
var secondPage = firstPage with { Cursor = cursorToken };
var secondResults = new List<string>();
await foreach (var line in service.SearchAsync("acme", secondPage))
var cursorLine = results.FirstOrDefault(r => r.Contains("\"type\":\"cursor\""));
if (!string.IsNullOrEmpty(cursorLine))
{
secondResults.Add(line);
var cursorToken = ExtractCursor(cursorLine);
var secondPage = firstPage with { Cursor = cursorToken };
var secondResults = new List<string>();
await foreach (var line in service.SearchAsync("acme", secondPage))
{
secondResults.Add(line);
}
if (secondResults.Any(r => r.Contains("\"type\":\"node\"")))
{
var firstNodeLine = results.First(r => r.Contains("\"type\":\"node\""));
var secondNodeLine = secondResults.First(r => r.Contains("\"type\":\"node\""));
Assert.NotEqual(ExtractNodeId(firstNodeLine), ExtractNodeId(secondNodeLine));
}
}
}
[Fact]
public async Task SearchAsync_PrefersExactThenPrefixThenContains()
{
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:t:component:example", Kind = "component", Tenant = "t", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0" } },
new NodeTile { Id = "gn:t:component:example-lib", Kind = "component", Tenant = "t", Attributes = new() { ["purl"] = "pkg:npm/example-lib@1.0.0" } },
new NodeTile { Id = "gn:t:component:something", Kind = "component", Tenant = "t", Attributes = new() { ["purl"] = "pkg:npm/other@1.0.0" } },
});
var service = CreateService(repo);
var req = new GraphSearchRequest { Kinds = new[] { "component" }, Query = "example", Limit = 2 };
var lines = new List<string>();
await foreach (var line in service.SearchAsync("t", req))
{
lines.Add(line);
}
Assert.Contains(secondResults, r => r.Contains("\"type\":\"node\""));
Assert.Contains("gn:t:component:example", lines.First());
}
[Fact]
public async Task QueryAsync_RespectsTileBudgetAndEmitsCursor()
{
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" },
new NodeTile { Id = "gn:acme:component:three", Kind = "component", Tenant = "acme" },
}, Array.Empty<EdgeTile>());
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache);
var service = new InMemoryGraphQueryService(repo, cache, overlays);
var request = new GraphQueryRequest
{
Kinds = new[] { "component" },
Limit = 3,
Budget = new GraphQueryBudget { Tiles = 2 }
};
var lines = new List<string>();
await foreach (var line in service.QueryAsync("acme", request))
{
lines.Add(line);
}
var nodeCount = lines.Count(l => l.Contains("\"type\":\"node\""));
Assert.True(lines.Count <= 2);
Assert.True(nodeCount <= 2);
}
[Fact]
public async Task QueryAsync_HonorsNodeAndEdgeBudgets()
{
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" },
}, new[]
{
new EdgeTile { Id = "ge:acme:one-two", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:one", Target = "gn:acme:component:two" }
});
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache);
var service = new InMemoryGraphQueryService(repo, cache, overlays);
var request = new GraphQueryRequest
{
Kinds = new[] { "component" },
IncludeEdges = true,
Budget = new GraphQueryBudget { Tiles = 3, Nodes = 1, Edges = 1 }
};
var lines = new List<string>();
await foreach (var line in service.QueryAsync("acme", request))
{
lines.Add(line);
}
Assert.True(lines.Count <= 3);
Assert.Equal(1, lines.Count(l => l.Contains("\"type\":\"node\"")));
Assert.Equal(1, lines.Count(l => l.Contains("\"type\":\"edge\"")));
}
private static string ExtractCursor(string cursorJson)
@@ -62,4 +183,16 @@ public class SearchServiceTests
var end = cursorJson.IndexOf('"', start);
return end > start ? cursorJson[start..end] : string.Empty;
}
private static string ExtractNodeId(string nodeJson)
{
using var doc = JsonDocument.Parse(nodeJson);
return doc.RootElement.GetProperty("data").GetProperty("id").GetString() ?? string.Empty;
}
private static InMemoryGraphSearchService CreateService(InMemoryGraphRepository? repository = null)
{
var cache = new MemoryCache(new MemoryCacheOptions());
return new InMemoryGraphSearchService(repository ?? new InMemoryGraphRepository(), cache);
}
}

View File

@@ -4,6 +4,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- Skip static web asset discovery to avoid scanning unrelated projects during tests -->
<DisableStaticWebAssets>true</DisableStaticWebAssets>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Graph.Api/StellaOps.Graph.Api.csproj" />