up
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
114
src/Graph/__Tests/StellaOps.Graph.Api.Tests/LoadTests.cs
Normal file
114
src/Graph/__Tests/StellaOps.Graph.Api.Tests/LoadTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
92
src/Graph/__Tests/StellaOps.Graph.Api.Tests/MetricsTests.cs
Normal file
92
src/Graph/__Tests/StellaOps.Graph.Api.Tests/MetricsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
114
src/Graph/__Tests/StellaOps.Graph.Api.Tests/QueryServiceTests.cs
Normal file
114
src/Graph/__Tests/StellaOps.Graph.Api.Tests/QueryServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user