Restructure solution layout by module
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			This commit is contained in:
		| @@ -1,12 +1,12 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <OutputType>Exe</OutputType> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="StackExchange.Redis" Version="2.8.24" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <OutputType>Exe</OutputType> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="StackExchange.Redis" Version="2.8.24" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -1,198 +1,198 @@ | ||||
| using System.Globalization; | ||||
| using System.Net.Http.Headers; | ||||
| using System.Linq; | ||||
| using System.Text.Json; | ||||
| using StackExchange.Redis; | ||||
|  | ||||
| static string RequireEnv(string name) | ||||
| { | ||||
|     var value = Environment.GetEnvironmentVariable(name); | ||||
|     if (string.IsNullOrWhiteSpace(value)) | ||||
|     { | ||||
|         throw new InvalidOperationException($"Environment variable '{name}' is required for Notify smoke validation."); | ||||
|     } | ||||
|  | ||||
|     return value; | ||||
| } | ||||
|  | ||||
| static string? GetField(StreamEntry entry, string fieldName) | ||||
| { | ||||
|     foreach (var pair in entry.Values) | ||||
|     { | ||||
|         if (string.Equals(pair.Name, fieldName, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return pair.Value.ToString(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     return null; | ||||
| } | ||||
|  | ||||
| static void Ensure(bool condition, string message) | ||||
| { | ||||
|     if (!condition) | ||||
|     { | ||||
|         throw new InvalidOperationException(message); | ||||
|     } | ||||
| } | ||||
|  | ||||
| var redisDsn = RequireEnv("NOTIFY_SMOKE_REDIS_DSN"); | ||||
| var redisStream = Environment.GetEnvironmentVariable("NOTIFY_SMOKE_STREAM"); | ||||
| if (string.IsNullOrWhiteSpace(redisStream)) | ||||
| { | ||||
|     redisStream = "stella.events"; | ||||
| } | ||||
|  | ||||
| var expectedKindsEnv = RequireEnv("NOTIFY_SMOKE_EXPECT_KINDS"); | ||||
|  | ||||
| var expectedKinds = expectedKindsEnv | ||||
|     .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) | ||||
|     .Select(kind => kind.ToLowerInvariant()) | ||||
|     .Distinct() | ||||
|     .ToArray(); | ||||
| Ensure(expectedKinds.Length > 0, "Expected at least one event kind in NOTIFY_SMOKE_EXPECT_KINDS."); | ||||
|  | ||||
| var lookbackMinutesEnv = RequireEnv("NOTIFY_SMOKE_LOOKBACK_MINUTES"); | ||||
| if (!double.TryParse(lookbackMinutesEnv, NumberStyles.Any, CultureInfo.InvariantCulture, out var lookbackMinutes)) | ||||
| { | ||||
|     throw new InvalidOperationException("NOTIFY_SMOKE_LOOKBACK_MINUTES must be numeric."); | ||||
| } | ||||
| Ensure(lookbackMinutes > 0, "NOTIFY_SMOKE_LOOKBACK_MINUTES must be greater than zero."); | ||||
|  | ||||
| var now = DateTimeOffset.UtcNow; | ||||
| var sinceThreshold = now - TimeSpan.FromMinutes(Math.Max(1, lookbackMinutes)); | ||||
|  | ||||
| Console.WriteLine($"ℹ️  Checking Redis stream '{redisStream}' for kinds [{string.Join(", ", expectedKinds)}] within the last {lookbackMinutes:F1} minutes."); | ||||
|  | ||||
| var redisConfig = ConfigurationOptions.Parse(redisDsn); | ||||
| redisConfig.AbortOnConnectFail = false; | ||||
|  | ||||
| await using var redisConnection = await ConnectionMultiplexer.ConnectAsync(redisConfig); | ||||
| var database = redisConnection.GetDatabase(); | ||||
|  | ||||
| var streamEntries = await database.StreamRangeAsync(redisStream, "-", "+", count: 200); | ||||
| if (streamEntries.Length > 1) | ||||
| { | ||||
|     Array.Reverse(streamEntries); | ||||
| } | ||||
| Ensure(streamEntries.Length > 0, $"Redis stream '{redisStream}' is empty."); | ||||
|  | ||||
| var recentEntries = new List<StreamEntry>(); | ||||
| foreach (var entry in streamEntries) | ||||
| { | ||||
|     var timestampText = GetField(entry, "ts"); | ||||
|     if (timestampText is null) | ||||
|     { | ||||
|         continue; | ||||
|     } | ||||
|  | ||||
|     if (!DateTimeOffset.TryParse(timestampText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var entryTimestamp)) | ||||
|     { | ||||
|         continue; | ||||
|     } | ||||
|  | ||||
|     if (entryTimestamp >= sinceThreshold) | ||||
|     { | ||||
|         recentEntries.Add(entry); | ||||
|     } | ||||
| } | ||||
|  | ||||
| Ensure(recentEntries.Count > 0, $"No Redis events newer than {sinceThreshold:u} located in stream '{redisStream}'."); | ||||
|  | ||||
| var missingKinds = new List<string>(); | ||||
| foreach (var kind in expectedKinds) | ||||
| { | ||||
|     var match = recentEntries.FirstOrDefault(entry => | ||||
|     { | ||||
|         var entryKind = GetField(entry, "kind")?.ToLowerInvariant(); | ||||
|         return entryKind == kind; | ||||
|     }); | ||||
|  | ||||
|     if (match.Equals(default(StreamEntry))) | ||||
|     { | ||||
|         missingKinds.Add(kind); | ||||
|     } | ||||
| } | ||||
|  | ||||
| Ensure(missingKinds.Count == 0, $"Missing expected Redis events for kinds: {string.Join(", ", missingKinds)}"); | ||||
|  | ||||
| Console.WriteLine("✅ Redis event stream contains the expected scanner events."); | ||||
|  | ||||
| var notifyBaseUrl = RequireEnv("NOTIFY_SMOKE_NOTIFY_BASEURL").TrimEnd('/'); | ||||
| var notifyToken = RequireEnv("NOTIFY_SMOKE_NOTIFY_TOKEN"); | ||||
| var notifyTenant = RequireEnv("NOTIFY_SMOKE_NOTIFY_TENANT"); | ||||
| var notifyTenantHeader = Environment.GetEnvironmentVariable("NOTIFY_SMOKE_NOTIFY_TENANT_HEADER"); | ||||
| if (string.IsNullOrWhiteSpace(notifyTenantHeader)) | ||||
| { | ||||
|     notifyTenantHeader = "X-StellaOps-Tenant"; | ||||
| } | ||||
|  | ||||
| var notifyTimeoutSeconds = 30; | ||||
| var notifyTimeoutEnv = Environment.GetEnvironmentVariable("NOTIFY_SMOKE_NOTIFY_TIMEOUT_SECONDS"); | ||||
| if (!string.IsNullOrWhiteSpace(notifyTimeoutEnv) && int.TryParse(notifyTimeoutEnv, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedTimeout)) | ||||
| { | ||||
|     notifyTimeoutSeconds = Math.Max(5, parsedTimeout); | ||||
| } | ||||
|  | ||||
| using var httpClient = new HttpClient | ||||
| { | ||||
|     Timeout = TimeSpan.FromSeconds(notifyTimeoutSeconds), | ||||
| }; | ||||
|  | ||||
| httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", notifyToken); | ||||
| httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); | ||||
| httpClient.DefaultRequestHeaders.Add(notifyTenantHeader, notifyTenant); | ||||
|  | ||||
| var sinceQuery = Uri.EscapeDataString(sinceThreshold.ToString("O", CultureInfo.InvariantCulture)); | ||||
| var deliveriesUrl = $"{notifyBaseUrl}/api/v1/deliveries?since={sinceQuery}&limit=200"; | ||||
|  | ||||
| Console.WriteLine($"ℹ️  Querying Notify deliveries via {deliveriesUrl}."); | ||||
|  | ||||
| using var response = await httpClient.GetAsync(deliveriesUrl); | ||||
| if (!response.IsSuccessStatusCode) | ||||
| { | ||||
|     var body = await response.Content.ReadAsStringAsync(); | ||||
|     throw new InvalidOperationException($"Notify deliveries request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {body}"); | ||||
| } | ||||
|  | ||||
| var json = await response.Content.ReadAsStringAsync(); | ||||
| if (string.IsNullOrWhiteSpace(json)) | ||||
| { | ||||
|     throw new InvalidOperationException("Notify deliveries response body was empty."); | ||||
| } | ||||
|  | ||||
| using var document = JsonDocument.Parse(json); | ||||
| var root = document.RootElement; | ||||
|  | ||||
| IEnumerable<JsonElement> EnumerateDeliveries(JsonElement element) | ||||
| { | ||||
|     return element.ValueKind switch | ||||
|     { | ||||
|         JsonValueKind.Array => element.EnumerateArray(), | ||||
|         JsonValueKind.Object when element.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Array => items.EnumerateArray(), | ||||
|         _ => throw new InvalidOperationException("Notify deliveries response was not an array or did not contain an 'items' collection.") | ||||
|     }; | ||||
| } | ||||
|  | ||||
| var deliveries = EnumerateDeliveries(root).ToArray(); | ||||
| Ensure(deliveries.Length > 0, "Notify deliveries response did not return any records."); | ||||
|  | ||||
| var missingDeliveryKinds = new List<string>(); | ||||
| foreach (var kind in expectedKinds) | ||||
| { | ||||
|     var found = deliveries.Any(delivery => | ||||
|         delivery.TryGetProperty("kind", out var kindProperty) && | ||||
|         kindProperty.GetString()?.Equals(kind, StringComparison.OrdinalIgnoreCase) == true && | ||||
|         delivery.TryGetProperty("status", out var statusProperty) && | ||||
|         !string.Equals(statusProperty.GetString(), "failed", StringComparison.OrdinalIgnoreCase)); | ||||
|  | ||||
|     if (!found) | ||||
|     { | ||||
|         missingDeliveryKinds.Add(kind); | ||||
|     } | ||||
| } | ||||
|  | ||||
| Ensure(missingDeliveryKinds.Count == 0, $"Notify deliveries missing successful records for kinds: {string.Join(", ", missingDeliveryKinds)}"); | ||||
|  | ||||
| Console.WriteLine("✅ Notify deliveries include the expected scanner events."); | ||||
| Console.WriteLine("🎉 Notify smoke validation completed successfully."); | ||||
| using System.Globalization; | ||||
| using System.Net.Http.Headers; | ||||
| using System.Linq; | ||||
| using System.Text.Json; | ||||
| using StackExchange.Redis; | ||||
|  | ||||
| static string RequireEnv(string name) | ||||
| { | ||||
|     var value = Environment.GetEnvironmentVariable(name); | ||||
|     if (string.IsNullOrWhiteSpace(value)) | ||||
|     { | ||||
|         throw new InvalidOperationException($"Environment variable '{name}' is required for Notify smoke validation."); | ||||
|     } | ||||
|  | ||||
|     return value; | ||||
| } | ||||
|  | ||||
| static string? GetField(StreamEntry entry, string fieldName) | ||||
| { | ||||
|     foreach (var pair in entry.Values) | ||||
|     { | ||||
|         if (string.Equals(pair.Name, fieldName, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return pair.Value.ToString(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     return null; | ||||
| } | ||||
|  | ||||
| static void Ensure(bool condition, string message) | ||||
| { | ||||
|     if (!condition) | ||||
|     { | ||||
|         throw new InvalidOperationException(message); | ||||
|     } | ||||
| } | ||||
|  | ||||
| var redisDsn = RequireEnv("NOTIFY_SMOKE_REDIS_DSN"); | ||||
| var redisStream = Environment.GetEnvironmentVariable("NOTIFY_SMOKE_STREAM"); | ||||
| if (string.IsNullOrWhiteSpace(redisStream)) | ||||
| { | ||||
|     redisStream = "stella.events"; | ||||
| } | ||||
|  | ||||
| var expectedKindsEnv = RequireEnv("NOTIFY_SMOKE_EXPECT_KINDS"); | ||||
|  | ||||
| var expectedKinds = expectedKindsEnv | ||||
|     .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) | ||||
|     .Select(kind => kind.ToLowerInvariant()) | ||||
|     .Distinct() | ||||
|     .ToArray(); | ||||
| Ensure(expectedKinds.Length > 0, "Expected at least one event kind in NOTIFY_SMOKE_EXPECT_KINDS."); | ||||
|  | ||||
| var lookbackMinutesEnv = RequireEnv("NOTIFY_SMOKE_LOOKBACK_MINUTES"); | ||||
| if (!double.TryParse(lookbackMinutesEnv, NumberStyles.Any, CultureInfo.InvariantCulture, out var lookbackMinutes)) | ||||
| { | ||||
|     throw new InvalidOperationException("NOTIFY_SMOKE_LOOKBACK_MINUTES must be numeric."); | ||||
| } | ||||
| Ensure(lookbackMinutes > 0, "NOTIFY_SMOKE_LOOKBACK_MINUTES must be greater than zero."); | ||||
|  | ||||
| var now = DateTimeOffset.UtcNow; | ||||
| var sinceThreshold = now - TimeSpan.FromMinutes(Math.Max(1, lookbackMinutes)); | ||||
|  | ||||
| Console.WriteLine($"ℹ️  Checking Redis stream '{redisStream}' for kinds [{string.Join(", ", expectedKinds)}] within the last {lookbackMinutes:F1} minutes."); | ||||
|  | ||||
| var redisConfig = ConfigurationOptions.Parse(redisDsn); | ||||
| redisConfig.AbortOnConnectFail = false; | ||||
|  | ||||
| await using var redisConnection = await ConnectionMultiplexer.ConnectAsync(redisConfig); | ||||
| var database = redisConnection.GetDatabase(); | ||||
|  | ||||
| var streamEntries = await database.StreamRangeAsync(redisStream, "-", "+", count: 200); | ||||
| if (streamEntries.Length > 1) | ||||
| { | ||||
|     Array.Reverse(streamEntries); | ||||
| } | ||||
| Ensure(streamEntries.Length > 0, $"Redis stream '{redisStream}' is empty."); | ||||
|  | ||||
| var recentEntries = new List<StreamEntry>(); | ||||
| foreach (var entry in streamEntries) | ||||
| { | ||||
|     var timestampText = GetField(entry, "ts"); | ||||
|     if (timestampText is null) | ||||
|     { | ||||
|         continue; | ||||
|     } | ||||
|  | ||||
|     if (!DateTimeOffset.TryParse(timestampText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var entryTimestamp)) | ||||
|     { | ||||
|         continue; | ||||
|     } | ||||
|  | ||||
|     if (entryTimestamp >= sinceThreshold) | ||||
|     { | ||||
|         recentEntries.Add(entry); | ||||
|     } | ||||
| } | ||||
|  | ||||
| Ensure(recentEntries.Count > 0, $"No Redis events newer than {sinceThreshold:u} located in stream '{redisStream}'."); | ||||
|  | ||||
| var missingKinds = new List<string>(); | ||||
| foreach (var kind in expectedKinds) | ||||
| { | ||||
|     var match = recentEntries.FirstOrDefault(entry => | ||||
|     { | ||||
|         var entryKind = GetField(entry, "kind")?.ToLowerInvariant(); | ||||
|         return entryKind == kind; | ||||
|     }); | ||||
|  | ||||
|     if (match.Equals(default(StreamEntry))) | ||||
|     { | ||||
|         missingKinds.Add(kind); | ||||
|     } | ||||
| } | ||||
|  | ||||
| Ensure(missingKinds.Count == 0, $"Missing expected Redis events for kinds: {string.Join(", ", missingKinds)}"); | ||||
|  | ||||
| Console.WriteLine("✅ Redis event stream contains the expected scanner events."); | ||||
|  | ||||
| var notifyBaseUrl = RequireEnv("NOTIFY_SMOKE_NOTIFY_BASEURL").TrimEnd('/'); | ||||
| var notifyToken = RequireEnv("NOTIFY_SMOKE_NOTIFY_TOKEN"); | ||||
| var notifyTenant = RequireEnv("NOTIFY_SMOKE_NOTIFY_TENANT"); | ||||
| var notifyTenantHeader = Environment.GetEnvironmentVariable("NOTIFY_SMOKE_NOTIFY_TENANT_HEADER"); | ||||
| if (string.IsNullOrWhiteSpace(notifyTenantHeader)) | ||||
| { | ||||
|     notifyTenantHeader = "X-StellaOps-Tenant"; | ||||
| } | ||||
|  | ||||
| var notifyTimeoutSeconds = 30; | ||||
| var notifyTimeoutEnv = Environment.GetEnvironmentVariable("NOTIFY_SMOKE_NOTIFY_TIMEOUT_SECONDS"); | ||||
| if (!string.IsNullOrWhiteSpace(notifyTimeoutEnv) && int.TryParse(notifyTimeoutEnv, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedTimeout)) | ||||
| { | ||||
|     notifyTimeoutSeconds = Math.Max(5, parsedTimeout); | ||||
| } | ||||
|  | ||||
| using var httpClient = new HttpClient | ||||
| { | ||||
|     Timeout = TimeSpan.FromSeconds(notifyTimeoutSeconds), | ||||
| }; | ||||
|  | ||||
| httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", notifyToken); | ||||
| httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); | ||||
| httpClient.DefaultRequestHeaders.Add(notifyTenantHeader, notifyTenant); | ||||
|  | ||||
| var sinceQuery = Uri.EscapeDataString(sinceThreshold.ToString("O", CultureInfo.InvariantCulture)); | ||||
| var deliveriesUrl = $"{notifyBaseUrl}/api/v1/deliveries?since={sinceQuery}&limit=200"; | ||||
|  | ||||
| Console.WriteLine($"ℹ️  Querying Notify deliveries via {deliveriesUrl}."); | ||||
|  | ||||
| using var response = await httpClient.GetAsync(deliveriesUrl); | ||||
| if (!response.IsSuccessStatusCode) | ||||
| { | ||||
|     var body = await response.Content.ReadAsStringAsync(); | ||||
|     throw new InvalidOperationException($"Notify deliveries request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {body}"); | ||||
| } | ||||
|  | ||||
| var json = await response.Content.ReadAsStringAsync(); | ||||
| if (string.IsNullOrWhiteSpace(json)) | ||||
| { | ||||
|     throw new InvalidOperationException("Notify deliveries response body was empty."); | ||||
| } | ||||
|  | ||||
| using var document = JsonDocument.Parse(json); | ||||
| var root = document.RootElement; | ||||
|  | ||||
| IEnumerable<JsonElement> EnumerateDeliveries(JsonElement element) | ||||
| { | ||||
|     return element.ValueKind switch | ||||
|     { | ||||
|         JsonValueKind.Array => element.EnumerateArray(), | ||||
|         JsonValueKind.Object when element.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Array => items.EnumerateArray(), | ||||
|         _ => throw new InvalidOperationException("Notify deliveries response was not an array or did not contain an 'items' collection.") | ||||
|     }; | ||||
| } | ||||
|  | ||||
| var deliveries = EnumerateDeliveries(root).ToArray(); | ||||
| Ensure(deliveries.Length > 0, "Notify deliveries response did not return any records."); | ||||
|  | ||||
| var missingDeliveryKinds = new List<string>(); | ||||
| foreach (var kind in expectedKinds) | ||||
| { | ||||
|     var found = deliveries.Any(delivery => | ||||
|         delivery.TryGetProperty("kind", out var kindProperty) && | ||||
|         kindProperty.GetString()?.Equals(kind, StringComparison.OrdinalIgnoreCase) == true && | ||||
|         delivery.TryGetProperty("status", out var statusProperty) && | ||||
|         !string.Equals(statusProperty.GetString(), "failed", StringComparison.OrdinalIgnoreCase)); | ||||
|  | ||||
|     if (!found) | ||||
|     { | ||||
|         missingDeliveryKinds.Add(kind); | ||||
|     } | ||||
| } | ||||
|  | ||||
| Ensure(missingDeliveryKinds.Count == 0, $"Notify deliveries missing successful records for kinds: {string.Join(", ", missingDeliveryKinds)}"); | ||||
|  | ||||
| Console.WriteLine("✅ Notify deliveries include the expected scanner events."); | ||||
| Console.WriteLine("🎉 Notify smoke validation completed successfully."); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user