using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.InboundAPI; using ScadaLink.NotificationService; namespace ScadaLink.IntegrationTests; /// /// WP-14: End-to-end integration tests for Phase 7 integration surfaces. /// public class IntegrationSurfaceTests { // ── Inbound API: auth + routing + parameter validation + error codes ── [Fact] public async Task InboundAPI_ApiKeyValidator_FullFlow_EndToEnd() { // Validates that ApiKeyValidator correctly chains all checks. var repository = Substitute.For(); var key = new ApiKey("test-key", "key-value-123") { Id = 1, IsEnabled = true }; var method = new ApiMethod("getStatus", "return 1;") { Id = 10, ParameterDefinitions = "[{\"Name\":\"deviceId\",\"Type\":\"String\",\"Required\":true}]", TimeoutSeconds = 30 }; repository.GetApiKeyByValueAsync("key-value-123").Returns(key); repository.GetMethodByNameAsync("getStatus").Returns(method); repository.GetApprovedKeysForMethodAsync(10).Returns(new List { key }); var validator = new ApiKeyValidator(repository); // Valid key + approved method var result = await validator.ValidateAsync("key-value-123", "getStatus"); Assert.True(result.IsValid); Assert.Equal(method, result.Method); // Then validate parameters using var doc = JsonDocument.Parse("{\"deviceId\": \"pump-01\"}"); var paramResult = ParameterValidator.Validate(doc.RootElement.Clone(), method.ParameterDefinitions); Assert.True(paramResult.IsValid); Assert.Equal("pump-01", paramResult.Parameters["deviceId"]); } [Fact] public void InboundAPI_ParameterValidation_ExtendedTypes() { // Validates the full extended type system: Boolean, Integer, Float, String, Object, List. var definitions = JsonSerializer.Serialize(new[] { new { Name = "flag", Type = "Boolean", Required = true }, new { Name = "count", Type = "Integer", Required = true }, new { Name = "ratio", Type = "Float", Required = true }, new { Name = "name", Type = "String", Required = true }, new { Name = "config", Type = "Object", Required = true }, new { Name = "tags", Type = "List", Required = true } }); var json = "{\"flag\":true,\"count\":42,\"ratio\":3.14,\"name\":\"test\",\"config\":{\"k\":\"v\"},\"tags\":[1,2]}"; using var doc = JsonDocument.Parse(json); var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions); Assert.True(result.IsValid); Assert.Equal(true, result.Parameters["flag"]); Assert.Equal((long)42, result.Parameters["count"]); Assert.Equal(3.14, result.Parameters["ratio"]); Assert.Equal("test", result.Parameters["name"]); Assert.NotNull(result.Parameters["config"]); Assert.NotNull(result.Parameters["tags"]); } // ── External System: error classification ── [Fact] public void ExternalSystem_ErrorClassification_TransientVsPermanent() { // WP-8: Verify the full classification spectrum Assert.True(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.InternalServerError)); Assert.True(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.ServiceUnavailable)); Assert.True(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.RequestTimeout)); Assert.True(ExternalSystemGateway.ErrorClassifier.IsTransient((HttpStatusCode)429)); Assert.False(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.BadRequest)); Assert.False(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.Unauthorized)); Assert.False(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.Forbidden)); Assert.False(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.NotFound)); } // ── Notification: mock SMTP delivery ── [Fact] public async Task Notification_Send_MockSmtp_Delivers() { var repository = Substitute.For(); var smtpClient = Substitute.For(); var list = new NotificationList("alerts") { Id = 1 }; var recipients = new List { new("Admin", "admin@example.com") { Id = 1, NotificationListId = 1 } }; var smtpConfig = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass" }; repository.GetListByNameAsync("alerts").Returns(list); repository.GetRecipientsByListIdAsync(1).Returns(recipients); repository.GetAllSmtpConfigurationsAsync().Returns(new List { smtpConfig }); var service = new NotificationDeliveryService( repository, () => smtpClient, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var result = await service.SendAsync("alerts", "Test Alert", "Something happened"); Assert.True(result.Success); await smtpClient.Received(1).SendAsync( "noreply@example.com", Arg.Is>(r => r.Contains("admin@example.com")), "Test Alert", "Something happened", Arg.Any()); } // ── Script Context: integration API wiring ── [Fact] public async Task ScriptContext_ExternalSystem_Call_Wired() { // Verify that ExternalSystem.Call is accessible from ScriptRuntimeContext var mockClient = Substitute.For(); mockClient.CallAsync("api", "getData", null, Arg.Any()) .Returns(new ExternalCallResult(true, "{\"value\":1}", null)); var context = CreateMinimalScriptContext(externalSystemClient: mockClient); var result = await context.ExternalSystem.Call("api", "getData"); Assert.True(result.Success); Assert.Equal("{\"value\":1}", result.ResponseJson); } [Fact] public async Task ScriptContext_Notify_Send_Wired() { var mockNotify = Substitute.For(); mockNotify.SendAsync("ops", "Alert", "Body", Arg.Any(), Arg.Any()) .Returns(new NotificationResult(true, null)); var context = CreateMinimalScriptContext(notificationService: mockNotify); var result = await context.Notify.To("ops").Send("Alert", "Body"); Assert.True(result.Success); } [Fact] public async Task ScriptContext_ExternalSystem_NoClient_Throws() { var context = CreateMinimalScriptContext(); await Assert.ThrowsAsync( () => context.ExternalSystem.Call("api", "method")); } [Fact] public async Task ScriptContext_Database_NoGateway_Throws() { var context = CreateMinimalScriptContext(); await Assert.ThrowsAsync( () => context.Database.Connection("db")); } [Fact] public async Task ScriptContext_Notify_NoService_Throws() { var context = CreateMinimalScriptContext(); await Assert.ThrowsAsync( () => context.Notify.To("list").Send("subj", "body")); } private static SiteRuntime.Scripts.ScriptRuntimeContext CreateMinimalScriptContext( IExternalSystemClient? externalSystemClient = null, IDatabaseGateway? databaseGateway = null, INotificationDeliveryService? notificationService = null) { // Create a minimal context — we use Substitute.For which is fine since // we won't exercise Akka functionality in these tests. var actorRef = Substitute.For(); var compilationService = new SiteRuntime.Scripts.ScriptCompilationService( Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var sharedLibrary = new SiteRuntime.Scripts.SharedScriptLibrary( compilationService, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); return new SiteRuntime.Scripts.ScriptRuntimeContext( actorRef, actorRef, sharedLibrary, currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5), instanceName: "test-instance", logger: Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, externalSystemClient: externalSystemClient, databaseGateway: databaseGateway, notificationService: notificationService); } }