diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxHostHelpers.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxHostHelpers.cs
index 4167b30..0ba317e 100644
--- a/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxHostHelpers.cs
+++ b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxHostHelpers.cs
@@ -1,5 +1,6 @@
using System.Data.Common;
using ScadaLink.Commons.Interfaces.Services;
+using ScadaLink.Commons.Messages.Notification;
namespace ScadaLink.CentralUI.ScriptAnalysis;
@@ -80,39 +81,62 @@ public class SandboxDatabaseHelper
}
}
+///
+/// Sandbox mirror of ScadaLink.SiteRuntime.Scripts.NotifyHelper — the
+/// Notify global. Signature-faithful to production so the same user code
+/// (Notify.To(...).Send(...) / Notify.Status(...)) compiles
+/// identically against both surfaces.
+///
+/// In the Notification Outbox design production no longer delivers notification
+/// email inline — Notify.Send enqueues into the site Store-and-Forward
+/// Engine and returns a NotificationId. The sandbox has no S&F engine
+/// and no central, so it is a pure no-op fake: Send returns a generated
+/// fake id and Status returns a placeholder .
+/// Nothing is delivered.
+///
public class SandboxNotifyHelper
{
- private readonly INotificationDeliveryService? _service;
private readonly string _instanceName;
- public SandboxNotifyHelper(INotificationDeliveryService? service, string instanceName)
+ public SandboxNotifyHelper(string instanceName)
{
- _service = service;
_instanceName = instanceName;
}
+ /// Selects the notification list to send to.
public SandboxNotifyTarget To(string listName) =>
- new(listName, _service, _instanceName);
+ new(listName, _instanceName);
+
+ ///
+ /// Queries the delivery status of a previously-sent notification. The
+ /// sandbox never delivers, so this always reports the placeholder
+ /// Unknown status — it exists for signature fidelity with
+ /// NotifyHelper.Status.
+ ///
+ public Task Status(string notificationId) =>
+ Task.FromResult(new NotificationDeliveryStatus("Unknown", 0, null, null));
}
+///
+/// Sandbox mirror of ScadaLink.SiteRuntime.Scripts.NotifyTarget — the
+/// target of Notify.To("listName").
+///
public class SandboxNotifyTarget
{
private readonly string _listName;
- private readonly INotificationDeliveryService? _service;
private readonly string _instanceName;
- internal SandboxNotifyTarget(string listName, INotificationDeliveryService? service, string instanceName)
+ internal SandboxNotifyTarget(string listName, string instanceName)
{
_listName = listName;
- _service = service;
_instanceName = instanceName;
}
- public Task Send(string subject, string message, CancellationToken cancellationToken = default)
- {
- if (_service == null)
- throw new ScriptSandboxException(
- $"Notify.To(\"{_listName}\").Send(...) — notification service not configured for Test Run.");
- return _service.SendAsync(_listName, subject, message, _instanceName, cancellationToken);
- }
+ ///
+ /// Mirrors NotifyTarget.Send — returns a NotificationId. In
+ /// the sandbox nothing is enqueued or delivered; a fake id is returned so
+ /// the call type-checks identically to production.
+ ///
+ public Task Send(string subject, string message, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Guid.NewGuid().ToString("N"));
}
diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxScriptHost.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxScriptHost.cs
index e3cfc0b..d4027c4 100644
--- a/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxScriptHost.cs
+++ b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxScriptHost.cs
@@ -13,9 +13,9 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// instance. With no instance bound they throw ;
/// with one bound (see ) they route to it.
///
-/// ExternalSystem, Database, Notify, and
-/// Scripts.CallShared run against central's real services and fire for
-/// real — they do not depend on a bound instance.
+/// ExternalSystem, Database, and Scripts.CallShared run
+/// against central's real services and fire for real; Notify is a
+/// signature-faithful no-op fake. None of them depend on a bound instance.
///
public class SandboxScriptHost
{
@@ -58,8 +58,8 @@ public interface ISandboxInstanceGateway
/// the Instance global. Attribute and sibling-script access needs a real
/// deployed instance: with no gateway wired it throws; with one (a bound
/// instance) it routes cross-site. ExternalSystem/Database/
-/// Notify/Scripts run against central's real services regardless
-/// of binding.
+/// Scripts run against central's real services regardless of binding;
+/// Notify is a signature-faithful no-op fake.
///
public class SandboxInstanceContext
{
@@ -80,7 +80,7 @@ public class SandboxInstanceContext
_gateway = gateway;
ExternalSystem = external ?? new SandboxExternalHelper(null, "");
Database = database ?? new SandboxDatabaseHelper(null, "");
- Notify = notify ?? new SandboxNotifyHelper(null, "");
+ Notify = notify ?? new SandboxNotifyHelper("");
Scripts = scripts ?? new SandboxScriptCallHelper(null);
}
diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs
index 0606775..a046ec6 100644
--- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs
+++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs
@@ -154,11 +154,12 @@ public class ScriptAnalysisService
/// scripts against .
/// Pure logic + the supplied Parameters always work.
/// For the SandboxScriptHost surface, Attributes still throws while
- /// External, Database, and Notify are wired to
- /// central's real ,
- /// , and
- /// — calls fire for real and
- /// have production-equivalent side effects (HTTP, SQL, SMTP).
+ /// External and Database are wired to central's real
+ /// and —
+ /// calls fire for real and have production-equivalent side effects (HTTP,
+ /// SQL). Notify is a signature-faithful no-op fake (production
+ /// enqueues into the site Store-and-Forward Engine, which has no
+ /// central-side equivalent in the sandbox).
/// CallShared compiles and executes the named shared script in the
/// same sandbox, with a recursion limit of
/// . CallScript still throws
@@ -269,10 +270,13 @@ public class ScriptAnalysisService
var externalClient = _services.GetService();
var databaseGateway = _services.GetService();
- var notifyService = _services.GetService();
var external = new SandboxExternalHelper(externalClient, instanceLabel);
var database = new SandboxDatabaseHelper(databaseGateway, instanceLabel);
- var notify = new SandboxNotifyHelper(notifyService, instanceLabel);
+ // The Notification Outbox sandbox Notify is a pure no-op fake — it
+ // mirrors production signatures so scripts compile identically, but it
+ // does not deliver (production now enqueues into the site S&F engine,
+ // which has no central-side equivalent here).
+ var notify = new SandboxNotifyHelper(instanceLabel);
var compileCache = new Dictionary>(StringComparer.Ordinal);
var compileCacheLock = new object();
diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs
index d5652ea..718e1b6 100644
--- a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs
+++ b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs
@@ -40,3 +40,18 @@ public record NotificationStatusResponse(
int RetryCount,
string? LastError,
DateTimeOffset? DeliveredAt);
+
+///
+/// Notification Outbox: the delivery status of a notification, as returned to a
+/// script by Notify.Status(id).
+///
+/// is either a central status (Pending, Retrying,
+/// Delivered, Parked, Discarded), the site-local Forwarding
+/// state (the notification is still buffered at the site and has not yet been
+/// forwarded/acked), or Unknown (no central row and not buffered locally).
+///
+public record NotificationDeliveryStatus(
+ string Status,
+ int RetryCount,
+ string? LastError,
+ DateTimeOffset? DeliveredAt);
diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
index 1b6b593..273e59b 100644
--- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
+++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
@@ -529,18 +529,3 @@ public class ScriptRuntimeContext
}
}
}
-
-///
-/// Notification Outbox: the delivery status of a notification, as returned to a
-/// script by Notify.Status(id).
-///
-/// is either a central status (Pending, Retrying,
-/// Delivered, Parked, Discarded), the site-local Forwarding
-/// state (the notification is still buffered at the site and has not yet been
-/// forwarded/acked), or Unknown (no central row and not buffered locally).
-///
-public record NotificationDeliveryStatus(
- string Status,
- int RetryCount,
- string? LastError,
- DateTimeOffset? DeliveredAt);
diff --git a/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
index ecca4b1..0c75bb0 100644
--- a/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
@@ -466,6 +466,42 @@ public class ScriptAnalysisServiceTests
Assert.Equal("42", result.ReturnValueJson);
}
+ [Fact]
+ public void NotifyOutboxShape_DiagnosesClean()
+ {
+ // Notification Outbox: the sandbox Notify surface must be
+ // signature-faithful to production NotifyHelper/NotifyTarget —
+ // Send returns Task (a NotificationId) and Status takes that
+ // id. A script using the new shape must compile clean in the sandbox,
+ // exactly as it would against the real site runtime.
+ var resp = _svc.Diagnose(new DiagnoseRequest(
+ "var id = await Notify.To(\"ops\").Send(\"subj\", \"body\"); " +
+ "var st = await Notify.Status(id); " +
+ "return st.Status;"));
+
+ Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
+ Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
+ }
+
+ [Fact]
+ public async Task RunInSandbox_NotifyOutboxShape_StillRuns()
+ {
+ // The new Notify shape must also run end-to-end in the no-op sandbox:
+ // Send yields a fake NotificationId, Status yields a placeholder
+ // NotificationDeliveryStatus.
+ var result = await _svc.RunInSandboxAsync(
+ new SandboxRunRequest(
+ "var id = await Notify.To(\"ops\").Send(\"subj\", \"body\"); " +
+ "var st = await Notify.Status(id); " +
+ "return st.Status;",
+ Parameters: null,
+ TimeoutSeconds: null),
+ CancellationToken.None);
+
+ Assert.True(result.Success);
+ Assert.Equal("\"Unknown\"", result.ReturnValueJson);
+ }
+
[Fact]
public async Task RunInSandbox_CapturesConsoleOutput()
{