From 513fa1aa16099a9a53e1018de90cf3ee9e109a02 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 15:05:49 -0400 Subject: [PATCH] =?UTF-8?q?docs(tests):=20fix=20CommentChecker=20findings?= =?UTF-8?q?=20across=20tests/=20=E2=80=94=2013,392=20=E2=86=92=200=20(repo?= =?UTF-8?q?-wide=20scan=20clean)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ on theories, on async Task methods, on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings. --- .../Services/AuditLogExportService.cs | 4 +- .../Actors/SiteAlarmAggregatorActor.cs | 2 +- .../SiteHealthState.cs | 2 +- .../ManagementActor.cs | 2 +- .../AddAuditLogTests.cs | 44 ++- ...ditLogIngestActorCombinedTelemetryTests.cs | 71 +++- .../Central/AuditLogIngestActorTests.cs | 53 ++- ...rtitionMaintenanceOptionsValidatorTests.cs | 3 + ...uditLogPartitionMaintenanceServiceTests.cs | 27 +- .../Central/AuditLogPurgeActorTests.cs | 52 ++- .../Central/AuditLogPurgeOptionsTests.cs | 6 +- .../AuditLogPurgeOptionsValidatorTests.cs | 8 + ...entralAuditRedactionFailureCounterTests.cs | 12 +- .../Central/CentralAuditWriteFailuresTests.cs | 26 +- .../Central/CentralAuditWriterTests.cs | 30 +- .../Central/GrpcPullAuditEventsClientTests.cs | 42 ++- .../Central/GrpcPullSiteCallsClientTests.cs | 42 ++- .../SiteAuditReconciliationActorTests.cs | 90 ++++- ...uditReconciliationOptionsValidatorTests.cs | 7 + .../SiteAuditTelemetryStalledTrackerTests.cs | 8 +- .../Central/SiteEnumeratorTests.cs | 14 +- .../AuditLogOptionsBindingTests.cs | 31 +- .../Configuration/AuditLogOptionsTests.cs | 14 +- .../AuditLogOptionsValidatorTests.cs | 15 +- .../AuditWriteFailureSafetyTests.cs | 44 ++- .../CachedCallCombinedTelemetryTests.cs | 12 +- .../CachedWriteCombinedTelemetryTests.cs | 6 + .../CombinedTelemetryIdempotencyTests.cs | 6 + .../DatabaseSyncEmissionEndToEndTests.cs | 10 +- .../ExecutionIdCorrelationTests.cs | 10 +- .../Integration/InboundApiAuditTests.cs | 17 +- .../CombinedTelemetryDispatcher.cs | 16 +- .../CombinedTelemetryHarness.cs | 26 +- .../DirectActorSiteStreamAuditClient.cs | 28 +- .../NotifyDispatcherAuditTrailTests.cs | 16 +- .../Integration/OutageReconciliationTests.cs | 23 +- .../ParentExecutionIdCorrelationTests.cs | 21 +- .../Integration/PartitionMaintenanceTests.cs | 10 +- .../Integration/PartitionPurgeTests.cs | 48 +-- .../SyncCallEmissionEndToEndTests.cs | 12 +- .../Kpi/AuditLogKpiSampleSourceTests.cs | 15 +- .../Redaction/AuditRedactorFailClosedTests.cs | 29 +- .../SafeDefaultAuditRedactorTests.cs | 8 +- .../ScadaBridgeAuditRedactorTests.cs | 50 ++- .../Site/FallbackAuditWriterTests.cs | 13 +- ...etricsAuditRedactionFailureCounterTests.cs | 7 +- ...lthMetricsAuditWriteFailureCounterTests.cs | 5 +- .../Site/RingBufferFallbackTests.cs | 7 +- .../SiteAuditBacklogReporterCadenceTests.cs | 29 +- .../Site/SiteAuditRetentionOptionsTests.cs | 7 +- ...SiteAuditRetentionOptionsValidatorTests.cs | 4 + .../Site/SiteAuditRetentionServiceTests.cs | 45 ++- ...SiteAuditTelemetryOptionsValidatorTests.cs | 5 + .../SqliteAuditWriterBacklogStatsTests.cs | 16 +- .../SqliteAuditWriterCommitFailureTests.cs | 6 +- .../SqliteAuditWriterOptionsValidatorTests.cs | 9 +- .../SqliteAuditWriterReadByExecutionTests.cs | 13 + .../Site/SqliteAuditWriterRetentionTests.cs | 6 + .../Site/SqliteAuditWriterSchemaTests.cs | 14 +- .../Site/SqliteAuditWriterWriteTests.cs | 92 ++++- .../CachedCallLifecycleBridgeTests.cs | 46 ++- .../CachedCallTelemetryForwarderTests.cs | 26 +- .../NoOpSiteStreamAuditClientTests.cs | 14 +- .../Telemetry/SiteAuditTelemetryActorTests.cs | 36 +- .../SiteCommunicationAuditClientTests.cs | 14 +- .../TestSupport/FakeNodeIdentityProvider.cs | 4 + .../StubAuditRepositoryProvider.cs | 1 + .../AuditConfigTableProjectionTests.cs | 9 + .../CliConfigTests.cs | 5 +- .../CommandHelpersTests.cs | 9 + .../CommandTreeTests.cs | 52 ++- .../Commands/AuditBackfillCommandTests.cs | 28 +- .../Commands/AuditCommandTestHarness.cs | 5 + .../Commands/AuditCommandsScaffoldTests.cs | 8 +- .../Commands/AuditConfigDeprecationTests.cs | 7 +- .../Commands/AuditExportCommandTests.cs | 83 ++++- .../Commands/AuditQueryCommandTests.cs | 59 +++- .../Commands/AuditTableFormatterTests.cs | 7 +- .../Commands/AuditTreeCommandTests.cs | 31 +- .../Commands/AuditVerifyChainCommandTests.cs | 8 +- .../Commands/BundleCommandsStreamingTests.cs | 31 +- .../Commands/CachedCallCommandsTests.cs | 10 + .../ExternalSystemRetryOptionTests.cs | 5 + .../ExternalSystemTimeoutOptionTests.cs | 7 +- .../Commands/NotificationSmsCommandTests.cs | 18 + .../Commands/SecuredWriteListCommandTests.cs | 5 +- .../Commands/SmtpUpdateCommandTests.cs | 11 + .../Commands/TemplateAttributeListTests.cs | 16 +- .../Commands/TemplateScriptTimingTests.cs | 18 +- .../TemplateTriggerKindIgnoredTests.cs | 7 +- .../Commands/TemplateTriggerKindTests.cs | 17 +- .../ConnectionBrowseCommandsTests.cs | 14 + .../CredentialResolutionTests.cs | 5 +- .../DebugStreamTests.cs | 18 +- .../DeployArtifactsExitCodeTests.cs | 5 + .../DeploySiteExitCodeTests.cs | 4 + .../DeploymentDeadlineParityTests.cs | 2 + .../DeploymentStatusTableProjectionTests.cs | 6 + .../EditingParityTests.cs | 7 + .../ExitCodeTests.cs | 9 +- .../FlowDebugCommandsTests.cs | 32 +- .../FormatOptionValidationTests.cs | 6 +- .../FormatResolutionTests.cs | 5 +- .../ImportNativeAlarmSourceOverridesTests.cs | 3 + .../ImportOverridesTests.cs | 5 + .../InstanceArgumentParsingTests.cs | 24 +- .../ManagementHttpClientTests.cs | 26 +- .../ManagementSurfaceParityTests.cs | 4 + .../NotificationParityTests.cs | 18 + .../OutputFormatterTests.cs | 5 + .../ProductionRootTests.cs | 1 + .../ResponseRenderingTests.cs | 9 +- .../SchemaSecuredParityTests.cs | 43 +++ .../TableHeaderUnionTests.cs | 5 +- .../TemplateTableProjectionTests.cs | 6 +- .../UpdateCommandContractTests.cs | 13 +- .../UrlValidationTests.cs | 6 +- .../Admin/ApiKeyCrudTests.cs | 8 + .../Admin/ApiSurfaceFixture.cs | 7 + .../Admin/LdapMappingCrudTests.cs | 14 +- .../Audit/AuditConfigurationTests.cs | 24 +- .../Audit/AuditDataSeeder.cs | 26 +- .../Audit/AuditGridColumnTests.cs | 12 +- .../Audit/AuditLogPageTests.cs | 40 ++- .../Audit/ConfigAuditDataSeeder.cs | 4 + .../Cluster/CliRunner.Helpers.cs | 61 ++++ .../Cluster/CliRunner.cs | 4 + .../Cluster/CliRunnerHelpersTests.cs | 10 + .../Cluster/CliRunnerSmokeTests.cs | 1 + .../Cluster/ClusterAvailability.cs | 5 +- .../Cluster/SkipSummaryReporter.cs | 3 +- .../Deployment/DebugViewTests.cs | 9 +- .../Deployment/DebugViewTreeTests.cs | 5 + .../Deployment/DeploymentActionTests.cs | 11 + .../Deployment/DeploymentFixture.cs | 2 + .../Deployment/DeploymentsRealtimeTests.cs | 7 + .../Deployment/FlowDebuggerFixture.cs | 6 +- .../Deployment/FlowDebuggerTests.cs | 17 +- .../Deployment/InstanceConfigureFixture.cs | 10 + .../Deployment/InstanceConfigureTests.cs | 15 + .../Deployment/TopologyAreaTests.cs | 15 + .../Design/ApiMethodFormTests.cs | 6 +- .../Design/DataConnectionCrudTests.cs | 4 + .../Design/ExternalSystemCrudTests.cs | 6 +- .../Design/M9SurfaceTests.cs | 7 +- .../Design/SharedScriptCrudTests.cs | 4 + .../Design/TemplateCrudTests.cs | 21 ++ .../LoginTests.cs | 12 + .../LowReviewSmokeTests.cs | 2 + .../Monitoring/EventLogsTests.cs | 4 + .../Monitoring/HealthDashboardTests.cs | 7 +- .../Monitoring/KpiTrendChartTests.cs | 5 +- .../Monitoring/ParkedMessagesActionTests.cs | 5 + .../Monitoring/ParkedMessagesTests.cs | 4 + .../NavCollapseTests.cs | 10 + .../NavigationTests.cs | 24 ++ .../Notifications/NotificationActionTests.cs | 14 +- .../Notifications/NotificationDataSeeder.cs | 10 +- .../Notifications/NotificationKpisTests.cs | 4 + .../NotificationListCrudTests.cs | 4 + .../Notifications/SmsNotificationE2ETests.cs | 5 + .../Notifications/SmtpConfigTests.cs | 5 + .../PendingUpdatesTests.cs | 9 + .../PlaywrightFixture.cs | 18 + .../RoleNavigationTests.cs | 31 ++ .../SandboxWorkerDeploymentTests.cs | 2 + .../SessionActivityTrustedInputTests.cs | 2 + .../SiteCalls/SiteCallDataSeeder.cs | 20 +- .../SiteCalls/SiteCallsPageTests.cs | 29 +- .../SiteCrudTests.cs | 17 + .../Transport/TransportExportTests.cs | 12 +- .../Transport/TransportImportTests.cs | 19 +- .../Admin/ApiKeyFormAuditDrillinTests.cs | 9 +- .../Admin/ApiKeysListPageTests.cs | 4 + .../Admin/SiteFormAuditDrillinTests.cs | 5 +- .../Admin/SitesPageTests.cs | 14 +- .../Audit/AuditExportEndpointsTests.cs | 28 +- .../Audit/FlowDebuggerDrillinTests.cs | 7 +- .../Auth/AuthEndpointsCsrfTests.cs | 8 +- .../Auth/AuthPingEndpointTests.cs | 8 +- .../Auth/CircuitRevocationRegressionTests.cs | 4 + .../Auth/ClaimsPrincipalExtensionsTests.cs | 12 +- .../CookieAuthenticationStateProviderTests.cs | 8 +- .../ParkedMessagesMutationRegressionTests.cs | 7 + .../Auth/RoutePolicyCoverageTests.cs | 1 + .../Auth/SessionExpiryComponentTests.cs | 12 +- .../Auth/SessionExpiryPolicyTests.cs | 5 +- .../Auth/SiteScopeServiceTests.cs | 40 ++- .../Auth/UiAuditRegressionTests.cs | 4 + .../ComponentRenderingTests.cs | 5 + .../Components/AlarmStateBadgesTests.cs | 3 +- .../Audit/AuditDrilldownDrawerTests.cs | 24 +- .../Components/Audit/AuditEventDetailTests.cs | 25 +- .../Components/Audit/AuditFilterBarTests.cs | 23 +- .../Components/Audit/AuditResultsGridTests.cs | 26 +- .../Audit/ExecutionDetailModalTests.cs | 16 +- .../Components/Audit/ExecutionTreeTests.cs | 12 +- .../Components/ConnectionCertificatesTests.cs | 7 +- .../Components/Health/AuditKpiTilesTests.cs | 11 +- .../Health/SiteCallKpiTilesTests.cs | 12 +- .../InstanceConfigureCsvImportTests.cs | 13 +- ...tanceConfigureNativeAlarmCsvImportTests.cs | 9 + .../NodeBrowserDialogSearchTests.cs | 7 +- .../NodeBrowserDialogSelectionTests.cs | 6 + .../Components/OpcUaEndpointVerifyTests.cs | 10 +- .../SecuredWriteDataTypeMapperTests.cs | 5 + .../Components/SecuredWritesTests.cs | 18 +- .../Components/Shared/KpiTrendChartTests.cs | 12 +- .../ConfigurationMutationTestExtensions.cs | 4 + .../DataConnectionFormTests.cs | 8 + .../DataConnectionsPageTests.cs | 27 +- .../Deployment/DebugTreeBuilderTests.cs | 40 ++- .../Deployment/DebugViewAlarmTableTests.cs | 8 +- .../DebugViewConnectDisposeRaceTests.cs | 19 +- .../Deployment/DebugViewDisposalTests.cs | 7 +- .../DebugViewInstancePickerTests.cs | 8 + .../DebugViewRenderCoalescingTests.cs | 9 +- .../Deployment/DebugViewStreamRaceTests.cs | 10 +- .../Deployment/DeploymentsPushUpdateTests.cs | 11 +- .../DeploymentsReloadDebounceTests.cs | 8 +- .../DeploymentsRequestGenerationTests.cs | 26 +- .../DeploymentsServerPagingTests.cs | 17 + .../Deployment/FlowDebuggerPageTests.cs | 66 +++- .../Deployment/FlowStepDetailTests.cs | 35 +- .../InstanceConfigureAuditDrillinTests.cs | 5 +- .../InstanceConfigureBindingBehaviourTests.cs | 5 + .../InstanceConfigureBindingGroupsTests.cs | 6 + .../InstanceConfigureBindingRowsTests.cs | 5 + .../InstanceConfigureListOverrideTests.cs | 8 +- .../InstanceConfigureMesReportableTests.cs | 13 + .../InstanceConfigureNativeAlarmTests.cs | 5 +- .../InstanceCreateTemplateFieldTests.cs | 8 + .../TopologyDisableConfirmLabelTests.cs | 5 + .../Design/AttributeListEditorTests.cs | 11 +- .../ExternalSystemFormAuditDrillinTests.cs | 10 +- ...ernalSystemsConnectionStringHidingTests.cs | 2 + .../ParameterValueFormMonacoSchemaTests.cs | 8 +- .../Design/ParameterValueFormTests.cs | 15 +- .../Design/SchemaLibraryPageTests.cs | 23 +- .../Design/SchemaLibraryQueryServiceTests.cs | 12 +- .../Design/ScriptFormSandboxDisposalTests.cs | 15 +- .../TemplateAlarmDialogMesReportableTests.cs | 4 + .../Design/TemplateEditInheritedSetTests.cs | 7 +- .../TemplateNativeAlarmSourceEditorTests.cs | 10 +- .../Design/TestRunWarningTests.cs | 7 +- .../Forms/OpcUaEndpointEditorTests.cs | 17 + .../Layout/NavMenuTests.cs | 13 +- .../Layout/PendingUpdatesIndicatorTests.cs | 26 ++ .../Monitoring/AlarmSummaryRenderTests.cs | 35 +- .../Monitoring/AlarmSummaryVirtualizeTests.cs | 9 +- .../Monitoring/BrowserTimeTests.cs | 9 +- .../Monitoring/FailoverConfirmLabelTests.cs | 8 +- .../Monitoring/HealthFailoverButtonTests.cs | 21 +- .../MonitoringAuthorizationTests.cs | 5 +- .../Pages/AuditLogPageExportUrlTests.cs | 12 +- .../Pages/AuditLogPagePermissionTests.cs | 30 +- .../Pages/AuditLogPageScaffoldTests.cs | 29 +- .../Pages/AuditLogPageTrendTests.cs | 8 +- .../Pages/Design/TransportExportPageTests.cs | 30 +- .../Pages/Design/TransportImportPageTests.cs | 64 +++- .../TransportImportSessionDisposeRaceTests.cs | 15 +- .../Pages/ExecutionTreePageTests.cs | 8 +- .../Pages/HealthPageTests.cs | 56 ++- .../Pages/NotificationKpisPageTests.cs | 26 +- .../Pages/NotificationListFormTests.cs | 9 + .../Pages/NotificationListsPageTests.cs | 17 +- .../NotificationReportDetailModalTests.cs | 21 ++ .../Pages/NotificationReportPageTests.cs | 29 +- .../Pages/PageScopedDetailStateTests.cs | 8 + .../Pages/QueryStringDrillInTests.cs | 35 +- .../Pages/SiteCallsReportPageTests.cs | 49 ++- .../Pages/SmsConfigurationPageTests.cs | 8 +- .../Pages/SmtpConfigurationPageTests.cs | 10 + .../PollGateTests.cs | 3 + .../BoundedSandboxOutputTests.cs | 4 + .../JsonSchemaShapeParserTests.cs | 10 + .../SandboxConsoleCaptureTests.cs | 7 +- .../SandboxWaitForAttributeTests.cs | 12 +- .../SandboxWorkerIsolationTests.cs | 6 + .../ScriptAnalysisAsyncResolveTests.cs | 8 +- .../ScriptAnalysisServiceTests.cs | 99 +++++- .../Services/AlarmSummaryServiceTests.cs | 12 +- .../ApiMethodKeyScopeReconcilerTests.cs | 6 + .../Services/AuditLogExportServiceTests.cs | 28 +- .../Services/AuditLogQueryServiceTests.cs | 34 +- .../Services/AuditTreeScopeTests.cs | 4 + .../Services/AuthorizedManualFailoverTests.cs | 3 + .../Services/BindingGroupBuilderTests.cs | 17 + .../ConnectionHealthQueryServiceTests.cs | 14 +- .../Services/CreateBindingPlannerTests.cs | 8 + .../Services/FlowDebugServiceTests.cs | 84 +++++ .../Services/KpiHistoryQueryServiceTests.cs | 38 ++- .../Services/PendingUpdatesCacheTests.cs | 12 + .../SharedAlarmSummaryServiceTests.cs | 25 +- .../Services/SingleFlightMemoTests.cs | 18 +- .../Services/TemplatePathFormatterTests.cs | 4 + .../Shared/AccessibilityTests.cs | 9 +- .../Shared/AlarmTriggerConfigCodecTests.cs | 36 ++ .../Shared/DarkModeToggleTests.cs | 5 + .../Shared/DataTablePagerTests.cs | 5 +- .../Shared/DateTimeRangeFilterTests.cs | 5 + .../Shared/DialogHostShowAsyncTests.cs | 13 + .../Shared/DialogServiceThreadingTests.cs | 15 +- .../Shared/DialogSeverityStylingTests.cs | 13 +- .../Shared/DiffDialogTests.cs | 8 +- .../Shared/DurationInputTests.cs | 13 + .../Shared/FlowGraphViewTests.cs | 46 +++ .../Shared/InstancePickerTests.cs | 31 ++ .../Shared/InstanceTreeBuilderTests.cs | 12 + .../Shared/JsInteropLoggingTests.cs | 32 +- .../Shared/KeysetPagerTests.cs | 6 + .../Shared/MonacoEditorLoggingTests.cs | 25 +- .../Shared/MoveDataConnectionDialogTests.cs | 8 +- .../Shared/OffsetPagerTests.cs | 11 + .../Shared/PagerWindowTests.cs | 9 +- .../Shared/SchemaBuilderModelTests.cs | 11 + .../Shared/ScriptTriggerConfigCodecTests.cs | 15 + .../Shared/ScriptTriggerEditorTests.cs | 3 + .../TemplateFolderTreeRevealKeysTests.cs | 11 + .../Shared/TemplatePickerDialogTests.cs | 15 + .../Shared/TimestampDisplayTests.cs | 9 +- .../Shared/ToastNotificationTests.cs | 8 +- .../Shared/TreeViewKeyboardNavigationTests.cs | 65 +++- .../Shared/TreeViewStorageResilienceTests.cs | 4 +- .../Shared/TriggerAnalysisKindTests.cs | 32 +- .../TemplatesPageTests.cs | 22 +- ...opologyLiveUpdatesTimerDisposeRaceTests.cs | 13 +- .../TopologyPageTests.cs | 40 ++- .../TreeViewMultiSelectTests.cs | 6 +- .../TreeViewTests.cs | 59 +++- .../UnitTest1.cs | 8 + .../ClusterBootstrapGuardTests.cs | 19 +- .../ClusterOptionsTests.cs | 8 +- .../ClusterOptionsValidatorTests.cs | 25 +- .../ServiceCollectionExtensionsTests.cs | 8 +- .../ArchitecturalConstraintTests.cs | 9 + .../Audit/ScriptTraceEnumTests.cs | 3 + .../Debugging/ExceptionHeaderTests.cs | 10 +- .../Debugging/FlowEnumContractTests.cs | 2 + .../Debugging/FlowGraphBuilderLogLineTests.cs | 12 + .../Debugging/FlowGraphBuilderTests.cs | 36 +- .../FlowStepProjectorLogLineTests.cs | 12 +- .../Debugging/FlowStepProjectorTests.cs | 20 +- .../Debugging/FlowTraceCollectorTests.cs | 9 + .../DeploymentFetchTokenTests.cs | 5 + .../Entities/Audit/SiteCallTests.cs | 1 + .../Entities/EntityConventionTests.cs | 3 + .../Entities/ExternalSystemDefinitionTests.cs | 1 + .../Entities/NativeAlarmSourceEntityTests.cs | 3 + .../Entities/NotificationEntityTests.cs | 7 +- .../Entities/NotificationRecipientTests.cs | 11 + .../Entities/TemplateFolderTests.cs | 3 + .../Protocol/AlarmCapableProtocolsTests.cs | 6 +- .../Services/AuditWriterContractTests.cs | 5 +- .../Services/ExternalCallResultTests.cs | 6 +- .../Kpi/KpiMetricAggregationTests.cs | 26 +- .../Kpi/KpiSampleTests.cs | 4 + .../Kpi/KpiSeriesBucketerTests.cs | 27 +- .../AlarmStateChangedEnrichmentTests.cs | 4 + .../Messages/BrowseCommandsRegistryTests.cs | 1 + .../BrowseVerifyCertSiteIdentifierTests.cs | 14 +- .../ClusterClientContractLockTests.cs | 9 + .../Messages/CompatibilityTests.cs | 29 +- .../ConnectionBindingSerializationTests.cs | 6 +- .../AuditTelemetryMessagesTests.cs | 9 +- .../Integration/CachedCallTelemetryTests.cs | 13 +- .../ManagementCommandRegistryTests.cs | 15 +- .../Messages/MessageConventionTests.cs | 7 + .../Messages/NativeAlarmMessagesTests.cs | 3 + .../Messages/NotificationMessagesTests.cs | 28 +- .../ReadTagValuesCommandRegistryTests.cs | 3 + .../Messages/SiteCallQueriesTests.cs | 10 +- .../Messages/TraceFlagDefaultsTests.cs | 2 + ...NativeAlarmSourceOverrideCsvParserTests.cs | 9 + .../Observability/DataConnectionGaugeTests.cs | 8 + .../OverrideCsvParserTests.cs | 17 + .../Scripts/ScriptTriggerAttributeTests.cs | 10 + .../Types/Alarms/AlarmConditionStateTests.cs | 2 + .../Types/AttributeValueCodecTests.cs | 43 ++- .../Types/Audit/AuditDetailsCodecTests.cs | 7 + .../Types/Audit/AuditFieldBuildersTests.cs | 7 + .../Types/Audit/AuditOutcomeProjectorTests.cs | 14 + .../ScadaBridgeAuditEventFactoryActorTests.cs | 4 + .../Types/AuditQueryParamParsersTests.cs | 28 +- .../MxGatewayEndpointConfigSerializerTests.cs | 5 + .../OpcUaEndpointConfigSerializerTests.cs | 39 ++- .../OpcUaReferenceFormTests.cs | 6 + .../Types/DynamicJsonElementTests.cs | 27 +- .../Types/EnumTests.cs | 5 + .../Types/Enums/AuditEnumTests.cs | 6 +- .../Types/FlatteningAndScriptScopeTests.cs | 6 +- .../Types/HandshakeResultTests.cs | 3 +- .../InboundApi/InboundApiSchemaRefTests.cs | 17 +- .../Types/PurgeTimerScheduleTests.cs | 4 + .../Types/ResultTests.cs | 12 +- .../Types/RetryPolicyTests.cs | 3 + .../Types/ScriptArgsTests.cs | 11 +- .../Types/ScriptParametersTests.cs | 52 ++- .../Secrets/SecretReferenceScannerTests.cs | 12 +- .../Types/SiteCallOperationalTests.cs | 3 +- .../Types/SiteNotificationKpiSnapshotTests.cs | 2 + .../Types/StaleTagMonitorRaceTests.cs | 2 +- .../Types/StaleTagMonitorTests.cs | 17 + .../Types/TrackedOperationIdTests.cs | 8 +- .../Types/Transport/BundleNameMapTests.cs | 11 +- .../Transport/EncryptionMetadataTests.cs | 14 +- .../Types/Transport/TransportRecordsTests.cs | 19 +- .../Types/ValidationResultSummaryTests.cs | 10 +- .../Types/ValueFormatterTests.cs | 10 +- .../MxGatewayEndpointConfigValidatorTests.cs | 7 + .../OpcUaEndpointConfigValidatorTests.cs | 19 ++ .../ActiveNodeEvaluatorTests.cs | 6 + .../SiteCommunicationActorTransportTests.cs | 8 + .../AuditEventDtoMapperTests.cs | 13 +- ...lCommunicationActorClientLifecycleTests.cs | 3 +- ...CentralCommunicationActorReconcileTests.cs | 1 + .../CentralCommunicationActorTests.cs | 12 +- ...CentralCommunicationActorTransportTests.cs | 9 +- .../CommunicationOptionsBindingTests.cs | 1 + .../CommunicationOptionsTests.cs | 9 +- .../CommunicationOptionsValidatorTests.cs | 28 +- .../CommunicationServiceTests.cs | 50 ++- .../CoordinatorSupervisionTests.cs | 15 +- .../DebugStreamServiceTests.cs | 14 +- .../Grpc/AuditWireBudgetTests.cs | 2 + .../Grpc/CentralControlDtoMapperTests.cs | 36 +- ...ntralControlGrpcServiceAuditIngestTests.cs | 12 +- .../Grpc/CentralControlGrpcServiceTests.cs | 17 + .../Grpc/CleanupVerificationTests.cs | 4 + .../Grpc/DebugStreamBridgeActorTests.cs | 96 ++++-- .../Grpc/GrpcOptionsWiringTests.cs | 5 +- ...NoClusterClientStreamingRegressionTests.cs | 4 + .../Grpc/ProtoContractTests.cs | 4 + .../Grpc/ProtoDescriptorContractTests.cs | 6 + .../Grpc/ProtoRoundtripTests.cs | 7 + .../Grpc/SiteAlarmAggregatorActorTests.cs | 78 ++++- .../Grpc/SiteCommandDtoMapperSecretsTests.cs | 10 +- .../SitePairChannelProviderDisposalTests.cs | 20 +- .../Grpc/SiteStreamEventBatcherTests.cs | 20 ++ ...SiteStreamGrpcClientFactoryDisposeTests.cs | 24 +- .../Grpc/SiteStreamGrpcClientFactoryTests.cs | 26 +- .../Grpc/SiteStreamGrpcClientTests.cs | 67 +++- .../Grpc/SiteStreamGrpcServerTests.cs | 84 ++++- .../Grpc/StreamRelayActorTests.cs | 15 +- .../GrpcSiteTransportDeadlineTests.cs | 10 + .../GrpcSiteTransportFailFastTests.cs | 9 +- .../HealthReportAckTests.cs | 2 + .../LooseValueCodecTests.cs | 4 + .../LowReviewTransportTests.cs | 5 + .../MessageContractTests.cs | 7 +- .../PendingDeploymentPurgeActorTests.cs | 4 +- .../Protos/AuditEventProtoTests.cs | 6 +- .../Protos/CachedTelemetryProtoTests.cs | 7 +- .../Protos/PullAuditEventsProtoTests.cs | 5 +- .../ReconcileServiceTests.cs | 12 + .../SiteAlarmLiveCacheServiceTests.cs | 35 +- .../SiteAlarmLiveCacheTelemetryTests.cs | 6 +- .../SiteCallDtoMapperTests.cs | 9 + .../SiteCommandDispatcherTests.cs | 23 ++ .../SiteCommandDtoMapperGoldenTests.cs | 25 ++ .../SiteCommandDtoMapperTraceTests.cs | 4 + .../SiteCommandSamples.cs | 4 +- .../SiteCommunicationActorTests.cs | 36 +- .../SiteStreamIngestAuditEventsTests.cs | 3 + .../SiteStreamIngestCachedTelemetryTests.cs | 3 + .../SiteStreamPullAuditEventsTests.cs | 24 +- .../SiteStreamPullSiteCallsTests.cs | 22 +- .../SiteStreamPullTraceEventsTests.cs | 13 + .../StreamRelayTraceStampTests.cs | 3 + .../SyntheticHeartbeatTests.cs | 16 +- .../WireSerializationPinTests.cs | 7 + .../AuditLogAppendOnlyGuardTests.cs | 33 +- .../AuditServiceTests.cs | 23 +- .../ConcurrencyTests.cs | 19 +- .../AuditLogEntityTypeConfigurationTests.cs | 22 +- .../SiteCallEntityTypeConfigurationTests.cs | 13 +- .../DataProtectionTests.cs | 3 + .../DeployedRevisionHashesRepositoryTests.cs | 4 + .../DeploymentListQueryRepositoryTests.cs | 40 ++- .../DesignTimeDbContextFactoryTests.cs | 7 +- .../EphemeralEncryptionFallbackTests.cs | 8 +- .../InboundApiRepositoryTests.cs | 7 + .../ListValueNormalizerTests.cs | 18 + .../AuditLogPartitionMaintenanceTests.cs | 32 +- .../Maintenance/BackfillSourceNodeTests.cs | 16 +- .../MediumPersistenceRegressionTests.cs | 12 + .../NativeAlarmSourceSchemaTests.cs | 4 + ...ationOutboxRepositoryKpiQueryShapeTests.cs | 13 +- ...ficationOutboxRepositoryPerNodeKpiTests.cs | 10 +- ...ficationOutboxRepositoryPerSiteKpiTests.cs | 6 +- .../PendingDeploymentRepositoryTests.cs | 28 ++ .../ReconcileRepositoryTests.cs | 10 + .../Repositories/AuditLogRepositoryTests.cs | 112 +++++- .../AuditLogRepositoryTraceEventsTests.cs | 13 +- .../Repositories/AuditPurgeBudgetTests.cs | 28 ++ .../BundleUnlockAttemptRepositoryTests.cs | 10 + .../Repositories/KpiHistoryRepositoryTests.cs | 70 +++- ...icationOutboxRepositoryIntegrationTests.cs | 14 +- ...ingDeploymentRepositoryIntegrationTests.cs | 6 + .../SiteCallAuditRepositoryTests.cs | 88 ++++- .../RepositoryCoverageTests.cs | 150 +++++++- .../RepositoryTests.cs | 50 +++ .../SchemaConfigurationTests.cs | 27 +- .../SecretEncryptionTests.cs | 16 +- .../SecuredWriteRepositoryTests.cs | 32 +- .../SeedDataTests.cs | 6 +- .../ServiceCollectionExtensionsTests.cs | 8 +- .../SharedSchemaRepositoryTests.cs | 20 +- .../SqliteTestHelper.cs | 14 +- .../TemplateEngineRepositoryTests.cs | 30 +- .../UnitTest1.cs | 27 +- .../Actors/DataConnectionActorBatchTests.cs | 28 +- .../DataConnectionDisconnectRaceTests.cs | 10 +- ...DataConnectionManagerBrowseHandlerTests.cs | 12 +- ...nectionManagerReadTagValuesHandlerTests.cs | 5 + ...DataConnectionManagerSearchHandlerTests.cs | 8 +- ...ConnectionOpcUaFaultClassificationTests.cs | 2 +- .../Actors/DataConnectionProbeSeedTests.cs | 2 +- .../Actors/DataConnectionReconfigureTests.cs | 1 + .../DataConnectionStaleCompletionTests.cs | 2 +- .../DataConnectionStaleConnectResultTests.cs | 4 +- .../Actors/FakeBatchDataConnection.cs | 21 +- .../Actors/HighReviewRegressionTests.cs | 18 + .../Adapters/BatchSeamPrimitiveTests.cs | 19 +- .../Adapters/FakeMxGatewayClient.cs | 14 + .../Adapters/MxGatewayBatchSeamTests.cs | 15 +- ...atewayDataConnectionAlarmReconnectTests.cs | 29 +- .../MxGatewayDataConnectionReconnectTests.cs | 15 +- .../MxGatewayDataConnectionSecretsTests.cs | 26 +- .../Adapters/MxGatewayDataConnectionTests.cs | 36 +- .../OpcUaDataConnectionBrowseTests.cs | 4 +- .../Adapters/OpcUaNodeReferenceTests.cs | 16 +- .../Adapters/OpcUaSecurityDowngradeTests.cs | 3 + .../RealOpcUaClientAlarmFilterTests.cs | 18 +- .../RealOpcUaClientArrayWriteTests.cs | 4 + .../RealOpcUaClientBatchRollbackTests.cs | 6 +- .../Adapters/RealOpcUaClientBrowseTests.cs | 4 + .../RealOpcUaClientEndpointRewriteTests.cs | 11 + .../Adapters/RealOpcUaSnapshotScopeTests.cs | 1 + .../AlarmConditionFilterTests.cs | 14 +- .../DataConnectionActorAlarmIndexTests.cs | 5 +- .../DataConnectionActorAlarmTests.cs | 54 +-- .../DataConnectionActorTests.cs | 187 ++++++---- .../DataConnectionFactoryTests.cs | 9 +- .../DataConnectionManagerActorTests.cs | 10 +- .../DataConnectionOptionsBindingTests.cs | 1 + .../DataConnectionOptionsValidatorTests.cs | 3 + .../GlobalOptionsValidationTests.cs | 3 + .../MxGatewayAlarmMapperTests.cs | 20 +- .../OpcUaAlarmLiveSmokeTests.cs | 6 +- .../OpcUaAlarmMapperTests.cs | 31 +- .../OpcUaDataConnectionTests.cs | 82 ++++- .../OpcUaDataTypeNameMapTests.cs | 10 +- .../RealOpcUaClientAlarmFilterTests.cs | 7 +- .../StubOpcUaClientBrowseTests.cs | 10 +- .../StubOpcUaClientSearchTests.cs | 16 +- .../VerifyEndpointResultMappingTests.cs | 13 + .../ArgParserTests.cs | 6 + .../ConfigLoaderTests.cs | 15 + .../HttpRecipeSenderTests.cs | 12 + .../JsonContractTests.cs | 2 + .../NotifierTests.cs | 10 + .../ReporterTests.cs | 2 + .../ArtifactDeploymentServiceTests.cs | 146 ++++++-- .../ArtifactRevisionHashServiceTests.cs | 22 +- .../DeploySiteAsyncTests.cs | 43 ++- .../DeploymentComparisonTests.cs | 13 +- .../DeploymentManagerOptionsValidatorTests.cs | 9 + .../DeploymentServiceStalenessTests.cs | 39 ++- .../DeploymentServiceTests.cs | 225 +++++++++--- .../DeploymentStatusNotifierTests.cs | 13 +- .../FlattenSessionCacheTests.cs | 13 +- ...latteningPipelineConnectionBindingTests.cs | 11 +- ...eningPipelineNativeAlarmCapabilityTests.cs | 8 +- .../FlatteningPipelinePreviewTests.cs | 7 + .../OperationLockManagerTests.cs | 28 +- .../PendingUpdatesEvaluatorTests.cs | 25 ++ .../ServiceCollectionExtensionsTests.cs | 8 +- .../SiteSecretsCollectorTests.cs | 47 ++- .../StaleInstanceProbeTests.cs | 11 +- .../StateTransitionValidatorTests.cs | 18 +- .../DatabaseGatewayTests.cs | 106 +++++- .../ErrorClassifierTests.cs | 14 +- .../ExternalSystemClientTests.cs | 180 ++++++++-- ...ernalSystemGatewayOptionsValidatorTests.cs | 6 + .../ServiceWiringTests.cs | 6 +- .../SqlErrorClassifierTests.cs | 17 +- ...AddSiteEventLogHealthMetricsBridgeTests.cs | 5 +- .../AuditRedactionFailureMetricTests.cs | 4 +- .../CentralHealthAggregatorTests.cs | 41 ++- .../CentralHealthReportLoopTests.cs | 42 ++- .../HealthMonitoringOptionsValidatorTests.cs | 15 +- .../HealthReportSenderTests.cs | 49 ++- .../CentralHealthAuditBacklogProviderTests.cs | 12 +- .../Kpi/SiteHealthKpiSampleSourceTests.cs | 23 +- .../LocalDbBacklogSustainedTests.cs | 9 + .../LocalDbReplicationStatusReporterTests.cs | 10 +- .../SecretMirrorStatusHealthFieldTests.cs | 5 +- .../SiteAuditBacklogMetricTests.cs | 6 +- .../SiteAuditWriteFailuresMetricTests.cs | 4 +- .../SiteEventLogFailureCountReporterTests.cs | 10 +- .../SiteEventLogWriteFailuresMetricTests.cs | 6 +- .../SiteHealthCollectorTests.cs | 28 +- .../StartupReconcilerHealthFieldTests.cs | 7 +- .../ActorPathTests.cs | 38 ++- .../ActorSystemBridgeTests.cs | 1 + .../AkkaBootstrapTests.cs | 5 +- .../AkkaHealthReportTransportTests.cs | 8 + .../AkkaHostedServiceAuditWiringTests.cs | 43 ++- .../BootstrapCancellationTests.cs | 2 + .../CaptiveDependencyGuard.cs | 2 +- .../CaptiveDependencyGuardTests.cs | 13 +- .../CentralControlAuthInterceptorTests.cs | 35 ++ .../CentralControlEndToEndTests.cs | 30 +- .../CentralDbTestEnvironment.cs | 6 +- .../CentralHttpBindPortsTests.cs | 9 + .../CentralListenerIsolationTests.cs | 3 + .../ClusterActivityEvaluatorTests.cs | 10 +- .../CompositionRootTests.cs | 61 +++- .../ConfigSecretsTests.cs | 7 +- .../ControlPlaneAuthEndToEndTests.cs | 26 +- .../ControlPlaneAuthInterceptorTests.cs | 34 ++ .../CoordinatedShutdownTests.cs | 8 +- .../DeadLetterMonitorTests.cs | 7 +- .../GrpcCentralTransportTests.cs | 61 +++- .../GrpcSiteTransportFailoverTests.cs | 25 +- .../HealthCheckTests.cs | 20 +- .../HoconBuilderTests.cs | 18 +- .../HostStartupTests.cs | 8 +- .../InProcessScriptArtifactChangeBusTests.cs | 7 +- .../InboundApiKeyStoreImportServiceTests.cs | 24 ++ .../InboundApiKeyStoreOptionsTests.cs | 7 + .../InboundBodyLimitKestrelTests.cs | 7 + .../KpiRateFoldIntervalCrossValidatorTests.cs | 7 +- .../LocalDbSyncAuthInterceptorTests.cs | 30 +- .../LoggerConfigurationTests.cs | 12 +- .../MappedGrpcAuthenticationCoverageTests.cs | 9 +- .../MetricsEndpointTests.cs | 6 +- .../NodeIdentityProviderTests.cs | 4 + .../NodeOptionsValidatorTests.cs | 12 + .../PreHostSecretsContentRootTests.cs | 6 + .../RequiredSingletonsHealthCheckTests.cs | 10 +- .../ScadaBridgeTelemetryTests.cs | 2 + .../SecretsAuthorizationTests.cs | 12 +- .../SecretsReplicationWiringTests.cs | 23 +- .../SerilogSinkConfigTests.cs | 4 +- .../SerilogTests.cs | 7 +- .../ServiceProviderValidationTests.cs | 10 + .../SessionActivityHttpIntegrationTests.cs | 29 ++ .../SingletonRegistrarTests.cs | 7 +- .../SiteCommandGrpcServiceTests.cs | 25 +- .../SiteEventLoggerLifetimePinTests.cs | 4 +- .../SiteHealthCheckTests.cs | 52 +++ .../SiteHealthEndpointTests.cs | 12 +- .../SiteKestrelSetupTests.cs | 4 +- .../SiteLocalDbAutoVacuumTests.cs | 6 +- .../SiteLocalDbCdcRegistrationTests.cs | 17 +- .../SiteLocalDbDirectoryTests.cs | 1 + .../SiteLocalDbLegacyMigratorTests.cs | 31 +- .../SiteLocalDbReplicatedTableFkGuardTests.cs | 8 +- .../SiteLocalDbWiringTests.cs | 32 +- .../SitePskProviderTests.cs | 27 ++ .../StandbyHealthCheckLogNoiseTests.cs | 10 +- .../StartupRetryTests.cs | 14 +- .../StartupValidatorTests.cs | 175 +++++++++- .../UnexpectedTerminationTests.cs | 6 +- .../UnreplicatedSiteLocalDbHealthTests.cs | 13 +- .../WindowsServiceTests.cs | 4 +- .../ApiMethodCacheTests.cs | 17 +- .../EndpointContentTypeTests.cs | 18 +- .../EndpointExtensionsTests.cs | 64 +++- .../EndpointGatingTests.cs | 34 +- .../ForbiddenApiCheckerTests.cs | 28 +- .../InboundApiOptionsValidatorTests.cs | 26 ++ .../InboundDatabaseHelperAuditTests.cs | 43 ++- .../InboundDatabaseHelperTests.cs | 37 +- .../InboundScriptExecutorStalenessTests.cs | 11 + .../InboundScriptExecutorTests.cs | 137 +++++++- .../InboundScriptLoggerTests.cs | 5 +- .../Middleware/AuditWriteMiddlewareTests.cs | 173 +++++++++- .../Middleware/MiddlewareOrderTests.cs | 27 +- .../ParameterValidatorTests.cs | 43 ++- .../RelayProvenanceTests.cs | 26 +- .../ReturnValueValidatorTests.cs | 29 +- .../RouteHelperTests.cs | 102 +++++- .../RouteHelperTraceTests.cs | 17 + .../SchemaRefRuntimeValidationTests.cs | 8 +- .../ScriptArtifactChangeSubscriberTests.cs | 17 +- .../StandbyRequestForwardingTests.cs | 63 ++++ .../AuditLog/SiteAuditPushFlowTests.cs | 27 +- .../AuditTransactionTests.cs | 8 +- .../AuthFlowTests.cs | 14 +- .../BulkDeployPoisonedContextTests.cs | 14 +- .../CentralFailoverTests.cs | 10 +- .../Cluster/ActiveNodeSemanticsTests.cs | 2 + .../ClusterBootstrapCoordinatorTests.cs | 13 +- .../Cluster/ManualFailoverTests.cs | 8 + .../Cluster/SbrFailoverTests.cs | 9 +- .../Cluster/SelfFirstSeedBootstrapTests.cs | 12 + .../Cluster/SiteFailoverRelayTests.cs | 6 +- .../Cluster/TwoNodeClusterFixture.cs | 36 ++ .../Cluster/TwoNodeClusterFixtureTests.cs | 2 + .../DeployNotifyFetchTests.cs | 19 ++ .../DualNodeRecoveryTests.cs | 9 +- .../GrpcStreamBatchingIntegrationTests.cs | 11 +- .../Grpc/GrpcStreamIntegrationTests.cs | 5 + .../Grpc/SiteAlarmStreamEndToEndTests.cs | 19 +- .../IntegrationSurfaceTests.cs | 18 +- .../LocalDbConfigConvergenceTests.cs | 16 +- .../LocalDbPhase2ConvergenceTests.cs | 12 +- .../LocalDbSitePairConvergenceTests.cs | 10 + .../LocalDbSitePairHarness.cs | 11 + .../LocalDbStoreAndForwardConvergenceTests.cs | 16 +- .../NotificationOutboxFlowTests.cs | 20 +- .../ObservabilityTests.cs | 10 +- .../ReadinessTests.cs | 6 +- .../RecoveryDrillTests.cs | 9 +- .../ScadaBridgeWebApplicationFactory.cs | 9 +- .../SecurityHardeningTests.cs | 13 +- .../SiteFailoverTests.cs | 10 +- .../StartupValidationTests.cs | 8 +- .../KpiHistoryOptionsValidatorTests.cs | 33 +- .../KpiHistoryRecorderActorTests.cs | 77 ++++- .../HarnessConfig.cs | 8 +- .../HarnessRun.cs | 8 +- .../Metrics/BatchSizeAggregator.cs | 2 +- .../Metrics/ResourceSampler.cs | 7 +- .../Probes/SiteWireStreamHost.cs | 5 +- .../Program.cs | 4 +- .../Scenarios/ObservabilityProbes.cs | 5 +- .../Scenarios/StoreAndForwardDrainScenario.cs | 2 +- .../SimulatedDataConnection.cs | 17 +- .../SiteRuntimeFixture.cs | 7 +- .../TagUpdateDriver.cs | 5 +- .../ApiKeyCreationTests.cs | 31 ++ .../ApiMethodTraceAllCallsTests.cs | 6 + .../AuditEndpointsTests.cs | 98 +++++- .../ConfigSecretScrubberTests.cs | 8 + .../ConfigurationTransactionTestExtensions.cs | 4 + .../DebugStreamHubSubscribeRaceTests.cs | 28 +- .../DebugStreamHubTests.cs | 29 +- .../DeploymentConfigEndpointsTests.cs | 7 + .../DispatchCoverageTests.cs | 18 + .../FlowDebugRunnerTests.cs | 93 +++++ .../HttpCommandEligibilityTests.cs | 2 + .../ManagementActorSupervisionTests.cs | 6 +- .../ManagementActorTests.cs | 320 ++++++++++++++++-- .../ManagementAuthenticatorTests.cs | 12 + .../ManagementEndpointsTests.cs | 21 +- .../ManagementPagingBoundaryTests.cs | 3 + .../ManagementServiceOptionsValidatorTests.cs | 7 + .../NotificationRecipientPreservationTests.cs | 3 + .../RequiredRoleMatrixTests.cs | 6 +- .../RoleMappingValidationTests.cs | 14 +- .../SchemaLibraryHandlerTests.cs | 27 +- .../SecretProjectionTests.cs | 22 +- .../SecuredWriteHandlerTests.cs | 90 +++-- .../TransportGateTests.cs | 4 + .../Delivery/DeliveryOutcomeTests.cs | 3 + ...mailNotificationDeliveryAdapterEwsTests.cs | 30 +- .../EmailNotificationDeliveryAdapterTests.cs | 27 +- .../SmsNotificationDeliveryAdapterTests.cs | 52 +++ .../NotificationIngestTypeStampingTests.cs | 4 + .../NotificationOutboxKpiSampleSourceTests.cs | 18 +- ...ficationOutboxActorAttemptEmissionTests.cs | 22 +- ...ificationOutboxActorAuditInjectionTests.cs | 6 +- .../NotificationOutboxActorDispatchTests.cs | 72 +++- .../NotificationOutboxActorIngestTests.cs | 15 +- .../NotificationOutboxActorPurgeTests.cs | 4 +- .../NotificationOutboxActorQueryTests.cs | 36 +- ...tificationOutboxActorRetryEmissionTests.cs | 8 +- ...icationOutboxActorTerminalEmissionTests.cs | 29 +- .../NotificationOutboxOptionsTests.cs | 1 + ...NotificationOutboxOptionsValidatorTests.cs | 11 +- .../OutboxRepositorySubstitute.cs | 1 + .../ProjectSmokeTest.cs | 1 + .../ServiceRegistrationTests.cs | 9 +- .../TestSupport/NoOpCentralAuditWriter.cs | 3 +- .../CredentialRedactorTests.cs | 11 +- .../EmailTransportParserTests.cs | 7 + .../Ews/EwsResponseParserTests.cs | 7 + .../Ews/EwsSoapEnvelopeTests.cs | 6 + .../Ews/EwsSoapMailSenderTests.cs | 38 +++ .../MailKitSmtpClientWrapperTests.cs | 23 +- .../NotificationOptionsTests.cs | 3 +- .../NotificationOptionsValidatorTests.cs | 5 + .../OAuth2TokenServiceTests.cs | 61 +++- .../SmtpErrorClassifierTests.cs | 18 +- .../SmtpTlsModeParserTests.cs | 11 +- .../AuditLog/HotPathLatencyTests.cs | 13 +- .../Failover/FailoverTimingTests.cs | 6 +- .../HealthAggregationTests.cs | 7 +- .../StaggeredStartupTests.cs | 4 +- .../Streaming/SiteStreamThroughputTests.cs | 3 + .../TargetScaleHarnessSmokeTests.cs | 4 +- .../CachingScriptMetadataResolverTests.cs | 22 ++ .../FlowShapePredictorTests.cs | 14 +- .../RoslynScriptCompilerTests.cs | 12 +- .../ScriptTrustValidatorTests.cs | 57 +++- .../AutoLoginAuthenticationHandlerTests.cs | 13 + .../CookieSessionValidatorTests.cs | 95 +++++- .../DisableLoginGuardTests.cs | 6 + .../DisableLoginRegistrationTests.cs | 7 +- .../ForwardedHeadersSetupTests.cs | 5 + .../HttpAuditActorAccessorTests.cs | 7 + .../LibraryInboundApiKeyAdminTests.cs | 24 ++ .../LoginThrottleTests.cs | 23 +- .../PassiveSessionRegressionTests.cs | 33 ++ .../RolesAllTests.cs | 1 + .../RolesTests.cs | 7 +- .../ScadaBridgeGroupRoleMapperTests.cs | 25 +- .../SecurityTests.cs | 212 +++++++++--- .../Kpi/SiteCallAuditKpiSampleSourceTests.cs | 32 +- .../SiteCallAuditActorTests.cs | 93 ++++- .../SiteCallAuditOptionsTests.cs | 12 +- .../SiteCallAuditOptionsValidatorTests.cs | 11 + .../SiteCallAuditPurgeTests.cs | 30 +- .../SiteCallAuditReconciliationTests.cs | 101 +++++- .../SiteCallRelayTests.cs | 35 +- .../TestSupport/StubRepositoryProvider.cs | 10 + .../EventLogCoverageTests.cs | 14 +- .../EventLogHandlerActorTests.cs | 8 +- .../EventLogPurgeServiceTests.cs | 84 ++++- .../EventLogQueryServiceTests.cs | 45 ++- .../EventLogStorageCapScopeTests.cs | 31 ++ .../SchemaIndexTests.cs | 7 +- .../ServiceWiringTests.cs | 6 +- .../SiteEventLogOptionsValidatorTests.cs | 8 + .../SiteEventLogSchemaTests.cs | 10 +- .../SiteEventLoggerAsyncTests.cs | 16 +- .../SiteEventLoggerBatchingTests.cs | 14 +- .../SiteEventLoggerTests.cs | 30 +- .../SiteEventsSchemaCalibrationTests.cs | 5 + .../SqliteCapabilityTests.cs | 2 + .../TestLocalDb.cs | 4 + .../Actors/AlarmActorTests.cs | 61 +++- .../AlarmCascadeParentExecutionTests.cs | 13 +- .../Actors/AlarmScriptLogTargetTests.cs | 20 +- .../Actors/AlarmScriptWatchdogTests.cs | 8 +- .../Actors/CertStoreActorTests.cs | 8 + .../Actors/DclConnectionArbitrationTests.cs | 26 +- ...loymentManagerActorArtifactSecretsTests.cs | 64 +++- .../Actors/DeploymentManagerActorTests.cs | 105 +++++- .../DeploymentManagerCertReconcileTests.cs | 5 + .../DeploymentManagerLoggerFactoryTests.cs | 12 +- .../DeploymentManagerMediumFindingsTests.cs | 21 +- .../Actors/DeploymentManagerRedeployTests.cs | 52 ++- .../DeploymentManagerRouteTraceTests.cs | 8 + .../Actors/DeploymentWarmThenGateTests.cs | 38 ++- .../Actors/HandshakeResetEchoTests.cs | 9 +- .../InstanceActorChildAttributeRaceTests.cs | 8 +- .../Actors/InstanceActorChildRoutingTests.cs | 6 +- .../Actors/InstanceActorIntegrationTests.cs | 13 +- ...InstanceActorMesReportableSnapshotTests.cs | 4 + .../Actors/InstanceActorNativeAlarmTests.cs | 12 +- .../Actors/InstanceActorSetAttributeTests.cs | 14 +- .../InstanceActorTagSubscribeBackoffTests.cs | 5 + .../Actors/InstanceActorTests.cs | 83 +++-- .../Actors/InstanceActorTraceArmTests.cs | 7 + .../InstanceActorWaitForAttributeTests.cs | 4 +- .../Actors/InstanceSweepSelfHealTests.cs | 35 +- .../Actors/NativeAlarmActorTests.cs | 55 ++- .../Actors/ReconcileStartsInstanceTests.cs | 37 +- .../RedeployStaticOverrideResetTests.cs | 18 +- .../Actors/ScriptActorTests.cs | 43 ++- .../Actors/ScriptActorTraceTests.cs | 15 + .../Actors/ScriptDeadlineAtEnqueueTests.cs | 9 +- .../Actors/ScriptRecursionVsRunCapTests.cs | 11 +- .../Actors/ScriptRunLauncherParityTests.cs | 36 +- .../Actors/ScriptRunShedTests.cs | 17 +- .../SelfHealCreatesDclConnectionTests.cs | 29 +- .../Actors/SiteReconciliationActorTests.cs | 31 ++ .../Actors/StuckScriptWatchdogTests.cs | 12 +- .../Actors/TriggerEvalStarvationTests.cs | 11 +- .../Deployment/DeployCompileValidatorTests.cs | 5 + .../HttpDeploymentConfigFetcherTests.cs | 31 ++ .../Integration/FailoverIntegrationTests.cs | 10 +- .../NegativeTests.cs | 5 + .../Persistence/ArtifactStorageTests.cs | 46 ++- .../DeployedSecretsStorageTests.cs | 44 ++- .../Persistence/NativeAlarmStateStoreTests.cs | 18 +- .../Persistence/SiteStorageSchemaTests.cs | 1 + .../Persistence/SiteStorageServiceTests.cs | 50 ++- .../Repositories/SiteRepositoryTests.cs | 46 ++- .../Scripts/AlarmsAccessorTests.cs | 17 + .../AttributeSchedulerRegressionTests.cs | 3 + .../Scripts/AuditingDbTransactionTests.cs | 5 + .../Scripts/CompileSurfaceParityTests.cs | 7 +- .../DatabaseCachedWriteEmissionTests.cs | 66 ++-- .../Scripts/DatabaseSyncEmissionTests.cs | 39 ++- .../ExecutionCorrelationContextTests.cs | 18 +- .../ExternalSystemCachedCallEmissionTests.cs | 57 +++- .../ExternalSystemCallAuditEmissionTests.cs | 41 ++- .../Scripts/NotifyHelperTests.cs | 44 ++- .../Scripts/NotifySendAuditEmissionTests.cs | 41 ++- .../Scripts/ParentExecutionTreeTests.cs | 14 +- .../Scripts/RecursionLimitSiteEventTests.cs | 12 +- .../Scripts/SandboxTests.cs | 35 +- .../Scripts/ScopeAccessorTests.cs | 31 +- .../Scripts/ScriptCompilationServiceTests.cs | 27 +- .../Scripts/ScriptExecutionSchedulerTests.cs | 15 +- .../Scripts/ScriptFileReaderTests.cs | 48 +++ .../Scripts/ScriptLoggerTests.cs | 48 ++- .../Scripts/ScriptPoolSizingTests.cs | 12 +- .../Scripts/ScriptRunEventPolicyTests.cs | 12 +- .../Scripts/ScriptRunLauncherTraceTests.cs | 7 +- .../Scripts/ScriptRunSummaryRecorderTests.cs | 20 +- .../ScriptRuntimeContextTraceEmissionTests.cs | 66 +++- .../ScriptSchedulerStatsReporterTests.cs | 2 + .../Scripts/SharedScriptLibraryTests.cs | 12 +- .../Scripts/SiteScriptCompileCacheTests.cs | 12 +- .../Scripts/TraceCapturingLoggerTests.cs | 29 +- .../Scripts/TrackingApiTests.cs | 8 +- .../Scripts/TriggerEvalGateLifecycleTests.cs | 3 + .../Scripts/TrustModelSemanticTests.cs | 16 +- .../Scripts/WriteBatchResetAndWaitTests.cs | 15 +- .../DeployedSecretsMirrorRealStackTests.cs | 14 +- .../Secrets/DeployedSecretsMirrorTests.cs | 146 +++++++- .../SecretMirrorStatusReporterTests.cs | 25 +- .../SiteRuntimeOptionsValidatorTests.cs | 23 +- .../SiteRuntimeServiceRegistrationTests.cs | 4 +- .../SiteStreamAlarmDropReporterTests.cs | 5 +- .../Streaming/SiteStreamManagerTests.cs | 25 +- .../TestSupport/FakeSiteEventLogger.cs | 23 +- .../TestSupport/ScopeSpyServiceProvider.cs | 11 +- .../TestSupport/TestOptionsMonitor.cs | 13 +- .../OperationTrackingOptionsValidatorTests.cs | 3 + .../Tracking/OperationTrackingSchemaTests.cs | 8 +- .../Tracking/OperationTrackingStoreTests.cs | 67 +++- .../CachedCallAttemptEmissionTests.cs | 66 +++- .../NotificationForwarderTests.cs | 24 +- .../ParkedMessageHandlerActorTests.cs | 22 +- .../ParkedOperationRelayTests.cs | 17 +- .../QueueDepthGaugeCollection.cs | 1 + .../QueueDepthGaugeTests.cs | 22 +- .../StoreAndForwardOptionsTests.cs | 5 +- .../StoreAndForwardOptionsValidatorTests.cs | 12 +- .../StoreAndForwardSchemaTests.cs | 1 + .../StoreAndForwardServiceTests.cs | 146 ++++++-- .../StoreAndForwardSiteEventTests.cs | 26 +- .../StoreAndForwardStorageTests.cs | 92 ++++- .../CollisionDetectorTests.cs | 16 +- .../CycleDetectorTests.cs | 23 +- .../ConnectionBindingOverrideTests.cs | 3 + .../DefaultDataSourceReferenceTests.cs | 3 + .../Flattening/DeploymentPackageTests.cs | 6 + .../Flattening/DiffServiceTests.cs | 49 ++- .../Flattening/FlatteningServiceMergeTests.cs | 20 ++ .../Flattening/FlatteningServiceTests.cs | 56 ++- .../Flattening/RevisionHashServiceTests.cs | 45 ++- .../LockEnforcerTests.cs | 29 +- .../Services/AreaServiceTests.cs | 31 ++ .../Services/InstanceServiceTests.cs | 45 ++- .../Services/SiteServiceTests.cs | 15 + .../Services/TemplateDeletionServiceTests.cs | 19 +- .../Services/TemplateFolderServiceTests.cs | 73 +++- .../SharedScriptServiceTests.cs | 39 ++- .../TemplateInheritanceResolverTests.cs | 20 +- .../TemplateNamingTests.cs | 4 + .../TemplateResolverTests.cs | 34 +- .../TemplateServiceTests.cs | 219 ++++++++++-- .../Validation/SchemaRefValidationTests.cs | 8 +- .../ScriptCompileVerdictCacheEvictionTests.cs | 6 +- .../Validation/ScriptCompilerTests.cs | 24 +- .../Validation/SemanticValidatorTests.cs | 72 +++- .../Validation/ValidationServiceTests.cs | 35 +- .../TestLocalDb.cs | 6 + .../CompositionImportTests.cs | 14 +- .../ConflictResolutionTests.cs | 10 +- .../Export/BundleExporterTests.cs | 16 +- .../Import/AreaTransportTests.cs | 10 +- .../Import/BundleImporterApplyTests.cs | 84 ++++- .../Import/BundleImporterPreviewTests.cs | 50 ++- .../BundleImporterRetryingStrategyTests.cs | 9 +- .../BundleImporterRollbackFailureTests.cs | 13 +- .../CreateMissingSiteRelationalTests.cs | 4 + .../Import/FleetWideUnlockThrottleTests.cs | 37 +- .../Import/InheritanceImportTests.cs | 12 + .../Import/NativeAlarmSourceImportTests.cs | 14 +- .../Import/SiteInstanceImportTests.cs | 69 +++- .../Import/TemplateScriptFidelityTests.cs | 12 +- .../RoundTripEquivalenceTests.cs | 34 +- .../RoundTripTests.cs | 10 +- .../SemanticValidatorImportTests.cs | 48 ++- .../ValidationFailureTests.cs | 9 +- .../Encryption/BundleSecretEncryptorTests.cs | 9 +- .../Export/DependencyResolverTests.cs | 30 +- .../Import/ArtifactDiffTests.cs | 43 ++- .../Import/BundleImporterLoadTests.cs | 49 ++- .../Import/BundleSessionObservabilityTests.cs | 37 +- .../Import/BundleSessionStoreTests.cs | 16 +- .../Import/BundleUnlockRateLimiterTests.cs | 23 +- .../Import/LineDifferTests.cs | 13 + .../BundleDtoSerializationTests.cs | 18 +- .../Serialization/BundleSerializerTests.cs | 7 +- .../Serialization/EntitySerializerTests.cs | 70 +++- .../Serialization/ManifestBuilderTests.cs | 15 +- .../TransportOptionsValidatorTests.cs | 16 + 998 files changed, 17896 insertions(+), 2395 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogExportService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogExportService.cs index b38e46d8..1f4ee709 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogExportService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogExportService.cs @@ -179,7 +179,7 @@ public sealed class AuditLogExportService : IAuditLogExportService /// cleanly on another. Free-text columns are run through /// before RFC 4180 quoting so a /// leading =/+/-/@/TAB/CR can't be interpreted as - /// a formula or DDE trigger by a spreadsheet application (CWE-1236). GUID/enum/numeric/date/bool columns this + /// a formula or DDE trigger by a spreadsheet application (CSV formula injection). GUID/enum/numeric/date/bool columns this /// formatter generates itself are never neutralized. /// /// The audit event view to format as a CSV row. @@ -228,7 +228,7 @@ public sealed class AuditLogExportService : IAuditLogExportService return; } - // OWASP CSV-injection neutralization (CWE-1236): prefix a literal single + // OWASP CSV-injection neutralization: prefix a literal single // quote before RFC 4180 quoting so a free-text cell starting with a // formula/DDE trigger character renders as inert text in Excel. if (freeText && NeedsFormulaNeutralization(value)) diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs index 8eee78f6..ceb49fa3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs @@ -708,7 +708,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers } /// - /// Arms the backoff retry for a failed fan-out: reconnectDelay × 2^(failures-1), + /// Arms the backoff retry for a failed fan-out: reconnectDelay × 2^(failures − 1), /// capped at × the reconcile interval. Once /// the cap is reached the periodic reconcile tick is doing the same work anyway, so no /// extra timer is armed. diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs index 56572e57..b1e23afe 100644 --- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs +++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs @@ -26,7 +26,7 @@ public sealed record SiteHealthState /// null if the site is known only via heartbeats and has not yet sent /// a report. Used by the UI to surface report staleness during failover; /// the null case must be rendered as "no report yet" rather than as a - /// timestamp (a default sentinel would display as year-0001). + /// timestamp (a default sentinel would display as year 0001). /// public DateTimeOffset? LastReportReceivedAt { get; init; } diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 63d0dbfb..9e9002f7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -2560,7 +2560,7 @@ public class ManagementActor : ReceiveActor if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode; if (cmd.Credentials is not null) config.Credentials = cmd.Credentials; // Preserve-if-null likewise for the OAuth2 token-endpoint overrides: an - // omitted authority/scope leaves the stored value (or its M365 default) intact. + // omitted authority/scope leaves the stored value (or its Microsoft 365 default) intact. if (cmd.OAuth2Authority is not null) config.OAuth2Authority = cmd.OAuth2Authority; if (cmd.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope; // Preserve-if-null for the transport too: an omitted value keeps the stored diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/AddAuditLogTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/AddAuditLogTests.cs index 2e08f66d..34999fb4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/AddAuditLogTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/AddAuditLogTests.cs @@ -15,8 +15,8 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests; /// -/// Bundle E (M2 Task E1) DI surface tests for AddAuditLog. M1 shipped -/// the options-only scaffold; M2 extends it with the site writer chain +/// DI surface tests for AddAuditLog. The initial version was an +/// options-only scaffold; it was then extended with the site writer chain /// ( + + /// ) and the telemetry collaborators /// (, , @@ -41,6 +41,8 @@ public class AddAuditLogTests return services.BuildServiceProvider(); } + /// Verifies that AddAuditLog registers the audit log options. + /// The result. [Fact] public void AddAuditLog_RegistersAuditLogOptions() { @@ -52,6 +54,8 @@ public class AddAuditLogTests Assert.NotNull(opts!.Value); } + /// Verifies that AddAuditLog throws when the service collection is null. + /// The result. [Fact] public void AddAuditLog_NullServices_Throws() { @@ -61,6 +65,8 @@ public class AddAuditLogTests () => ServiceCollectionExtensions.AddAuditLog(null!, config)); } + /// Verifies that AddAuditLog throws when the configuration is null. + /// The result. [Fact] public void AddAuditLog_NullConfig_Throws() { @@ -70,8 +76,10 @@ public class AddAuditLogTests () => services.AddAuditLog(null!)); } - // -- Bundle E (M2 Task E1) --------------------------------------------- + // -- Site writer chain DI registrations --------------------------------------------- + /// Verifies that AddAuditLog registers the SQLite audit writer as a singleton resolvable from DI. + /// The result. [Fact] public void AddAuditLog_Registers_SqliteAuditWriter_Singleton_FromDI() { @@ -90,6 +98,8 @@ public class AddAuditLogTests Assert.Same(writer, provider.GetService()); } + /// Verifies that AddAuditLog registers IAuditWriter as the fallback audit writer. + /// The result. [Fact] public void AddAuditLog_Registers_IAuditWriter_AsFallbackAuditWriter() { @@ -104,6 +114,8 @@ public class AddAuditLogTests Assert.IsType(writer); } + /// Verifies that AddAuditLog registers ISiteAuditQueue as the same instance as the SQLite audit writer. + /// The result. [Fact] public void AddAuditLog_Registers_ISiteAuditQueue_AsSameInstance_As_SqliteAuditWriter() { @@ -123,6 +135,8 @@ public class AddAuditLogTests Assert.Same(writer, queue); } + /// Verifies that AddAuditLog registers the ring-buffer fallback as a singleton. + /// The result. [Fact] public void AddAuditLog_Registers_RingBufferFallback_Singleton() { @@ -136,6 +150,8 @@ public class AddAuditLogTests Assert.Same(ring, provider.GetService()); } + /// Verifies that AddAuditLog registers a no-op audit write failure counter by default. + /// The result. [Fact] public void AddAuditLog_Registers_AuditWriteFailureCounter_AsNoOpDefault() { @@ -149,6 +165,8 @@ public class AddAuditLogTests Assert.IsType(counter); } + /// Verifies that AddAuditLog registers a no-op site stream audit client by default. + /// The result. [Fact] public void AddAuditLog_Registers_SiteStreamAuditClient_AsNoOpDefault() { @@ -162,8 +180,10 @@ public class AddAuditLogTests Assert.IsType(client); } - // -- M4 Bundle B (B1) central direct-write audit writer ----------------- + // -- Central direct-write audit writer ----------------- + /// Verifies that AddAuditLog registers ICentralAuditWriter as the central audit writer. + /// The result. [Fact] public void AddAuditLog_Registers_ICentralAuditWriter_AsCentralAuditWriter() { @@ -177,6 +197,8 @@ public class AddAuditLogTests Assert.IsType(writer); } + /// Verifies that the ICentralAuditWriter registration is a singleton. + /// The result. [Fact] public void AddAuditLog_ICentralAuditWriter_IsSingleton() { @@ -190,6 +212,8 @@ public class AddAuditLogTests Assert.Same(w1, w2); } + /// Verifies that the SQLite writer options bind from configuration and round-trip. + /// The result. [Fact] public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter() { @@ -208,6 +232,8 @@ public class AddAuditLogTests Assert.Equal(75, opts.FlushIntervalMs); } + /// Verifies that the site telemetry options bind from configuration and round-trip. + /// The result. [Fact] public void AddAuditLog_Options_Bind_RoundTrip_SiteTelemetry() { @@ -224,8 +250,10 @@ public class AddAuditLogTests Assert.Equal(60, opts.IdleIntervalSeconds); } - // -- Bundle G (M2 Task G1) Site Health Monitoring bridge ---------------- + // -- Site Health Monitoring bridge ---------------- + /// Verifies that the health metrics bridge swaps the failure counter for the health metrics implementation. + /// The result. [Fact] public void AddAuditLogHealthMetricsBridge_Swaps_FailureCounter_To_HealthMetrics_Implementation() { @@ -251,6 +279,8 @@ public class AddAuditLogTests Assert.IsType(counter); } + /// Verifies that the health metrics bridge still resolves without health monitoring but errors when used. + /// The result. [Fact] public void AddAuditLogHealthMetricsBridge_Without_HealthMonitoring_Still_Resolves_But_Errors_On_Use() { @@ -276,10 +306,12 @@ public class AddAuditLogTests () => provider.GetRequiredService()); } + /// Verifies that registering the health metrics bridge twice does not register the hosted service twice. + /// The result. [Fact] public void AddAuditLogHealthMetricsBridge_IsIdempotent_DoesNotDoubleRegister_HostedService() { - // AuditLog-011: AddHostedService has no TryAdd variant, so a second + // AddHostedService has no TryAdd variant, so a second // call without the sentinel guard would spin up a second // SiteAuditBacklogReporter on the same SQLite file. The helper must // be a no-op on the second call — exactly one hosted-service diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs index 6fd62f45..92c02d93 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs @@ -19,9 +19,9 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// -/// Bundle D D2 tests for 's M3 combined- -/// telemetry dual-write transaction. Uses the same -/// as the M1 + M2 repository tests so the actor exercises real +/// Tests for 's combined-telemetry +/// dual-write transaction. Uses the same +/// as the audit-log and site-call repository tests so the actor exercises real /// + /// against a per-test MSSQL /// database. The transaction commits or rolls back inside one @@ -31,6 +31,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< { private readonly MsSqlMigrationFixture _fixture; + /// Initializes the test class with the shared MSSQL migration fixture. + /// The shared MSSQL migration fixture. public AuditLogIngestActorCombinedTelemetryTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -125,6 +127,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< serviceProvider, NullLogger.Instance))); + /// Verifies that one cached-telemetry packet writes both an audit row and a site-call row and acknowledges the event id. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_OneCachedPacket_WritesAuditRow_AND_SiteCallRow_AcksId() { @@ -156,6 +160,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< Assert.Equal(siteCall.Status, siteCallRow!.Status); } + /// Verifies that a duplicate event id with an unchanged status is a no-op that leaves the row counts unchanged and still acknowledges the id. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_DuplicateEventId_SameStatus_NoOp_RowCountUnchanged_AcksId() { @@ -195,6 +201,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< Assert.Equal(1, siteCallCount); } + /// Verifies that a duplicate event id carrying an advanced site-call status updates the existing site-call row. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_DuplicateEventId_AdvancedSiteCallStatus_UpdatesSiteCall() { @@ -206,7 +214,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< var sp = BuildServiceProvider(); var actor = CreateActor(sp); - // 1st packet — Submitted (audit EventId #1, SiteCalls Status=Submitted). + // 1st packet — Submitted (first audit EventId, SiteCalls Status=Submitted). var (auditSubmit, siteCallSubmit) = NewEntry( siteId, trackedId, status: "Submitted", auditStatus: AuditStatus.Submitted); actor.Tell( @@ -214,7 +222,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< TestActor); ExpectMsg(TimeSpan.FromSeconds(15)); - // 2nd packet — Attempted with retry count 1 (audit EventId #2, + // 2nd packet — Attempted with retry count 1 (second audit EventId, // SiteCalls Status=Attempted — monotonic upsert wins). Same // TrackedOperationId throughout. var (auditAttempt, siteCallAttempt) = NewEntry( @@ -242,6 +250,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< Assert.Equal(1, siteCallRow.RetryCount); } + /// Verifies that when the site-call upsert throws after the audit insert succeeds, both writes are rolled back and no orphan audit row remains. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_AuditInsertSucceeds_SiteCallThrows_BothRolledBack_NoOrphanRow() { @@ -273,6 +283,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< Assert.Null(siteCallRow); } + /// Verifies that five cached-telemetry packets are each persisted separately and all are acknowledged. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_FiveCachedPackets_AllPersistedSeparately_AllAcked() { @@ -303,6 +315,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< Assert.Equal(5, siteCallCount); } + /// Verifies that a packet failing after an earlier packet succeeded leaves the first one committed and lets the batch continue. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_OnePacketSucceeds_NextPacketThrows_FirstStillCommitted_BatchContinues() { @@ -348,7 +362,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< } /// - /// arch-review 2026-08-19 P5-1: the per-entry fallback loop (used when the + /// The per-entry fallback loop (used when the /// batch fast path fails) must run under its own short /// , not an unbounded /// wait — otherwise a single slow/wedged downstream call can freeze this @@ -368,6 +382,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< /// ExpectMsg below would time out and fail, which is the fail-unfixed /// proof for this fix. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_CachedTelemetry_PerEntryFallbackHangs_BudgetBoundsWait_RemainderSkipped() { @@ -430,38 +445,51 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< /// private sealed class BatchFailingAuditRepo : IAuditLogRepository { + /// public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); private readonly IAuditLogRepository _inner; + /// Initializes the double with the real repository that every non-failing member delegates to. + /// The real repository to delegate to. public BatchFailingAuditRepo(IAuditLogRepository inner) { _inner = inner; } + /// public Task InsertManyIfNotExistsAsync( IReadOnlyList events, TimeSpan? commandTimeout = null, CancellationToken ct = default) => throw new InvalidOperationException("simulated batch ingest failure — forces the per-entry fallback"); + /// public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) => _inner.InsertIfNotExistsAsync(evt, ct); + /// public Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => _inner.QueryAsync(filter, paging, ct); + /// public Task SwitchOutPartitionAsync( DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => _inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct); + /// public Task PurgeChannelOlderThanAsync( string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => _inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows); + /// public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => _inner.GetPartitionBoundariesOlderThanAsync(threshold, ct); + /// public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => _inner.GetKpiSnapshotAsync(window, nowUtc, ct); + /// public Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => _inner.GetExecutionTreeAsync(executionId, ct); + /// public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => _inner.GetDistinctSourceNodesAsync(ct); + /// public Task BackfillSourceNodeAsync( string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => _inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct); @@ -471,15 +499,17 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< /// Test double — hangs on /// Task.Delay(Timeout.InfiniteTimeSpan, ct) until the caller's token /// cancels, recording the call count and the token it was handed. Used to - /// prove the per-entry fallback loop's own budget bounds the wait (arch-review - /// 2026-08-19 P5-1) rather than blocking forever on a wedged downstream call. + /// prove the per-entry fallback loop's own budget bounds the wait rather blocking forever on a wedged downstream call. /// private sealed class HangingSiteCallRepo : ISiteCallAuditRepository { private int _callCount; + /// Gets the number of times has been invoked. public int CallCount => _callCount; + /// Gets the cancellation token most recently passed to . public CancellationToken? ReceivedToken { get; private set; } + /// public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) { Interlocked.Increment(ref _callCount); @@ -487,19 +517,25 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false); } + /// public Task GetAsync(TrackedOperationId id, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new NotSupportedException(); @@ -513,22 +549,31 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< private sealed class ThrowingSiteCallRepo : ISiteCallAuditRepository { private readonly ISiteCallAuditRepository _inner; + /// Initializes the double with the real repository that read members delegate to. + /// The real repository to delegate to. public ThrowingSiteCallRepo(ISiteCallAuditRepository inner) { _inner = inner; } + /// public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => throw new InvalidOperationException("simulated SiteCalls upsert failure"); + /// public Task GetAsync(TrackedOperationId id, CancellationToken ct = default) => _inner.GetAsync(id, ct); + /// public Task> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => _inner.QueryAsync(filter, paging, ct); + /// public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => _inner.PurgeTerminalAsync(olderThanUtc, ct); + /// public Task ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct); + /// public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct); + /// public Task> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct); @@ -543,11 +588,15 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< { private readonly ISiteCallAuditRepository _inner; private readonly TrackedOperationId _poisonId; + /// Initializes the double with the real repository and the id whose upsert should fail. + /// The real repository to delegate to. + /// The tracked operation id whose upsert throws. public PoisonOnIdSiteCallRepo(ISiteCallAuditRepository inner, TrackedOperationId poisonId) { _inner = inner; _poisonId = poisonId; } + /// public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) { if (siteCall.TrackedOperationId == _poisonId) @@ -556,19 +605,25 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< } return _inner.UpsertAsync(siteCall, ct); } + /// public Task GetAsync(TrackedOperationId id, CancellationToken ct = default) => _inner.GetAsync(id, ct); + /// public Task> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => _inner.QueryAsync(filter, paging, ct); + /// public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => _inner.PurgeTerminalAsync(olderThanUtc, ct); + /// public Task ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct); + /// public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct); + /// public Task> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct); diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs index de464437..a0e97181 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs @@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// /// Bundle D D2 tests for . Uses the same -/// as the M1 repository tests so the actor +/// as the repository tests so the actor /// exercises real /// against a partitioned MSSQL schema (the only way to verify the /// IngestedAtUtc stamp + duplicate-key idempotency end to end). @@ -27,6 +27,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixtureInitializes the test class with the shared MSSQL migration fixture. + /// The shared MSSQL migration fixture. public AuditLogIngestActorTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -61,6 +63,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture.Instance))); } + /// Verifies that a batch of five events calls the repository five times and acknowledges all five. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_BatchOf5_Calls_Repo_5Times_Acks_All_5() { @@ -87,6 +91,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixtureVerifies that a batch containing an already-existing event acknowledges every event without inserting a duplicate. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_BatchWith_AlreadyExistingEvent_AcksAll_NoDoubleInsert() { @@ -126,12 +132,14 @@ public class AuditLogIngestActorTests : TestKit, IClassFixtureVerifies that duplicate event ids within one packet produce a single row and are all acknowledged. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_DuplicateEventIds_WithinOnePacket_ProduceOneRow_AndAreAllAcked() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); - // WP2.2 writes each packet as ONE set-based statement, whose anti-semi-join + // The repository writes each packet as ONE set-based statement, whose anti-semi-join // only sees already-COMMITTED rows. Two copies of an EventId inside one // packet would therefore both pass it and collide on the clustered PK, // taking the whole packet down — the repository de-duplicates first. @@ -161,6 +169,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixtureVerifies that sending the same packet twice is idempotent across packets. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_SamePacketTwice_IsIdempotent_AcrossPackets() { @@ -194,6 +204,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixtureVerifies that overlapping packets insert only the new rows. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_OverlappingPackets_InsertOnlyTheNewRows() { @@ -225,6 +237,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixtureVerifies that the actor stamps IngestedAtUtc before inserting an event. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_Sets_IngestedAtUtc_Before_Insert() { @@ -257,6 +271,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixtureVerifies that when the repository throws for one event, the other four are still persisted. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Receive_RepoThrowsForOneEvent_Other4StillPersisted() { @@ -289,10 +305,12 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture r.EventId == poisonId); } + /// Verifies that when repository resolution throws, the actor survives, replies with an empty acknowledgement, and counts the failure. + /// A task that represents the asynchronous operation. [Fact] public async Task Receive_WhenRepositoryResolutionThrows_ActorSurvives_RepliesEmpty_CountsFailure() { - // AuditLog-017 (covers the AuditLog-014 guard): the production ctor resolves the + // The production ctor resolves the // scoped repository per message. If scope creation / repository resolution throws // (transient DI or DbContext-factory fault, pooled-context init, a resolution race // during host churn), the outer guard must keep the singleton ALIVE, increment the @@ -343,6 +361,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture + /// A task that represents the asynchronous operation. [Fact] public async Task Receive_WhenBatchFails_PerRowFallbackUsesAFreshToken() { @@ -371,7 +390,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixtureCounts how many times the guard's catch surfaced a write failure. private sealed class CountingFailureCounter : ICentralAuditWriteFailureCounter { + /// Gets the number of failures counted so far. public int Count { get; private set; } + /// public void Increment() => Count++; } @@ -382,13 +403,17 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture private sealed class TokenRecordingRepository : IAuditLogRepository { + /// public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + /// Gets the cancellation token handed to the set-based batch insert. public CancellationToken? BatchToken { get; private set; } + /// Gets the cancellation tokens handed to each per-row insert. public List RowTokens { get; } = new(); + /// public Task InsertManyIfNotExistsAsync( IReadOnlyList events, TimeSpan? commandTimeout = null, CancellationToken ct = default) { @@ -396,40 +421,49 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) { RowTokens.Add(ct); return Task.CompletedTask; } + /// public Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task SwitchOutPartitionAsync( DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task PurgeChannelOlderThanAsync( string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => throw new NotSupportedException(); + /// public Task BackfillSourceNodeAsync( string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => throw new NotSupportedException(); + /// public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => throw new NotSupportedException(); } @@ -441,18 +475,23 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture private sealed class ThrowingRepository : IAuditLogRepository { + /// public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); private readonly IAuditLogRepository _inner; private readonly Guid _poisonId; + /// Initializes the double with an inner repository and the id of the event that must fail. + /// The real repository to delegate to. + /// The event id for which inserts throw. public ThrowingRepository(IAuditLogRepository inner, Guid poisonId) { _inner = inner; _poisonId = poisonId; } + /// public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) { if (evt.EventId == _poisonId) @@ -462,33 +501,41 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture public Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => _inner.QueryAsync(filter, paging, ct); + /// public Task SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => _inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct); + /// public Task PurgeChannelOlderThanAsync( string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => _inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows); + /// public Task BackfillSourceNodeAsync( string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => _inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct); + /// public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => _inner.GetPartitionBoundariesOlderThanAsync(threshold, ct); + /// public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => _inner.GetKpiSnapshotAsync(window, nowUtc, ct); + /// public Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => _inner.GetExecutionTreeAsync(executionId, ct); + /// public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => _inner.GetDistinctSourceNodesAsync(ct); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPartitionMaintenanceOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPartitionMaintenanceOptionsValidatorTests.cs index 2b45be34..c278099e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPartitionMaintenanceOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPartitionMaintenanceOptionsValidatorTests.cs @@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// public class AuditLogPartitionMaintenanceOptionsValidatorTests { + /// Verifies that the default options pass validation. [Fact] public void DefaultOptions_AreValid() { @@ -17,6 +18,7 @@ public class AuditLogPartitionMaintenanceOptionsValidatorTests Assert.True(validator.Validate(null, new AuditLogPartitionMaintenanceOptions()).Succeeded); } + /// Verifies that a zero interval fails validation and names the interval property. [Fact] public void ZeroIntervalSeconds_Fails() { @@ -28,6 +30,7 @@ public class AuditLogPartitionMaintenanceOptionsValidatorTests f => f.Contains(nameof(AuditLogPartitionMaintenanceOptions.IntervalSeconds), StringComparison.Ordinal)); } + /// Verifies that a zero look-ahead fails validation and names the look-ahead property. [Fact] public void ZeroLookaheadMonths_Fails() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPartitionMaintenanceServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPartitionMaintenanceServiceTests.cs index 69055d58..cd63f9d8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPartitionMaintenanceServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPartitionMaintenanceServiceTests.cs @@ -9,7 +9,7 @@ using Xunit; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// -/// Bundle D (#23 M6-T5) tests for . +/// Tests for . /// All tests use an in-memory stub — /// the real EF/MSSQL implementation is exercised by the /// AuditLogPartitionMaintenanceTests integration suite in @@ -28,6 +28,7 @@ public class AuditLogPartitionMaintenanceServiceTests public int EnsureCallCount; public Exception? ThrowOnce; + /// public Task> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default) { Interlocked.Increment(ref EnsureCallCount); @@ -39,6 +40,7 @@ public class AuditLogPartitionMaintenanceServiceTests return Task.FromResult>(Array.Empty()); } + /// public Task GetMaxBoundaryAsync(CancellationToken ct = default) => Task.FromResult(DateTime.UtcNow.AddMonths(6)); } @@ -50,12 +52,27 @@ public class AuditLogPartitionMaintenanceServiceTests /// private sealed class CapturingLogger : ILogger { + /// Gets the log entries captured so far. public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new(); + /// Returns no scope; the fake does not track scopes. + /// The type of the scope state. + /// The scope state (ignored). + /// Always null. public IDisposable? BeginScope(TState state) where TState : notnull => null; + /// Reports every log level as enabled. + /// The log level to check. + /// Always true. public bool IsEnabled(LogLevel logLevel) => true; + /// Records the log entry for later assertions. + /// The type of the log state. + /// The severity of the entry. + /// The event identifier. + /// The state object to format. + /// The exception associated with the entry, if any. + /// Formats the state and exception into the message. public void Log( LogLevel logLevel, EventId eventId, @@ -77,6 +94,8 @@ public class AuditLogPartitionMaintenanceServiceTests return services.BuildServiceProvider(); } + /// Verifies that the service starts and stops without throwing. + /// A task that represents the asynchronous operation. [Fact] public async Task StartStop_NoExceptions() { @@ -113,6 +132,8 @@ public class AuditLogPartitionMaintenanceServiceTests Assert.True(maintenance.EnsureCallCount >= 1, $"expected at least 1 ensure call, got {maintenance.EnsureCallCount}"); } + /// Verifies that an exception thrown during maintenance is logged and not propagated. + /// A task that represents the asynchronous operation. [Fact] public async Task SafeMaintain_ExceptionLogged_NotPropagated() { @@ -152,10 +173,12 @@ public class AuditLogPartitionMaintenanceServiceTests Assert.Equal(1, maintenance.EnsureCallCount); } + /// Verifies that stopping the service after it has been disposed does not throw. + /// A task that represents the asynchronous operation. [Fact] public async Task StopAsync_AfterDispose_DoesNotThrow() { - // Regression (Gitea #15 follow-up): Dispose tears down the CTS that + // Regression: Dispose tears down the CTS that // StopAsync cancels, and the host does not guarantee that every // IHostedService.StopAsync is driven before the DI container is // disposed — WebApplicationFactory's teardown reaches Dispose first and diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs index 9db8dba8..f1e0a0fe 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs @@ -18,18 +18,20 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// -/// Bundle C (#23 M6-T4) tests for . The fast, +/// Tests for . The fast, /// schedule-only tests substitute a recording stub for /// so the timer + per-boundary error-isolation /// + event-publish machinery can be exercised without an MSSQL container. /// The end-to-end "real partition gets switched out" assertion lives in the -/// repository tests (Bundle C of M6-T4); this actor file is purely about the +/// repository tests; this actor file is purely about the /// actor's policy decisions. /// public class AuditLogPurgeActorTests : TestKit, IClassFixture { private readonly MsSqlMigrationFixture _fixture; + /// Initializes the test class with the shared MSSQL migration fixture. + /// The shared MSSQL migration fixture. public AuditLogPurgeActorTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -45,38 +47,52 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture private sealed class RecordingRepo : IAuditLogRepository { + /// public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + /// Gets the thresholds passed to each partition-boundary query. public List ThresholdQueries { get; } = new(); + /// Gets the partition boundaries that were switched out, in call order. public List SwitchedBoundaries { get; } = new(); + /// Gets or sets the function that returns the row count reported for a switched boundary. public Func RowsPerBoundary { get; set; } = _ => 0L; + /// Gets or sets the boundary whose switch-out should throw, if any. public DateTime? ThrowOnBoundary { get; set; } + /// Gets or sets the exception thrown for ; a default failure is used when null. public Exception? BoundaryException { get; set; } - // Task 4: captures the maintenance command timeout the actor threads through to the + // Captures the maintenance command timeout the actor threads through to the // switch-out + per-channel purge, so tests can assert the options value reaches the repo. + /// Gets the command timeouts passed to each partition switch-out call. public List SwitchTimeouts { get; } = new(); + /// Gets the command timeouts passed to each per-channel purge call. public List ChannelTimeouts { get; } = new(); - // M5.5 (T3): records every per-channel purge call as + // Records every per-channel purge call as // (channel, threshold, batchSize) so tests can assert which channels the // actor chose to purge and with what window. + /// Gets every per-channel purge call as a channel, threshold, batch size and row ceiling. public List<(string Channel, DateTime Threshold, int BatchSize, int MaxRows)> ChannelPurges { get; } = new(); + /// Gets or sets the function that returns the row count reported for a purged channel. public Func RowsPerChannel { get; set; } = _ => 0L; // The actor enumerator returns whichever list is configured here. // Mutating this between ticks lets tests simulate "no longer // eligible" boundaries on the second tick. + /// Gets or sets the partition boundaries returned by the next threshold query. public List Boundaries { get; set; } = new(); + /// public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask; + /// public Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + /// public Task SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) { SwitchTimeouts.Add(commandTimeout); @@ -88,6 +104,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) { @@ -95,6 +112,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture>(Boundaries.ToArray()); } + /// public Task PurgeChannelOlderThanAsync( string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) { @@ -103,18 +121,22 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture public Task BackfillSourceNodeAsync( string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => Task.FromResult(0L); + /// public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); + /// public Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => Task.FromResult>(Array.Empty()); + /// public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => Task.FromResult>(Array.Empty()); } @@ -194,6 +216,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixtureVerifies that the purge actor runs a purge pass on each daily interval tick. [Fact] public void Tick_Fires_OnDailyInterval() { @@ -216,6 +239,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixtureVerifies that a tick switches out partitions older than the retention threshold. [Fact] public void Tick_OldPartitions_SwitchedOut() { @@ -247,6 +271,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixtureVerifies that a tick leaves partitions newer than the retention threshold untouched. [Fact] public void Tick_NewerPartitions_Untouched() { @@ -276,6 +301,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixtureVerifies that a tick publishes a purged event carrying the number of rows removed. [Fact] public void Tick_PublishesPurgedEvent_WithRowCount() { @@ -301,6 +327,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixtureVerifies that a failed switch-out does not prevent the remaining partitions from being processed. [Fact] public void Tick_SwitchThrows_OtherPartitionsStillProcessed() { @@ -333,6 +360,8 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixtureVerifies that a real partition is switched out end to end, its rows are removed and a purged event is published. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished() { @@ -401,7 +430,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture.Instance))); - // Fish for the Jan-2026 partition boundary — the only eligible one in this + // Fish for the January 2026 partition boundary — the only eligible one in this // fixture DB. The generous timeout covers the real drop-and-rebuild dance // against MSSQL which routinely takes a couple of seconds on a busy dev container. var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -436,6 +465,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixtureVerifies that the purge threshold is derived from the configured audit log retention days. [Fact] public void Threshold_UsesAuditLogOptionsRetentionDays() { @@ -467,9 +497,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixtureVerifies that a per-channel retention override shorter than the global window triggers a channel purge. [Fact] public void PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge() { @@ -515,9 +546,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixtureVerifies that a per-channel retention override equal to or longer than the global window does not trigger a channel purge. [Fact] public void PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge() { @@ -561,9 +593,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixtureVerifies that a purge tick passes the configured maintenance command timeout to the repository. [Fact] public void PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository() { @@ -608,9 +641,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixtureVerifies that a failing switch-out publishes a purge-failed event and increments the health failure counter. [Fact] public void SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeOptionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeOptionsTests.cs index fddf8298..5400ad0c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeOptionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeOptionsTests.cs @@ -3,13 +3,14 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// -/// Task 4 tests for / +/// Tests for / /// : the maintenance timeout /// defaults to 30 minutes and is clamped to a 1-minute floor so a misconfigured 0/negative value /// cannot yield a zero/negative ADO.NET command timeout (arch-review 04, S2). /// public class AuditLogPurgeOptionsTests { + /// Verifies that the maintenance command timeout defaults to 30 minutes. [Fact] public void MaintenanceCommandTimeout_Defaults_To_30_Minutes() { @@ -18,6 +19,9 @@ public class AuditLogPurgeOptionsTests Assert.Equal(TimeSpan.FromMinutes(30), options.ResolvedMaintenanceCommandTimeout); } + /// Verifies that the resolved maintenance command timeout is clamped to a minimum of one minute. + /// The configured timeout in minutes. + /// The expected resolved timeout in minutes. [Theory] [InlineData(0, 1)] // clamp floor — a zero config value cannot become a zero timeout [InlineData(-5, 1)] // clamp floor — negatives are rejected by ADO.NET diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeOptionsValidatorTests.cs index d2c05964..6aac7bad 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeOptionsValidatorTests.cs @@ -12,6 +12,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// public class AuditLogPurgeOptionsValidatorTests { + /// Verifies that the default options are valid. + /// The result. [Fact] public void DefaultOptions_AreValid() { @@ -19,6 +21,8 @@ public class AuditLogPurgeOptionsValidatorTests Assert.True(validator.Validate(null, new AuditLogPurgeOptions()).Succeeded); } + /// Verifies that a zero interval in hours fails validation. + /// The result. [Fact] public void ZeroIntervalHours_Fails() { @@ -29,6 +33,8 @@ public class AuditLogPurgeOptionsValidatorTests f => f.Contains(nameof(AuditLogPurgeOptions.IntervalHours), StringComparison.Ordinal)); } + /// Verifies that a zero channel purge batch size fails validation. + /// The result. [Fact] public void ZeroChannelPurgeBatchSize_Fails() { @@ -40,6 +46,8 @@ public class AuditLogPurgeOptionsValidatorTests f => f.Contains(nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured), StringComparison.Ordinal)); } + /// Verifies that a zero maintenance command timeout fails validation. + /// The result. [Fact] public void ZeroMaintenanceCommandTimeout_Fails() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditRedactionFailureCounterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditRedactionFailureCounterTests.cs index 1932241f..834a6059 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditRedactionFailureCounterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditRedactionFailureCounterTests.cs @@ -11,16 +11,17 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Payload; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// -/// Bundle E (M6-T9) coverage for the central-side payload-filter redactor -/// failure bridge. M5 wired the SITE bridge +/// Coverage for the central-side payload-filter redactor +/// failure bridge. The SITE bridge /// (HealthMetricsAuditRedactionFailureCounter) that pushes increments -/// into the site health report; M6 mirrors that with +/// into the site health report; the central bridge mirrors that with /// so the same payload /// filter — when it runs on the central writer paths — surfaces failures on /// the central . /// public class CentralAuditRedactionFailureCounterTests : TestKit { + /// Verifies that incrementing the counter routes the failure to the central health snapshot. [Fact] public void Increment_Routes_To_Snapshot() { @@ -34,6 +35,7 @@ public class CentralAuditRedactionFailureCounterTests : TestKit Assert.Equal(3, snapshot.AuditRedactionFailure); } + /// Verifies that constructing the counter with a null snapshot throws. [Fact] public void Construction_With_Null_Snapshot_Throws() { @@ -41,6 +43,7 @@ public class CentralAuditRedactionFailureCounterTests : TestKit () => new CentralAuditRedactionFailureCounter(null!)); } + /// Verifies that registering central maintenance replaces the redaction failure counter with the central implementation. [Fact] public void AddAuditLogCentralMaintenance_Replaces_IAuditRedactionFailureCounter_With_CentralImpl() { @@ -72,11 +75,12 @@ public class CentralAuditRedactionFailureCounterTests : TestKit Assert.IsType(counter); } + /// Verifies that the default redaction failure counter registered by the audit log is a no-op. [Fact] public void AddAuditLog_Default_IAuditRedactionFailureCounter_Is_NoOp() { // Sanity check: without AddAuditLogCentralMaintenance the default - // remains the NoOp from M5 — the central bridge only takes effect + // remains the NoOp — the central bridge only takes effect // when the central-only registration runs. var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs index ee4594d5..5221c689 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs @@ -13,7 +13,7 @@ using IAuditInboundCeilingHitsCounter = ZB.MOM.WW.ScadaBridge.AuditLog.Central.I namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// -/// Bundle E (M6-T8) regression coverage for the central-side audit-write +/// Regression coverage for the central-side audit-write /// failure counter. and /// both swallow repository throws (audit /// must NEVER abort the user-facing action, alog.md §13) but bump the @@ -37,33 +37,43 @@ public class CentralAuditWriteFailuresTests : TestKit /// private sealed class ThrowingRepo : IAuditLogRepository { + /// public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + /// public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("simulated repo failure"); + /// public Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + /// public Task SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => Task.FromResult(0L); + /// public Task PurgeChannelOlderThanAsync( string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => Task.FromResult(0L); + /// public Task BackfillSourceNodeAsync( string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => Task.FromResult(0L); + /// public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + /// public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); + /// public Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => Task.FromResult>(Array.Empty()); + /// public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => Task.FromResult>(Array.Empty()); } @@ -75,10 +85,14 @@ public class CentralAuditWriteFailuresTests : TestKit private sealed class RecordingFailureCounter : ICentralAuditWriteFailureCounter { private int _count; + /// Gets the number of increments recorded so far. public int Count => Volatile.Read(ref _count); + /// public void Increment() => Interlocked.Increment(ref _count); } + /// Verifies that a forced repository failure increments the audit-write failure counter. + /// A task that represents the asynchronous operation. [Fact] public async Task Forced_Failure_Increments_Counter() { @@ -101,6 +115,8 @@ public class CentralAuditWriteFailuresTests : TestKit Assert.Equal(1, counter.Count); } + /// Verifies that a repository failure inside the ingest actor increments the audit-write failure counter. + /// A task that represents the asynchronous operation. [Fact] public async Task AuditLogIngestActor_Failure_Increments_Counter() { @@ -128,6 +144,7 @@ public class CentralAuditWriteFailuresTests : TestKit Assert.Equal(batch.Length, counter.Count); } + /// Verifies that the health snapshot aggregates the failure counters together with the stalled-telemetry state. [Fact] public void Snapshot_Aggregates_Counters_And_StalledState() { @@ -165,6 +182,7 @@ public class CentralAuditWriteFailuresTests : TestKit Assert.Equal(1, snapshot.AuditRedactionFailure); } + /// Verifies that a newly constructed health snapshot reports empty counters. [Fact] public void Snapshot_Empty_OnConstruction() { @@ -178,11 +196,12 @@ public class CentralAuditWriteFailuresTests : TestKit } // --------------------------------------------------------------------- - // M5.3 (T7) AuditInboundCeilingHits counter + // AuditInboundCeilingHits counter // AuditCentralHealthSnapshot implements IAuditInboundCeilingHitsCounter. // Incrementing through the interface surface is reflected on the snapshot. // --------------------------------------------------------------------- + /// Verifies that the inbound ceiling-hit counter starts at zero. [Fact] public void AuditInboundCeilingHits_StartsAtZero() { @@ -190,6 +209,7 @@ public class CentralAuditWriteFailuresTests : TestKit Assert.Equal(0, snapshot.AuditInboundCeilingHits); } + /// Verifies that incrementing the ceiling-hit counter through its interface is reflected on the snapshot. [Fact] public void AuditInboundCeilingHits_IncrementedThroughInterface_ReflectedOnSnapshot() { @@ -203,6 +223,7 @@ public class CentralAuditWriteFailuresTests : TestKit Assert.Equal(3, snapshot.AuditInboundCeilingHits); } + /// Verifies that concurrent increments of the ceiling-hit counter are not lost. [Fact] public void AuditInboundCeilingHits_IsThreadSafe() { @@ -217,6 +238,7 @@ public class CentralAuditWriteFailuresTests : TestKit Assert.Equal(incrementCount, snapshot.AuditInboundCeilingHits); } + /// Verifies that the ceiling-hit counter does not affect the other health counters. [Fact] public void AuditInboundCeilingHits_IsIndependentOfOtherCounters() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriterTests.cs index dbff7744..1f17dc70 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriterTests.cs @@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// -/// M4 Bundle B1 — unit tests for , the +/// Unit tests for , the /// central-only direct-write implementation of . /// The writer is a thin wrapper around /// : it stamps @@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// public class CentralAuditWriterTests { - // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory. + // Canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory. private static AuditEvent NewEvent(Guid? eventId = null) => ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.Notification, @@ -43,6 +43,8 @@ public class CentralAuditWriterTests return (new CentralAuditWriter(provider, NullLogger.Instance), repo); } + /// Verifies that WriteAsync passes the event to the repository's InsertIfNotExistsAsync. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync() { @@ -56,6 +58,8 @@ public class CentralAuditWriterTests Arg.Any()); } + /// Verifies that WriteAsync stamps the ingested-at UTC time on the event before inserting it. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert() { @@ -66,7 +70,7 @@ public class CentralAuditWriterTests var after = DateTime.UtcNow; await repo.Received(1).InsertIfNotExistsAsync( - // C3 (Task 2.5): IngestedAtUtc now rides in DetailsJson on the canonical + // IngestedAtUtc now rides in DetailsJson on the canonical // record — read it back via the decomposed row view. Arg.Is(e => e.AsRow().IngestedAtUtc != null && @@ -75,6 +79,8 @@ public class CentralAuditWriterTests Arg.Any()); } + /// Verifies that an exception thrown by the repository during WriteAsync does not propagate to the caller. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_Repository_Throws_DoesNotPropagate() { @@ -90,6 +96,8 @@ public class CentralAuditWriterTests await writer.WriteAsync(NewEvent()); } + /// Verifies that WriteAsync resolves the repository from a fresh scope on every call. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope() { @@ -114,6 +122,7 @@ public class CentralAuditWriterTests Assert.NotSame(instances[0], instances[1]); } + /// Verifies that constructing the writer with a null service provider throws. [Fact] public void Constructor_NullServices_Throws() { @@ -121,6 +130,7 @@ public class CentralAuditWriterTests () => new CentralAuditWriter(null!, NullLogger.Instance)); } + /// Verifies that constructing the writer with a null logger throws. [Fact] public void Constructor_NullLogger_Throws() { @@ -129,7 +139,7 @@ public class CentralAuditWriterTests () => new CentralAuditWriter(services, null!)); } - // ----- SourceNode stamping (Task 12) ----- // + // ----- SourceNode stamping ----- // private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity( INodeIdentityProvider? nodeIdentity) @@ -147,6 +157,8 @@ public class CentralAuditWriterTests return (writer, repo); } + /// Verifies that WriteAsync stamps the source node from the identity provider when the event has none. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone() { @@ -159,6 +171,8 @@ public class CentralAuditWriterTests Arg.Any()); } + /// Verifies that WriteAsync preserves a source node supplied by the caller. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_PreservesCallerProvidedSourceNode() { @@ -172,6 +186,8 @@ public class CentralAuditWriterTests Arg.Any()); } + /// Verifies that WriteAsync leaves the source node null when the identity provider returns null. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull() { @@ -184,11 +200,13 @@ public class CentralAuditWriterTests Arg.Any()); } + /// Verifies that WriteAsync passes the caller's source node through unchanged when no identity provider is injected. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected() { // Locks the back-compat contract for the optional `nodeIdentity = null` - // ctor parameter: when no provider is wired (e.g. legacy M4 test + // ctor parameter: when no provider is wired (e.g. legacy test // composition roots), the writer must not stamp — caller value passes // through unmodified. Distinct code path from // "provider supplied, returns null", which the test above covers. @@ -201,6 +219,8 @@ public class CentralAuditWriterTests Arg.Any()); } + /// Verifies that WriteAsync leaves the source node null when neither the caller nor an identity provider supplies one. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/GrpcPullAuditEventsClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/GrpcPullAuditEventsClientTests.cs index 61cba883..48eedc62 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/GrpcPullAuditEventsClientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/GrpcPullAuditEventsClientTests.cs @@ -12,7 +12,7 @@ using ProtoPullResponse = ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEven namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// -/// Bundle (M6) tests for — the +/// Tests for — the /// production that dials a site over gRPC /// and issues the PullAuditEvents unary RPC for the reconciliation loop. /// The real GrpcChannel is replaced by an injected @@ -29,7 +29,10 @@ public class GrpcPullAuditEventsClientTests private sealed class StaticEnumerator : ISiteEnumerator { private readonly IReadOnlyList _sites; + /// Initializes the enumerator with the fixed set of sites it will return. + /// The sites the enumerator returns on every call. public StaticEnumerator(params SiteEntry[] sites) => _sites = sites; + /// public Task> EnumerateAsync(CancellationToken ct = default) => Task.FromResult(_sites); } @@ -41,8 +44,11 @@ public class GrpcPullAuditEventsClientTests /// private sealed class FakeInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker { + /// Gets the endpoint most recently dialed through this invoker. public string? Endpoint { get; private set; } + /// Gets the request most recently passed to this invoker. public ProtoPullRequest? Request { get; private set; } + /// Gets the number of times this invoker has been called. public int CallCount { get; private set; } private readonly ProtoPullResponse? _response; @@ -54,9 +60,17 @@ public class GrpcPullAuditEventsClientTests _throw = toThrow; } + /// Creates an invoker that returns the given response. + /// The response returned from every invocation. + /// An invoker that returns the given response. public static FakeInvoker Returning(ProtoPullResponse response) => new(response, null); + /// Creates an invoker that throws the given exception. + /// The exception thrown from every invocation. + /// An invoker that throws the given exception. public static FakeInvoker Throwing(Exception ex) => new(null, ex); + // Records the call and returns the canned response or throws the canned exception. + /// public Task InvokeAsync( string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct) { @@ -72,17 +86,22 @@ public class GrpcPullAuditEventsClientTests } /// - /// Per-endpoint fake invoker (Task 18): routes dials by endpoint string so a + /// Per-endpoint fake invoker: routes dials by endpoint string so a /// NodeA-down / NodeB-up failover can be scripted. Records dialed endpoints. /// private sealed class PerEndpointInvoker : GrpcPullAuditEventsClient.IPullAuditEventsInvoker { private readonly Dictionary> _byEndpoint; + /// Gets the endpoints dialed so far, in call order. public List Dialed { get; } = new(); + /// Initializes the invoker with the scripted response factory for each endpoint. + /// The response factory keyed by endpoint address. public PerEndpointInvoker(Dictionary> byEndpoint) => _byEndpoint = byEndpoint; + // Records the dialed endpoint and returns the response scripted for it. + /// public Task InvokeAsync( string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct) { @@ -100,6 +119,8 @@ public class GrpcPullAuditEventsClientTests status: AuditStatus.Delivered, sourceSiteId: "site-a")); + /// Verifies that the pull dials the resolved endpoint and maps the returned events oldest first. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_dials_the_resolved_endpoint_and_maps_events_oldest_first() { @@ -132,6 +153,8 @@ public class GrpcPullAuditEventsClientTests Assert.Equal(newer, result.Events[1].EventId); } + /// Verifies that pulling from a site with no known endpoint returns an empty response. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown() { @@ -148,6 +171,9 @@ public class GrpcPullAuditEventsClientTests Assert.Equal(0, invoker.CallCount); // never dialled — nothing to dial } + /// Verifies that tolerable transport faults are swallowed and produce an empty response. + /// The gRPC status code the invoker fails with. + /// A task that represents the asynchronous operation. [Theory] [InlineData(StatusCode.Unavailable)] // connection refused / site offline [InlineData(StatusCode.DeadlineExceeded)] // slow site / network blip @@ -167,6 +193,8 @@ public class GrpcPullAuditEventsClientTests Assert.False(result.MoreAvailable); } + /// Verifies that connection-layer faults are swallowed and produce an empty response. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_swallows_connection_layer_faults_to_empty_response() { @@ -184,6 +212,8 @@ public class GrpcPullAuditEventsClientTests Assert.False(result.MoreAvailable); } + /// Verifies that unexpected faults are swallowed and produce an empty response. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_swallows_unexpected_faults_to_empty_response() { @@ -203,6 +233,8 @@ public class GrpcPullAuditEventsClientTests Assert.False(result.MoreAvailable); } + /// Verifies that an unspecified minimum-value cursor does not throw and the site is still dialed. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials() { @@ -233,10 +265,12 @@ public class GrpcPullAuditEventsClientTests Assert.False(result.MoreAvailable); } + /// Verifies that the pull fails over to the secondary node when the primary raises a transport fault. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault() { - // Task 18: NodeA (primary) is unreachable, NodeB (fallback) answers — the + // NodeA (primary) is unreachable, NodeB (fallback) answers — the // pull must return NodeB's events rather than collapsing to empty. var id = Guid.NewGuid(); var fallbackResponse = new ProtoPullResponse { MoreAvailable = false }; @@ -259,6 +293,8 @@ public class GrpcPullAuditEventsClientTests Assert.Equal(id, evt.EventId); } + /// Verifies that no failover is attempted when the site has no fallback endpoint configured. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_does_not_fail_over_when_no_fallback_configured() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/GrpcPullSiteCallsClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/GrpcPullSiteCallsClientTests.cs index 4daa0a21..df3b7d18 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/GrpcPullSiteCallsClientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/GrpcPullSiteCallsClientTests.cs @@ -11,7 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// /// Tests for — the production /// that dials a site over gRPC and issues the -/// PullSiteCalls unary RPC for the Site Call Audit (#22) reconciliation +/// PullSiteCalls unary RPC for the Site Call Audit reconciliation /// loop. The real GrpcChannel is replaced by an injected /// seam so the /// client's mapping / ordering / SourceSite-restamp / fault-swallowing behaviour @@ -26,15 +26,21 @@ public class GrpcPullSiteCallsClientTests private sealed class StaticEnumerator : ISiteEnumerator { private readonly IReadOnlyList _sites; + /// Initializes a new instance of the StaticEnumerator class. + /// The sites. public StaticEnumerator(params SiteEntry[] sites) => _sites = sites; + /// public Task> EnumerateAsync(CancellationToken ct = default) => Task.FromResult(_sites); } private sealed class FakeInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker { + /// Gets the endpoint most recently dialed. public string? Endpoint { get; private set; } + /// Gets the request most recently received. public ProtoPullRequest? Request { get; private set; } + /// Gets the number of calls made. public int CallCount { get; private set; } private readonly ProtoPullResponse? _response; @@ -46,9 +52,16 @@ public class GrpcPullSiteCallsClientTests _throw = toThrow; } + /// Creates an invoker that returns the given response. + /// The response to return. + /// A fake invoker returning . public static FakeInvoker Returning(ProtoPullResponse response) => new(response, null); + /// Creates an invoker that throws the given exception. + /// The exception to throw. + /// A fake invoker that throws . public static FakeInvoker Throwing(Exception ex) => new(null, ex); + /// public Task InvokeAsync( string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct) { @@ -64,18 +77,22 @@ public class GrpcPullSiteCallsClientTests } /// - /// Per-endpoint fake invoker (Task 18): dials are routed by endpoint string so + /// Per-endpoint fake invoker: dials are routed by endpoint string so /// a NodeA-down / NodeB-up failover can be scripted. Records every dialed /// endpoint in order. /// private sealed class PerEndpointInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker { private readonly Dictionary> _byEndpoint; + /// Gets the endpoints dialed, in order. public List Dialed { get; } = new(); + /// Initializes a new instance of the PerEndpointInvoker class. + /// The by endpoint. public PerEndpointInvoker(Dictionary> byEndpoint) => _byEndpoint = byEndpoint; + /// public Task InvokeAsync( string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct) { @@ -103,6 +120,8 @@ public class GrpcPullSiteCallsClientTests UpdatedAtUtc = Timestamp.FromDateTime(updatedAtUtc), }; + /// Verifies that PullAsync dials the resolved endpoint, maps rows oldest first and restamps the source site. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_dials_resolved_endpoint_maps_oldest_first_and_restamps_source_site() { @@ -144,6 +163,8 @@ public class GrpcPullSiteCallsClientTests Assert.Equal(1, result.SiteCalls[0].RetryCount); } + /// Verifies that PullAsync returns an empty response when the site endpoint is unknown. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown() { @@ -160,6 +181,9 @@ public class GrpcPullSiteCallsClientTests Assert.Equal(0, invoker.CallCount); // never dialled — nothing to dial } + /// Verifies that PullAsync turns tolerable transport faults into an empty response. + /// The code. + /// A task that represents the asynchronous operation. [Theory] [InlineData(StatusCode.Unavailable)] [InlineData(StatusCode.DeadlineExceeded)] @@ -178,6 +202,8 @@ public class GrpcPullSiteCallsClientTests Assert.False(result.MoreAvailable); } + /// Verifies that PullAsync turns connection-layer faults into an empty response. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_swallows_connection_layer_faults_to_empty_response() { @@ -193,6 +219,8 @@ public class GrpcPullSiteCallsClientTests Assert.False(result.MoreAvailable); } + /// Verifies that PullAsync turns unexpected faults into an empty response. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_swallows_unexpected_faults_to_empty_response() { @@ -208,6 +236,8 @@ public class GrpcPullSiteCallsClientTests Assert.False(result.MoreAvailable); } + /// Verifies that PullAsync skips a poison row and still returns the good rows. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_skips_poison_row_and_returns_the_good_rows() { @@ -244,6 +274,8 @@ public class GrpcPullSiteCallsClientTests Assert.False(result.MoreAvailable); } + /// Verifies that PullAsync with a MinValue unspecified-kind cursor does not throw and still dials the site. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials() { @@ -271,10 +303,12 @@ public class GrpcPullSiteCallsClientTests Assert.False(result.MoreAvailable); } + /// Verifies that PullAsync fails over to node B when the primary raises a transport fault. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault() { - // Task 18: NodeA (primary) is unreachable, NodeB (fallback) answers — the + // NodeA (primary) is unreachable, NodeB (fallback) answers — the // pull must return NodeB's rows rather than collapsing to empty. var id = Guid.NewGuid(); var fallbackResponse = new ProtoPullResponse(); @@ -299,6 +333,8 @@ public class GrpcPullSiteCallsClientTests Assert.Equal("site-a", row.SourceSite); } + /// Verifies that PullAsync does not fail over when no fallback is configured. + /// A task that represents the asynchronous operation. [Fact] public async Task PullAsync_does_not_fail_over_when_no_fallback_configured() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs index c145a9a9..deb2a259 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs @@ -18,19 +18,21 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// -/// Bundle B (M6-T3) tests for . Most +/// Tests for . Most /// tests substitute the with an in-memory /// recording stub so the actor's tick / cursor / stalled state machinery can /// be exercised in milliseconds without an MSSQL container. The duplicate / /// idempotency assertion uses the real against /// the so we verify InsertIfNotExistsAsync -/// actually swallows duplicate-key collisions (the M2 Bundle A race-fix the +/// actually swallows duplicate-key collisions (the race fix the /// reconciliation puller depends on). /// public class SiteAuditReconciliationActorTests : TestKit, IClassFixture { private readonly MsSqlMigrationFixture _fixture; + /// Initializes the test class with the shared MSSQL migration fixture. + /// The shared MSSQL migration fixture. public SiteAuditReconciliationActorTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -68,13 +70,17 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture private sealed class RecordingRepo : IAuditLogRepository { + /// public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + /// Gets the audit events recorded by this stub. public List Inserted { get; } = new(); private readonly HashSet _seen = new(); + /// Gets the number of times InsertIfNotExistsAsync was called. public int InsertCallCount { get; private set; } + /// public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) { InsertCallCount++; @@ -85,33 +91,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture public Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => Task.FromResult>(Inserted); + /// public Task SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => Task.FromResult(0L); + /// public Task PurgeChannelOlderThanAsync( string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => Task.FromResult(0L); + /// public Task BackfillSourceNodeAsync( string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => Task.FromResult(0L); + /// public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + /// public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); + /// public Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => Task.FromResult>(Array.Empty()); + /// public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => Task.FromResult>(Array.Empty()); } @@ -120,24 +134,30 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture throws for ONE /// poison (every attempt) but records every /// other row — including the synthetic ReconciliationAbandoned row the actor - /// emits once it gives up on the poison row (Task 17). + /// emits once it gives up on the poison row. /// private sealed class PoisonInsertRepo : IAuditLogRepository { + /// public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); private readonly Guid _poisonId; private readonly Exception _fault; + /// Gets the audit events recorded by this stub. public List Inserted { get; } = new(); private readonly HashSet _seen = new(); + /// Initializes the repository so that inserting the given event id always throws the given fault. + /// The event id whose insert always fails. + /// The exception thrown on every attempt to insert the poison row. public PoisonInsertRepo(Guid poisonId, Exception fault) { _poisonId = poisonId; _fault = fault; } + /// public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) { if (evt.EventId == _poisonId) @@ -151,33 +171,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture public Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => Task.FromResult>(Inserted); + /// public Task SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => Task.FromResult(0L); + /// public Task PurgeChannelOlderThanAsync( string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => Task.FromResult(0L); + /// public Task BackfillSourceNodeAsync( string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => Task.FromResult(0L); + /// public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + /// public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); + /// public Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => Task.FromResult>(Array.Empty()); + /// public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => Task.FromResult>(Array.Empty()); } @@ -191,19 +219,22 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureMaxPermanentInsertAttempts, so it keeps /// hasUnresolvedFailure true and holds the cursor at since forever - /// (P5-6's defect trigger). Dedupes inserted rows on + /// (the defect trigger). Dedupes inserted rows on /// exactly like the real InsertIfNotExistsAsync, so a test asserting on /// 's count is exercising the same idempotency contract /// production relies on. /// private sealed class AlwaysFailUnlessSyntheticRepo : IAuditLogRepository { + /// public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + /// Gets the audit events recorded by this stub. public List Inserted { get; } = new(); private readonly HashSet _seen = new(); + /// public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) { if (!evt.Action.EndsWith(".ReconciliationAbandoned", StringComparison.Ordinal)) @@ -217,33 +248,41 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture public Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => Task.FromResult>(Inserted); + /// public Task SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => Task.FromResult(0L); + /// public Task PurgeChannelOlderThanAsync( string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => Task.FromResult(0L); + /// public Task BackfillSourceNodeAsync( string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => Task.FromResult(0L); + /// public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + /// public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); + /// public Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => Task.FromResult>(Array.Empty()); + /// public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => Task.FromResult>(Array.Empty()); } @@ -254,7 +293,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture _sites; + /// Initializes the enumerator with a fixed set of sites. + /// The sites returned on every enumeration. public StaticEnumerator(params SiteEntry[] sites) => _sites = sites; + /// public Task> EnumerateAsync(CancellationToken ct = default) => Task.FromResult(_sites); } @@ -266,22 +308,32 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture private sealed class ScriptedPullClient : IPullAuditEventsClient { + /// Gets the recorded pull calls in the order they were made. public List<(string SiteId, DateTime SinceUtc, string? AfterId, int BatchSize)> Calls { get; } = new(); private readonly Dictionary> _scripted = new(); private readonly Dictionary _throwOnSite = new(); + /// Queues scripted responses for the given site. + /// The site whose pulls receive the scripted responses. + /// The responses returned in order on successive pulls. + /// This client, for chaining. public ScriptedPullClient Script(string siteId, params PullAuditEventsResponse[] responses) { _scripted[siteId] = new Queue(responses); return this; } + /// Makes every pull for the given site throw the given exception. + /// The site whose pulls fail. + /// The exception to throw. + /// This client, for chaining. public ScriptedPullClient ThrowFor(string siteId, Exception ex) { _throwOnSite[siteId] = ex; return this; } + /// public Task PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) { @@ -373,6 +425,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureVerifies that the reconciliation timer fires on the configured interval. [Fact] public void Timer_Fires_OnConfiguredInterval() { @@ -400,6 +453,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureVerifies that each tick pulls from every known site. [Fact] public void Tick_PullsFromEachKnownSite() { @@ -426,6 +480,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureVerifies that pulled events are ingested through the repository insert-if-not-exists call. [Fact] public void Tick_IngestEvents_ViaInsertIfNotExistsAsync() { @@ -456,6 +511,8 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture() .UseSqlServer(_fixture.ConnectionString).Options); + /// Verifies that duplicate events are not inserted twice. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Tick_Duplicates_NotDoubleInserted() { @@ -509,6 +566,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureVerifies that the cursor advances to the maximum occurred-at timestamp of the pulled events. [Fact] public void Cursor_Advances_ToMaxOccurredAtUtc() { @@ -545,13 +603,14 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureVerifies that the cursor advances on the event id tiebreak when every row shares one instant. [Fact] public void Cursor_AdvancesOnTheIdTiebreak_WhenEveryRowSharesOneInstant() { @@ -583,6 +642,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureVerifies that both cursor halves are held back while a row is still being retried. [Fact] public void BothCursorHalves_AreHeldBack_WhileARowIsStillBeingRetried() { @@ -612,41 +672,51 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureRepository whose every insert throws, so the retry hold-back path is taken. private sealed class AlwaysThrowingRepo : IAuditLogRepository { + /// public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + /// public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("central insert failed"); + /// public Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task SwitchOutPartitionAsync( DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task PurgeChannelOlderThanAsync( string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => throw new NotSupportedException(); + /// public Task BackfillSourceNodeAsync( string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => throw new NotSupportedException(); + /// public Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default, IReadOnlyList? permittedSiteIds = null) => throw new NotSupportedException(); + /// public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => throw new NotSupportedException(); } @@ -655,6 +725,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureVerifies that a failure pulling from one site does not stop the other sites from being processed. [Fact] public void Tick_OneSiteThrows_OtherSitesStillProcessed() { @@ -686,6 +757,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureVerifies that two consecutive non-draining cycles publish a stalled state of true. [Fact] public void StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue() { @@ -717,6 +789,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureVerifies that a draining cycle publishes a stalled state of false. [Fact] public void StalledDetection_DrainingCycle_PublishesStalledFalse() { @@ -750,11 +823,12 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureVerifies that a permanently abandoned row emits a ReconciliationAbandoned row and advances the cursor. [Fact] public void PermanentAbandonment_EmitsReconciliationAbandonedRow_AndAdvancesCursor() { @@ -804,11 +878,11 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture - /// Reproduces the P5-6 defect end to end: a permanently-poisoned row sits + /// Reproduces the rotating-poison defect end to end: a permanently-poisoned row sits /// in a batch alongside a co-batch row that ALSO fails on every pull but /// carries a fresh each time (modelling a /// site that keeps producing new events with transient insert failures). diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationOptionsValidatorTests.cs index 67fa7229..b2b70472 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationOptionsValidatorTests.cs @@ -12,6 +12,9 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// public class SiteAuditReconciliationOptionsValidatorTests { + /// Verifies that the batch size is valid only up to the RPC ceiling. + /// The batch size under test. + /// Whether the batch size is expected to validate. [Theory] [InlineData(1, true)] [InlineData(2000, true)] @@ -21,6 +24,7 @@ public class SiteAuditReconciliationOptionsValidatorTests => Assert.Equal(valid, new SiteAuditReconciliationOptionsValidator().Validate(null, new SiteAuditReconciliationOptions { BatchSize = size }).Succeeded); + /// Verifies that the default options pass validation. [Fact] public void DefaultOptions_AreValid() { @@ -28,6 +32,7 @@ public class SiteAuditReconciliationOptionsValidatorTests Assert.True(validator.Validate(null, new SiteAuditReconciliationOptions()).Succeeded); } + /// Verifies that a zero reconciliation interval fails validation. [Fact] public void ZeroReconciliationInterval_Fails() { @@ -39,6 +44,7 @@ public class SiteAuditReconciliationOptionsValidatorTests f => f.Contains(nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds), StringComparison.Ordinal)); } + /// Verifies that a zero batch size fails validation. [Fact] public void ZeroBatchSize_Fails() { @@ -50,6 +56,7 @@ public class SiteAuditReconciliationOptionsValidatorTests f => f.Contains(nameof(SiteAuditReconciliationOptions.BatchSize), StringComparison.Ordinal)); } + /// Verifies that a zero stalled-cycle threshold fails validation. [Fact] public void ZeroStalledCycles_Fails() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditTelemetryStalledTrackerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditTelemetryStalledTrackerTests.cs index 8171e5d5..485689c8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditTelemetryStalledTrackerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditTelemetryStalledTrackerTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; /// -/// Bundle E (M6-T7) tests for . +/// Tests for . /// The tracker subscribes to the actor system's EventStream for /// publications and maintains a /// per-site latch the central health surface can read. Since reconciliation is @@ -36,6 +36,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit interval: TimeSpan.FromMilliseconds(20)); } + /// Verifies that a newly created tracker reports an empty snapshot. [Fact] public void Initial_Snapshot_IsEmpty() { @@ -46,6 +47,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit Assert.Empty(snapshot); } + /// Verifies that a stalled event makes the tracker report the site as stalled. [Fact] public void StalledTrue_Event_TrackerReports_Stalled() { @@ -57,6 +59,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit Assert.True(snapshot["siteA"]); } + /// Verifies that a not-stalled event makes the tracker report the site as not stalled. [Fact] public void StalledFalse_Event_TrackerReports_NotStalled() { @@ -72,6 +75,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit Assert.False(snapshot["siteA"]); } + /// Verifies that multiple sites are tracked independently of one another. [Fact] public void Multiple_Sites_Tracked_Independently() { @@ -88,6 +92,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit Assert.True(snapshot["siteC"]); } + /// Verifies that constructing the tracker with a null actor system throws. [Fact] public void Constructor_With_Null_ActorSystem_Throws() { @@ -95,6 +100,7 @@ public class SiteAuditTelemetryStalledTrackerTests : TestKit () => new SiteAuditTelemetryStalledTracker((ActorSystem)null!)); } + /// Verifies that disposing the tracker unsubscribes it from the event stream. [Fact] public void Dispose_Unsubscribes_From_EventStream() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteEnumeratorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteEnumeratorTests.cs index 81febddb..448d1991 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteEnumeratorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteEnumeratorTests.cs @@ -41,6 +41,8 @@ public class SiteEnumeratorTests return services.BuildServiceProvider(); } + /// Verifies that sites with a NodeA address are projected and sites with a blank address are skipped. + /// A task that represents the asynchronous operation. [Fact] public async Task EnumerateAsync_ProjectsSitesWithNodeAAddress_AndSkipsBlankOnes() { @@ -60,6 +62,8 @@ public class SiteEnumeratorTests Assert.Equal("http://site-a:8083", entry.GrpcEndpoint); } + /// Verifies that a site with a null NodeA address is skipped. + /// A task that represents the asynchronous operation. [Fact] public async Task EnumerateAsync_SkipsNullNodeAAddress() { @@ -76,10 +80,12 @@ public class SiteEnumeratorTests Assert.Empty(result); } + /// Verifies that a site with distinct NodeA and NodeB addresses projects NodeB as the fallback endpoint. + /// A task that represents the asynchronous operation. [Fact] public async Task EnumerateAsync_BothAddresses_ProjectsNodeBAsFallback() { - // Task 18: a site with both NodeA and a DISTINCT NodeB yields + // A site with both NodeA and a DISTINCT NodeB yields // SiteEntry(primary=NodeA, FallbackGrpcEndpoint=NodeB). var repository = Substitute.For(); repository.GetAllSitesAsync(Arg.Any()).Returns(new List @@ -95,10 +101,12 @@ public class SiteEnumeratorTests Assert.Equal("http://site-b:8083", entry.FallbackGrpcEndpoint); } + /// Verifies that a site with only a NodeB address is not skipped and has no fallback endpoint. + /// A task that represents the asynchronous operation. [Fact] public async Task EnumerateAsync_OnlyNodeB_IsNotSkipped_AndHasNoFallback() { - // Task 18: a site with a blank NodeA but a valid NodeB is no longer + // A site with a blank NodeA but a valid NodeB is no longer // skipped — NodeB becomes the primary and there is no separate fallback. var repository = Substitute.For(); repository.GetAllSitesAsync(Arg.Any()).Returns(new List @@ -114,6 +122,8 @@ public class SiteEnumeratorTests Assert.Null(entry.FallbackGrpcEndpoint); } + /// Verifies that enumeration returns an empty list when there are no sites. + /// A task that represents the asynchronous operation. [Fact] public async Task EnumerateAsync_ReturnsEmpty_WhenNoSites() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs index f1393d16..f362c03f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs @@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration; /// -/// Bundle D (M5-T8) tests for hot-reloadable +/// Tests for hot-reloadable /// binding. The first test pins the JSON-realistic binding shape end-to-end /// (scalars, lists, per-target overrides) so accidental drift in the section /// layout breaks the build. The second test exercises the live hot-reload @@ -23,13 +23,14 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration; /// flowing through without a restart. /// /// -/// Distinct from (M1-T9) which covered +/// Distinct from which covered /// section binding + validator failures via single-key in-memory config — those /// tests exist; these add (a) end-to-end binding from a realistic JSON literal -/// and (b) the hot-reload behavioural contract the M5-T8 spec calls out. +/// and (b) the hot-reload behavioural contract the spec calls out. /// public class AuditLogOptionsBindingTests { + /// Verifies that the AuditLog configuration section binds every field. [Fact] public void AuditLog_Section_Binds_AllFields() { @@ -71,7 +72,7 @@ public class AuditLogOptionsBindingTests // HeaderRedactList: the Microsoft.Extensions.Configuration list binder // APPENDS to the default list, so we assert containment rather than - // exact equality (see M1-T9 AuditLogOptionsTests for the rationale). + // exact equality (see AuditLogOptionsTests for the rationale). Assert.Contains("Authorization", opts.HeaderRedactList); Assert.Contains("Custom-Token", opts.HeaderRedactList); @@ -90,10 +91,11 @@ public class AuditLogOptionsBindingTests Assert.Equal("@token|@secret", ov.RedactSqlParamsMatching); } + /// Verifies that the purge options bind from the documented section and keys. [Fact] public void PurgeOptions_Bind_FromDocumentedSectionAndKeys() { - // AuditLog-013: the design doc (Component-AuditLog.md §Configuration) + // The design doc (Component-AuditLog.md §Configuration) // documents the purge tuning as the nested `AuditLog:Purge` section with // keys `IntervalHours` + `ChannelPurgeBatchSize`. This test pins that the // code binds from EXACTLY that shape — the section path the production @@ -139,6 +141,7 @@ public class AuditLogOptionsBindingTests Assert.Equal(1000, opts.ChannelPurgeBatchSize); } + /// Verifies that the filter behavior updates when the configuration reloads. [Fact] public void Filter_Behavior_Updates_OnConfigReload() { @@ -165,7 +168,7 @@ public class AuditLogOptionsBindingTests Assert.True(Encoding.UTF8.GetByteCount(resultBefore.AsRow().RequestSummary!) <= 4096); // Reload: cap raised to 16384 — next event must NOT truncate. This is - // the M5-T8 contract: the filter sees the new value on the very next + // the hot-reload contract: the filter sees the new value on the very next // Apply, without process restart. monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 }); @@ -174,6 +177,7 @@ public class AuditLogOptionsBindingTests Assert.Equal(body, resultAfter.AsRow().RequestSummary); } + /// Verifies that the filter picks up a new body redactor when the configuration reloads. [Fact] public void Filter_PicksUp_NewBodyRedactor_OnConfigReload() { @@ -219,12 +223,21 @@ public class AuditLogOptionsBindingTests private T _current; private readonly List> _listeners = new(); + /// Initializes a new instance of the TestOptionsMonitor class. + /// The initial. public TestOptionsMonitor(T initial) => _current = initial; + /// Gets the current value value. public T CurrentValue => _current; + /// Returns the current options value regardless of the name. + /// The options name; ignored. + /// The current options value. public T Get(string? name) => _current; + /// Registers a listener to be notified when the options change. + /// The change callback. + /// A disposable that removes the listener, or null when none is tracked. public IDisposable? OnChange(Action listener) { lock (_listeners) @@ -234,6 +247,8 @@ public class AuditLogOptionsBindingTests return new Unsubscribe(_listeners, listener); } + /// Replaces the current value and notifies registered listeners. + /// The value. public void Set(T value) { _current = value; @@ -252,11 +267,15 @@ public class AuditLogOptionsBindingTests { private readonly List> _listeners; private readonly Action _listener; + /// Initializes a new instance of the Unsubscribe class. + /// The listeners. + /// The listener. public Unsubscribe(List> listeners, Action listener) { _listeners = listeners; _listener = listener; } + /// Releases the resources held by the test fixture. public void Dispose() { lock (_listeners) diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs index 19c27774..1471f7f3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs @@ -6,8 +6,8 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration; /// -/// Task 9 (Bundle E): binding + validator -/// behavior. The validator enforces invariants used by M2+ writers +/// Tests for binding and validator +/// behavior. The validator enforces invariants used by the audit writers /// (per docs/plans/2026-05-20-auditlog-m1-foundation.md): /// DefaultCapBytes > 0, ErrorCapBytes >= DefaultCapBytes, /// RetentionDays in [30, 3650]. Header-redact defaults match the @@ -25,6 +25,7 @@ public class AuditLogOptionsTests return services.BuildServiceProvider().GetRequiredService>(); } + /// Verifies that a fully populated configuration section binds every scalar field onto the options. [Fact] public void ValidBinding_PopulatesAllScalarFields() { @@ -40,6 +41,7 @@ public class AuditLogOptionsTests Assert.Equal(180, opts.RetentionDays); } + /// Verifies that an empty configuration section yields reasonable default option values. [Fact] public void DefaultsAreReasonable_WhenSectionEmpty() { @@ -56,6 +58,7 @@ public class AuditLogOptionsTests Assert.Empty(opts.PerTargetOverrides); } + /// Verifies that header redaction entries bound from configuration are appended to the default list. [Fact] public void HeaderRedactList_BindsFromConfig_AppendsToDefaults() { @@ -77,6 +80,7 @@ public class AuditLogOptionsTests Assert.Contains("X-Api-Key", opts.HeaderRedactList); } + /// Verifies that per-target override settings bind from configuration. [Fact] public void PerTargetOverrides_BindsFromConfig() { @@ -93,6 +97,7 @@ public class AuditLogOptionsTests Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!); } + /// Verifies that an invalid default payload cap fails validation. [Fact] public void InvalidDefaultCapBytes_FailsValidation() { @@ -104,6 +109,7 @@ public class AuditLogOptionsTests Assert.Contains("DefaultCapBytes", result.FailureMessage); } + /// Verifies that an invalid error payload cap fails validation. [Fact] public void InvalidErrorCapBytes_FailsValidation() { @@ -115,6 +121,7 @@ public class AuditLogOptionsTests Assert.Contains("ErrorCapBytes", result.FailureMessage); } + /// Verifies that a retention period below the minimum fails validation. [Fact] public void RetentionDaysBelowMinimum_FailsValidation() { @@ -126,6 +133,7 @@ public class AuditLogOptionsTests Assert.Contains("RetentionDays", result.FailureMessage); } + /// Verifies that a retention period above the maximum fails validation. [Fact] public void RetentionDaysAboveMaximum_FailsValidation() { @@ -137,6 +145,7 @@ public class AuditLogOptionsTests Assert.Contains("RetentionDays", result.FailureMessage); } + /// Verifies that the default options pass validation. [Fact] public void DefaultOptions_PassValidation() { @@ -147,6 +156,7 @@ public class AuditLogOptionsTests Assert.True(result.Succeeded, result.FailureMessage); } + /// Verifies that an invalid retention value bound from configuration is rejected when the options value is accessed. [Fact] public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs index b3207e42..d65958df 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration; /// -/// Task 1 of docs/plans/2026-05-23-inbound-api-full-response-audit.md: +/// Per docs/plans/2026-05-23-inbound-api-full-response-audit.md, this class /// pins the default to 1 MiB and /// the validator bounds to [8 KiB, 16 MiB]. The inbound channel needs a /// much larger ceiling than the 8 KiB / 64 KiB default/error caps that other @@ -14,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration; /// public class AuditLogOptionsValidatorTests { + /// Verifies that the default options set the inbound maximum body size to one mebibyte. [Fact] public void Validate_InboundMaxBytes_DefaultOptions_IsOneMebibyte() { @@ -24,6 +25,8 @@ public class AuditLogOptionsValidatorTests Assert.Equal(1_048_576, opts.InboundMaxBytes); } + /// Verifies that an inbound maximum body size within the documented range passes validation. + /// The inbound maximum body size in bytes. [Theory] [InlineData(8_192)] // documented min [InlineData(1_048_576)] // default @@ -35,6 +38,8 @@ public class AuditLogOptionsValidatorTests Assert.True(validator.Validate(null, opts).Succeeded); } + /// Verifies that an inbound maximum body size outside the documented range fails validation. + /// The inbound maximum body size in bytes. [Theory] [InlineData(0)] [InlineData(8_191)] @@ -52,9 +57,10 @@ public class AuditLogOptionsValidatorTests } // --------------------------------------------------------------------- - // M5.5 (T3) per-channel retention overrides + // Per-channel retention overrides // --------------------------------------------------------------------- + /// Verifies that a per-channel retention shorter than the global retention passes validation. [Fact] public void Validate_PerChannelRetention_ShorterThanGlobal_Passes() { @@ -75,6 +81,7 @@ public class AuditLogOptionsValidatorTests Assert.True(validator.Validate(null, opts).Succeeded); } + /// Verifies that a per-channel retention equal to the global retention passes validation. [Fact] public void Validate_PerChannelRetention_EqualToGlobal_Passes() { @@ -90,6 +97,7 @@ public class AuditLogOptionsValidatorTests Assert.True(validator.Validate(null, opts).Succeeded); } + /// Verifies that a per-channel retention longer than the global retention fails validation. [Fact] public void Validate_PerChannelRetention_LongerThanGlobal_Fails() { @@ -110,6 +118,7 @@ public class AuditLogOptionsValidatorTests && f.Contains("ApiInbound", StringComparison.Ordinal)); } + /// Verifies that a per-channel retention below the minimum fails validation. [Fact] public void Validate_PerChannelRetention_BelowMinimum_Fails() { @@ -127,6 +136,7 @@ public class AuditLogOptionsValidatorTests f => f.Contains(nameof(AuditLogOptions.PerChannelRetentionDays), StringComparison.Ordinal)); } + /// Verifies that a per-channel retention keyed by an unknown channel fails validation. [Fact] public void Validate_PerChannelRetention_UnknownChannelKey_Fails() { @@ -146,6 +156,7 @@ public class AuditLogOptionsValidatorTests f => f.Contains("NotAChannel", StringComparison.Ordinal)); } + /// Verifies that the default empty per-channel retention map passes validation. [Fact] public void Validate_PerChannelRetention_DefaultEmpty_Passes() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/AuditWriteFailureSafetyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/AuditWriteFailureSafetyTests.cs index 48214a9b..278a5c4a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/AuditWriteFailureSafetyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/AuditWriteFailureSafetyTests.cs @@ -36,17 +36,16 @@ using System.Text.Encodings.Web; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// -/// Audit Log #23 — M4 Bundle E (Task E4) cross-boundary safety suite verifying +/// Cross-boundary safety suite verifying /// the alog.md §13 contract: an always-throwing audit writer NEVER aborts the -/// user-facing action. Exercises every boundary that emits audit rows in M2, -/// M3, and M4: +/// user-facing action. Exercises every boundary that emits audit rows: /// /// -/// External system sync call (M2 Bundle F). -/// External system cached call (M3 Bundle E). -/// Database sync write (M4 Bundle A). -/// Inbound API request (M4 Bundle D). -/// Notification dispatcher (M4 Bundle B). +/// External system sync call (sync). +/// External system cached call (cached). +/// Database sync write (sync write). +/// Inbound API request (request). +/// Notification dispatcher (dispatch). /// /// /// @@ -64,6 +63,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixtureInitializes the suite with the shared MS SQL migration fixture. + /// The shared migrated MS SQL database fixture. public AuditWriteFailureSafetyTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -81,8 +82,10 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixtureGets the number of times WriteAsync has been attempted. public int Attempts => Volatile.Read(ref _attempts); + /// public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Interlocked.Increment(ref _attempts); @@ -100,8 +103,10 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixtureGets the number of times WriteAsync has been attempted. public int Attempts => Volatile.Read(ref _attempts); + /// public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Interlocked.Increment(ref _attempts); @@ -118,8 +123,10 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixtureGets the number of times ForwardAsync has been attempted. public int Attempts => Volatile.Read(ref _attempts); + /// public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default) { Interlocked.Increment(ref _attempts); @@ -132,6 +139,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixtureVerifies that a synchronous external system call still returns its result when the audit writer throws. + /// A task that represents the asynchronous operation. [Fact] public async Task EsgSyncCall_BrokenAuditWriter_StillReturnsResult() { @@ -171,6 +180,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixtureVerifies that a cached external system call still returns a tracked operation id when the audit writer throws. + /// A task that represents the asynchronous operation. [Fact] public async Task EsgCachedCall_BrokenAuditWriter_StillReturnsTrackedOperationId() { @@ -214,6 +225,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixtureVerifies that a synchronous database write still returns the rows-affected count when the audit writer throws. + /// A task that represents the asynchronous operation. [Fact] public async Task DbSyncWrite_BrokenAuditWriter_StillReturnsRowsAffected() { @@ -269,6 +282,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixtureVerifies that an inbound API request still returns HTTP 200 when the audit writer throws. + /// A task that represents the asynchronous operation. [Fact] public async Task InboundApi_BrokenAuditWriter_StillReturns200() { @@ -290,6 +305,8 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixtureVerifies that a notification dispatch still transitions to delivered when the audit writer throws. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task NotificationDispatch_BrokenAuditWriter_StillTransitionsToDelivered() { @@ -381,7 +398,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixtureInitializes the adapter with the outcome to return for every delivery. + /// The delivery outcome returned by every call. public SingleOutcomeAdapter(DeliveryOutcome outcome) { _outcome = outcome; } + /// public NotificationType Type => NotificationType.Email; + /// public Task DeliverAsync( Notification notification, CancellationToken cancellationToken = default) => Task.FromResult(_outcome); @@ -472,12 +493,17 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture { + /// Initializes the handler with the standard authentication handler dependencies. + /// The authentication scheme options monitor. + /// The logger factory. + /// The URL encoder. public AlwaysAuthenticatedHandler( IOptionsMonitor options, Microsoft.Extensions.Logging.ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } + /// protected override Task HandleAuthenticateAsync() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs index 3a37cbc9..aa723ca7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs @@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// /// Bundle G G2 end-to-end suite for cached ExternalSystem.CachedCall -/// lifecycle telemetry (Audit Log #23 / M3). Wires the full M3 pipeline: +/// lifecycle telemetry. Wires the full pipeline: /// site-local SQLite audit writer + operation tracking store + the production /// + the test-side /// that ALSO pushes each combined @@ -28,7 +28,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// /// The bridge is driven directly via /// — these tests do NOT spin up the actual S&F retry loop; that would -/// require a full SiteRuntime host and is out of scope for M3 (the S&F +/// require a full SiteRuntime host and is out of scope here (the S&F /// observer hooks are exercised in ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests at /// unit level). The submit row is emitted via /// because the @@ -45,6 +45,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixtureInitializes the test with the shared SQL Server migration fixture. + /// The shared MS SQL migration fixture. public CachedCallCombinedTelemetryTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -106,6 +108,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixtureVerifies that two failed attempts followed by a success emit five audit rows and one delivered site call. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CachedCall_FailFailSuccess_Emits_5_AuditRows_AND_1_SiteCall_Delivered() { @@ -177,6 +181,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixtureVerifies that a cached call whose attempts all fail and are parked emits a terminal Parked row. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CachedCall_AllAttemptsFailedAndParked_Emits_Terminal_Parked() { @@ -227,6 +233,8 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixtureVerifies that an immediate success without store-and-forward emits an attempted row and resolves as delivered. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CachedCall_ImmediateSuccess_NoSF_Emits_Attempted_And_Resolve_Delivered() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedWriteCombinedTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedWriteCombinedTelemetryTests.cs index 43e0a87c..7abd6101 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedWriteCombinedTelemetryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedWriteCombinedTelemetryTests.cs @@ -34,6 +34,8 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixtureInitializes the test class with the shared SQL Server migration fixture. + /// The shared SQL Server migration fixture. public CachedWriteCombinedTelemetryTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -93,6 +95,8 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixtureVerifies that a successful cached write emits the full lifecycle ending in a delivered row. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CachedWrite_Success_Emits_Delivered_Lifecycle() { @@ -144,6 +148,8 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixtureVerifies that a parked cached write emits a terminal parked row. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CachedWrite_Parked_Emits_Terminal_Parked() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs index acf143ca..6ea91e4b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs @@ -34,6 +34,8 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixtureInitializes the suite with the shared MS SQL migration fixture. + /// The shared migrated MS SQL database fixture. public CombinedTelemetryIdempotencyTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -100,6 +102,8 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixtureVerifies that a duplicate telemetry packet leaves a single audit row and upserts the site call monotonically. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DuplicatePacket_AuditLogStaysAtOneRow_SiteCallsUpserted_Monotonically() { @@ -144,6 +148,8 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixtureVerifies that an older status arriving after a newer one is a no-op. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task OutOfOrderPackets_OlderStatus_ArrivesAfterNewer_IsNoOp() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs index 0a7997b8..2e46f13c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs @@ -23,7 +23,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// -/// Audit Log #23 — M4 Bundle E (Task E1) end-to-end suite verifying every +/// End-to-end suite verifying every /// synchronous Database.Connection(name).Execute* / /// ExecuteReader call made via the Bundle A /// emits exactly one @@ -33,7 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// /// /// -/// Composes the same pipeline as the M2 : +/// Composes the same pipeline as the : /// in-memory + + /// on the site, drained by a real /// through a @@ -57,6 +57,8 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixtureInitializes the suite with the shared MS SQL migration fixture. + /// The shared migrated MS SQL database fixture. public DatabaseSyncEmissionEndToEndTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -170,6 +172,8 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixtureVerifies that a database insert emits one central audit row carrying a write operation marker and the rows-affected count. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DbWrite_Insert_Emits_OneCentralRow_WithExtraOpWrite_AndRowsAffected() { @@ -240,6 +244,8 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixtureVerifies that a database select emits one central audit row carrying a read operation marker and the rows-returned count. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DbWrite_Select_Emits_OneCentralRow_WithExtraOpRead_AndRowsReturned() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs index a936f0ee..f16ca3ce 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs @@ -22,7 +22,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// -/// Audit Log #23 — ExecutionId end-to-end correlation suite verifying the +/// ExecutionId end-to-end correlation suite verifying the /// universal per-run correlation promise: every audit row produced by one /// script execution carries the same non-null . /// @@ -39,8 +39,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// reads the rows back from the central store and asserts the shared id. /// /// -/// Composes the same pipeline as the M2 -/// and the M4 : an in-memory +/// Composes the same pipeline as the sync-call +/// and the database-write : an in-memory /// + + /// on the site, drained by a real /// through the shared @@ -57,6 +57,8 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixtureInitializes the suite with the shared MS SQL migration fixture. + /// The shared MS SQL migration fixture. public ExecutionIdCorrelationTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -146,6 +148,8 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture.Instance))); + /// Verifies that one execution performing an API call and a database write produces central rows that all share one non-null execution id. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task OneExecution_ApiCallAndDbWrite_AllCentralRows_ShareOneNonNullExecutionId() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/InboundApiAuditTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/InboundApiAuditTests.cs index c5c84506..84f48fc7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/InboundApiAuditTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/InboundApiAuditTests.cs @@ -24,7 +24,7 @@ using System.Text.Encodings.Web; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// -/// Audit Log #23 — M4 Bundle E (Task E3) end-to-end audit trail for the +/// End-to-end audit trail for the /// inbound API surface. Wires the production /// into a Microsoft.AspNetCore.TestHost /// pipeline that mirrors the production @@ -53,6 +53,8 @@ public class InboundApiAuditTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; + /// Initializes the test class with the shared MS SQL migration fixture. + /// The shared MS SQL migration fixture. public InboundApiAuditTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -144,12 +146,17 @@ public class InboundApiAuditTests : IClassFixture /// private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler { + /// Initializes the handler with the standard authentication-handler dependencies. + /// The authentication scheme options monitor. + /// The logger factory. + /// The URL encoder. public AlwaysAuthenticatedHandler( Microsoft.Extensions.Options.IOptionsMonitor options, Microsoft.Extensions.Logging.ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } + /// protected override Task HandleAuthenticateAsync() { var identity = new ClaimsIdentity( @@ -200,6 +207,8 @@ public class InboundApiAuditTests : IClassFixture return Assert.Single(finalRows); } + /// Verifies that a post with a valid actor emits a delivered inbound-request audit row with HTTP status 200 and the actor populated. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task PostToApi_WithValidActor_Emits_InboundRequest_StatusDelivered_HttpStatus200_ActorPopulated() { @@ -232,6 +241,8 @@ public class InboundApiAuditTests : IClassFixture Assert.NotNull(evt.AsRow().IngestedAtUtc); } + /// Verifies that a post without authentication emits a failed inbound-auth-failure audit row with HTTP status 401 and no actor. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task PostToApi_Without_Auth_Emits_InboundAuthFailure_StatusFailed_HttpStatus401_ActorNull() { @@ -260,12 +271,14 @@ public class InboundApiAuditTests : IClassFixture Assert.Equal(AuditStatus.Failed, evt.AsRow().Status); Assert.Equal(401, evt.AsRow().HttpStatus); // Never echo back an unauthenticated principal — middleware suppresses - // the framework user resolution on 401/403 paths. C3 (Task 2.5): the + // the framework user resolution on 401/403 paths. The // canonical Actor is a non-null string (empty when absent); the row view // maps empty → null, preserving the "no principal" assertion. Assert.Null(evt.AsRow().Actor); } + /// Verifies that a post whose endpoint returns 500 emits a failed inbound-request audit row with HTTP status 500. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task PostToApi_Returning500_Emits_InboundRequest_StatusFailed_HttpStatus500() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryDispatcher.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryDispatcher.cs index 37e1a3e1..5675816b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryDispatcher.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryDispatcher.cs @@ -20,12 +20,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure; /// /// /// -/// Production wiring keeps the wire push deferred to M6 — the site SQLite hot -/// path is the source of truth and a future M6 drain will push the rows +/// Production wiring keeps the wire push deferred — the site SQLite hot +/// path is the source of truth and a future drain will push the rows /// through the gRPC client. For end-to-end testing today we need a way to /// exercise the central dual-write transaction immediately, so this /// dispatcher synthesises the wire packet inline and round-trips it through -/// the stub client. The shape mirrors what the M6 drain will eventually emit. +/// the stub client. The shape mirrors what the future drain will eventually emit. /// /// /// Best-effort: both the inner forwarder call and the wire push are @@ -40,6 +40,9 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder private readonly ICachedCallTelemetryForwarder _inner; private readonly ISiteStreamAuditClient _wireClient; + /// Initializes the dispatcher with the inner forwarder and the wire client. + /// The forwarder that handles site-local telemetry. + /// The client used to push audit rows over the wire. public CombinedTelemetryDispatcher( ICachedCallTelemetryForwarder inner, ISiteStreamAuditClient wireClient) @@ -48,7 +51,8 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder _wireClient = wireClient ?? throw new ArgumentNullException(nameof(wireClient)); } - /// + // Forwards the telemetry through the inner forwarder and then pushes its audit rows to the wire client. + /// public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(telemetry); @@ -67,7 +71,7 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder } // Project the same packet onto the wire and push it through the stub - // client. This is the bit a future M6 drain will subsume — until + // client. This is the bit a future drain will subsume — until // then the test wraps the two halves into one observer-driven step. try { @@ -79,7 +83,7 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder { // Swallow — the audit row is still in SQLite for a future drain; // the central row will materialise the next time the wire path - // is exercised (or via the M6 reconciliation pull). + // is exercised (or via the reconciliation pull). } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs index c7bdeff9..25d6431f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs @@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure; /// -/// Shared end-to-end harness for the M3 cached-call combined telemetry tests +/// Shared end-to-end harness for the cached-call combined telemetry tests /// (G2/G3/G4). Composes the full pipeline: /// /// Site-local SQLite (in-memory) + @@ -48,15 +48,25 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure; /// public sealed class CombinedTelemetryHarness : IAsyncDisposable { + /// The site SQLite audit writer under test. public SqliteAuditWriter SqliteWriter { get; } + /// The in-memory ring buffer fallback for audit events. public RingBufferFallback Ring { get; } + /// The fallback audit writer that wraps the SQLite writer and the ring buffer. public FallbackAuditWriter FallbackWriter { get; } + /// The site-local operation tracking store. public OperationTrackingStore TrackingStore { get; } + /// The inner cached-call telemetry forwarder. public CachedCallTelemetryForwarder InnerForwarder { get; } + /// The combined telemetry dispatcher that sends audit and operational state together. public CombinedTelemetryDispatcher Dispatcher { get; } + /// The cached-call lifecycle bridge wired to the forwarder. public CachedCallLifecycleBridge Bridge { get; } + /// The stub site-stream audit client that delivers packets directly to the ingest actor. public DirectActorSiteStreamAuditClient StubClient { get; } + /// The central audit ingest actor. public IActorRef IngestActor { get; } + /// The service provider backing the central repositories. public IServiceProvider ServiceProvider { get; } private readonly MsSqlMigrationFixture _fixture; @@ -64,6 +74,10 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable private readonly ServiceProvider _trackingLocalDbProvider; private bool _disposed; + /// Builds the harness, wiring the site writer chain, dispatcher and central ingest actor against the shared fixture. + /// The MS SQL migration fixture providing the central database. + /// The Akka test kit that hosts the ingest actor. + /// Optional factory that replaces the default site-call audit repository. public CombinedTelemetryHarness( MsSqlMigrationFixture fixture, Akka.TestKit.Xunit2.TestKit testKit, @@ -133,15 +147,23 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable /// dispatcher (the bridge's hooks fire only for S&F retry-loop /// attempts; submit-row emission happens at the script call site). /// + /// The submit packet to forward. + /// A token to cancel the forward. + /// A task that represents the asynchronous operation. public Task EmitSubmitAsync(CachedCallTelemetry submit, CancellationToken ct = default) => Dispatcher.ForwardAsync(submit, ct); /// /// Convenience: route a per-attempt or terminal outcome through the bridge. /// + /// The attempt or terminal-outcome context to route. + /// A token to cancel the emit. + /// A task that represents the asynchronous operation. public Task EmitAttemptAsync(CachedCallAttemptContext context, CancellationToken ct = default) => Bridge.OnAttemptCompletedAsync(context, ct); + /// Creates a fresh central database context for reading back persisted rows. + /// A new . public ScadaBridgeDbContext CreateReadContext() { var options = new DbContextOptionsBuilder() @@ -173,6 +195,8 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable return services.BuildServiceProvider(); } + /// Releases the resources held by this test class asynchronously. + /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { if (_disposed) return; diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/DirectActorSiteStreamAuditClient.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/DirectActorSiteStreamAuditClient.cs index 85bab4e6..f757af89 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/DirectActorSiteStreamAuditClient.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/DirectActorSiteStreamAuditClient.cs @@ -12,8 +12,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure; /// Shared component-level test double that /// short-circuits the gRPC wire and forwards each batch directly to a central /// via Akka . -/// Lives under Integration/Infrastructure/ so both the M2 sync-call and -/// M3 cached-call end-to-end suites can reuse it. +/// Lives under Integration/Infrastructure/ so both the sync-call and +/// cached-call end-to-end suites can reuse it. /// /// /// @@ -27,8 +27,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure; /// /// /// arms a deterministic number of failures -/// before the stub recovers; it applies to BOTH RPCs because the M2 sync-call -/// retry behaviour and the M3 cached-telemetry retry behaviour share a single +/// before the stub recovers; it applies to BOTH RPCs because the sync-call +/// retry behaviour and the cached-telemetry retry behaviour share a single /// SiteAuditTelemetryActor drain. Tests that need to differentiate per-RPC /// failures should reach for a per-test wrapper rather than extending this /// shared infrastructure. @@ -41,6 +41,8 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient private int _callCount; private int _cachedTelemetryCallCount; + /// Initializes the client with the central ingest actor that receives every forwarded batch. + /// The central AuditLogIngestActor reference batches are asked of. public DirectActorSiteStreamAuditClient(IActorRef ingestActor) { _ingestActor = ingestActor ?? throw new ArgumentNullException(nameof(ingestActor)); @@ -69,6 +71,7 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient /// public int CachedTelemetryCallCount => Volatile.Read(ref _cachedTelemetryCallCount); + /// public async Task IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct) { Interlocked.Increment(ref _callCount); @@ -106,18 +109,11 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient return ack; } - /// - /// M3 dual-write path: decode each into - /// the paired (, ) entry and - /// Ask the central ingest actor with an . - /// The accepted EventIds returned by the actor's dual-write transaction map - /// back into the proto ack. - /// - /// - /// Uses the shared for the audit half - /// and for the SiteCall half — the same - /// canonical mappers the production SiteStreamGrpcServer uses. - /// + // Dual-write path: decodes each CachedTelemetryPacket into the paired (AuditEvent, SiteCall) + // entry via the shared AuditEventDtoMapper.FromDto and SiteCallDtoMapper.FromDto (the same + // canonical mappers the production SiteStreamGrpcServer uses), then Asks the central ingest + // actor with an IngestCachedTelemetryCommand; the accepted EventIds map back into the proto ack. + /// public async Task IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct) { Interlocked.Increment(ref _cachedTelemetryCallCount); diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/NotifyDispatcherAuditTrailTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/NotifyDispatcherAuditTrailTests.cs index 365be0ce..886e0895 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/NotifyDispatcherAuditTrailTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/NotifyDispatcherAuditTrailTests.cs @@ -20,7 +20,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// -/// Audit Log #23 — M4 Bundle E (Task E2): end-to-end audit trail produced by +/// End-to-end audit trail produced by /// the central dispatcher loop. Wires /// the production onto the real /// against the per-class @@ -51,6 +51,8 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixtureInitializes the test class with the shared MSSQL migration fixture. + /// The shared MSSQL migration fixture. public NotifyDispatcherAuditTrailTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -105,13 +107,17 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture _outcomes; public int CallCount; + /// Initializes the adapter with the delivery outcomes it returns in order. + /// The outcomes returned by successive delivery calls. public QueuedOutcomeAdapter(params DeliveryOutcome[] outcomes) { _outcomes = new Queue(outcomes); } + /// public NotificationType Type => NotificationType.Email; + /// public Task DeliverAsync( Notification notification, CancellationToken cancellationToken = default) { @@ -133,7 +139,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixtureNextAttemptAt is immediately due — /// useful so the SECOND DispatchTick re-claims the row without waiting. - /// NO-002: the dispatcher now clamps a non-positive RetryDelay to the + /// The dispatcher now clamps a non-positive RetryDelay to the /// 1-minute fallback to avoid burn-looping on transient failures, so this /// must be a strictly positive value (1 ms is fine for tests). /// @@ -242,6 +248,8 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixtureVerifies that a transient failure followed by a success emits two attempt rows and a terminal delivered row. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task NotifyDispatcher_FailThenSuccess_Emits_TwoAttempts_OneDelivered_Terminal() { @@ -319,6 +327,8 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixtureVerifies that a throwing audit writer does not prevent the notification delivery from succeeding. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task NotifyDispatcher_AuditWriter_Throws_DeliveryStillSucceeds() { @@ -394,8 +404,10 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixtureGets the number of write attempts made against this writer. public int AttemptCount => Volatile.Read(ref _attemptCount); + /// public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Interlocked.Increment(ref _attemptCount); diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/OutageReconciliationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/OutageReconciliationTests.cs index c02d5840..020c8df5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/OutageReconciliationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/OutageReconciliationTests.cs @@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// -/// Bundle F (#23 M6-T10) end-to-end test for the central-outage + reconciliation +/// End-to-end test for the central-outage + reconciliation /// recovery loop. Wires the real site SQLite hot-path /// () and the central /// with an backed by the real @@ -39,7 +39,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// the real repository. /// /// -/// The from M3 is push-only — it has no +/// The is push-only — it has no /// reconciliation puller — so we build the smaller stub inline rather than /// retrofitting the shared harness with a code path it doesn't otherwise /// need. @@ -49,6 +49,8 @@ public class OutageReconciliationTests : TestKit, IClassFixtureInitializes the test class with the shared MS SQL migration fixture. + /// The shared MS SQL migration fixture. public OutageReconciliationTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -77,19 +79,23 @@ public class OutageReconciliationTests : TestKit, IClassFixtureGets the number of times PullAsync has been invoked. public int CallCount { get; private set; } + /// Initializes the client over the given site audit queue. + /// The site audit queue to pull from. public QueueBackedPullClient(ISiteAuditQueue siteQueue) { _siteQueue = siteQueue ?? throw new ArgumentNullException(nameof(siteQueue)); } + /// public async Task PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) { CallCount++; - // Mirrors SiteStreamGrpcServer.PullAuditEvents exactly (WP2.3): the + // Mirrors SiteStreamGrpcServer.PullAuditEvents exactly: the // INCOMING cursor is central's receipt, so everything at or before it // is retired FIRST; the rows this call serves are NOT retired, because // nothing yet proves central consumed them. A fault between here and @@ -119,7 +125,10 @@ public class OutageReconciliationTests : TestKit, IClassFixture _sites; + /// Initializes the enumerator with a fixed set of sites. + /// The fixed sites to enumerate. public StaticEnumerator(params SiteEntry[] sites) => _sites = sites; + /// public Task> EnumerateAsync(CancellationToken ct = default) => Task.FromResult(_sites); } @@ -208,6 +217,8 @@ public class OutageReconciliationTests : TestKit, IClassFixtureVerifies that 200 events buffered during a central outage are caught up by reconciliation with no duplicates. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CentralOutage_200Events_Buffer_Then_Reconciliation_Catches_Up_NoDuplicates() { @@ -297,8 +308,8 @@ public class OutageReconciliationTests : TestKit, IClassFixtureVerifies that repeated reconciliation pull cycles are idempotent. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ReconciliationPull_Idempotent_Across_Two_Cycles() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs index a1b826cc..2b06d233 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs @@ -40,7 +40,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// -/// Audit Log #23 — ParentExecutionId cross-execution correlation headline +/// ParentExecutionId cross-execution correlation headline /// end-to-end suite. Verifies the inbound-API → routed-site-script bridge: an /// inbound HTTP request runs an inbound method script that calls /// Route.Call into a site instance; the routed site script does a sync @@ -101,6 +101,8 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixtureInitializes the test class with the shared MS SQL migration fixture. + /// The shared MS SQL migration fixture. public ParentExecutionIdCorrelationTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -126,6 +128,8 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixtureVerifies that every audit row of a routed site run carries the inbound request's execution id as its parent execution id. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task InboundRoutedRun_AllRoutedRows_CarryInboundExecutionId_AsParentExecutionId() { @@ -507,7 +511,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture { var pending = await sqliteWriter.ReadPendingAsync(256); - // AuditLog-001: ReadPendingAsync now excludes the cached-lifecycle + // ReadPendingAsync now excludes the cached-lifecycle // kinds (they ride the combined-telemetry drain), so we union // them in via the dedicated read surface to keep the durability // assertion covering EVERY expected Kind. @@ -534,8 +538,10 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture private sealed class AlwaysDeliversAdapter : INotificationDeliveryAdapter { + /// public NotificationType Type => NotificationType.Email; + /// public Task DeliverAsync( ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification notification, CancellationToken cancellationToken = default) @@ -568,6 +574,12 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture public string? NotificationId { get; private set; } + /// Initializes the router with the collaborators the routed script run needs. + /// The site identifier stamped on routed runs. + /// The external system client used by the routed script. + /// The audit writer the routed script emits rows through. + /// The cached-call telemetry forwarder. + /// The store-and-forward service that buffers the notification. public BridgingInstanceRouter( string siteId, IExternalSystemClient externalClient, @@ -582,6 +594,8 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture public async Task RouteToCallAsync( string siteId, RouteToCallRequest request, CancellationToken cancellationToken) { @@ -626,16 +640,19 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture public Task RouteToGetAttributesAsync( string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); + /// public Task RouteToSetAttributesAsync( string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); // WaitForAttribute is not part of this fixture's routed-Call audit scenario; // mirror the other non-Call methods (unexercised here). + /// public Task RouteToWaitForAttributeAsync( string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionMaintenanceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionMaintenanceTests.cs index 279e4502..4bb31edd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionMaintenanceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionMaintenanceTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// -/// Bundle F (#23 M6-T12) end-to-end tests for the +/// End-to-end tests for the /// hosted service running /// the real EF/MSSQL against the /// per-class . The migration seeds @@ -30,6 +30,8 @@ public class PartitionMaintenanceTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; + /// Initializes the test class with the shared MS SQL migration fixture. + /// The shared MS SQL migration fixture. public PartitionMaintenanceTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -116,6 +118,8 @@ public class PartitionMaintenanceTests : IClassFixture // 1. EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered // --------------------------------------------------------------------- + /// Verifies that the default lookahead adds no new partition boundaries when future partitions are already covered. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered() { @@ -162,6 +166,8 @@ public class PartitionMaintenanceTests : IClassFixture // 2. EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries // --------------------------------------------------------------------- + /// Verifies that a lookahead larger than the covered range splits in new partition boundaries. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries() { @@ -218,6 +224,8 @@ public class PartitionMaintenanceTests : IClassFixture // 3. EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns // --------------------------------------------------------------------- + /// Verifies that running partition maintenance twice adds boundaries only once. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs index 6b228b2a..6c30c1e4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs @@ -19,7 +19,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// -/// Bundle F (#23 M6-T11) end-to-end test for the daily partition-switch +/// End-to-end test for the daily partition-switch /// purge: seeds three monthly partitions (Jan / Feb / Mar 2026) with direct /// INSERTs that bypass the standard repository ingest path (so the seed /// timestamps are explicit), drives against @@ -46,6 +46,8 @@ public class PartitionPurgeTests : TestKit, IClassFixture { private readonly MsSqlMigrationFixture _fixture; + /// Initializes a new instance of the class. + /// The fixture used by the tests. public PartitionPurgeTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -71,11 +73,11 @@ public class PartitionPurgeTests : TestKit, IClassFixture string kind = "ApiCall") { await using var cmd = conn.CreateCommand(); - // C5 (Task 2.5): dbo.AuditLog is now the 10 canonical columns + DetailsJson; + // dbo.AuditLog is now the 10 canonical columns + DetailsJson; // the ScadaBridge domain fields (channel/kind/status/sourceSiteId) ride in // DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive. // Action = "{channel}.{kind}", Category = channel name, Outcome = Success. - // The channel/kind are parameterized so the M5.5 per-channel purge test can + // The channel/kind are parameterized so the per-channel purge test can // seed multiple channels into the same partition. cmd.CommandText = @" INSERT INTO dbo.AuditLog @@ -111,7 +113,7 @@ VALUES /// /// Asserts the post-purge index state: the partition-ALIGNED clustered /// PK_AuditLog is intact and the non-aligned UX_AuditLog_EventId - /// is absent (WP2.2 — AlignAuditLogEventIdUniqueness). + /// is absent (AlignAuditLogEventIdUniqueness). /// /// /// The purge used to bracket its SWITCH with a DROP/CREATE of @@ -164,7 +166,7 @@ SELECT /// /// Deferred-work register row 82. Every actor these tests create ticks every /// 100 ms () and switches - /// out the Jan-2026 partition each time it finds one eligible. The tests relied on + /// out the January 2026 partition each time it finds one eligible. The tests relied on /// 's Dispose → Shutdown to stop them between /// test methods, but that shutdown is bounded by a timeout and does NOT fail when /// it expires — so on a loaded machine a previous test's actor was still ticking @@ -172,9 +174,9 @@ SELECT /// /// /// That is what made PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows - /// flake under a parallel full-solution run: it seeds a Jan-15-2026 row and expects + /// flake under a parallel full-solution run: it seeds a 2026-01-15 row and expects /// its own per-channel DELETE to remove exactly one row, but a leaked actor could - /// switch the whole Jan-2026 partition out first, leaving nothing to delete + /// switch the whole January 2026 partition out first, leaving nothing to delete /// (observed: Assert.Equal() Failure … Expected: 1, Actual: 0). Stopping the /// actor inside the test that created it removes the dependency on shutdown timing /// altogether. @@ -228,9 +230,9 @@ SELECT var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1; return ( - Old: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), // in Jan-2026 partition → PURGED - Mid: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), // in Apr-2026 partition → KEPT - Recent: new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc), // in Jun-2026 partition → KEPT + Old: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), // in January 2026 partition → PURGED + Mid: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), // in April 2026 partition → KEPT + Recent: new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc), // in June 2026 partition → KEPT RetentionDays: retentionDays ); } @@ -239,6 +241,8 @@ SELECT // 1. EndToEnd_OldestPartition_PurgedViaActor_NewerKept // --------------------------------------------------------------------- + /// Verifies the case: end To End, oldest Partition, purged Via Actor, newer Kept. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EndToEnd_OldestPartition_PurgedViaActor_NewerKept() { @@ -283,7 +287,7 @@ SELECT var actor = CreateActor(sp, purgeOptions, auditOptions); - // The Jan-2026 partition boundary is the only eligible one in this fixture DB. + // The January 2026 partition boundary is the only eligible one in this fixture DB. var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var matched = probe.FishForMessage( isMessage: m => m.MonthBoundary == janBoundary, @@ -313,14 +317,16 @@ SELECT // 2. EndToEnd_AlignedUniqueness_AfterPurge // --------------------------------------------------------------------- + /// Verifies the case: end To End, aligned Uniqueness, after Purge. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EndToEnd_AlignedUniqueness_AfterPurge() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); - // Same shape as test 1 — purge the Jan-2026 partition and then assert the + // Same shape as test 1 — purge the January 2026 partition and then assert the // index state. RetentionDays is computed dynamically so the threshold always - // lands near 2026-01-20 (see SeedOccurredAt()). Since WP2.2 the switch touches + // lands near 2026-01-20 (see SeedOccurredAt()). The switch touches // no index at all: uniqueness rides the aligned clustered PK, so there is // nothing to drop before the SWITCH and nothing to rebuild after it. var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8); @@ -370,14 +376,16 @@ SELECT // 3. EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge // --------------------------------------------------------------------- + /// Verifies the case: end To End, insert If Not Exists Async, still Idempotent, after Purge. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); - // Seed + purge the Jan-2026 row, THEN exercise InsertIfNotExistsAsync twice for + // Seed + purge the January 2026 row, THEN exercise InsertIfNotExistsAsync twice for // a fresh recent EventId. The second call must be a no-op (duplicate-key collision - // swallowed by the repository, per M2 Bundle A's race-fix) — which means the + // swallowed by the repository, per the repository's race fix) — which means the // aligned clustered PK is still enforcing uniqueness as intended. // RetentionDays is computed dynamically so the threshold always lands near // 2026-01-20 (see SeedOccurredAt()). @@ -455,11 +463,11 @@ SELECT } // --------------------------------------------------------------------- - // 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows (M5.5 T3) + // 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows // --------------------------------------------------------------------- /// - /// M5.5 (T3): exercises + /// Exercises /// directly against the real repository + fixture DB. Seeds, in the SAME partition, /// old + recent rows for an OVERRIDDEN channel (ApiOutbound) and old + recent /// rows for an UN-overridden channel (DbOutbound), then runs the per-channel @@ -474,6 +482,7 @@ SELECT /// the append-only writer-role DENYs do not bind (the role granularity is exercised /// in the repository/migration tests). /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows() { @@ -538,17 +547,18 @@ SELECT } // --------------------------------------------------------------------- - // 5. SwitchOut_WithExplicitCommandTimeout_Succeeds (Task 4) + // 5. SwitchOut_WithExplicitCommandTimeout_Succeeds // --------------------------------------------------------------------- /// - /// Task 4 (arch-review 04, S2): proves the explicit- maintenance-timeout + /// Proves the explicit- maintenance-timeout /// overload of runs the real /// staging/switch batch to completion against SQL Server — the old row is purged, the kept row /// survives, and the returned sampled row-count reflects the switched partition. Exercising the /// timeout path end-to-end guards against a regression that only sets the timeout on the sample /// command and forgets the DDL batch (or vice versa). /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task SwitchOut_WithExplicitCommandTimeout_Succeeds() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs index 409610fa..5b4af438 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs @@ -20,7 +20,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// -/// Bundle H — end-to-end test wiring the full Audit Log #23 M2 sync-call pipeline: +/// End-to-end test wiring the full sync-call audit pipeline: /// over a backed by /// an in-memory SQLite database; the drains /// Pending rows and pushes them through a stub @@ -30,7 +30,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// /// /// This is a component-level integration test, not a full Akka-cluster -/// test (per the M2 brainstorm decision). The stub gRPC client short-circuits +/// test. The stub gRPC client short-circuits /// the wire so we exercise the real telemetry actor, the real ingest actor, the /// real SQLite writer, and the real MSSQL repository — without standing up a /// Kestrel host or two-cluster topology. @@ -51,6 +51,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixtureInitializes the test with the shared MSSQL migration fixture. + /// The MSSQL fixture that supplies the central database. public SyncCallEmissionEndToEndTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -129,6 +131,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture.Instance))); + /// Verifies that one written audit event reaches the central AuditLog within a reasonable time. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EndToEnd_OneWrittenEvent_Reaches_Central_AuditLog_Within_Reasonable_Time() { @@ -174,6 +178,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixtureVerifies that a gRPC stub error leaves the row pending and the next tick delivers it successfully. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EndToEnd_GrpcStubError_RowStays_Pending_NextTick_Succeeds() { @@ -246,6 +252,8 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixtureVerifies that submitting the same event twice results in only one central row. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EndToEnd_DuplicateSubmit_OnlyOneCentralRow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Kpi/AuditLogKpiSampleSourceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Kpi/AuditLogKpiSampleSourceTests.cs index d6c732a5..9b22d2df 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Kpi/AuditLogKpiSampleSourceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Kpi/AuditLogKpiSampleSourceTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Kpi; /// -/// M6 "KPI History & Trends" (K8) coverage for — +/// "KPI History & Trends" coverage for — /// the the /// central recorder enumerates to snapshot Audit Log KPIs. Asserts the source maps the /// repository's onto exactly three Global-scope samples @@ -19,6 +19,7 @@ public class AuditLogKpiSampleSourceTests { private static readonly DateTime CapturedAt = new(2026, 6, 17, 12, 30, 0, DateTimeKind.Utc); + /// Verifies that the sample source reports the AuditLog source name. [Fact] public void Source_is_AuditLog() { @@ -28,6 +29,8 @@ public class AuditLogKpiSampleSourceTests Assert.Equal(KpiSources.AuditLog, sut.Source); } + /// Verifies that collecting emits three global samples mapped from the health snapshot. + /// A task that represents the asynchronous operation. [Fact] public async Task CollectAsync_emits_three_global_samples_mapped_from_snapshot() { @@ -64,6 +67,8 @@ public class AuditLogKpiSampleSourceTests AssertMetric(samples, "backlogTotal", 305); } + /// Verifies that the sample window is anchored on the snapshot's capture time. + /// A task that represents the asynchronous operation. [Fact] public async Task CollectAsync_anchors_the_window_on_capturedAtUtc() { @@ -83,6 +88,8 @@ public class AuditLogKpiSampleSourceTests Arg.Any()); } + /// Verifies that collecting returns no samples when the snapshot is null. + /// A task that represents the asynchronous operation. [Fact] public async Task CollectAsync_returns_empty_when_snapshot_is_null() { @@ -98,6 +105,8 @@ public class AuditLogKpiSampleSourceTests Assert.Empty(samples); } + /// Verifies that the backlog provider's value is used when one is registered. + /// A task that represents the asynchronous operation. [Fact] public async Task CollectAsync_uses_backlog_provider_when_present() { @@ -127,11 +136,13 @@ public class AuditLogKpiSampleSourceTests AssertMetric(samples, "backlogTotal", 42); } + /// Verifies that the snapshot's own backlog total is used when no backlog provider is registered. + /// A task that represents the asynchronous operation. [Fact] public async Task CollectAsync_falls_back_to_snapshot_backlog_when_provider_absent() { // No provider (sites / unit tests): the sample source degrades to the - // snapshot's own BacklogTotal — unchanged pre-Task-10 behavior. + // snapshot's own BacklogTotal — unchanged behavior from before the provider existed. var snapshot = new AuditLogKpiSnapshot( TotalEventsLastHour: 10, ErrorEventsLastHour: 1, diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/AuditRedactorFailClosedTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/AuditRedactorFailClosedTests.cs index 477de7a0..8b924c6c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/AuditRedactorFailClosedTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/AuditRedactorFailClosedTests.cs @@ -10,8 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction; /// -/// Guards the fail-closed contract for unavailable redactor patterns -/// (Gitea #35). +/// Guards the fail-closed contract for unavailable redactor patterns. /// /// /// The defect. rejects a pattern that is @@ -118,6 +117,7 @@ public class AuditRedactorFailClosedTests Assert.Equal(0, counter.Count); } + /// Verifies that an unavailable global body redactor suppresses the payload rather than emitting it raw. [Fact] public void UnavailableGlobalBodyRedactor_SuppressesPayload_RatherThanEmittingItRaw() { @@ -152,6 +152,7 @@ public class AuditRedactorFailClosedTests Assert.Equal(1, counter.Count); } + /// Verifies that an unavailable per-target body redactor suppresses the payload. [Fact] public void UnavailablePerTargetBodyRedactor_SuppressesPayload() { @@ -267,16 +268,27 @@ public class AuditRedactorFailClosedTests private sealed class CountingCounter : IAuditRedactionFailureCounter { private int _count; + /// Gets the number of redaction failures counted so far. public int Count => Volatile.Read(ref _count); + /// public void Increment() => Interlocked.Increment(ref _count); } private sealed class StaticMonitor : IOptionsMonitor { private readonly AuditLogOptions _value; + /// Initializes the monitor with a fixed options value. + /// The value. public StaticMonitor(AuditLogOptions value) => _value = value; + /// Gets the current options value. public AuditLogOptions CurrentValue => _value; + /// Returns the current options value regardless of the name. + /// The options name; ignored. + /// The current options value. public AuditLogOptions Get(string? name) => _value; + /// Registers a listener to be notified when the options change. + /// The change callback. + /// A disposable that removes the listener, or null when none is tracked. public IDisposable? OnChange(Action listener) => null; } @@ -286,10 +298,14 @@ public class AuditRedactorFailClosedTests private readonly List> _listeners = new(); private int _reads; + /// Initializes the monitor with an initial options value. + /// The value. public MutableMonitor(AuditLogOptions value) => _value = value; + /// Gets the number of times CurrentValue has been read. public int CurrentValueReads => Volatile.Read(ref _reads); + /// Gets the current options value. public AuditLogOptions CurrentValue { get @@ -299,8 +315,14 @@ public class AuditRedactorFailClosedTests } } + /// Gets the current options value. + /// The options name (ignored). + /// The current options value. public AuditLogOptions Get(string? name) => CurrentValue; + /// Registers a listener to be notified when the options change. + /// The change callback. + /// A disposable that removes the listener, or null when none is tracked. public IDisposable? OnChange(Action listener) { lock (_listeners) @@ -310,6 +332,8 @@ public class AuditRedactorFailClosedTests return new Noop(); } + /// Replaces the current options value and notifies every registered listener. + /// The value. public void Set(AuditLogOptions value) { _value = value; @@ -326,6 +350,7 @@ public class AuditRedactorFailClosedTests private sealed class Noop : IDisposable { + /// Releases the resources held by the test fixture. public void Dispose() { } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/SafeDefaultAuditRedactorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/SafeDefaultAuditRedactorTests.cs index 1f639c2e..e46476be 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/SafeDefaultAuditRedactorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/SafeDefaultAuditRedactorTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction; /// -/// ScadaBridge audit re-architecture stage C2 (Task 2.5) tests for +/// Tests for /// — the minimal always-safe /// fallback. /// Header-only scrub of the always-sensitive default headers inside @@ -35,6 +35,7 @@ public class SafeDefaultAuditRedactorTests private static AuditDetails Details(AuditEvent evt) => AuditDetailsCodec.Deserialize(evt.DetailsJson); + /// Verifies that the default sensitive headers are redacted in the request summary. [Fact] public void Redacts_DefaultSensitiveHeaders_InRequestSummary() { @@ -48,6 +49,7 @@ public class SafeDefaultAuditRedactorTests Assert.Contains("Content-Type: application/json", d.RequestSummary!); } + /// Verifies that the default sensitive headers are redacted in the response summary. [Fact] public void Redacts_DefaultSensitiveHeaders_InResponseSummary() { @@ -61,6 +63,7 @@ public class SafeDefaultAuditRedactorTests Assert.Contains("X-Other: ok", d.ResponseSummary!); } + /// Verifies that header names are matched case-insensitively when redacting. [Fact] public void CaseInsensitive_HeaderName_Redacted() { @@ -72,6 +75,7 @@ public class SafeDefaultAuditRedactorTests Assert.DoesNotContain("x-y-z", Details(result).RequestSummary!); } + /// Verifies that a non-sensitive header is preserved unchanged. [Fact] public void NonSensitiveHeader_Preserved() { @@ -84,6 +88,7 @@ public class SafeDefaultAuditRedactorTests Assert.Contains("Accept: application/json", d.RequestSummary!); } + /// Verifies that an event with null details takes the fast path and returns the same instance. [Fact] public void NullDetails_FastPath_ReturnsSameInstance() { @@ -102,6 +107,7 @@ public class SafeDefaultAuditRedactorTests Assert.Same(evt, result); } + /// Verifies that malformed details JSON never causes the redactor to throw. [Fact] public void MalformedDetailsJson_NeverThrows() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/ScadaBridgeAuditRedactorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/ScadaBridgeAuditRedactorTests.cs index f6ccec76..075b0df9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/ScadaBridgeAuditRedactorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/ScadaBridgeAuditRedactorTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction; /// -/// ScadaBridge audit re-architecture stage C2 (Task 2.5) tests for +/// ScadaBridge audit re-architecture stage C2 tests for /// — the canonical /// implementation. Covers the header-redaction / /// body-regex / SQL-param / safety-net / truncation pipeline operating on @@ -20,6 +20,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction; /// public class ScadaBridgeAuditRedactorTests { + /// Verifies that every field of an error row is redacted and the error message is capped, whether the redactor is configured globally or per target. + /// True to configure the redactor through a per-target override; false to configure it globally. [Theory] [InlineData(false)] [InlineData(true)] @@ -35,6 +37,7 @@ public class ScadaBridgeAuditRedactorTests Assert.True(Encoding.UTF8.GetByteCount(Details(result).ErrorMessage!) <= 128); } + /// Verifies that the SQL parameter policy does not persist values echoed back in an exception message. [Fact] public void EI3_SqlParameterPolicy_DoesNotPersistExceptionEchoedValues() { @@ -101,6 +104,7 @@ public class ScadaBridgeAuditRedactorTests // ---- Header redaction (ports HeaderRedactionTests) --------------------- + /// Verifies that an Authorization bearer header is redacted. [Fact] public void HeaderRedaction_AuthorizationBearer_Redacted() { @@ -116,6 +120,7 @@ public class ScadaBridgeAuditRedactorTests Assert.Contains("application/json", d.RequestSummary); } + /// Verifies that header redaction is case-insensitive, so a lowercase authorization header is redacted. [Fact] public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted() { @@ -127,6 +132,7 @@ public class ScadaBridgeAuditRedactorTests Assert.Contains("\"authorization\":\"\"", Details(result).RequestSummary!); } + /// Verifies that a custom redact list redacts a custom header name. [Fact] public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName() { @@ -141,6 +147,7 @@ public class ScadaBridgeAuditRedactorTests Assert.Contains("Bearer keep-me", d.RequestSummary!); } + /// Verifies that a request summary that is not JSON passes through header redaction unchanged. [Fact] public void HeaderRedaction_NonJson_RequestSummary_Unchanged() { @@ -153,6 +160,7 @@ public class ScadaBridgeAuditRedactorTests // ---- Body regex redaction (ports BodyRegexRedactionTests) -------------- + /// Verifies that the global regex redacts a Hunter-style password value. [Fact] public void GlobalRegex_HunterPassword_Redacted() { @@ -170,6 +178,7 @@ public class ScadaBridgeAuditRedactorTests Assert.Contains("alice", d.RequestSummary!); } + /// Verifies that the body regex is applied to the error detail and the extra field. [Fact] public void BodyRegex_AppliesToErrorDetailAndExtra() { @@ -190,6 +199,7 @@ public class ScadaBridgeAuditRedactorTests Assert.Contains("", d.Extra!); } + /// Verifies that a per-target regex is applied only to the matching target. [Fact] public void PerTargetRegex_OnlyAppliedToMatchingTarget() { @@ -213,6 +223,7 @@ public class ScadaBridgeAuditRedactorTests Assert.Equal(input, Details(unmatched).RequestSummary); } + /// Verifies that a field is left unchanged when no regex is configured. [Fact] public void NoRegexConfigured_FieldUnchanged() { @@ -223,6 +234,7 @@ public class ScadaBridgeAuditRedactorTests Assert.Equal("{\"password\":\"hunter2\"}", Details(result).RequestSummary); } + /// Verifies that a regex that times out turns the field into the redacted marker and increments the failure counter. [Fact] public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements() { @@ -257,6 +269,7 @@ public class ScadaBridgeAuditRedactorTests return sb.ToString(); } + /// Verifies that SQL parameters are kept verbatim when no opt-in is configured. [Fact] public void NoOptIn_ParamsVerbatim_Unchanged() { @@ -272,6 +285,7 @@ public class ScadaBridgeAuditRedactorTests Assert.Equal(input, Details(result).RequestSummary); } + /// Verifies that an opt-in regex on parameter names redacts the matching values and keeps the others. [Fact] public void OptInRegex_AtToken_RedactsThoseValues_KeepsOthers() { @@ -298,6 +312,7 @@ public class ScadaBridgeAuditRedactorTests Assert.DoesNotContain("k-987", d.RequestSummary!); } + /// Verifies that a channel other than database-outbound is not affected by the SQL parameter policy. [Fact] public void NonDbOutboundChannel_NotAffected() { @@ -320,6 +335,7 @@ public class ScadaBridgeAuditRedactorTests // ---- Truncation + cap selection (ports TruncationTests) ---------------- + /// Verifies that a 10 KB request summary on a success row is truncated to 8 KB and flagged as payload-truncated. [Fact] public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue() { @@ -334,6 +350,7 @@ public class ScadaBridgeAuditRedactorTests Assert.True(d.PayloadTruncated); } + /// Verifies that a 10 KB request summary on a failed row is not truncated because it is under the error cap. [Fact] public void ErrorRow_Failed_10KB_RequestSummary_NotTruncated_UnderErrorCap() { @@ -348,6 +365,7 @@ public class ScadaBridgeAuditRedactorTests Assert.False(d.PayloadTruncated); } + /// Verifies that a 70 KB request summary on a failed row is truncated to 64 KB. [Fact] public void ErrorRow_Failed_70KB_RequestSummary_TruncatedTo64KB() { @@ -362,6 +380,7 @@ public class ScadaBridgeAuditRedactorTests Assert.True(d.PayloadTruncated); } + /// Verifies that an attempted status is treated as an error and uses the error cap even though the outcome is success. [Fact] public void StatusAttempted_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess() { @@ -379,6 +398,7 @@ public class ScadaBridgeAuditRedactorTests Assert.False(d.PayloadTruncated); } + /// Verifies that a skipped status is treated as an error and uses the error cap even though the outcome is success. [Fact] public void StatusSkipped_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess() { @@ -391,6 +411,7 @@ public class ScadaBridgeAuditRedactorTests Assert.False(Details(result).PayloadTruncated); } + /// Verifies that a submitted status is treated as a success and uses the default cap. [Fact] public void StatusSubmitted_TreatedAsSuccess_UsesDefaultCap() { @@ -406,6 +427,7 @@ public class ScadaBridgeAuditRedactorTests Assert.True(d.PayloadTruncated); } + /// Verifies that a large inbound API body uses the inbound cap rather than the default cap. [Fact] public void ApiInbound_LargeBody_UsesInboundCap_NotDefault() { @@ -420,6 +442,7 @@ public class ScadaBridgeAuditRedactorTests Assert.Equal(100_000, Encoding.UTF8.GetByteCount(d.RequestSummary!)); } + /// Verifies that a failed inbound API body above the inbound maximum is truncated to that maximum. [Fact] public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes() { @@ -435,6 +458,7 @@ public class ScadaBridgeAuditRedactorTests Assert.True(Encoding.UTF8.GetByteCount(d.ResponseSummary!) <= 16_384); } + /// Verifies that multibyte UTF-8 text is truncated at a character boundary rather than in the middle of a byte sequence. [Fact] public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte() { @@ -453,6 +477,7 @@ public class ScadaBridgeAuditRedactorTests Assert.True(d.PayloadTruncated); } + /// Verifies that a details payload that was already marked truncated stays truncated. [Fact] public void ExistingDetailsPayloadTruncated_RemainsTrue() { @@ -467,6 +492,7 @@ public class ScadaBridgeAuditRedactorTests // ---- Target length cap ------------------------------------------------- + /// Verifies that a target over the cap is truncated at a byte boundary. [Fact] public void Target_OverCap_Truncated_ToByteBoundary() { @@ -483,6 +509,7 @@ public class ScadaBridgeAuditRedactorTests // ---- Fast-path --------------------------------------------------------- + /// Verifies that the fast path returns the same instance when the details are null and the target is short. [Fact] public void FastPath_NullDetailsAndShortTarget_ReturnsSameInstance() { @@ -502,6 +529,7 @@ public class ScadaBridgeAuditRedactorTests Assert.Same(evt, result); } + /// Verifies that the fast path returns the same instance when the details are empty and the target is short. [Fact] public void FastPath_EmptyDetailsAndShortTarget_ReturnsSameInstance() { @@ -523,6 +551,7 @@ public class ScadaBridgeAuditRedactorTests // ---- Never-throws safety net ------------------------------------------- + /// Verifies that malformed details JSON never throws and yields a safe copy. [Fact] public void MalformedDetailsJson_NeverThrows_ReturnsSafeCopy() { @@ -545,6 +574,7 @@ public class ScadaBridgeAuditRedactorTests Assert.Null(ex); } + /// Verifies that a fault thrown by the options monitor never leaks sensitive values, because the outer catch over-redacts every sensitive field. [Fact] public void OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted() { @@ -601,7 +631,9 @@ public class ScadaBridgeAuditRedactorTests private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter { private int _count; + /// Gets the number of recorded failures. public int Count => _count; + /// public void Increment() => System.Threading.Interlocked.Increment(ref _count); } @@ -609,9 +641,18 @@ public class ScadaBridgeAuditRedactorTests private sealed class StaticMonitor : IOptionsMonitor { private readonly AuditLogOptions _value; + /// Initializes a new instance of the class. + /// The options snapshot returned on every read. public StaticMonitor(AuditLogOptions value) => _value = value; + /// Gets the options value this monitor serves. public AuditLogOptions CurrentValue => _value; + /// Returns the options value regardless of name. + /// The options name (ignored). + /// The options value. public AuditLogOptions Get(string? name) => _value; + /// Ignores the listener; the options never change. + /// The change listener (ignored). + /// Always null. public IDisposable? OnChange(Action listener) => null; } @@ -622,9 +663,16 @@ public class ScadaBridgeAuditRedactorTests /// private sealed class ThrowingMonitor : IOptionsMonitor { + /// Gets the options value this monitor serves. public AuditLogOptions CurrentValue => throw new InvalidOperationException("Simulated options fault for outer-catch test"); + /// Returns the options value regardless of name. + /// The options name (ignored). + /// The options value. public AuditLogOptions Get(string? name) => new AuditLogOptions(); + /// Ignores the listener; the options never change. + /// The change listener (ignored). + /// Always null. public IDisposable? OnChange(Action listener) => null; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/FallbackAuditWriterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/FallbackAuditWriterTests.cs index 2047de9c..42ac0be0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/FallbackAuditWriterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/FallbackAuditWriterTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// -/// Bundle B (M2-T4) tests for — composes the +/// Tests for — composes the /// primary , the drop-oldest /// , and an /// health counter. @@ -28,9 +28,12 @@ public class FallbackAuditWriterTests /// Flip-switch primary writer mock. private sealed class FlipSwitchPrimary : IAuditWriter { + /// Gets or sets a value indicating whether the next write fails. public bool FailNext { get; set; } + /// Gets the events the primary has accepted. public List Written { get; } = new(); + /// public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { if (FailNext) @@ -42,6 +45,8 @@ public class FallbackAuditWriterTests } } + /// Verifies that when the primary writer throws, the event lands in the ring buffer and the call still returns successfully. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess() { @@ -59,6 +64,8 @@ public class FallbackAuditWriterTests counter.Received(1).Increment(); } + /// Verifies that after the primary recovers, the ring buffer drains in FIFO order on the next write. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite() { @@ -93,6 +100,8 @@ public class FallbackAuditWriterTests Assert.Equal("c", primary.Written[3].Target); } + /// Verifies that the ring buffer stays empty when the primary writer always succeeds. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty() { @@ -112,6 +121,8 @@ public class FallbackAuditWriterTests counter.DidNotReceive().Increment(); } + /// Verifies that the failure counter is incremented once per primary failure. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_FailureCounter_Incremented_Per_PrimaryFailure() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/HealthMetricsAuditRedactionFailureCounterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/HealthMetricsAuditRedactionFailureCounterTests.cs index 4413d8a0..5f094ac6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/HealthMetricsAuditRedactionFailureCounterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/HealthMetricsAuditRedactionFailureCounterTests.cs @@ -5,17 +5,18 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// -/// Bundle C (M5-T7) — the +/// The /// adapter is the production binding for /// on /// site nodes; it forwards every /// redactor over-redaction event into the shared /// so the site health report surfaces the -/// count as AuditRedactionFailure. Mirrors the M2 Bundle G +/// count as AuditRedactionFailure. Mirrors the /// HealthMetricsAuditWriteFailureCounter shape one-for-one. /// public class HealthMetricsAuditRedactionFailureCounterTests { + /// Verifies the case: increment, routes, to, collector, increment Audit Redaction Failure. [Fact] public void Increment_Routes_To_Collector_IncrementAuditRedactionFailure() { @@ -27,6 +28,7 @@ public class HealthMetricsAuditRedactionFailureCounterTests collector.Received(1).IncrementAuditRedactionFailure(); } + /// Verifies the case: increment, multiple, calls, route, to, collector, each, time. [Fact] public void Increment_Multiple_Calls_Route_To_Collector_Each_Time() { @@ -40,6 +42,7 @@ public class HealthMetricsAuditRedactionFailureCounterTests collector.Received(3).IncrementAuditRedactionFailure(); } + /// Verifies the case: construction, with, null, collector, throws, argument Null Exception. [Fact] public void Construction_With_Null_Collector_Throws_ArgumentNullException() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/HealthMetricsAuditWriteFailureCounterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/HealthMetricsAuditWriteFailureCounterTests.cs index 57738105..838f441f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/HealthMetricsAuditWriteFailureCounterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/HealthMetricsAuditWriteFailureCounterTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// -/// Bundle G (M2-T11) — the +/// The /// adapter is the production binding for /// on site nodes; it forwards every FallbackAuditWriter primary failure into /// the shared so the site health report @@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// public class HealthMetricsAuditWriteFailureCounterTests { + /// Verifies that a single increment is routed to the collector as a site audit write failure. [Fact] public void Increment_Routes_To_Collector_IncrementSiteAuditWriteFailures() { @@ -24,6 +25,7 @@ public class HealthMetricsAuditWriteFailureCounterTests collector.Received(1).IncrementSiteAuditWriteFailures(); } + /// Verifies that each of several increments is routed to the collector. [Fact] public void Increment_Multiple_Calls_Route_To_Collector_Each_Time() { @@ -37,6 +39,7 @@ public class HealthMetricsAuditWriteFailureCounterTests collector.Received(3).IncrementSiteAuditWriteFailures(); } + /// Verifies that constructing the counter with a null collector throws an . [Fact] public void Construction_With_Null_Collector_Throws_ArgumentNullException() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/RingBufferFallbackTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/RingBufferFallbackTests.cs index 995f6f17..28af1fee 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/RingBufferFallbackTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/RingBufferFallbackTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// -/// Bundle B (M2-T3) tests for — the +/// Tests for — the /// drop-oldest fallback used by when the /// primary SQLite writer is throwing. /// @@ -23,6 +23,8 @@ public class RingBufferFallbackTests target: target); } + /// Verifies that enqueuing 1025 events into a ring with a capacity of 1024 drops the oldest event and raises the overflow notification exactly once. + /// A task that represents the asynchronous operation. [Fact] public async Task Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflowOnce() { @@ -51,6 +53,8 @@ public class RingBufferFallbackTests Assert.Equal("1024", drained[^1].Target); } + /// Verifies that draining yields the queued events in FIFO order and then completes once the ring is empty. + /// A task that represents the asynchronous operation. [Fact] public async Task DrainAsync_Yields_FIFO_Then_Completes_When_Empty() { @@ -76,6 +80,7 @@ public class RingBufferFallbackTests } } + /// Verifies that enqueuing a single event into a ring with spare capacity succeeds and returns true. [Fact] public void TryEnqueue_AllSucceeds_ReturnsTrue() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditBacklogReporterCadenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditBacklogReporterCadenceTests.cs index daa0826d..1cff7d15 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditBacklogReporterCadenceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditBacklogReporterCadenceTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// -/// Deferred-work #21: the backlog reporter's poll cadence is configurable via +/// The backlog reporter's poll cadence is configurable via /// instead of the /// old hard-coded 30 s constant. /// @@ -26,6 +26,8 @@ public class SiteAuditBacklogReporterCadenceTests explicitInterval, options); + /// Verifies that stopping the reporter while a probe is in flight lets the loop complete cleanly. + /// A task that represents the asynchronous operation. [Fact] public async Task StopAsync_WhileProbeInFlight_LoopCompletesCleanly() { @@ -62,10 +64,12 @@ public class SiteAuditBacklogReporterCadenceTests throw new UnreachableException("the delay above always throws on cancellation"); } + /// Verifies that stopping the reporter after it has been disposed does not throw. + /// A task that represents the asynchronous operation. [Fact] public async Task StopAsync_AfterDispose_DoesNotThrow() { - // Regression (Gitea #15 follow-up): Dispose tears down the CTS StopAsync + // Regression (follow-up): Dispose tears down the CTS StopAsync // cancels, and the host does not guarantee StopAsync is driven before the DI // container is disposed. Cancel() on a disposed CTS throws, and letting that // escape an IHostedService aborts the host's whole shutdown sequence. @@ -79,6 +83,7 @@ public class SiteAuditBacklogReporterCadenceTests reporter.Dispose(); } + /// Verifies that the poll cadence comes from the options when one is configured. [Fact] public void Cadence_ComesFromOptions_WhenConfigured() { @@ -89,6 +94,7 @@ public class SiteAuditBacklogReporterCadenceTests Assert.Equal(TimeSpan.FromSeconds(12), reporter.RefreshInterval); } + /// Verifies that a non-positive configured interval falls back to the default cadence. [Fact] public void Cadence_FallsBackToDefault_WhenOptionsNonPositive() { @@ -99,6 +105,7 @@ public class SiteAuditBacklogReporterCadenceTests Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval); } + /// Verifies that the default cadence is used when no options are supplied. [Fact] public void Cadence_FallsBackToDefault_WhenNoOptions() { @@ -107,6 +114,7 @@ public class SiteAuditBacklogReporterCadenceTests Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval); } + /// Verifies that an explicitly supplied interval takes precedence over the options value. [Fact] public void ExplicitInterval_WinsOverOptions() { @@ -119,6 +127,7 @@ public class SiteAuditBacklogReporterCadenceTests // ----- Stale-Pending signal (review F5) ----- // + /// Verifies that a stale pending backlog is logged as a warning and that repeat warnings are then rate-limited. [Fact] public void StalePendingBacklog_IsWarned_ThenRateLimited() { @@ -143,6 +152,7 @@ public class SiteAuditBacklogReporterCadenceTests Assert.Contains("pending", warning.Message, StringComparison.OrdinalIgnoreCase); } + /// Verifies that a fresh or empty pending backlog does not log a warning. [Fact] public void FreshOrEmptyPendingBacklog_IsNotWarned() { @@ -163,12 +173,27 @@ public class SiteAuditBacklogReporterCadenceTests /// Captures log entries so the stale-pending signal can be asserted. private sealed class CapturingLogger : ILogger { + /// Gets the log entries captured so far. public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new(); + /// Returns no scope; the fake logger does not track scopes. + /// The type of the scope state. + /// The scope state, ignored. + /// Always null or a no-op scope. public IDisposable? BeginScope(TState state) where TState : notnull => null; + /// Reports every log level as enabled. + /// The log level being queried. + /// Always true. public bool IsEnabled(LogLevel logLevel) => true; + /// Records the log entry for later assertions. + /// The type of the log state. + /// The severity of the entry. + /// The event identifier. + /// The log state. + /// The associated exception, if any. + /// Formats the state and exception into the message. public void Log( LogLevel logLevel, EventId eventId, diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionOptionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionOptionsTests.cs index da9d66da..f626c02f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionOptionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionOptionsTests.cs @@ -3,13 +3,16 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// -/// Tests for clamping (PLAN-04 Task 2). +/// Tests for clamping. /// The resolved knobs guard the daily site retention job against misconfiguration: /// retention days are clamped to [1, 90]; the purge interval is clamped away from /// TimeSpan.Zero (an Akka/Timer spin footgun). /// public class SiteAuditRetentionOptionsTests { + /// Verifies that resolved retention days, clamps. + /// Test case input. + /// Test case input. [Theory] [InlineData(0, 1)] // clamp floor [InlineData(7, 7)] // default passthrough @@ -17,6 +20,7 @@ public class SiteAuditRetentionOptionsTests public void ResolvedRetentionDays_Clamps(int configured, int expected) => Assert.Equal(expected, new SiteAuditRetentionOptions { RetentionDays = configured }.ResolvedRetentionDays); + /// Verifies that defaults, are, seven days, daily purge, five minute initial delay. [Fact] public void Defaults_Are_SevenDays_DailyPurge_FiveMinuteInitialDelay() { @@ -26,6 +30,7 @@ public class SiteAuditRetentionOptionsTests Assert.Equal(TimeSpan.FromMinutes(5), o.InitialDelay); } + /// Verifies that resolved purge interval, clamps zero to one minute. [Fact] public void ResolvedPurgeInterval_ClampsZeroToOneMinute() => Assert.Equal(TimeSpan.FromMinutes(1), diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionOptionsValidatorTests.cs index 42c8d777..857bd533 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionOptionsValidatorTests.cs @@ -12,6 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// public class SiteAuditRetentionOptionsValidatorTests { + /// Verifies that the default options pass validation. [Fact] public void DefaultOptions_AreValid() { @@ -19,6 +20,7 @@ public class SiteAuditRetentionOptionsValidatorTests Assert.True(validator.Validate(null, new SiteAuditRetentionOptions()).Succeeded); } + /// Verifies that a retention period of zero days fails validation. [Fact] public void ZeroRetentionDays_Fails() { @@ -29,6 +31,7 @@ public class SiteAuditRetentionOptionsValidatorTests f => f.Contains(nameof(SiteAuditRetentionOptions.RetentionDays), StringComparison.Ordinal)); } + /// Verifies that a purge interval of zero fails validation. [Fact] public void ZeroPurgeInterval_Fails() { @@ -40,6 +43,7 @@ public class SiteAuditRetentionOptionsValidatorTests f => f.Contains(nameof(SiteAuditRetentionOptions.PurgeInterval), StringComparison.Ordinal)); } + /// Verifies that a negative initial delay fails validation. [Fact] public void NegativeInitialDelay_Fails() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionServiceTests.cs index 817fda22..a66a4ca5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionServiceTests.cs @@ -10,7 +10,7 @@ using AuditEvent = ZB.MOM.WW.Audit.AuditEvent; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// -/// Tests for (PLAN-04 Task 3). The hosted +/// Tests for The hosted /// service runs the site SQLite retention purge on a timer: first tick after /// , then every /// , passing a cutoff of @@ -19,10 +19,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// public class SiteAuditRetentionServiceTests { + /// Verifies that stopping the service after it has been disposed does not throw. + /// A task that represents the asynchronous operation. [Fact] public async Task StopAsync_AfterDispose_DoesNotThrow() { - // Regression (Gitea #15 follow-up): Dispose tears down the CTS StopAsync + // Regression: Dispose tears down the CTS StopAsync // cancels, and the host does not guarantee StopAsync is driven before the DI // container is disposed. Cancel() on a disposed CTS throws, and letting that // escape an IHostedService aborts the host's whole shutdown sequence. @@ -43,6 +45,8 @@ public class SiteAuditRetentionServiceTests svc.Dispose(); } + /// Verifies that a tick purges events older than the retention cutoff. + /// A task that represents the asynchronous operation. [Fact] public async Task Tick_Purges_With_RetentionCutoff() { @@ -66,6 +70,8 @@ public class SiteAuditRetentionServiceTests DateTime.UtcNow.AddDays(-7).AddMinutes(1)); } + /// Verifies that a failing tick is swallowed and later ticks still run. + /// A task that represents the asynchronous operation. [Fact] public async Task Tick_SwallowsExceptions_AndKeepsTicking() { @@ -87,6 +93,8 @@ public class SiteAuditRetentionServiceTests Assert.True(queue.PurgeCalls.Count >= 2); } + /// Verifies that stopping the service during an in-flight purge completes cleanly without surfacing a cancellation. + /// A task that represents the asynchronous operation. [Fact] public async Task StopAsync_MidPurge_CompletesCleanly_WithoutSurfacingCancellation() { @@ -127,6 +135,13 @@ public class SiteAuditRetentionServiceTests /// Stub queue that records purge cutoffs and can throw on the first tick. private sealed class RecordingSiteAuditQueue : ISiteAuditQueue { + /// Not supported by this stub. + /// The execution ids to read. + /// The lower time bound. + /// The maximum number of rows. + /// A token to cancel the operation. + /// The keyset cursor. + /// Never returns; always throws . public Task> ReadByExecutionIdsAsync( IReadOnlyCollection executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null) => throw new NotSupportedException("not exercised by the retention service"); @@ -134,13 +149,17 @@ public class SiteAuditRetentionServiceTests private readonly ConcurrentQueue _purgeCalls = new(); private int _calls; + /// Gets the cutoffs passed to each purge call, in call order. public IReadOnlyCollection PurgeCalls => _purgeCalls; + /// Gets a value indicating whether the first purge call throws a simulated transient failure. public bool ThrowOnFirstCall { get; init; } /// When set, PurgeExpiredAsync records the call then blocks until the token /// cancels — simulating a shutdown that lands while a purge is in flight. public bool BlockUntilCancelled { get; init; } + // Test stub: Records the cutoff and optionally throws or blocks, as configured. + /// public async Task PurgeExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default) { var n = Interlocked.Increment(ref _calls); @@ -158,19 +177,41 @@ public class SiteAuditRetentionServiceTests return 0; } + /// Not supported by this stub. + /// The maximum number of rows. + /// A token to cancel the operation. + /// Never returns; always throws . public Task> ReadPendingAsync(int limit, CancellationToken ct = default) => throw new NotSupportedException(); + /// Not supported by this stub. + /// The maximum number of rows. + /// A token to cancel the operation. + /// Never returns; always throws . public Task> ReadPendingCachedTelemetryAsync(int limit, CancellationToken ct = default) => throw new NotSupportedException(); + // Test stub: Not supported by this stub. + /// public Task MarkForwardedAsync(IReadOnlyList eventIds, CancellationToken ct = default) => throw new NotSupportedException(); + /// Not supported by this stub. + /// The lower time bound. + /// The maximum batch size. + /// The keyset cursor. + /// A token to cancel the operation. + /// Never returns; always throws . public Task> ReadPendingSinceAsync( DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default) => throw new NotSupportedException(); + // Test stub: Not supported by this stub. + /// public Task MarkReconciledAsync(IReadOnlyList eventIds, CancellationToken ct = default) => throw new NotSupportedException(); + // Test stub: Not supported by this stub. + /// public Task MarkReconciledUpToAsync(DateTime sinceUtc, string? afterId, CancellationToken ct = default) => throw new NotSupportedException(); + // Test stub: Not supported by this stub. + /// public Task GetBacklogStatsAsync(CancellationToken ct = default) => throw new NotSupportedException(); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditTelemetryOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditTelemetryOptionsValidatorTests.cs index 4d7c05a8..680c2d22 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditTelemetryOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditTelemetryOptionsValidatorTests.cs @@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// public class SiteAuditTelemetryOptionsValidatorTests { + /// Verifies that default options are valid. [Fact] public void DefaultOptions_AreValid() { @@ -18,6 +19,7 @@ public class SiteAuditTelemetryOptionsValidatorTests Assert.True(validator.Validate(null, new SiteAuditTelemetryOptions()).Succeeded); } + /// Verifies that zero batch size fails. [Fact] public void ZeroBatchSize_Fails() { @@ -28,6 +30,7 @@ public class SiteAuditTelemetryOptionsValidatorTests f => f.Contains(nameof(SiteAuditTelemetryOptions.BatchSize), StringComparison.Ordinal)); } + /// Verifies that zero busy interval fails. [Fact] public void ZeroBusyInterval_Fails() { @@ -38,6 +41,7 @@ public class SiteAuditTelemetryOptionsValidatorTests f => f.Contains(nameof(SiteAuditTelemetryOptions.BusyIntervalSeconds), StringComparison.Ordinal)); } + /// Verifies that zero idle interval fails. [Fact] public void ZeroIdleInterval_Fails() { @@ -48,6 +52,7 @@ public class SiteAuditTelemetryOptionsValidatorTests f => f.Contains(nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds), StringComparison.Ordinal)); } + /// Verifies that idle shorter than busy fails. [Fact] public void IdleShorterThanBusy_Fails() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs index 49195d1c..457945c0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs @@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// -/// Bundle E (M6-T6) tests for . +/// Tests for . /// Exercises the health-metric surface that SiteAuditBacklogReporter /// polls every 30 s and pushes onto the site health report as /// SiteAuditBacklog. @@ -18,6 +18,7 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable { private readonly string _dbPath; + /// Creates a unique temporary database path for each test. public SqliteAuditWriterBacklogStatsTests() { // OnDiskBytes assertions only make sense against a real file — the @@ -28,6 +29,7 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable $"audit-backlog-stats-{Guid.NewGuid():N}.db"); } + /// Releases the resources held by this test class. public void Dispose() { if (File.Exists(_dbPath)) @@ -52,6 +54,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable kind: AuditKind.ApiCall, status: AuditStatus.Delivered); + /// Verifies that an empty database reports zero pending events, no oldest-pending timestamp and zero bytes on disk. + /// A task that represents the asynchronous operation. [Fact] public async Task EmptyDb_Returns_Zero_Null_AndZeroBytes() { @@ -70,6 +74,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable $"OnDiskBytes must be non-negative, got {snapshot.OnDiskBytes}"); } + /// Verifies that five pending events are reported as a pending count of five. + /// A task that represents the asynchronous operation. [Fact] public async Task Pending_5_Returns_5() { @@ -85,6 +91,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable Assert.Equal(5, snapshot.PendingCount); } + /// Verifies that the oldest-pending timestamp is the earliest OccurredAtUtc among the pending events. + /// A task that represents the asynchronous operation. [Fact] public async Task OldestPending_Is_Earliest_OccurredAtUtc() { @@ -110,10 +118,12 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable Assert.Equal(t1, snapshot.OldestPendingUtc!.Value); } + /// Verifies that reading the backlog stats does not block while a concurrent write load is running. + /// A task that represents the asynchronous operation. [Fact] public async Task GetBacklogStatsAsync_DoesNotBlockOnConcurrentWriteLoad() { - // AuditLog-005: GetBacklogStatsAsync previously took _writeLock, the + // GetBacklogStatsAsync previously took _writeLock, the // same lock that serialises every batch INSERT in FlushBatch. Under a // backlog growing to hundreds of thousands of rows a COUNT(*)+MIN // index scan could park the hot-path writer for hundreds of ms. The @@ -163,6 +173,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable $"backlog probe should see at least the seeded rows; got {snapshot.PendingCount}"); } + /// Verifies that the on-disk byte count matches the size of the database file. + /// A task that represents the asynchronous operation. [Fact] public async Task OnDiskBytes_ReturnsFileSize() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterCommitFailureTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterCommitFailureTests.cs index 628e1017..d6660621 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterCommitFailureTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterCommitFailureTests.cs @@ -14,7 +14,7 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// -/// arch-review 2026-08-19 P4-2 regression coverage. +/// Regression coverage. /// used to call pending.Completion.TrySetResult() per row BEFORE /// transaction.Commit() ran. A later failure in the SAME batch — another row's insert /// throwing, or the commit itself failing (SQLITE_FULL, IOERR) — rolled the whole transaction @@ -102,6 +102,8 @@ public class SqliteAuditWriterCommitFailureTests return (writer, dataSource); } + /// Verifies that a mid-batch insert failure faults every row in the batch, not just the failing row. + /// A task that represents the asynchronous operation. [Fact] public async Task FlushBatch_MidBatchInsertFailure_FaultsEveryRowInBatch_NotJustTheFailingRow() { @@ -152,6 +154,8 @@ public class SqliteAuditWriterCommitFailureTests Assert.Equal(0L, (long)countCmd.ExecuteScalar()!); } + /// Verifies that the fallback writer rings every faulted row of a mid-batch insert failure and increments the failure counter once per row. + /// A task that represents the asynchronous operation. [Fact] public async Task FallbackAuditWriter_MidBatchInsertFailure_RingsEveryFaultedRow_AndIncrementsFailureCounterForEach() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterOptionsValidatorTests.cs index ca0345ef..c6597d54 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterOptionsValidatorTests.cs @@ -10,8 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// non-positive value has a documented fall-back-to-30s contract. /// /// -/// DatabasePath is intentionally NOT covered here (arch-review remediation -/// WP1.2): it defaults to "" (no CWD-relative default — mirrors +/// DatabasePath is intentionally NOT covered here (arch-review remediation): it defaults to "" (no CWD-relative default — mirrors /// LocalDb:Path), but this validator runs on both Central and Site /// composition roots via ValidateOnStart, and only Site nodes ever resolve /// the writer. The Site-only requirement is enforced pre-host by @@ -20,6 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// public class SqliteAuditWriterOptionsValidatorTests { + /// Verifies that the default options pass validation. [Fact] public void DefaultOptions_AreValid() { @@ -29,6 +29,7 @@ public class SqliteAuditWriterOptionsValidatorTests Assert.True(validator.Validate(null, new SqliteAuditWriterOptions()).Succeeded); } + /// Verifies that a channel capacity of zero fails validation. [Fact] public void ZeroChannelCapacity_Fails() { @@ -39,6 +40,7 @@ public class SqliteAuditWriterOptionsValidatorTests f => f.Contains(nameof(SqliteAuditWriterOptions.ChannelCapacity), StringComparison.Ordinal)); } + /// Verifies that a batch size of zero fails validation. [Fact] public void ZeroBatchSize_Fails() { @@ -49,6 +51,7 @@ public class SqliteAuditWriterOptionsValidatorTests f => f.Contains(nameof(SqliteAuditWriterOptions.BatchSize), StringComparison.Ordinal)); } + /// Verifies that a flush interval of zero fails validation. [Fact] public void ZeroFlushInterval_Fails() { @@ -59,6 +62,8 @@ public class SqliteAuditWriterOptionsValidatorTests f => f.Contains(nameof(SqliteAuditWriterOptions.FlushIntervalMs), StringComparison.Ordinal)); } + /// Verifies that a non-positive backlog poll interval still passes validation. + /// The non-positive poll interval, in milliseconds, to validate. [Theory] [InlineData(0)] [InlineData(-1)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterReadByExecutionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterReadByExecutionTests.cs index 5672b7f6..4ae85d59 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterReadByExecutionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterReadByExecutionTests.cs @@ -19,6 +19,7 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"audit-readbyexec-{Guid.NewGuid():N}.db"); private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc); + /// Releases the resources held by the test fixture. public void Dispose() { if (File.Exists(_dbPath)) { try { File.Delete(_dbPath); } catch { /* best-effort */ } } @@ -33,6 +34,9 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, kind, AuditStatus.Delivered, occurredAtUtc: at, executionId: exec, parentExecutionId: parent, sourceSiteId: "site-a"); + /// Verifies that the trace read plan uses the execution-id indexes instead of scanning the retained store. + /// The number of execution ids included in the read. + /// A task that represents the asynchronous operation. [Theory] [InlineData(1)] [InlineData(64)] @@ -56,6 +60,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable Assert.DoesNotContain(plan, p => p.StartsWith("SCAN audit_event", StringComparison.Ordinal)); } + /// Verifies that legacy rows with malformed details JSON do not break startup or trace reads. + /// A task that represents the asynchronous operation. [Fact] public async Task LegacyMalformedDetails_DoNotBreakStartupOrTraceReads() { @@ -81,6 +87,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable Assert.Equal(row.EventId, Assert.Single(await reopenedAgain.ReadByExecutionIdsAsync([root], DateTime.MinValue, 2000)).EventId); } + /// Verifies that the cursor pages through rows with equal timestamps without repeating or skipping any. + /// A task that represents the asynchronous operation. [Fact] public async Task Cursor_PagesEqualTimestampsWithoutRepeatingOrSkippingRows() { @@ -100,6 +108,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable first.Concat(second).Select(e => e.EventId)); } + /// Verifies that rows matching either the execution id or the parent execution id are returned in order and within the bound. + /// A task that represents the asynchronous operation. [Fact] public async Task MatchesExecutionId_OrParentExecutionId_OrderedAndBounded() { @@ -145,6 +155,7 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable /// ApiOutbound row (or any other channel) stored under the execution id is served alongside /// the ScriptTrace rows, in time order. Pins that no channel predicate creeps in. /// + /// A task that represents the asynchronous operation. [Fact] public async Task ServesOutboundRows_NoChannelPredicate() { @@ -171,6 +182,8 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable Assert.Equal(AuditChannel.ApiOutbound, AuditRowProjection.Decompose(hits[1]).Channel); } + /// Verifies that the read guards reject an invalid limit and an oversized id list. + /// A task that represents the asynchronous operation. [Fact] public async Task Guards_LimitAndIdCap() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterRetentionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterRetentionTests.cs index 9df9f090..320dc603 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterRetentionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterRetentionTests.cs @@ -22,12 +22,14 @@ public class SqliteAuditWriterRetentionTests : IDisposable { private readonly string _dbPath; + /// Initializes the test class with a unique temporary database path. public SqliteAuditWriterRetentionTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"audit-retention-{Guid.NewGuid():N}.db"); } + /// Releases the resources held by the test fixture. public void Dispose() { if (File.Exists(_dbPath)) @@ -58,6 +60,8 @@ public class SqliteAuditWriterRetentionTests : IDisposable return id; } + /// Verifies that purging deletes forwarded and reconciled rows older than the cutoff. + /// A task that represents the asynchronous operation. [Fact] public async Task PurgeExpired_DeletesForwardedAndReconciled_OlderThanCutoff() { @@ -80,6 +84,8 @@ public class SqliteAuditWriterRetentionTests : IDisposable Assert.Equal(1, stats.PendingCount); } + /// Verifies that purging expired rows is idempotent. + /// A task that represents the asynchronous operation. [Fact] public async Task PurgeExpired_IsIdempotent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs index 5284cd87..539180ff 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// -/// C4 (Task 2.5) schema-bootstrap tests for 's +/// Schema-bootstrap tests for 's /// two-table site schema — the append-only canonical audit_event table + /// the mutable operational audit_forward_state sidecar + the IX_fwd /// drain index. Uses an in-memory shared-cache SQLite database so the same @@ -80,6 +80,7 @@ public class SqliteAuditWriterSchemaTests return Convert.ToInt32(cmd.ExecuteScalar()) > 0; } + /// Verifies that opens, creates, audit, event, canonical, table, with, 10 columns, and, pk, on, event id. [Fact] public void Opens_Creates_audit_event_Canonical_Table_With_10Columns_And_PK_On_EventId() { @@ -113,6 +114,7 @@ public class SqliteAuditWriterSchemaTests } } + /// Verifies that opens, creates, audit, forward, state, sidecar, table, with, expected, columns. [Fact] public void Opens_Creates_audit_forward_state_Sidecar_Table_With_Expected_Columns() { @@ -145,6 +147,7 @@ public class SqliteAuditWriterSchemaTests } } + /// Verifies that opens, creates, ix, fwd, index, on, forward state, is cached kind, occurred. [Fact] public void Opens_Creates_IX_fwd_Index_On_ForwardState_IsCachedKind_Occurred() { @@ -180,6 +183,7 @@ public class SqliteAuditWriterSchemaTests } } + /// Verifies that pragma, auto, vacuum, is, incremental. [Fact] public void PRAGMA_auto_vacuum_Is_INCREMENTAL() { @@ -265,6 +269,8 @@ public class SqliteAuditWriterSchemaTests return connection; } + /// Verifies that opening, over, pre existing, old single table, db, drops, it, and, creates, two, table, schema. + /// A task that represents the asynchronous operation. [Fact] public async Task Opening_Over_PreExisting_OldSingleTable_Db_Drops_It_And_Creates_Two_Table_Schema() { @@ -316,6 +322,8 @@ public class SqliteAuditWriterSchemaTests // ----- Canonical / sidecar field persistence ----- // + /// Verifies that write async, persists, source node, field. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_persists_SourceNode_field() { @@ -337,6 +345,8 @@ public class SqliteAuditWriterSchemaTests } } + /// Verifies that write async, persists, null, source node. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_persists_null_SourceNode() { @@ -358,6 +368,8 @@ public class SqliteAuditWriterSchemaTests } } + /// Verifies that write async, execution id, round trips, through, details json. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_ExecutionId_RoundTrips_Through_DetailsJson() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs index 2f388a04..07aee1cc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// -/// C4 (Task 2.5) hot-path + drain tests for 's +/// Hot-path + drain tests for 's /// two-table site schema. Exercise the Channel-based enqueue, the background /// writer's per-event canonical(audit_event) + sidecar /// (audit_forward_state) INSERTs, duplicate-EventId swallowing, the @@ -87,7 +87,7 @@ public class SqliteAuditWriterWriteTests return byState; } - // C4 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared + // Build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared // factory. The SQLite writer stores the 10 canonical fields directly in // audit_event and writes a Pending sidecar row into audit_forward_state, with // IsCachedKind precomputed from the event's Kind. Reads recompose the canonical @@ -120,6 +120,8 @@ public class SqliteAuditWriterWriteTests eventId: id ?? Guid.NewGuid(), occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow); + /// Verifies that a fresh event persists its canonical fields and a Pending sidecar row. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending() { @@ -140,6 +142,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId)); } + /// Verifies that the canonical fields written for an event round-trip unchanged through a read. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_Roundtrips_Canonical_Fields_Through_Read() { @@ -164,6 +168,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(evt.DetailsJson, row.DetailsJson); } + /// Verifies that 1000 concurrent writes all persist without throwing. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions() { @@ -186,16 +192,17 @@ public class SqliteAuditWriterWriteTests Assert.Equal(1000, Convert.ToInt64(sidecarCmd.ExecuteScalar())); } - // ----- FlushIntervalMs soft-flush coalescing (arch-review remediation WP1.2) ----- // + // ----- FlushIntervalMs soft-flush coalescing (arch-review remediation) ----- // /// /// A trickle of events — well under , /// spaced closer together than — /// must coalesce into ONE transaction rather than fsyncing once per event. This is the - /// bug WP1.2 fixes: FlushIntervalMs was validated but never read by the writer + /// bug this fixes: FlushIntervalMs was validated but never read by the writer /// loop, so every trickle-rate write incurred its own commit (and, under SQLite's /// default synchronous=FULL, its own fsync). /// + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_TrickleLoad_CoalescesIntoOneTransaction() { @@ -229,6 +236,7 @@ public class SqliteAuditWriterWriteTests /// waiting for a partner — the deadline bounds worst-case added latency, it does /// not turn into an unbounded debounce. /// + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_EventsSpacedBeyondFlushInterval_FlushSeparately() { @@ -249,6 +257,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(2, writer.FlushCountForTests); } + /// Verifies that a duplicate event id is swallowed without an exception and the first write wins. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_DuplicateEventId_FirstWriteWins_NoException() { @@ -281,13 +291,15 @@ public class SqliteAuditWriterWriteTests Assert.Equal("first", targetCmd.ExecuteScalar() as string); } + /// Verifies that a write always sets the sidecar forward state to Pending. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_ForcesSidecarForwardStatePending() { var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesSidecarForwardStatePending)); await using var _ = writer; - // C4 (Task 2.5): ForwardState is not a field on the canonical record; a + // ForwardState is not a field on the canonical record; a // fresh event's sidecar row defaults to Pending on INSERT. var evt = NewEvent(); await writer.WriteAsync(evt); @@ -297,6 +309,8 @@ public class SqliteAuditWriterWriteTests // ----- IsCachedKind drain split (precomputed at insert) ----- // + /// Verifies that a cached audit kind sets the cached-kind flag to 1 and a non-cached kind sets it to 0. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0() { @@ -320,6 +334,9 @@ public class SqliteAuditWriterWriteTests Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar())); } + /// Verifies that cached audit kinds drain through the cached-telemetry read and not the ordinary pending read. + /// The cached audit kind under test. + /// A task that represents the asynchronous operation. [Theory] [InlineData(AuditKind.CachedSubmit)] [InlineData(AuditKind.ApiCallCached)] @@ -342,6 +359,8 @@ public class SqliteAuditWriterWriteTests Assert.DoesNotContain(pendingRows, r => r.EventId == cached.EventId); } + /// Verifies that a non-cached audit kind drains through the ordinary pending read and not the cached-telemetry read. + /// A task that represents the asynchronous operation. [Fact] public async Task NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry() { @@ -358,6 +377,8 @@ public class SqliteAuditWriterWriteTests Assert.DoesNotContain(cachedRows, r => r.EventId == nonCached.EventId); } + /// Verifies that the pending read returns the oldest rows first, limited to the requested count. + /// A task that represents the asynchronous operation. [Fact] public async Task ReadPendingAsync_Returns_OldestFirst_LimitedToN() { @@ -387,6 +408,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc); } + /// Verifies that marking events as forwarded flips their sidecar rows to Forwarded. + /// A task that represents the asynchronous operation. [Fact] public async Task MarkForwardedAsync_FlipsSidecarRowsToForwarded() { @@ -406,6 +429,8 @@ public class SqliteAuditWriterWriteTests Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString())); } + /// Verifies that marking events as forwarded increments the attempt count and stamps the last-attempt time. + /// A task that represents the asynchronous operation. [Fact] public async Task MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc() { @@ -428,6 +453,8 @@ public class SqliteAuditWriterWriteTests Assert.False(reader.IsDBNull(1)); // LastAttemptUtc stamped } + /// Verifies that marking a non-existent event id as forwarded does not throw. + /// A task that represents the asynchronous operation. [Fact] public async Task MarkForwardedAsync_NonExistentId_NoThrow() { @@ -440,6 +467,8 @@ public class SqliteAuditWriterWriteTests // No assertion needed: the call must complete without throwing. } + /// Verifies that the forwarded read returns only rows in the Forwarded state. + /// A task that represents the asynchronous operation. [Fact] public async Task ReadForwardedAsync_Returns_Only_Forwarded_Rows() { @@ -457,8 +486,10 @@ public class SqliteAuditWriterWriteTests Assert.Equal(forwarded.EventId, row.EventId); } - // ----- M6 reconciliation pull surface ----- // + // ----- Reconciliation pull surface ----- // + /// Verifies that the since-read returns pending and forwarded rows oldest first, limited to the requested count. + /// A task that represents the asynchronous operation. [Fact] public async Task ReadPendingSinceAsync_Returns_PendingAndForwarded_OldestFirst_LimitedToN() { @@ -488,6 +519,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc); } + /// Verifies that the since-read excludes rows older than the supplied cutoff. + /// A task that represents the asynchronous operation. [Fact] public async Task ReadPendingSinceAsync_ExcludesRowsOlderThanSinceUtc() { @@ -511,6 +544,8 @@ public class SqliteAuditWriterWriteTests Assert.DoesNotContain(rows, r => r.EventId == old.EventId); } + /// Verifies that the since-read excludes rows that are already reconciled. + /// A task that represents the asynchronous operation. [Fact] public async Task ReadPendingSinceAsync_ExcludesReconciledRows() { @@ -531,8 +566,10 @@ public class SqliteAuditWriterWriteTests Assert.Equal(pending.EventId, rows[0].EventId); } - // ----- WP2.3: composite keyset cursor + cursor-proved retirement ----- // + // ----- Composite keyset cursor + cursor-proved retirement ----- // + /// Verifies that supplying an after-id skips rows at or before the composite timestamp-and-id cursor. + /// A task that represents the asynchronous operation. [Fact] public async Task ReadPendingSinceAsync_WithAfterId_SkipsRowsAtOrBeforeTheCompositeCursor() { @@ -567,6 +604,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(sameInstant.Select(e => e.EventId).ToHashSet(), allIds); } + /// Verifies that reconciling up to a cursor with an id retires only rows at or before that cursor. + /// A task that represents the asynchronous operation. [Fact] public async Task MarkReconciledUpToAsync_WithCursorId_RetiresOnlyRowsAtOrBeforeIt() { @@ -599,6 +638,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(3, remaining.Count); // the two un-consumed at the instant + newer } + /// Verifies that reconciling up to a timestamp without a cursor id leaves rows at the boundary instant servable. + /// A task that represents the asynchronous operation. [Fact] public async Task MarkReconciledUpToAsync_WithoutCursorId_LeavesTheBoundaryInstantServable() { @@ -626,6 +667,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(boundary.EventId, row.EventId); } + /// Verifies that rows served without an advancing cursor remain servable so they are delivered at least once. + /// A task that represents the asynchronous operation. [Fact] public async Task ServingRows_WithoutAnAdvancingCursor_KeepsThemServable_AtLeastOnce() { @@ -652,6 +695,8 @@ public class SqliteAuditWriterWriteTests second.Select(r => r.EventId).ToHashSet()); } + /// Verifies that a late-stamped insert below the cursor is never retired by reconciling up to the cursor. + /// A task that represents the asynchronous operation. [Fact] public async Task MarkReconciledUpToAsync_LateStampedInsertBelowTheCursor_IsNeverRetired() { @@ -695,6 +740,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(lateStamped.EventId, Assert.Single(reread).EventId); } + /// Verifies that forwarded rows are retired by reconciling up to a cursor even if they were never pulled. + /// A task that represents the asynchronous operation. [Fact] public async Task MarkReconciledUpToAsync_ForwardedRowsRetire_EvenWithoutHavingBeenPulled() { @@ -722,6 +769,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, neverShipped.EventId)); } + /// Verifies that reconciling before any pull retires no pending rows. + /// A task that represents the asynchronous operation. [Fact] public async Task MarkReconciledUpToAsync_BeforeAnyPull_RetiresNothingPending() { @@ -745,6 +794,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(1, await writer.MarkReconciledUpToAsync(t0.AddHours(1), afterId: null)); } + /// Verifies that the since-read throws for an invalid batch size. + /// A task that represents the asynchronous operation. [Fact] public async Task ReadPendingSinceAsync_InvalidBatchSize_Throws() { @@ -757,6 +808,8 @@ public class SqliteAuditWriterWriteTests () => writer.ReadPendingSinceAsync(DateTime.MinValue, batchSize: -3)); } + /// Verifies that marking events as reconciled flips both pending and forwarded rows to Reconciled. + /// A task that represents the asynchronous operation. [Fact] public async Task MarkReconciledAsync_FlipsPendingAndForwarded_To_Reconciled() { @@ -781,6 +834,8 @@ public class SqliteAuditWriterWriteTests Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString())); } + /// Verifies that marking reconciled is idempotent and leaves already-reconciled rows untouched. + /// A task that represents the asynchronous operation. [Fact] public async Task MarkReconciledAsync_Idempotent_LeavesAlreadyReconciledRowsUntouched() { @@ -796,6 +851,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(AuditForwardState.Reconciled.ToString(), ReadForwardState(dataSource, a.EventId)); } + /// Verifies that marking a non-existent event id as reconciled does not throw. + /// A task that represents the asynchronous operation. [Fact] public async Task MarkReconciledAsync_NonExistentId_NoThrow() { @@ -807,7 +864,7 @@ public class SqliteAuditWriterWriteTests } /// - /// Fix 2 / M1 state guard: + /// State guard: /// must NOT demote a row back to /// . When a batch contains both a /// Pending ID and an already-Reconciled ID: @@ -818,6 +875,7 @@ public class SqliteAuditWriterWriteTests /// This mirrors the idempotency guard already present on /// . /// + /// A task that represents the asynchronous operation. [Fact] public async Task MarkForwardedAsync_DoesNotDemoteReconciledRow_WhilePendingStillTransitions() { @@ -879,6 +937,7 @@ public class SqliteAuditWriterWriteTests /// call flip atomically — and /// chunks the same way. /// + /// A task that represents the asynchronous operation. [Fact] public async Task MarkForwardedThenReconciled_LargeBatch_ChunksParameters_AllFlip() { @@ -912,6 +971,8 @@ public class SqliteAuditWriterWriteTests // ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- // + /// Verifies that a non-null execution id round-trips through a write and read. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_NonNullExecutionId_RoundTrips() { @@ -928,6 +989,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal(executionId, row.AsRow().ExecutionId); } + /// Verifies that a null execution id round-trips as null. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_NullExecutionId_RoundTripsAsNull() { @@ -945,6 +1008,8 @@ public class SqliteAuditWriterWriteTests // ----- SourceNode stamping (Tasks 11/12) ----- // + /// Verifies that the source node is stamped from the node identity provider when the event has none. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone() { @@ -962,6 +1027,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal("node-a", row.SourceNode); } + /// Verifies that a source node supplied by the caller is preserved. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_PreservesCallerProvidedSourceNode() { @@ -980,6 +1047,8 @@ public class SqliteAuditWriterWriteTests Assert.Equal("node-z", row.SourceNode); } + /// Verifies that the source node stays null when the provider returns null. + /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull() { @@ -999,13 +1068,14 @@ public class SqliteAuditWriterWriteTests // ----- C4 hardening: safe enum-parse in MapRow ----- // /// - /// C4 hardening (Task 2.5): a row whose stored Outcome column holds an + /// C4 hardening: a row whose stored Outcome column holds an /// unknown/renamed enum string must NOT fault the read path; it degrades /// gracefully to (the safe /// fallback). The read is /// exercised via the public /// surface which calls the private MapRow. /// + /// A task that represents the asynchronous operation. [Fact] public async Task ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback() { @@ -1033,8 +1103,10 @@ public class SqliteAuditWriterWriteTests Assert.Equal(AuditOutcome.Success, row.Outcome); } - // ── Task 24: pin the inclusive (>=) reconciliation cursor boundary ── + // ── Pin the inclusive (>=) reconciliation cursor boundary ── + /// Verifies that a row at the boundary timestamp is included in the since-read and that event-id deduplication absorbs the resulting duplicates. + /// A task that represents the asynchronous operation. [Fact] public async Task ReadPendingSince_BoundaryTimestamp_IsInclusive_EventIdDedupAbsorbsDuplicates() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs index 36d62369..b3eedc45 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs @@ -53,6 +53,8 @@ public class CachedCallLifecycleBridgeTests SourceScript: sourceScript, ParentExecutionId: parentExecutionId); + /// Verifies that a transient failure emits one Attempted row and no resolve row. + /// A task that represents the asynchronous operation. [Fact] public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve() { @@ -78,6 +80,8 @@ public class CachedCallLifecycleBridgeTests Assert.Null(packet.Operational.TerminalAtUtc); } + /// Verifies that a delivered attempt emits an Attempted row and a CachedResolve row with Delivered status. + /// A task that represents the asynchronous operation. [Fact] public async Task Delivered_EmitsAttemptedRow_AndCachedResolveDelivered() { @@ -104,6 +108,8 @@ public class CachedCallLifecycleBridgeTests Assert.Equal(_id.Value, resolve.Audit.AsRow().CorrelationId); } + /// Verifies that a permanent failure emits an Attempted row and a CachedResolve row with Parked status. + /// A task that represents the asynchronous operation. [Fact] public async Task PermanentFailure_EmitsAttempted_AndCachedResolveParked() { @@ -123,6 +129,8 @@ public class CachedCallLifecycleBridgeTests Assert.Equal("Parked", captured[1].Operational.Status); } + /// Verifies that parking after the maximum retries emits an Attempted row and a CachedResolve row with Parked status. + /// A task that represents the asynchronous operation. [Fact] public async Task ParkedMaxRetries_EmitsAttempted_AndCachedResolveParked() { @@ -138,6 +146,8 @@ public class CachedCallLifecycleBridgeTests Assert.Equal(AuditStatus.Parked, captured[1].Audit.AsRow().Status); } + /// Verifies that the database channel maps to the DbWriteCached kind and the DbOutbound channel. + /// A task that represents the asynchronous operation. [Fact] public async Task DbChannel_MapsToDbWriteCachedKind_AndDbOutboundChannel() { @@ -157,6 +167,8 @@ public class CachedCallLifecycleBridgeTests Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.AsRow().Channel); } + /// Verifies that the bridge does not throw when the telemetry forwarder throws. + /// A task that represents the asynchronous operation. [Fact] public async Task BridgeDoesNotThrow_WhenForwarderThrows() { @@ -170,6 +182,8 @@ public class CachedCallLifecycleBridgeTests await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered)); } + /// Verifies that the bridge populates provenance fields from the attempt context. + /// A task that represents the asynchronous operation. [Fact] public async Task BridgePopulatesProvenance_FromAttemptContext() { @@ -192,12 +206,14 @@ public class CachedCallLifecycleBridgeTests Assert.Equal(_id.Value, captured.Audit.AsRow().CorrelationId); } - // ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ── + // ── ExecutionId / SourceScript ── + /// Verifies that a retry-loop Attempted row carries the ExecutionId and SourceScript from the attempt context. + /// A task that represents the asynchronous operation. [Fact] public async Task RetryLoopAttemptedRow_CarriesExecutionIdAndSourceScript_FromContext() { - // Task 4: the ExecutionId + SourceScript threaded through the S&F + // The ExecutionId + SourceScript threaded through the S&F // buffer arrive on the CachedCallAttemptContext; the bridge must stamp // both onto the per-attempt ApiCallCached row (previously SourceScript // was hard-coded null with a "not threaded through S&F" comment). @@ -218,6 +234,8 @@ public class CachedCallLifecycleBridgeTests Assert.Equal("Plant.Pump42/OnTick", packet.Audit.AsRow().SourceScript); } + /// Verifies that a retry-loop CachedResolve row carries the ExecutionId and SourceScript from the attempt context. + /// A task that represents the asynchronous operation. [Fact] public async Task RetryLoopCachedResolveRow_CarriesExecutionIdAndSourceScript_FromContext() { @@ -245,10 +263,12 @@ public class CachedCallLifecycleBridgeTests Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.AsRow().SourceScript); } + /// Verifies that a retry-loop row leaves ExecutionId and SourceScript null when the context has none. + /// A task that represents the asynchronous operation. [Fact] public async Task RetryLoopRow_NullExecutionIdAndSourceScript_RemainNull() { - // Back-compat: a pre-Task-4 buffered row has no ExecutionId / + // Back-compat: a buffered row written before ExecutionId was threaded through has no ExecutionId / // SourceScript; the bridge must leave the audit row's fields null // rather than throwing. CachedCallTelemetry? captured = null; @@ -263,12 +283,14 @@ public class CachedCallLifecycleBridgeTests Assert.Null(captured.Audit.AsRow().SourceScript); } - // ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ── + // ── ParentExecutionId ── + /// Verifies that a retry-loop Attempted row carries the ParentExecutionId from the attempt context. + /// A task that represents the asynchronous operation. [Fact] public async Task RetryLoopAttemptedRow_CarriesParentExecutionId_FromContext() { - // Task 6: the ParentExecutionId threaded through the S&F buffer (the + // The ParentExecutionId threaded through the S&F buffer (the // inbound-API run that spawned the originating script) arrives on the // CachedCallAttemptContext; the bridge must stamp it onto the // per-attempt ApiCallCached row beside ExecutionId. @@ -287,6 +309,8 @@ public class CachedCallLifecycleBridgeTests Assert.Equal(parentExecutionId, packet.Audit.AsRow().ParentExecutionId); } + /// Verifies that a retry-loop CachedResolve row carries the ParentExecutionId from the attempt context. + /// A task that represents the asynchronous operation. [Fact] public async Task RetryLoopCachedResolveRow_CarriesParentExecutionId_FromContext() { @@ -312,6 +336,8 @@ public class CachedCallLifecycleBridgeTests Assert.Equal(parentExecutionId, attempted.Audit.AsRow().ParentExecutionId); } + /// Verifies that a retry-loop row leaves ParentExecutionId null when the context has none. + /// A task that represents the asynchronous operation. [Fact] public async Task RetryLoopRow_NullParentExecutionId_RemainsNull() { @@ -329,12 +355,14 @@ public class CachedCallLifecycleBridgeTests Assert.Null(captured!.Audit.AsRow().ParentExecutionId); } - // ── SourceNode-stamping (Task 14) ── + // ── SourceNode stamping ── + /// Verifies that a retry-loop row stamps the source node name from the node identity provider. + /// A task that represents the asynchronous operation. [Fact] public async Task RetryLoopRow_StampsSourceNode_FromNodeIdentityProvider() { - // SourceNode-stamping (Task 14): when an INodeIdentityProvider is + // SourceNode stamping: when an INodeIdentityProvider is // wired the bridge stamps the local node name (node-a/node-b) onto // the SiteCallOperational.SourceNode column of every emitted packet. var nodeIdentity = Substitute.For(); @@ -353,6 +381,8 @@ public class CachedCallLifecycleBridgeTests Assert.All(captured, p => Assert.Equal("node-a", p.Operational.SourceNode)); } + /// Verifies that a retry-loop row leaves the source node null when no node identity provider is configured. + /// A task that represents the asynchronous operation. [Fact] public async Task RetryLoopRow_NoNodeIdentityProvider_LeavesSourceNodeNull() { @@ -371,6 +401,8 @@ public class CachedCallLifecycleBridgeTests Assert.Null(packet.Operational.SourceNode); } + /// Verifies that a retry-loop row leaves the source node null when the node identity has a null node name. + /// A task that represents the asynchronous operation. [Fact] public async Task RetryLoopRow_NodeIdentityWithNullNodeName_LeavesSourceNodeNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs index 0c9a72e9..3ceceed2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs @@ -111,6 +111,8 @@ public class CachedCallTelemetryForwarderTests UpdatedAtUtc: _now, TerminalAtUtc: _now)); + /// Verifies that forwarding a submit packet writes an audit event and records the enqueue. + /// A task that represents the asynchronous operation. [Fact] public async Task ForwardAsync_Submit_WritesAuditEvent_AndRecordsEnqueue() { @@ -130,7 +132,7 @@ public class CachedCallTelemetryForwarderTests // Tracking row: insert-if-not-exists with kind discriminator. // Default CreateSut() does NOT supply an INodeIdentityProvider, so the // forwarder passes null sourceNode to RecordEnqueueAsync (legacy / test - // host behaviour). The Task 14 stamping path is covered by the + // host behaviour). The stamping path is covered by the // ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider test // below. await _tracking.Received(1).RecordEnqueueAsync( @@ -147,6 +149,8 @@ public class CachedCallTelemetryForwarderTests default, default!, default, default, default); } + /// Verifies that forwarding an attempted packet writes an audit event and records the attempt. + /// A task that represents the asynchronous operation. [Fact] public async Task ForwardAsync_Attempted_WritesAuditEvent_AndRecordsAttempt() { @@ -170,6 +174,8 @@ public class CachedCallTelemetryForwarderTests default, default!, default, default, default); } + /// Verifies that forwarding a resolve packet writes an audit event and records the terminal state. + /// A task that represents the asynchronous operation. [Fact] public async Task ForwardAsync_Resolve_WritesAuditEvent_AndRecordsTerminal() { @@ -196,6 +202,8 @@ public class CachedCallTelemetryForwarderTests // ── 2026-09-06: the audit row is enqueued, never awaited to commit ───── // Design: docs/plans/2026-09-06-tracking-store-async-writes-design.md + /// Verifies that forwarding returns while the audit commit is still pending. + /// A task that represents the asynchronous operation. [Fact] public async Task ForwardAsync_ReturnsWhileTheAuditCommitIsStillPending() { @@ -216,6 +224,8 @@ public class CachedCallTelemetryForwarderTests _id, "Delivered", Arg.Any(), Arg.Any(), Arg.Any()); } + /// Verifies that a later audit commit fault is swallowed rather than surfaced to the caller. + /// A task that represents the asynchronous operation. [Fact] public async Task ForwardAsync_AuditCommitFaultsLater_IsSwallowed() { @@ -234,6 +244,8 @@ public class CachedCallTelemetryForwarderTests Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } + /// Verifies that a throwing audit writer is logged and the exception does not propagate. + /// A task that represents the asynchronous operation. [Fact] public async Task ForwardAsync_WriterThrows_Logs_DoesNotPropagate() { @@ -257,6 +269,8 @@ public class CachedCallTelemetryForwarderTests Arg.Any()); } + /// Verifies that a throwing tracking store is logged and the exception does not propagate. + /// A task that represents the asynchronous operation. [Fact] public async Task ForwardAsync_TrackingStoreThrows_Logs_DoesNotPropagate() { @@ -279,6 +293,8 @@ public class CachedCallTelemetryForwarderTests Arg.Any(), Arg.Any()); } + /// Verifies that forwarding a null packet throws. + /// A task that represents the asynchronous operation. [Fact] public async Task ForwardAsync_NullPacket_Throws() { @@ -288,12 +304,14 @@ public class CachedCallTelemetryForwarderTests () => sut.ForwardAsync(null!, CancellationToken.None)); } - // ── SourceNode-stamping (Task 14) ── + // ── SourceNode stamping ── + /// Verifies that a submit packet is stamped with the source node from the node identity provider. + /// A task that represents the asynchronous operation. [Fact] public async Task ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider() { - // SourceNode-stamping (Task 14): when an INodeIdentityProvider is + // SourceNode stamping: when an INodeIdentityProvider is // wired the forwarder must stamp its NodeName onto the // RecordEnqueueAsync sourceNode parameter so the tracking row // captures the originating node (node-a/node-b). @@ -315,6 +333,8 @@ public class CachedCallTelemetryForwarderTests Arg.Any()); } + /// Verifies that a node identity with a null node name results in a null source node. + /// A task that represents the asynchronous operation. [Fact] public async Task ForwardAsync_Submit_NodeIdentityNullNodeName_PassesNullSourceNode() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/NoOpSiteStreamAuditClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/NoOpSiteStreamAuditClientTests.cs index 4b73f8a2..03351c6d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/NoOpSiteStreamAuditClientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/NoOpSiteStreamAuditClientTests.cs @@ -4,15 +4,17 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site.Telemetry; /// -/// Bundle E E1 tests for . The NoOp -/// client is the default binding until M6 -/// delivers the gRPC-backed implementation; both IngestAuditEventsAsync -/// (M2) and IngestCachedTelemetryAsync (M3) must return an empty ack +/// Tests for . The NoOp +/// client is the default binding until the +/// gRPC-backed implementation is delivered; both IngestAuditEventsAsync +/// and IngestCachedTelemetryAsync must return an empty ack /// (no rows flipped to Forwarded) without throwing or partially handling the /// batch. /// public class NoOpSiteStreamAuditClientTests { + /// Verifies that ingesting an empty cached-telemetry batch returns an empty acknowledgement. + /// A task that represents the asynchronous operation. [Fact] public async Task IngestCachedTelemetryAsync_EmptyBatch_ReturnsEmptyAck() { @@ -25,6 +27,8 @@ public class NoOpSiteStreamAuditClientTests Assert.Empty(ack.AcceptedEventIds); } + /// Verifies that ingesting a populated batch still returns an empty acknowledgement, because the no-op client forwards nothing. + /// A task that represents the asynchronous operation. [Fact] public async Task IngestCachedTelemetryAsync_PopulatedBatch_ReturnsEmptyAck() { @@ -47,6 +51,8 @@ public class NoOpSiteStreamAuditClientTests Assert.Empty(ack.AcceptedEventIds); } + /// Verifies that ingesting a null batch throws an . + /// A task that represents the asynchronous operation. [Fact] public async Task IngestCachedTelemetryAsync_NullBatch_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs index c180043e..bd68e20b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs @@ -55,7 +55,7 @@ public class SiteAuditTelemetryActorTests : TestKit (IOperationTrackingStore?)null))); /// - /// AuditLog-001: builds an actor with the optional + /// Builds an actor with the optional /// wired in so the cached-drain /// scheduler is armed alongside the audit-only drain. Used by the new /// cached-drain regression tests below. @@ -86,6 +86,9 @@ public class SiteAuditTelemetryActorTests : TestKit return ack; } + /// Verifies that a drain of large rows stays within the wire budget and does not acknowledge rows that were not sent. + /// true to drain cached-call rows, false to drain audit-only rows. + /// A task that represents the asynchronous operation. [Theory] [InlineData(false)] [InlineData(true)] @@ -126,6 +129,8 @@ public class SiteAuditTelemetryActorTests : TestKit Assert.Equal(256, sent.Count); } + /// Verifies that a drain of 50 pending rows sends one batch of 50 and then marks the rows as forwarded. + /// A task that represents the asynchronous operation. [Fact] public async Task Drain_With_50PendingRows_Sends_OneBatch_Of_50_Then_FlipsToForwarded() { @@ -166,6 +171,8 @@ public class SiteAuditTelemetryActorTests : TestKit Arg.Any()); } + /// Verifies that rows stay pending when the gRPC call throws and the next drain retries them. + /// A task that represents the asynchronous operation. [Fact] public async Task Drain_GrpcThrows_RowsStayPending_NextDrainRetries() { @@ -206,6 +213,8 @@ public class SiteAuditTelemetryActorTests : TestKit Assert.True(calls >= 2, $"Expected at least 2 client calls (1 failure + 1 retry); saw {calls}"); } + /// Verifies that a drain with no pending rows schedules the next drain at the idle interval without calling the client. + /// A task that represents the asynchronous operation. [Fact] public async Task Drain_ZeroPending_SchedulesAtIdleInterval_NoClientCall() { @@ -230,6 +239,8 @@ public class SiteAuditTelemetryActorTests : TestKit Assert.InRange(readCalls, 1, 2); } + /// Verifies that a drain that finds pending rows schedules the next drain at the busy interval. + /// A task that represents the asynchronous operation. [Fact] public async Task Drain_NonZeroPending_SchedulesAtBusyInterval() { @@ -254,6 +265,8 @@ public class SiteAuditTelemetryActorTests : TestKit $"Expected ≥2 pushes within 3s when busy=1s; saw {pushCalls}"); } + /// Verifies that only the event ids the central ack accepted are marked as forwarded. + /// A task that represents the asynchronous operation. [Fact] public async Task Drain_AcceptedEventIdsSubset_OnlyMarksAccepted() { @@ -292,7 +305,7 @@ public class SiteAuditTelemetryActorTests : TestKit } // ──────────────────────────────────────────────────────────────────────── - // AuditLog-001: combined-telemetry cached-drain regression tests. Verify + // Combined-telemetry cached-drain regression tests. Verify // that the production wiring of the previously-unreachable cached transport // routes cached rows through ReadPendingCachedTelemetryAsync + // IngestCachedTelemetryAsync (and NOT IngestAuditEventsAsync), and that @@ -305,7 +318,7 @@ public class SiteAuditTelemetryActorTests : TestKit Guid? eventId = null, Guid? correlationId = null, string sourceSiteId = "site-1") => - // C3 (Task 2.5): canonical record via the shared factory; ForwardState is + // Canonical record via the shared factory; ForwardState is // no longer a record field (the SQLite shim defaults it on INSERT). ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiOutbound, @@ -335,12 +348,14 @@ public class SiteAuditTelemetryActorTests : TestKit SourceScript: "script-1", SourceNode: "node-a"); + /// Verifies that cached rows are sent through the cached-telemetry RPC rather than the audit-events RPC. + /// A task that represents the asynchronous operation. [Fact] public async Task CachedDrain_CachedRows_RouteThrough_IngestCachedTelemetry_NotIngestAuditEvents() { // Arrange — three cached audit rows on the cached queue, each with a // matching tracking snapshot. The audit-only queue is empty (those - // rows are excluded by ReadPendingAsync after AuditLog-001). + // rows are excluded by ReadPendingAsync after the combined-telemetry change). var cachedRows = new[] { NewCachedEvent(AuditKind.CachedSubmit), @@ -401,6 +416,8 @@ public class SiteAuditTelemetryActorTests : TestKit Assert.Equal(expectedIds, emittedEventIds); } + /// Verifies that a cached row with no tracking snapshot past the grace period is abandoned while a valid row still flows. + /// A task that represents the asynchronous operation. [Fact] public async Task CachedDrain_OrphanRow_PastGrace_IsAbandoned_AndTheValidRowStillFlows() { @@ -494,6 +511,8 @@ public class SiteAuditTelemetryActorTests : TestKit Assert.Equal(valid.EventId.ToString(), capturedBatch.Packets[0].AuditEvent.EventId); } + /// Verifies that a cached row with no tracking snapshot inside the grace period is retried rather than abandoned. + /// A task that represents the asynchronous operation. [Fact] public async Task CachedDrain_OrphanRow_InsideGrace_IsRetried_NotAbandoned() { @@ -536,6 +555,8 @@ public class SiteAuditTelemetryActorTests : TestKit Arg.Any>(), Arg.Any()); } + /// Verifies that a full batch of unresolvable cached rows does not starve the rest of the queue. + /// A task that represents the asynchronous operation. [Fact] public async Task CachedDrain_FullBatchOfUnresolvableRows_DoesNotStarveTheQueue() { @@ -575,6 +596,8 @@ public class SiteAuditTelemetryActorTests : TestKit Arg.Any(), Arg.Any()); } + /// Verifies that a tracking-store failure does not cause the cached row to be abandoned. + /// A task that represents the asynchronous operation. [Fact] public async Task CachedDrain_TrackingStoreThrow_DoesNotAbandonTheRow() { @@ -608,13 +631,15 @@ public class SiteAuditTelemetryActorTests : TestKit Arg.Any>(), Arg.Any()); } + /// Verifies that audit-only rows still drain when the cached drain is disabled. + /// A task that represents the asynchronous operation. [Fact] public async Task AuditOnlyDrain_StillFlows_When_CachedDrain_IsDisabled() { // Arrange — ordinary (non-cached) audit rows on the audit-only queue; // the actor is constructed WITHOUT a tracking store so the cached // scheduler is never armed. Regression guard against the audit-only - // drain regressing during the AuditLog-001 refactor. + // drain regressing during the combined-telemetry refactor. var rows = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList(); _queue.ReadPendingAsync(Arg.Any(), Arg.Any()) .Returns( @@ -655,6 +680,7 @@ public class SiteAuditTelemetryActorTests : TestKit /// test in this class masks the bug by returning already-completed tasks /// from the mocks, which keeps the continuations on the actor thread. /// + /// A task that represents the asynchronous operation. [Fact] public async Task Drains_Whose_Awaits_Complete_Off_The_Actor_Thread_Keep_Draining_Without_Crashing() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteCommunicationAuditClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteCommunicationAuditClientTests.cs index fbb4b062..daf12fb5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteCommunicationAuditClientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteCommunicationAuditClientTests.cs @@ -61,6 +61,8 @@ public class SiteCommunicationAuditClientTests : TestKit UpdatedAtUtc = Timestamp.FromDateTime(new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)), }; + /// Verifies that a full acknowledgement maps all accepted event ids onto the returned ack. + /// A task that represents the asynchronous operation. [Fact] public async Task IngestAuditEventsAsync_FullAck_MapsAllAcceptedIdsOntoAck() { @@ -88,6 +90,8 @@ public class SiteCommunicationAuditClientTests : TestKit ack.AcceptedEventIds.ToHashSet()); } + /// Verifies that a partial acknowledgement lists only the accepted event ids on the returned ack. + /// A task that represents the asynchronous operation. [Fact] public async Task IngestAuditEventsAsync_PartialAck_OnlyAcceptedIdsAppearOnAck() { @@ -110,6 +114,8 @@ public class SiteCommunicationAuditClientTests : TestKit ack.AcceptedEventIds.ToHashSet()); } + /// Verifies that an Ask timeout throws so the drain loop keeps its rows pending. + /// A task that represents the asynchronous operation. [Fact] public async Task IngestAuditEventsAsync_AskTimeout_Throws_SoDrainLoopKeepsRowsPending() { @@ -127,6 +133,8 @@ public class SiteCommunicationAuditClientTests : TestKit await Assert.ThrowsAnyAsync(() => task); } + /// Verifies that a faulted reply causes the ingest call to throw. + /// A task that represents the asynchronous operation. [Fact] public async Task IngestAuditEventsAsync_FaultedReply_Throws() { @@ -136,13 +144,15 @@ public class SiteCommunicationAuditClientTests : TestKit var task = sut.IngestAuditEventsAsync(BatchOf(new[] { NewEvent() }), CancellationToken.None); probe.ExpectMsg(TimeSpan.FromSeconds(3)); - // A Status.Failure from central (Task 1: central does not swallow an + // A Status.Failure from central (central does not swallow an // ingest fault into an empty ack) must propagate as a thrown exception. probe.Reply(new Status.Failure(new InvalidOperationException("central ingest faulted"))); await Assert.ThrowsAnyAsync(() => task); } + /// Verifies that cached telemetry is routed as a command and the reply is mapped back. + /// A task that represents the asynchronous operation. [Fact] public async Task IngestCachedTelemetryAsync_RoutesCommand_AndMapsReply() { @@ -178,6 +188,8 @@ public class SiteCommunicationAuditClientTests : TestKit ack.AcceptedEventIds.ToHashSet()); } + /// Verifies that an Ask timeout during cached telemetry ingestion throws. + /// A task that represents the asynchronous operation. [Fact] public async Task IngestCachedTelemetryAsync_AskTimeout_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/TestSupport/FakeNodeIdentityProvider.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/TestSupport/FakeNodeIdentityProvider.cs index 116ca949..798fcb08 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/TestSupport/FakeNodeIdentityProvider.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/TestSupport/FakeNodeIdentityProvider.cs @@ -11,8 +11,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport; /// internal sealed class FakeNodeIdentityProvider : INodeIdentityProvider { + // Returns the configured node name, which may be null. + /// public string? NodeName { get; } + /// Initializes the fake with the node name to report. + /// The node name to report, or null for none. public FakeNodeIdentityProvider(string? nodeName = null) { NodeName = nodeName; diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/TestSupport/StubAuditRepositoryProvider.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/TestSupport/StubAuditRepositoryProvider.cs index fb3d27da..97fd261d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/TestSupport/StubAuditRepositoryProvider.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/TestSupport/StubAuditRepositoryProvider.cs @@ -30,6 +30,7 @@ internal static class StubAuditRepositoryProvider /// Builds a provider whose every scope yields the same audit-log repository instance. /// /// Repository instance shared by every scope. + /// A service provider whose scopes all resolve the supplied repository. public static ServiceProvider Build(IAuditLogRepository repository) { ArgumentNullException.ThrowIfNull(repository); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/AuditConfigTableProjectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/AuditConfigTableProjectionTests.cs index 7e469999..5f5a620b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/AuditConfigTableProjectionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/AuditConfigTableProjectionTests.cs @@ -41,6 +41,7 @@ public class AuditConfigTableProjectionTests totalCount), new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + /// Verifies that projecting entries unwraps the envelope into rows. [Fact] public void ProjectEntries_UnwrapsTheEnvelopeIntoRows() { @@ -68,6 +69,7 @@ public class AuditConfigTableProjectionTests Assert.Equal("MES", second.GetProperty("entityName").GetString()); } + /// Verifies that projecting entries drops the after-state JSON. [Fact] public void ProjectEntries_DropsAfterStateJson() { @@ -80,6 +82,7 @@ public class AuditConfigTableProjectionTests Assert.DoesNotContain("afterStateJson", compact, StringComparison.OrdinalIgnoreCase); } + /// Verifies that a truncated page appends a note with the total count. [Fact] public void ProjectEntries_TruncatedPage_AppendsTotalCountNote() { @@ -96,6 +99,7 @@ public class AuditConfigTableProjectionTests Assert.False(root[2].TryGetProperty("id", out _)); } + /// Verifies that a result set returned in full emits no note column. [Fact] public void ProjectEntries_WholeResultSet_EmitsNoNoteColumn() { @@ -108,6 +112,7 @@ public class AuditConfigTableProjectionTests Assert.DoesNotContain("note", compact, StringComparison.Ordinal); } + /// Verifies that an empty result stays an empty array. [Fact] public void ProjectEntries_EmptyResult_StaysAnEmptyArray() { @@ -124,6 +129,7 @@ public class AuditConfigTableProjectionTests Assert.Equal(0, doc.RootElement.GetArrayLength()); } + /// Verifies that a bare array is still projected. [Fact] public void ProjectEntries_BareArray_IsStillProjected() { @@ -143,6 +149,7 @@ public class AuditConfigTableProjectionTests Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _)); } + /// Verifies that an unrecognised JSON object is returned verbatim. [Fact] public void ProjectEntries_UnrecognisedObject_ReturnedVerbatim() { @@ -152,6 +159,7 @@ public class AuditConfigTableProjectionTests Assert.Equal(other, AuditConfigTableProjection.ProjectEntries(other)); } + /// Verifies that non-JSON input is returned verbatim. [Fact] public void ProjectEntries_NonJson_ReturnedVerbatim() { @@ -159,6 +167,7 @@ public class AuditConfigTableProjectionTests Assert.Equal(notJson, AuditConfigTableProjection.ProjectEntries(notJson)); } + /// Verifies that the projection is substantially smaller than the raw payload. [Fact] public void ProjectEntries_IsSubstantiallySmallerThanTheRawPayload() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CliConfigTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CliConfigTests.cs index b6040472..e620eaae 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CliConfigTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CliConfigTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; [Collection("Environment")] public class CliConfigTests { + /// Verifies that loading configuration with no overrides defaults the output format to JSON. [Fact] public void Load_DefaultFormat_IsJson() { @@ -28,6 +29,7 @@ public class CliConfigTests } } + /// Verifies that the management URL is read from the environment. [Fact] public void Load_ManagementUrl_FromEnvironment() { @@ -46,6 +48,7 @@ public class CliConfigTests } } + /// Verifies that the output format is read from the environment. [Fact] public void Load_Format_FromEnvironment() { @@ -65,7 +68,7 @@ public class CliConfigTests } /// - /// CLI-021 regression: a malformed ~/.scadabridge/config.json must NOT abort the + /// Regression: a malformed ~/.scadabridge/config.json must NOT abort the /// CLI before any command runs — Load() must warn (to stderr) and return a /// usable default config so command-line overrides (--url, --username, etc.) /// and env vars can still take effect. diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandHelpersTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandHelpersTests.cs index 2d08f0c9..33d583c7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandHelpersTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandHelpersTests.cs @@ -6,6 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; [Collection("Console")] public class CommandHelpersTests { + /// Verifies that a successful response in JSON format returns exit code zero. [Fact] public void HandleResponse_Success_JsonFormat_ReturnsZero() { @@ -21,6 +22,7 @@ public class CommandHelpersTests Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); } + /// Verifies that a successful response with array data in table format returns exit code zero. [Fact] public void HandleResponse_Success_TableFormat_ArrayData_ReturnsZero() { @@ -41,6 +43,7 @@ public class CommandHelpersTests Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); } + /// Verifies that a successful response with object data in table format returns exit code zero. [Fact] public void HandleResponse_Success_TableFormat_ObjectData_ReturnsZero() { @@ -61,6 +64,7 @@ public class CommandHelpersTests Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); } + /// Verifies that an empty array in table format reports that there are no results. [Fact] public void HandleResponse_Success_TableFormat_EmptyArray_ShowsNoResults() { @@ -76,6 +80,7 @@ public class CommandHelpersTests Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); } + /// Verifies that an error response returns exit code one. [Fact] public void HandleResponse_Error_ReturnsOne() { @@ -91,6 +96,7 @@ public class CommandHelpersTests Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); } + /// Verifies that an unauthorized response returns exit code two. [Fact] public void HandleResponse_Unauthorized_ReturnsTwo() { @@ -106,6 +112,7 @@ public class CommandHelpersTests Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); } + /// Verifies that an authentication failure response returns exit code one. [Fact] public void HandleResponse_AuthFailure_ReturnsOne() { @@ -121,6 +128,7 @@ public class CommandHelpersTests Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); } + /// Verifies that a connection failure response returns exit code one. [Fact] public void HandleResponse_ConnectionFailure_ReturnsOne() { @@ -136,6 +144,7 @@ public class CommandHelpersTests Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); } + /// Verifies that a timeout response returns exit code one. [Fact] public void HandleResponse_Timeout_ReturnsOne() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs index d239d753..d2cb044e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// -/// Regression tests for CLI-013 — the command-tree wiring was untested. These tests +/// Regression tests for the command-tree wiring, which was previously untested. These tests /// build every command group and assert the tree is well-formed (every leaf has an /// action, no group is empty), and that every management command record the CLI sends /// resolves via (so command-name derivation @@ -21,6 +21,7 @@ public class CommandTreeTests // Exercise the production root: a separately maintained group list can drift. private static IEnumerable AllCommandGroups() => CliApplication.BuildRoot().Subcommands; + /// Verifies that the registered operational command groups include the secured-writes and connection tools. [Fact] public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools() { @@ -47,6 +48,7 @@ public class CommandTreeTests yield return leaf; } + /// Verifies that every command group builds without throwing. [Fact] public void AllCommandGroups_Build_WithoutThrowing() { @@ -55,10 +57,11 @@ public class CommandTreeTests Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name))); } + /// Verifies that the set of command groups contains the audit and bundle groups. [Fact] public void AllCommandGroups_Contains_AuditAndBundle() { - // CLI-022: explicit group-presence assertion so the harness does not + // Explicit group-presence assertion so the harness does not // silently drift back to excluding new groups. Use names because that // is what users actually type at the prompt. var groupNames = AllCommandGroups().Select(g => g.Name).ToHashSet(); @@ -66,10 +69,11 @@ public class CommandTreeTests Assert.Contains("bundle", groupNames); } + /// Verifies that the audit command group exposes the query, export and verify-chain subcommands. [Fact] public void AuditCommandGroup_HasQueryExportAndVerifyChain() { - // CLI-022: pin the audit sub-command surface so a rename / accidental + // Pin the audit sub-command surface so a rename / accidental // removal of one of these is caught. var audit = AuditCommands.Build(Url, Format, Username, Password); var subNames = audit.Subcommands.Select(c => c.Name).ToHashSet(); @@ -78,10 +82,11 @@ public class CommandTreeTests Assert.Contains("verify-chain", subNames); } + /// Verifies that the bundle command group exposes the export, preview and import subcommands. [Fact] public void BundleCommandGroup_HasExportPreviewAndImport() { - // CLI-022: pin the bundle sub-command surface. + // Pin the bundle sub-command surface. var bundle = BundleCommands.Build(Url, Format, Username, Password); var subNames = bundle.Subcommands.Select(c => c.Name).ToHashSet(); Assert.Contains("export", subNames); @@ -89,6 +94,7 @@ public class CommandTreeTests Assert.Contains("import", subNames); } + /// Verifies that every leaf command in the tree has an action attached. [Fact] public void EveryLeafCommand_HasAnAction() { @@ -100,10 +106,11 @@ public class CommandTreeTests Assert.True(leaf.Action != null, $"Leaf command '{leaf.Name}' has no action.")); } + /// Verifies that template composition delete is keyed by the composition id alone. [Fact] public void TemplateCompositionDelete_IsKeyedByIdOnly() { - // CLI-015: the in-repo README documented `template composition delete` with + // The in-repo README documented `template composition delete` with // --template-id / --instance-name, but the implementation keys deletion by the // composition's own integer ID via a single --id option. Pin the real surface. var template = TemplateCommands.Build(Url, Format, Username, Password); @@ -116,6 +123,7 @@ public class CommandTreeTests Assert.DoesNotContain("--instance-name", optionNames); } + /// Verifies that the template native alarm source group has add, list and remove subcommands. [Fact] public void TemplateNativeAlarmSource_HasAddListRemove() { @@ -127,10 +135,11 @@ public class CommandTreeTests Assert.Contains("remove", subNames); } + /// Verifies that the deploy group has a site verb that requires a site id. [Fact] public void Deploy_HasSiteVerb_WithRequiredSiteId() { - // WP2.5: bulk site deploy. Named `deploy site` to sit alongside + // Bulk site deploy. Named `deploy site` to sit alongside // `deploy instance` / `deploy artifacts` — the group's convention is that // the verb names the SCOPE of the deploy. var deploy = DeployCommands.Build(Url, Format, Username, Password); @@ -143,10 +152,11 @@ public class CommandTreeTests Assert.Empty(site.Parse(["--site-id", "3"]).Errors); } + /// Verifies that deploy site has a force flag that defaults to the non-destructive behaviour. [Fact] public void DeploySite_HasForceFlag_DefaultingToTheNonDestructiveBehaviour() { - // P7-6: bulk deploy skips instances whose config is unchanged, because a + // Bulk deploy skips instances whose config is unchanged, because a // redeploy resets their static overrides and native alarm state at the // site. --force is the operator escape hatch (restored volume, rebuilt // node) and must be a FLAG — absent means skip-unchanged, which is the @@ -165,6 +175,7 @@ public class CommandTreeTests Assert.True(withForce.GetValue(forceOption)); } + /// Verifies that the instance native alarm source group has set and clear subcommands. [Fact] public void InstanceNativeAlarmSource_HasSetAndClear() { @@ -175,6 +186,7 @@ public class CommandTreeTests Assert.Contains("clear", subNames); } + /// Verifies that the template folder group covers every folder management command. [Fact] public void TemplateFolder_HasFullManagementCommandParity() { @@ -192,6 +204,7 @@ public class CommandTreeTests Assert.Contains("delete", subNames); } + /// Verifies that the parent id is optional on template folder create so the root folder is reachable. [Fact] public void TemplateFolderCreate_ParentIdIsOptional_SoRootIsReachable() { @@ -211,6 +224,7 @@ public class CommandTreeTests Assert.Equal(7, nestedParse.GetValue((Option)parentOption)); } + /// Verifies that the parent id is optional on template folder move so the root folder is reachable. [Fact] public void TemplateFolderMove_ParentIdIsOptional_SoRootIsReachable() { @@ -224,6 +238,8 @@ public class CommandTreeTests Assert.Null(parse.GetValue((Option)parentOption)); } + /// Verifies that template folder reorder accepts the up and down directions. + /// The reorder direction under test. [Theory] [InlineData("up")] [InlineData("down")] @@ -237,6 +253,8 @@ public class CommandTreeTests Assert.Empty(parse.Errors); } + /// Verifies that the folder reorder command rejects any direction other than the accepted literals. + /// The rejected direction value. [Theory] [InlineData("sideways")] // AcceptOnlyFromAmong is case-SENSITIVE, so the literals are lowercase only. @@ -245,6 +263,8 @@ public class CommandTreeTests // case-sensitivity rejected values the server accepts. --direction keeps it: it is // a pure client-side literal that never reaches a server-side parse, so there is no // authoritative check behind it to defer to. + /// Verifies that template folder reorder rejects any direction other than up or down. + /// The invalid reorder direction under test. [InlineData("Up")] public void TemplateFolderReorder_RejectsAnythingElse(string direction) { @@ -256,6 +276,7 @@ public class CommandTreeTests Assert.NotEmpty(parse.Errors); } + /// Verifies that template folder reorder requires a direction. [Fact] public void TemplateFolderReorder_RequiresDirection() { @@ -278,6 +299,8 @@ public class CommandTreeTests // treatment row 88 gave `deploy status`). These tests fail before that change. // ======================================================================== + /// Provides the audit filter option names and sample values used by the theory data. + /// Theory data of subcommand, option and value triples. public static TheoryData AuditFilterValues() => new() { // subcommand, option, value — lowercase, uppercase and mixed case of a real member. @@ -290,6 +313,10 @@ public class CommandTreeTests { "export", "--kind", "dbwrite" }, }; + /// Verifies that audit filter options accept values in any letter case. + /// The audit subcommand under test. + /// The filter option name. + /// The option value whose casing varies. [Theory] [MemberData(nameof(AuditFilterValues))] public void AuditFilterOptions_AcceptValuesInAnyCase(string subcommand, string option, string value) @@ -308,6 +335,8 @@ public class CommandTreeTests Assert.Empty(parse.Errors); } + /// Verifies that the audit export format option accepts values in any letter case. + /// The export format value under test. [Theory] [InlineData("csv")] [InlineData("CSV")] @@ -328,6 +357,7 @@ public class CommandTreeTests Assert.Empty(parse.Errors); } + /// Verifies that an unrecognised audit query filter value is not rejected on the client side. [Fact] public void AuditQuery_UnrecognisedFilterValue_IsNotRejectedClientSide() { @@ -342,6 +372,7 @@ public class CommandTreeTests Assert.Empty(parse.Errors); } + /// Verifies that the site area group has a move verb. [Fact] public void SiteArea_HasMoveVerb() { @@ -353,6 +384,7 @@ public class CommandTreeTests Assert.Contains("move", subNames); } + /// Verifies that the parent id is optional on site area move so the site root is reachable. [Fact] public void SiteAreaMove_ParentIdIsOptional_SoSiteRootIsReachable() { @@ -372,6 +404,7 @@ public class CommandTreeTests Assert.Equal(2, reparentParse.GetValue((Option)parentOption)); } + /// Verifies that site area move requires an area id. [Fact] public void SiteAreaMove_RequiresId() { @@ -383,6 +416,7 @@ public class CommandTreeTests Assert.NotEmpty(parse.Errors); } + /// Verifies that template list has skip and take paging options. [Fact] public void TemplateList_HasSkipAndTakePagingOptions() { @@ -394,6 +428,7 @@ public class CommandTreeTests Assert.Contains("--take", optionNames); } + /// Verifies that instance list has skip and take paging options. [Fact] public void InstanceList_HasSkipAndTakePagingOptions() { @@ -405,6 +440,7 @@ public class CommandTreeTests Assert.Contains("--take", optionNames); } + /// Verifies that instance list parses the value of the take option. [Fact] public void InstanceList_ParsesTakeOptionValue() { @@ -418,6 +454,8 @@ public class CommandTreeTests Assert.Equal(5, parse.GetValue((Option)takeOption)); } + /// Verifies that each command payload type resolves through the management command registry. + /// The command payload type under test. [Theory] [InlineData(typeof(GetInstanceCommand))] [InlineData(typeof(ListSitesCommand))] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditBackfillCommandTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditBackfillCommandTests.cs index ac352665..a17a12bf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditBackfillCommandTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditBackfillCommandTests.cs @@ -8,9 +8,8 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// Tests for the scadabridge audit backfill-source-node subcommand -/// (Audit Log #23 M5.6 T5): argument parsing, request-body construction, -/// HTTP wiring, and CLI scaffold. +/// Tests for the scadabridge audit backfill-source-node subcommand: +/// argument parsing, request-body construction, HTTP wiring, and CLI scaffold. /// [Collection("Console")] public class AuditBackfillCommandTests @@ -19,6 +18,7 @@ public class AuditBackfillCommandTests // BuildRequestBody // ───────────────────────────────────────────────────────────────────── + /// Verifies that building a request body from default arguments contains the expected sentinel, before and batch size fields. [Fact] public void BuildRequestBody_DefaultArgs_ContainsExpectedFields() { @@ -38,6 +38,7 @@ public class AuditBackfillCommandTests Assert.Equal(5000, root.GetProperty("batchSize").GetInt32()); } + /// Verifies that a custom sentinel and batch size are reflected in the request body JSON. [Fact] public void BuildRequestBody_CustomSentinelAndBatch_ReflectedInJson() { @@ -66,16 +67,23 @@ public class AuditBackfillCommandTests private readonly HttpStatusCode _status; private readonly string _responseBody; + /// Initializes a new handler that returns the given status and body for every request. + /// The HTTP status code to return. + /// The response body to return. public CapturingHandler(HttpStatusCode status, string responseBody) { _status = status; _responseBody = responseBody; } + /// Gets the path and query of the last request received. public string? LastRequestUri { get; private set; } + /// Gets the body of the last request received. public string? LastRequestBody { get; private set; } + /// Gets the HTTP method of the last request received. public string? LastMethod { get; private set; } + /// protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { @@ -95,6 +103,8 @@ public class AuditBackfillCommandTests private static string SuccessBody(long rowsUpdated = 42, string sentinel = "unknown", string before = "2026-01-01T00:00:00.0000000Z") => JsonSerializer.Serialize(new { rowsUpdated, sentinel, before }); + /// Verifies that a successful backfill returns exit code zero and writes the response to the output. + /// A task that represents the asynchronous operation. [Fact] public async Task RunBackfill_Success_ReturnsZeroAndWritesOutput() { @@ -117,6 +127,8 @@ public class AuditBackfillCommandTests Assert.Contains("backfill complete", text, StringComparison.OrdinalIgnoreCase); } + /// Verifies that the backfill request is sent to the backfill endpoint path. + /// A task that represents the asynchronous operation. [Fact] public async Task RunBackfill_RequestUri_ContainsBackfillPath() { @@ -133,6 +145,8 @@ public class AuditBackfillCommandTests Assert.Equal("POST", handler.LastMethod); } + /// Verifies that the backfill request body contains the sentinel and the before timestamp. + /// A task that represents the asynchronous operation. [Fact] public async Task RunBackfill_RequestBody_ContainsSentinelAndBefore() { @@ -157,6 +171,8 @@ public class AuditBackfillCommandTests Assert.Equal(2000, doc.RootElement.GetProperty("batchSize").GetInt32()); } + /// Verifies that an HTTP 403 response yields exit code 2. + /// A task that represents the asynchronous operation. [Fact] public async Task RunBackfill_Http403_ReturnsExitCode2() { @@ -173,6 +189,8 @@ public class AuditBackfillCommandTests Assert.Equal(2, exit); } + /// Verifies that an HTTP 500 response yields exit code 1. + /// A task that represents the asynchronous operation. [Fact] public async Task RunBackfill_Http500_ReturnsExitCode1() { @@ -193,6 +211,7 @@ public class AuditBackfillCommandTests // CLI parsing // ───────────────────────────────────────────────────────────────────── + /// Verifies that the backfill-source-node subcommand exists in the audit command group. [Fact] public void BackfillSourceNode_Subcommand_ExistsInAuditCommandGroup() { @@ -201,6 +220,7 @@ public class AuditBackfillCommandTests Assert.Empty(parse.Errors); } + /// Verifies that the before option is required on the backfill-source-node subcommand. [Fact] public void BackfillSourceNode_BeforeOption_IsRequired() { @@ -209,6 +229,7 @@ public class AuditBackfillCommandTests Assert.NotEqual(0, exit); } + /// Verifies that the help text describes the sentinel and before options. [Fact] public void BackfillSourceNode_HelpText_DescribesSentinelAndBefore() { @@ -223,6 +244,7 @@ public class AuditBackfillCommandTests Assert.Contains("before", text, StringComparison.OrdinalIgnoreCase); } + /// Verifies that the default sentinel value is unknown. [Fact] public void BackfillSourceNode_DefaultSentinel_IsUnknown() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditCommandTestHarness.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditCommandTestHarness.cs index 1cae60bd..9cec6205 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditCommandTestHarness.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditCommandTestHarness.cs @@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// internal static class AuditCommandTestHarness { + /// Builds a root command with the global options and the audit command tree attached. + /// The configured root command. public static RootCommand BuildRoot() { var url = new Option("--url") { Recursive = true }; @@ -33,6 +35,9 @@ internal static class AuditCommandTestHarness /// the returned Out/Err strings. Callers must be in the Console /// xUnit collection so the global redirect is not racy. /// + /// The root command to parse against. + /// The command-line arguments to parse and invoke. + /// The exit code and the captured standard output and error text. public static (int Exit, string Out, string Err) Invoke(RootCommand root, params string[] args) { var output = new StringWriter(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs index 0d573765..8b93275e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs @@ -4,9 +4,9 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// Scaffold tests for the scadabridge audit command group (Audit Log #23 M8-T1). +/// Scaffold tests for the scadabridge audit command group (Audit Log). /// Verifies the parent command exists with its subcommands and that every leaf -/// has an action wired. Updated for M5.6 T5 to cover backfill-source-node. +/// has an action wired. Also covers backfill-source-node. /// public class AuditCommandsScaffoldTests { @@ -18,6 +18,7 @@ public class AuditCommandsScaffoldTests private static Command BuildAudit() => AuditCommands.Build(Url, Format, Username, Password); + /// Verifies that the command group is named audit. [Fact] public void Audit_Command_IsNamedAudit() { @@ -26,6 +27,7 @@ public class AuditCommandsScaffoldTests Assert.False(string.IsNullOrWhiteSpace(audit.Description)); } + /// Verifies that the audit group exposes the query, export, tree, verify-chain and backfill-source-node subcommands. [Fact] public void Audit_HasFiveSubcommands_QueryExportTreeVerifyChainBackfillSourceNode() { @@ -36,6 +38,7 @@ public class AuditCommandsScaffoldTests names); } + /// Verifies that the audit help text lists every subcommand. [Fact] public void Audit_HelpText_ListsAllSubcommands() { @@ -55,6 +58,7 @@ public class AuditCommandsScaffoldTests Assert.Contains("backfill-source-node", text); } + /// Verifies that every leaf command in the audit group has an action wired. [Fact] public void Audit_EveryLeafCommand_HasAnAction() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditConfigDeprecationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditConfigDeprecationTests.cs index 0c349e18..c518255f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditConfigDeprecationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditConfigDeprecationTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// Tests for the audit-log → audit-config rename (Audit Log #23 M8-T7): +/// Tests for the audit-log → audit-config rename: /// the new name parses, the deprecated audit-log alias still resolves the full /// subcommand tree and emits a stderr deprecation warning, and the renamed group does /// not collide with the distinct audit group from Bundle A. @@ -28,6 +28,7 @@ public class AuditConfigDeprecationTests return root; } + /// Verifies that the audit-config query command works. [Fact] public void AuditConfig_Query_Works() { @@ -37,6 +38,7 @@ public class AuditConfigDeprecationTests Assert.Empty(parse.Errors); } + /// Verifies that the legacy audit-log query command still works but emits a deprecation warning to stderr. [Fact] public void AuditLog_Query_StillWorks_ButEmitsDeprecationWarning_ToStderr() { @@ -54,6 +56,7 @@ public class AuditConfigDeprecationTests Assert.Contains("audit-config", warning); } + /// Verifies that no deprecation warning is emitted when the new command name is used. [Fact] public void DeprecationWarning_NotEmitted_ForNewName() { @@ -64,6 +67,7 @@ public class AuditConfigDeprecationTests Assert.Equal("", stderr.ToString()); } + /// Verifies that no deprecation warning is emitted for an unrelated command. [Fact] public void DeprecationWarning_NotEmitted_ForUnrelatedCommand() { @@ -73,6 +77,7 @@ public class AuditConfigDeprecationTests Assert.Equal("", stderr.ToString()); } + /// Verifies that the audit and audit-config groups are distinct commands that do not conflict. [Fact] public void Audit_And_AuditConfig_AreDistinctCommands_NoConflict() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditExportCommandTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditExportCommandTests.cs index fc97dc64..01af7e72 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditExportCommandTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditExportCommandTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// Tests for the scadabridge audit export subcommand (Audit Log #23 M8-T3): +/// Tests for the scadabridge audit export subcommand (Audit Log): /// required-flag enforcement, query-string construction, streaming the response body /// to the output file, and the parquet-not-implemented (501) path. /// @@ -17,6 +17,7 @@ public class AuditExportCommandTests { // ---- CLI parsing: required flags -------------------------------------- + /// Verifies that omitting a required flag produces a parse error and a non-zero exit code. [Fact] public void Export_MissingRequiredFlag_ProducesParseErrorAndNonZeroExit() { @@ -28,6 +29,7 @@ public class AuditExportCommandTests Assert.Contains("--output", err); } + /// Verifies that the command parses without error when all required flags are present. [Fact] public void Export_AllRequiredFlagsPresent_ParsesWithoutError() { @@ -40,6 +42,7 @@ public class AuditExportCommandTests Assert.Empty(parse.Errors); } + /// Verifies that an invalid format value is not rejected at the parse layer. [Fact] public void Export_InvalidFormat_IsNotRejectedAtTheParseLayer() { @@ -56,6 +59,8 @@ public class AuditExportCommandTests Assert.Empty(parse.Errors); } + /// Verifies that the format value is accepted in any letter case. + /// The format value to parse, in varying case. [Theory] [InlineData("CSV")] [InlineData("Csv")] @@ -73,6 +78,7 @@ public class AuditExportCommandTests // ---- Query string ----------------------------------------------------- + /// Verifies that the query string includes the time window, the format and the filters. [Fact] public void BuildQueryString_IncludesWindowFormatAndFilters() { @@ -96,6 +102,7 @@ public class AuditExportCommandTests Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]); } + /// Verifies that multi-value filters emit one query key per value. [Fact] public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue() { @@ -120,6 +127,7 @@ public class AuditExportCommandTests Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId")); } + /// Verifies that multi-value filters that were not set are omitted from the query string. [Fact] public void BuildQueryString_OmitsUnsetMultiValueFilters() { @@ -140,6 +148,7 @@ public class AuditExportCommandTests Assert.Null(parsed["sourceSiteId"]); } + /// Verifies that multiple channel values supplied in a single token are accepted. [Fact] public void Export_MultipleChannelValues_SingleToken_AreAccepted() { @@ -154,6 +163,7 @@ public class AuditExportCommandTests Assert.Empty(parse.Errors); } + /// Verifies that multiple channel values supplied through a repeated flag are accepted. [Fact] public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted() { @@ -167,6 +177,7 @@ public class AuditExportCommandTests Assert.Empty(parse.Errors); } + /// Verifies that a multi-value channel option still parses when one name is unrecognised. [Fact] public void Export_MultiValueChannel_WithOneUnrecognisedName_StillParses() { @@ -188,14 +199,19 @@ public class AuditExportCommandTests { private readonly HttpStatusCode _status; private readonly Func _content; + /// Gets the path and query string of the last request the handler received. public string? RequestPathAndQuery { get; private set; } + /// Initializes a handler that answers every request with the given status and content. + /// The HTTP status code to return. + /// A factory that creates the response content. public BodyHandler(HttpStatusCode status, Func content) { _status = status; _content = content; } + /// protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { @@ -204,6 +220,8 @@ public class AuditExportCommandTests } } + /// Verifies that a successful export streams the response body to the output file. + /// A task that represents the asynchronous operation. [Fact] public async Task RunExport_Success_StreamsResponseToOutputFile() { @@ -234,11 +252,13 @@ public class AuditExportCommandTests } } + /// Verifies that an HTTP 403 response returns exit code 2. + /// A task that represents the asynchronous operation. [Fact] public async Task RunExport_Http403_ReturnsExitCode2() { - // CLI-018: an HTTP 403 on /api/audit/export must produce exit code 2 per the - // documented CLI contract — the legacy bare-1 return masked auth failures + // An HTTP 403 on /api/audit/export must produce exit code 2 per the + // documented CLI contract — the legacy plain exit code 1 masked auth failures // as generic command failures. var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv"); try @@ -262,6 +282,8 @@ public class AuditExportCommandTests } } + /// Verifies that an unauthorized error code on a response whose status is not 403 also returns exit code 2. + /// A task that represents the asynchronous operation. [Fact] public async Task RunExport_UnauthorizedCodeOnNon403_ReturnsExitCode2() { @@ -286,6 +308,8 @@ public class AuditExportCommandTests } } + /// Verifies that a parquet 501 response prints the server message and returns a non-zero exit code. + /// A task that represents the asynchronous operation. [Fact] public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero() { @@ -312,6 +336,8 @@ public class AuditExportCommandTests } } + /// Verifies that a large response body is streamed to the file rather than fully buffered. + /// A task that represents the asynchronous operation. [Fact] public async Task RunExport_LargeBody_IsStreamedNotFullyBuffered() { @@ -351,18 +377,27 @@ public class AuditExportCommandTests private readonly byte _value; private long _remaining; + /// Initializes a stream that yields the given byte repeatedly. + /// The byte value to repeat. + /// The total number of bytes the stream yields. public RepeatingStream(byte value, long length) { _value = value; _remaining = length; } + /// public override bool CanRead => true; + /// public override bool CanSeek => false; + /// public override bool CanWrite => false; + /// public override long Length => throw new NotSupportedException(); + /// public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + /// public override int Read(byte[] buffer, int offset, int count) { if (_remaining <= 0) return 0; @@ -372,14 +407,18 @@ public class AuditExportCommandTests return n; } + /// public override void Flush() { } + /// public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + /// public override void SetLength(long value) => throw new NotSupportedException(); + /// public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } // ───────────────────────────────────────────────────────────────────── - // Deadline + Ctrl-C cancellation (arch-review 2026-08-19 P6-8: audit export + // Deadline + Ctrl-C cancellation (audit export // was the one CLI HTTP path with NO deadline — a hung server hung the CLI // forever). Every test below bounds its own wait well under what the old, // deadline-less implementation would have done (indefinite hang) — a @@ -389,8 +428,10 @@ public class AuditExportCommandTests /// A handler that never completes the request until its fires — simulates a server that accepted the connection but never answers. private sealed class HangingHandler : HttpMessageHandler { + /// Gets the number of requests the handler has received. public int CallCount { get; private set; } + /// protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { @@ -410,17 +451,26 @@ public class AuditExportCommandTests private readonly byte[] _firstChunk; private bool _servedFirstChunk; + /// Initializes a stream that serves the given first chunk and then stalls. + /// The bytes served before the stream goes silent. public StallingStream(byte[] firstChunk) => _firstChunk = firstChunk; + /// public override bool CanRead => true; + /// public override bool CanSeek => false; + /// public override bool CanWrite => false; + /// public override long Length => throw new NotSupportedException(); + /// public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + /// public override int Read(byte[] buffer, int offset, int count) => ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + /// public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { if (!_servedFirstChunk) @@ -435,9 +485,13 @@ public class AuditExportCommandTests return 0; // unreachable — Task.Delay(Infinite) only returns via cancellation } + /// public override void Flush() { } + /// public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + /// public override void SetLength(long value) => throw new NotSupportedException(); + /// public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } @@ -447,17 +501,26 @@ public class AuditExportCommandTests private readonly byte[] _firstChunk; private bool _servedFirstChunk; + /// Initializes a stream that serves the given first chunk and then throws. + /// The bytes served before the stream faults. public FaultingStream(byte[] firstChunk) => _firstChunk = firstChunk; + /// public override bool CanRead => true; + /// public override bool CanSeek => false; + /// public override bool CanWrite => false; + /// public override long Length => throw new NotSupportedException(); + /// public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + /// public override int Read(byte[] buffer, int offset, int count) => ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + /// public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { if (!_servedFirstChunk) @@ -471,9 +534,13 @@ public class AuditExportCommandTests throw new IOException("connection reset by peer (simulated)"); } + /// public override void Flush() { } + /// public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + /// public override void SetLength(long value) => throw new NotSupportedException(); + /// public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } @@ -494,6 +561,8 @@ public class AuditExportCommandTests return capture.ToString(); } + /// Verifies that a server that never sends response headers causes a timeout with a friendly error message. + /// A task that represents the asynchronous operation. [Fact] public async Task RunExport_ServerNeverSendsHeaders_TimesOutWithFriendlyError() { @@ -521,6 +590,8 @@ public class AuditExportCommandTests Assert.False(File.Exists(path)); } + /// Verifies that the inactivity timeout fires when the stream stalls mid-transfer. + /// A task that represents the asynchronous operation. [Fact] public async Task RunExport_StreamStallsMidTransfer_InactivityTimeoutFires() { @@ -562,6 +633,8 @@ public class AuditExportCommandTests } } + /// Verifies that an I/O exception during streaming produces a clean message rather than a stack trace. + /// A task that represents the asynchronous operation. [Fact] public async Task RunExport_MidStreamIOException_ProducesCleanMessageNotStackTrace() { @@ -596,6 +669,8 @@ public class AuditExportCommandTests } } + /// Verifies that a Ctrl-C cancellation token cancels the export promptly with a clean message. + /// A task that represents the asynchronous operation. [Fact] public async Task RunExport_CtrlCToken_CancelsPromptlyWithCleanMessage() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditQueryCommandTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditQueryCommandTests.cs index ebe82352..0fce8bad 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditQueryCommandTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditQueryCommandTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// Tests for the scadabridge audit query subcommand (Audit Log #23 M8-T2): +/// Tests for the scadabridge audit query subcommand: /// time-spec resolution, query-string construction, formatter selection, error /// handling, and keyset-cursor paging via --all. /// @@ -17,6 +17,7 @@ public class AuditQueryCommandTests { // ---- Time-spec parsing ------------------------------------------------- + /// Verifies that a relative hours time spec resolves to the current time minus that offset. [Fact] public void ResolveTimeSpec_RelativeHours_ResolvesToNowMinusOffset() { @@ -25,6 +26,7 @@ public class AuditQueryCommandTests Assert.Equal(DateTimeOffset.Parse("2026-05-20T11:00:00Z"), resolved); } + /// Verifies that a relative days time spec resolves to the current time minus that offset. [Fact] public void ResolveTimeSpec_RelativeDays_ResolvesToNowMinusOffset() { @@ -33,6 +35,7 @@ public class AuditQueryCommandTests Assert.Equal(DateTimeOffset.Parse("2026-05-13T12:00:00Z"), resolved); } + /// Verifies that an absolute ISO 8601 time spec is parsed verbatim. [Fact] public void ResolveTimeSpec_AbsoluteIso8601_ParsedVerbatim() { @@ -41,6 +44,7 @@ public class AuditQueryCommandTests Assert.Equal(DateTimeOffset.Parse("2026-01-02T03:04:05Z"), resolved); } + /// Verifies that an unparseable time spec throws. [Fact] public void ResolveTimeSpec_Garbage_Throws() { @@ -50,6 +54,7 @@ public class AuditQueryCommandTests // ---- Query string construction ---------------------------------------- + /// Verifies that a fully populated flag set produces the expected query-string parameters. [Fact] public void BuildQueryString_FullFlagSet_ProducesExpectedParameters() { @@ -90,6 +95,7 @@ public class AuditQueryCommandTests Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]); } + /// Verifies that the errors-only flag maps to the failed status filter. [Fact] public void BuildQueryString_ErrorsOnly_MapsToFailedStatus() { @@ -100,6 +106,7 @@ public class AuditQueryCommandTests Assert.Equal("Failed", parsed["status"]); } + /// Verifies that a multi-value channel filter emits one query key per value. [Fact] public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue() { @@ -119,6 +126,7 @@ public class AuditQueryCommandTests Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId")); } + /// Verifies that the errors-only flag overrides any explicit status values. [Fact] public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues() { @@ -137,6 +145,7 @@ public class AuditQueryCommandTests Assert.Equal(new[] { "Failed" }, parsed.GetValues("status")); } + /// Verifies that the paging cursor is appended after the filter parameters. [Fact] public void BuildQueryString_Cursor_AppendsAfterParameters() { @@ -149,6 +158,7 @@ public class AuditQueryCommandTests Assert.Equal("2026-05-20T10:00:00.0000000+00:00", parsed["afterOccurredAtUtc"]); } + /// Verifies that unset filters are omitted from the query string. [Fact] public void BuildQueryString_OmitsUnsetFilters() { @@ -165,6 +175,7 @@ public class AuditQueryCommandTests Assert.Equal("100", parsed["pageSize"]); } + /// Verifies that an execution id filter emits the execution id parameter. [Fact] public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter() { @@ -176,6 +187,7 @@ public class AuditQueryCommandTests Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]); } + /// Verifies that a parent execution id filter emits the parent execution id parameter. [Fact] public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter() { @@ -192,13 +204,17 @@ public class AuditQueryCommandTests private sealed class RecordingHandler : HttpMessageHandler { private readonly Queue _bodies; + /// The request URIs received by the handler, in order. public List RequestUris { get; } = new(); + /// Creates a handler that replies with the given JSON bodies in sequence. + /// The response bodies to return, one per request. public RecordingHandler(params string[] bodies) { _bodies = new Queue(bodies); } + /// protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { @@ -211,6 +227,8 @@ public class AuditQueryCommandTests } } + /// Verifies that a single page of results is written as JSON lines. + /// A task that represents the asynchronous operation. [Fact] public async Task RunQuery_SinglePage_WritesEventsAsJsonLines() { @@ -231,6 +249,8 @@ public class AuditQueryCommandTests Assert.Single(handler.RequestUris); } + /// Verifies that the all flag follows the next cursor across pages. + /// A task that represents the asynchronous operation. [Fact] public async Task RunQuery_WithAll_FollowsNextCursorAcrossPages() { @@ -251,6 +271,8 @@ public class AuditQueryCommandTests Assert.Equal(2, lines.Length); } + /// Verifies that without the all flag only the first page is fetched even when a cursor is present. + /// A task that represents the asynchronous operation. [Fact] public async Task RunQuery_WithoutAll_StopsAfterFirstPageEvenWhenCursorPresent() { @@ -267,6 +289,8 @@ public class AuditQueryCommandTests Assert.Single(handler.RequestUris); } + /// Verifies that a server error returns a non-zero exit code. + /// A task that represents the asynchronous operation. [Fact] public async Task RunQuery_ServerError_ReturnsNonZeroExit() { @@ -281,12 +305,14 @@ public class AuditQueryCommandTests Assert.NotEqual(0, exit); } + /// Verifies that an HTTP 403 response returns exit code 2. + /// A task that represents the asynchronous operation. [Fact] public async Task RunQuery_Http403_ReturnsExitCode2() { - // CLI-018: an authorization failure on /api/audit/query (HTTP 403) must + // An authorization failure on /api/audit/query (HTTP 403) must // produce exit code 2 per the documented CLI exit-code contract — the - // legacy bare-1 return masked auth failures as generic command failures. + // legacy bare exit code 1 return masked auth failures as generic command failures. var handler = new StatusHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}"); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); @@ -298,11 +324,13 @@ public class AuditQueryCommandTests Assert.Equal(2, exit); } + /// Verifies that an UNAUTHORIZED error code on a status other than 403 returns exit code 2. + /// A task that represents the asynchronous operation. [Fact] public async Task RunQuery_UnauthorizedCodeOnNon403_ReturnsExitCode2() { // The server may signal authorization failure via the error code on a - // non-403 status (e.g. 400 + code=UNAUTHORIZED). Honour both channels. + // status other than 403 (e.g. 400 + code=UNAUTHORIZED). Honour both channels. var handler = new StatusHandler(HttpStatusCode.BadRequest, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}"); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); @@ -314,10 +342,12 @@ public class AuditQueryCommandTests Assert.Equal(2, exit); } + /// Verifies that a generic server error returns exit code 1. + /// A task that represents the asynchronous operation. [Fact] public async Task RunQuery_GenericServerError_ReturnsExitCode1() { - // Authentication / internal errors (non-403, no auth code) must remain + // Authentication / internal errors (status other than 403, no auth code) must remain // exit code 1 — exit 2 is reserved for authorization failures. var handler = new StatusHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}"); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); @@ -332,6 +362,7 @@ public class AuditQueryCommandTests private sealed class ErrorHandler : HttpMessageHandler { + /// protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError) @@ -344,7 +375,11 @@ public class AuditQueryCommandTests { private readonly HttpStatusCode _status; private readonly string _body; + /// Creates a handler that always replies with the given status and body. + /// The HTTP status code to return. + /// The response body to return. public StatusHandler(HttpStatusCode status, string body) { _status = status; _body = body; } + /// protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) }); @@ -352,6 +387,7 @@ public class AuditQueryCommandTests // ---- CLI parsing ------------------------------------------------------- + /// Verifies that an unknown flag produces a parse error and a non-zero exit code. [Fact] public void Query_UnknownFlag_ProducesParseErrorAndNonZeroExit() { @@ -361,6 +397,7 @@ public class AuditQueryCommandTests Assert.NotEqual("", err); } + /// Verifies that the table output format is accepted. [Fact] public void Query_FormatTable_IsAccepted() { @@ -369,6 +406,7 @@ public class AuditQueryCommandTests Assert.Empty(parse.Errors); } + /// Verifies that the execution id option is accepted. [Fact] public void Query_ExecutionIdOption_IsAccepted() { @@ -381,6 +419,7 @@ public class AuditQueryCommandTests Assert.Empty(parse.Errors); } + /// Verifies that the parent execution id option is accepted. [Fact] public void Query_ParentExecutionIdOption_IsAccepted() { @@ -395,6 +434,7 @@ public class AuditQueryCommandTests // ---- Enum-name validation (fast-fail) ---------------------------------- + /// Verifies that a channel option using a real enum name is accepted. [Fact] public void Query_ChannelWithRealEnumName_IsAccepted() { @@ -403,6 +443,7 @@ public class AuditQueryCommandTests Assert.Empty(parse.Errors); } + /// Verifies that multiple channel values in a single token are accepted. [Fact] public void Query_MultipleChannelValues_SingleToken_AreAccepted() { @@ -412,6 +453,7 @@ public class AuditQueryCommandTests Assert.Empty(parse.Errors); } + /// Verifies that multiple channel values given by repeating the flag are accepted. [Fact] public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted() { @@ -438,6 +480,9 @@ public class AuditQueryCommandTests // carried through to the request rather than rejected locally. // ======================================================================== + /// Verifies that an unrecognised filter value is not rejected at the parse layer. + /// The filter option name. + /// The filter value to pass. [Theory] [InlineData("--channel", "OutboundApi")] // old (non-existent) name; the real enum is "ApiOutbound" [InlineData("--kind", "CachedCall")] @@ -449,6 +494,7 @@ public class AuditQueryCommandTests Assert.Empty(parse.Errors); } + /// Verifies that a multi-value channel filter containing one unrecognised name still parses. [Fact] public void Query_MultiValueChannel_WithOneUnrecognisedName_StillParses() { @@ -459,6 +505,9 @@ public class AuditQueryCommandTests Assert.Empty(parse.Errors); } + /// Verifies that a filter value in any letter case is accepted. + /// The filter option name. + /// The filter value to pass. [Theory] [InlineData("--channel", "apioutbound")] [InlineData("--kind", "apicall")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTableFormatterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTableFormatterTests.cs index 9578b2b2..db1b1f32 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTableFormatterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTableFormatterTests.cs @@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// /// Tests for the table output formatter of the scadabridge audit query -/// subcommand (Audit Log #23 M8-T6): header rendering, long-field truncation, the +/// subcommand: header rendering, long-field truncation, the /// empty-result-set case, and null-actor handling. /// public class AuditTableFormatterTests @@ -18,6 +18,7 @@ public class AuditTableFormatterTests .ToList(); } + /// Verifies that the table renders a header row with the expected columns. [Fact] public void Table_RendersHeaderRow_WithExpectedColumns() { @@ -38,6 +39,7 @@ public class AuditTableFormatterTests } } + /// Verifies that a long target is truncated with an ellipsis. [Fact] public void Table_TruncatesLongTarget_WithEllipsis() { @@ -57,6 +59,7 @@ public class AuditTableFormatterTests Assert.DoesNotContain(longTarget, text); } + /// Verifies that an empty result set renders only the header or a no-rows message. [Fact] public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage() { @@ -73,6 +76,7 @@ public class AuditTableFormatterTests Assert.Contains("OccurredAtUtc", lines[0]); } + /// Verifies that a null actor renders as a blank cell. [Fact] public void Table_NullActor_RendersBlank() { @@ -93,6 +97,7 @@ public class AuditTableFormatterTests Assert.Contains("InboundApi", lines[1]); } + /// Verifies that the header is emitted once per page and the data rows are aligned. [Fact] public void Table_HeaderEmittedOncePerPage_DataRowsAligned() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTreeCommandTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTreeCommandTests.cs index cae5687c..721407a1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTreeCommandTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditTreeCommandTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// Tests for the scadabridge audit tree subcommand (Audit Log #23 M5.1-T8): +/// Tests for the scadabridge audit tree subcommand: /// tree rendering (table format), JSON output, error handling, and CLI parsing. /// [Collection("Console")] @@ -58,6 +58,7 @@ public class AuditTreeCommandTests // ParseNodes // ───────────────────────────────────────────────────────────────────── + /// Verifies that parsing a valid JSON array returns the matching node DTOs. [Fact] public void ParseNodes_ValidArray_ReturnsDtos() { @@ -75,6 +76,7 @@ public class AuditTreeCommandTests Assert.Equal(3, nodes[0].RowCount); } + /// Verifies that parsing an empty JSON array returns an empty list. [Fact] public void ParseNodes_EmptyArray_ReturnsEmpty() { @@ -82,6 +84,7 @@ public class AuditTreeCommandTests Assert.Empty(nodes); } + /// Verifies that parsing invalid JSON returns an empty list. [Fact] public void ParseNodes_InvalidJson_ReturnsEmpty() { @@ -93,6 +96,7 @@ public class AuditTreeCommandTests // WriteTable — ASCII tree rendering // ───────────────────────────────────────────────────────────────────── + /// Verifies that writing a table for no nodes prints the fallback message. [Fact] public void WriteTable_EmptyNodes_PrintsFallbackMessage() { @@ -101,6 +105,7 @@ public class AuditTreeCommandTests Assert.Contains("no execution tree found", output.ToString()); } + /// Verifies that writing a table for a single root node prints it with no indent. [Fact] public void WriteTable_SingleRootNode_PrintsWithNoIndent() { @@ -117,6 +122,7 @@ public class AuditTreeCommandTests Assert.Contains("[*]", line); // queried node marked } + /// Verifies that writing a table for a multi-level tree indents each child level correctly. [Fact] public void WriteTable_MultiLevelTree_IndentsChildrenCorrectly() { @@ -144,6 +150,7 @@ public class AuditTreeCommandTests Assert.StartsWith(" ", grandLine); } + /// Verifies that writing a table marks the queried node and leaves the others unmarked. [Fact] public void WriteTable_QueriedNodeIsMarked_OthersAreNot() { @@ -167,6 +174,7 @@ public class AuditTreeCommandTests // WriteJson // ───────────────────────────────────────────────────────────────────── + /// Verifies that writing JSON for valid nodes emits a valid JSON array. [Fact] public void WriteJson_ValidNodes_EmitsValidJsonArray() { @@ -183,6 +191,7 @@ public class AuditTreeCommandTests Assert.Equal(2, doc.RootElement.GetArrayLength()); } + /// Verifies that writing JSON for no nodes emits an empty array. [Fact] public void WriteJson_EmptyNodes_EmitsEmptyArray() { @@ -204,14 +213,19 @@ public class AuditTreeCommandTests private readonly HttpStatusCode _status; private readonly string _body; + /// Initializes a new instance of the FixedHandler class. + /// The status. + /// The body. public FixedHandler(HttpStatusCode status, string body) { _status = status; _body = body; } + /// Gets the last request uri value. public string? LastRequestUri { get; private set; } + /// protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { @@ -223,6 +237,8 @@ public class AuditTreeCommandTests } } + /// Verifies that a successful tree run returns exit code zero and writes the output. + /// A task that represents the asynchronous operation. [Fact] public async Task RunTree_Success_ReturnsZeroAndWritesOutput() { @@ -239,6 +255,8 @@ public class AuditTreeCommandTests Assert.Contains(rootId, output.ToString()); } + /// Verifies that a tree run with an empty response returns exit code zero and prints the fallback message. + /// A task that represents the asynchronous operation. [Fact] public async Task RunTree_EmptyResponse_ReturnsZeroWithFallbackMessage() { @@ -253,6 +271,8 @@ public class AuditTreeCommandTests Assert.Contains("no execution tree found", output.ToString()); } + /// Verifies that a tree run with the JSON format emits valid JSON. + /// A task that represents the asynchronous operation. [Fact] public async Task RunTree_JsonFormat_EmitsValidJson() { @@ -269,6 +289,8 @@ public class AuditTreeCommandTests Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); } + /// Verifies that a tree run that receives HTTP 403 returns exit code 2. + /// A task that represents the asynchronous operation. [Fact] public async Task RunTree_Http403_ReturnsExitCode2() { @@ -282,6 +304,8 @@ public class AuditTreeCommandTests Assert.Equal(2, exit); } + /// Verifies that a tree run that receives HTTP 500 returns exit code 1. + /// A task that represents the asynchronous operation. [Fact] public async Task RunTree_Http500_ReturnsExitCode1() { @@ -295,6 +319,8 @@ public class AuditTreeCommandTests Assert.Equal(1, exit); } + /// Verifies that the tree request URL contains the execution id. + /// A task that represents the asynchronous operation. [Fact] public async Task RunTree_RequestUrlContainsExecutionId() { @@ -313,6 +339,7 @@ public class AuditTreeCommandTests // CLI parsing — audit tree subcommand // ───────────────────────────────────────────────────────────────────── + /// Verifies that the tree subcommand exists in the audit command group. [Fact] public void Tree_Subcommand_ExistsInAuditCommandGroup() { @@ -322,6 +349,7 @@ public class AuditTreeCommandTests Assert.Empty(parse.Errors); } + /// Verifies that the tree execution id option is required. [Fact] public void Tree_ExecutionIdOption_IsRequired() { @@ -332,6 +360,7 @@ public class AuditTreeCommandTests Assert.NotEqual(0, exit); } + /// Verifies that the tree help text describes the execution id. [Fact] public void Tree_HelpText_DescribesExecutionId() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditVerifyChainCommandTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditVerifyChainCommandTests.cs index b8e0797e..1aa67755 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditVerifyChainCommandTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditVerifyChainCommandTests.cs @@ -3,13 +3,14 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// Tests for the scadabridge audit verify-chain subcommand (Audit Log #23 M8-T4). +/// Tests for the scadabridge audit verify-chain subcommand. /// v1 is a no-op stub: a valid --month prints the documented "not enabled" /// message and exits 0; a malformed month or a missing --month exits non-zero. /// [Collection("Console")] public class AuditVerifyChainCommandTests { + /// Verifies that verifying the chain for a valid month exits with zero and prints the documented message. [Fact] public void VerifyChain_ValidMonth_ExitsZeroWithDocumentedMessage() { @@ -22,6 +23,7 @@ public class AuditVerifyChainCommandTests Assert.Contains("Component-AuditLog.md", output); } + /// Verifies that a malformed month value makes the command exit with a non-zero code. [Fact] public void VerifyChain_MalformedMonth_ExitsNonZero() { @@ -32,6 +34,7 @@ public class AuditVerifyChainCommandTests Assert.NotEqual(0, exit); } + /// Verifies that omitting the month option produces the required-option error. [Fact] public void VerifyChain_MissingMonth_ProducesRequiredFlagError() { @@ -42,6 +45,9 @@ public class AuditVerifyChainCommandTests Assert.Contains("--month", err); } + /// Verifies that month validation accepts only values in yyyy-MM format. + /// The month string to validate. + /// Whether the month is expected to be valid. [Theory] [InlineData("2026-05", true)] [InlineData("2026-01", true)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs index b142703a..78b0b439 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// CLI-019 regression tests for . +/// Regression tests for . /// The pre-fix code did Convert.FromBase64String(...) → File.WriteAllBytes(...), /// doubling the bundle's bytes onto the LOH and writing synchronously. The new /// streaming helper decodes the base64 string in fixed-size chunks straight into @@ -15,11 +15,13 @@ public class BundleCommandsStreamingTests : IDisposable { private readonly string _tempPath; + /// Creates a unique temporary output path for each test. public BundleCommandsStreamingTests() { _tempPath = Path.Combine(Path.GetTempPath(), $"bundle-stream-test-{Guid.NewGuid():N}.bin"); } + /// Releases the resources used by this instance. public void Dispose() { if (File.Exists(_tempPath)) @@ -28,6 +30,7 @@ public class BundleCommandsStreamingTests : IDisposable } } + /// Verifies that a small payload is written to the file and read back unchanged. [Fact] public void StreamBase64ToFile_SmallPayload_RoundTrips() { @@ -41,6 +44,7 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Equal(bytes, roundTripped); } + /// Verifies that a payload spanning several chunks, with a short final chunk, round-trips unchanged. [Fact] public void StreamBase64ToFile_PayloadCrossesChunkBoundary_RoundTrips() { @@ -60,6 +64,7 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Equal(bytes, roundTripped); } + /// Verifies that an empty base64 string produces an empty file. [Fact] public void StreamBase64ToFile_EmptyString_WritesEmptyFile() { @@ -70,6 +75,7 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Empty(File.ReadAllBytes(_tempPath)); } + /// Verifies that invalid base64 text throws a . [Fact] public void StreamBase64ToFile_InvalidBase64_ThrowsFormatException() { @@ -81,20 +87,23 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Throws(() => BundleCommands.StreamBase64ToFile(invalid, _tempPath)); } + /// Verifies that a null base64 string throws an . [Fact] public void StreamBase64ToFile_NullBase64_Throws() { Assert.Throws(() => BundleCommands.StreamBase64ToFile(null!, _tempPath)); } + /// Verifies that an empty output path throws an . [Fact] public void StreamBase64ToFile_EmptyOutputPath_Throws() { Assert.Throws(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty)); } - // ---- M8 (B4): --sites / --instances comma-split + flag parsing ---------- + // ---- --sites / --instances comma-split + flag parsing ---------- + /// Verifies that a comma-separated list is split and each name is trimmed. [Fact] public void ParseNameList_CommaSeparated_SplitsAndTrims() { @@ -103,6 +112,8 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Equal(new[] { "North-01", "East-02", "West-03" }, result); } + /// Verifies that a null or blank token yields a null list. + /// The raw option value to parse. [Theory] [InlineData(null)] [InlineData("")] @@ -112,6 +123,7 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Null(BundleCommands.ParseNameList(token)); } + /// Verifies that empty and whitespace-only entries are dropped from the list. [Fact] public void ParseNameList_EmptyEntries_AreDropped() { @@ -119,6 +131,7 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Equal(new[] { "a", "b", "c" }, result); } + /// Verifies that the export command accepts the sites and instances flags without parse errors. [Fact] public void BundleExport_SitesAndInstancesFlags_ParseWithoutError() { @@ -138,14 +151,16 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Empty(parse.Errors); } - // ---- M8 (D3): --map-site / --map-connection spec parsing ---------------- + // ---- --map-site / --map-connection spec parsing ---------------- + /// Verifies that a null mapping list yields no site mappings. [Fact] public void ParseSiteMappings_Null_ReturnsEmpty() { Assert.Empty(BundleCommands.ParseSiteMappings(null)); } + /// Verifies that a source=target spec maps the source site to an existing target site. [Fact] public void ParseSiteMappings_SrcEqualsDst_MapsToExisting() { @@ -155,6 +170,8 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Equal("PLANT-A", spec.TargetSiteIdentifier); } + /// Verifies that a spec with no target means the site should be created new. + /// The raw site mapping spec. [Theory] [InlineData("NORTH-01")] // no '=' part -> create-new [InlineData("NORTH-01=")] // empty rhs -> create-new @@ -167,6 +184,7 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Null(spec.TargetSiteIdentifier); } + /// Verifies that repeated mapping specs accumulate into one list. [Fact] public void ParseSiteMappings_Repeated_AccumulatesAll() { @@ -176,12 +194,14 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Null(specs[1].TargetSiteIdentifier); } + /// Verifies that a spec with an empty source throws a . [Fact] public void ParseSiteMappings_EmptySource_Throws() { Assert.Throws(() => BundleCommands.ParseSiteMappings(new[] { "=PLANT-A" })); } + /// Verifies that a site/name=target spec maps the source connection to an existing target connection. [Fact] public void ParseConnectionMappings_SrcEqualsDst_MapsToExisting() { @@ -192,6 +212,8 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Equal("OpcLive", spec.TargetConnectionName); } + /// Verifies that a connection spec with no target means the connection should be created new. + /// The raw connection mapping spec. [Theory] [InlineData("NORTH-01/OpcA")] // no '=' part -> create-new [InlineData("NORTH-01/OpcA=")] // empty rhs -> create-new @@ -204,6 +226,8 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Null(spec.TargetConnectionName); } + /// Verifies that a connection spec with a malformed source throws a . + /// The malformed connection mapping spec. [Theory] [InlineData("OpcA")] // missing site/name shape [InlineData("NORTH-01/")] // empty connection name @@ -213,6 +237,7 @@ public class BundleCommandsStreamingTests : IDisposable Assert.Throws(() => BundleCommands.ParseConnectionMappings(new[] { token })); } + /// Verifies that the import command accepts the map and create-missing flags without parse errors. [Fact] public void BundleImport_MapAndCreateMissingFlags_ParseWithoutError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/CachedCallCommandsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/CachedCallCommandsTests.cs index 3c55ecfc..5f0ac34f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/CachedCallCommandsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/CachedCallCommandsTests.cs @@ -21,6 +21,7 @@ public class CachedCallCommandsTests // ── retry ────────────────────────────────────────────────────────────── + /// Verifies that the retry command with a site id and message id produces the expected management command. [Fact] public void Retry_WithSiteIdAndMessageId_ProducesCorrectCommand() { @@ -37,6 +38,7 @@ public class CachedCallCommandsTests Assert.Equal("11111111-2222-3333-4444-555555555555", cmd.MessageId); } + /// Verifies that the retry command requires both the site id and the tracked operation id. [Fact] public void Retry_SiteIdAndTrackedOperationId_AreRequired() { @@ -50,6 +52,7 @@ public class CachedCallCommandsTests Assert.True(opId.Required, "--tracked-operation-id must be required."); } + /// Verifies that the retry command reports parse errors when its options are missing. [Fact] public void Retry_MissingOptions_ProducesParseErrors() { @@ -63,6 +66,7 @@ public class CachedCallCommandsTests // ── discard ──────────────────────────────────────────────────────────── + /// Verifies that the discard command with a site id and message id produces the expected management command. [Fact] public void Discard_WithSiteIdAndMessageId_ProducesCorrectCommand() { @@ -79,6 +83,7 @@ public class CachedCallCommandsTests Assert.Equal("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", cmd.MessageId); } + /// Verifies that the discard command requires both the site id and the tracked operation id. [Fact] public void Discard_SiteIdAndTrackedOperationId_AreRequired() { @@ -92,6 +97,7 @@ public class CachedCallCommandsTests Assert.True(opId.Required, "--tracked-operation-id must be required."); } + /// Verifies that the discard command reports parse errors when its options are missing. [Fact] public void Discard_MissingOptions_ProducesParseErrors() { @@ -105,6 +111,7 @@ public class CachedCallCommandsTests // ── group shape ──────────────────────────────────────────────────────── + /// Verifies that the cached-call command group contains the retry and discard subcommands. [Fact] public void CachedCallGroup_HasRetryAndDiscard() { @@ -115,6 +122,7 @@ public class CachedCallCommandsTests Assert.Contains("discard", subNames); } + /// Verifies that the cached-call command group is named cached-call. [Fact] public void CachedCallGroup_Name_IsCachedCall() { @@ -124,6 +132,7 @@ public class CachedCallCommandsTests // ── registry round-trip ──────────────────────────────────────────────── + /// Verifies that the retry-parked-message command resolves through the command registry. [Fact] public void RetryParkedMessageCommand_ResolvesViaRegistry() { @@ -132,6 +141,7 @@ public class CachedCallCommandsTests Assert.Equal(typeof(RetryParkedMessageCommand), ManagementCommandRegistry.Resolve(name)); } + /// Verifies that the discard-parked-message command resolves through the command registry. [Fact] public void DiscardParkedMessageCommand_ResolvesViaRegistry() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/ExternalSystemRetryOptionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/ExternalSystemRetryOptionTests.cs index a465e55e..b9d05795 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/ExternalSystemRetryOptionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/ExternalSystemRetryOptionTests.cs @@ -26,6 +26,8 @@ public class ExternalSystemRetryOptionTests private static Option? FindOption(Command command, string name) => command.Options.SingleOrDefault(o => o.Name == name); + /// Verifies that the create and update subcommands expose the optional retry-policy options. + /// The name of the subcommand under test. [Theory] [InlineData("create")] [InlineData("update")] @@ -42,6 +44,7 @@ public class ExternalSystemRetryOptionTests Assert.False(retryDelay!.Required); } + /// Verifies that the create subcommand parses the supplied retry-policy values. [Fact] public void Create_ParsesRetryPolicyValues() { @@ -56,6 +59,7 @@ public class ExternalSystemRetryOptionTests Assert.Equal(60, parse.GetValue("--retry-delay-seconds")); } + /// Verifies that the update subcommand parses the supplied retry-policy values. [Fact] public void Update_ParsesRetryPolicyValues() { @@ -70,6 +74,7 @@ public class ExternalSystemRetryOptionTests Assert.Equal(10, parse.GetValue("--retry-delay-seconds")); } + /// Verifies that the retry-delay option alias is accepted by the parser. [Fact] public void RetryDelayAliasIsAccepted() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/ExternalSystemTimeoutOptionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/ExternalSystemTimeoutOptionTests.cs index d79048e2..3d8b1219 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/ExternalSystemTimeoutOptionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/ExternalSystemTimeoutOptionTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// PLAN-06 Task 11: external-system create and external-system update +/// external-system create and external-system update /// must expose an optional --timeout-seconds (?) flag that /// carries the per-system ESG HTTP timeout. Omitting it preserves the existing value /// (update) / uses the gateway default (create). @@ -21,6 +21,7 @@ public class ExternalSystemTimeoutOptionTests private static Option? FindOption(Command command, string name) => command.Options.SingleOrDefault(o => o.Name == name); + /// Verifies that the create command exposes an optional timeout-seconds option. [Fact] public void Create_HasOptionalTimeoutSecondsOption() { @@ -30,6 +31,7 @@ public class ExternalSystemTimeoutOptionTests Assert.False(option!.Required); } + /// Verifies that the update command exposes an optional timeout-seconds option. [Fact] public void Update_HasOptionalTimeoutSecondsOption() { @@ -39,6 +41,7 @@ public class ExternalSystemTimeoutOptionTests Assert.False(option!.Required); } + /// Verifies that the create command parses a supplied timeout-seconds value. [Fact] public void Create_ParsesTimeoutSecondsValue() { @@ -52,6 +55,7 @@ public class ExternalSystemTimeoutOptionTests Assert.Equal(15, parse.GetValue("--timeout-seconds")); } + /// Verifies that the update command parses a supplied timeout-seconds value. [Fact] public void Update_ParsesTimeoutSecondsValue() { @@ -65,6 +69,7 @@ public class ExternalSystemTimeoutOptionTests Assert.Equal(20, parse.GetValue("--timeout-seconds")); } + /// Verifies that omitting timeout-seconds on create parses without error. [Fact] public void Create_OmittedTimeoutSeconds_ParsesWithoutError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/NotificationSmsCommandTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/NotificationSmsCommandTests.cs index 9a0c4a1f..2efca96f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/NotificationSmsCommandTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/NotificationSmsCommandTests.cs @@ -40,6 +40,7 @@ public class NotificationSmsCommandTests // ---- list create/update: --type + --phones ---------------------------- + /// Verifies that creating an SMS list with phones builds an SMS command carrying the recipient phones. [Fact] public void ListCreate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones() { @@ -55,6 +56,7 @@ public class NotificationSmsCommandTests Assert.Empty(cmd.RecipientEmails); } + /// Verifies that the list type option is matched case-insensitively. [Fact] public void ListCreate_TypeIsCaseInsensitive() { @@ -66,6 +68,7 @@ public class NotificationSmsCommandTests Assert.Equal(NotificationType.Sms, cmd.Type); } + /// Verifies that a list created without a type defaults to email. [Fact] public void ListCreate_DefaultType_IsEmail() { @@ -79,6 +82,7 @@ public class NotificationSmsCommandTests Assert.Null(cmd.RecipientPhones); } + /// Verifies that an email list given phone numbers is rejected. [Fact] public void ListCreate_EmailTypeWithPhones_Throws() { @@ -90,6 +94,7 @@ public class NotificationSmsCommandTests Assert.Contains("--phones", ex.Message); } + /// Verifies that an SMS list with no phone numbers is rejected. [Fact] public void ListCreate_SmsTypeWithoutPhones_Throws() { @@ -100,6 +105,7 @@ public class NotificationSmsCommandTests Assert.Contains("--phones", ex.Message); } + /// Verifies that an email list with no email addresses is rejected. [Fact] public void ListCreate_EmailTypeWithoutEmails_Throws() { @@ -110,6 +116,7 @@ public class NotificationSmsCommandTests Assert.Contains("--emails", ex.Message); } + /// Verifies that an SMS list given email addresses is rejected. [Fact] public void ListCreate_SmsTypeWithEmails_Throws() { @@ -121,6 +128,7 @@ public class NotificationSmsCommandTests Assert.Contains("--emails", ex.Message); } + /// Verifies that an unknown list type is rejected. [Fact] public void ListCreate_UnknownType_Throws() { @@ -131,6 +139,7 @@ public class NotificationSmsCommandTests () => NotificationCommands.BuildCreateNotificationListCommand(parse)); } + /// Verifies that updating an SMS list with phones builds an SMS command carrying the recipient phones. [Fact] public void ListUpdate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones() { @@ -146,6 +155,7 @@ public class NotificationSmsCommandTests Assert.Empty(cmd.RecipientEmails); } + /// Verifies that updating an email list with phone numbers is rejected. [Fact] public void ListUpdate_EmailTypeWithPhones_Throws() { @@ -160,6 +170,7 @@ public class NotificationSmsCommandTests // ---- notification sms group ------------------------------------------- + /// Verifies that the notification command has an SMS group with list and update subcommands. [Fact] public void Notification_HasSmsGroupWithListAndUpdate() { @@ -168,6 +179,7 @@ public class NotificationSmsCommandTests Assert.Contains("update", subNames); } + /// Verifies that an SMS update with every flag produces a command carrying all of them. [Fact] public void SmsUpdate_WithAllFlags_ProducesCommandCarryingThem() { @@ -187,6 +199,7 @@ public class NotificationSmsCommandTests Assert.Equal("new-secret", cmd.AuthToken); } + /// Verifies that omitting the optional flags produces a command with null values for them. [Fact] public void SmsUpdate_WithoutOptionalFlags_ProducesCommandWithNulls() { @@ -204,6 +217,7 @@ public class NotificationSmsCommandTests Assert.Null(cmd.AuthToken); } + /// Verifies that the optional SMS update flags are not marked as required. [Fact] public void SmsUpdate_OptionalFlags_AreNotRequired() { @@ -214,6 +228,7 @@ public class NotificationSmsCommandTests Assert.False(update.Options.Single(o => o.Name == "--auth-token").Required); } + /// Verifies that an SMS update with only a messaging service SID and no from number builds successfully. [Fact] public void SmsUpdate_MessagingServiceSidOnly_NoFromNumber_Builds() { @@ -229,6 +244,7 @@ public class NotificationSmsCommandTests Assert.Equal("MGonly", cmd.MessagingServiceSid); } + /// Verifies that an SMS update with neither a from number nor a messaging service SID is rejected. [Fact] public void SmsUpdate_NeitherFromNorMessagingService_Throws() { @@ -239,6 +255,7 @@ public class NotificationSmsCommandTests Assert.Throws(() => NotificationCommands.BuildUpdateSmsConfigCommand(parse)); } + /// Verifies that both SMS commands resolve through the management command registry. [Fact] public void SmsCommands_ResolveViaRegistry() { @@ -262,6 +279,7 @@ public class NotificationSmsCommandTests [Collection("Console")] public class NotificationSmsAuthTokenRenderingTests { + /// Verifies that a rendered SMS configuration list never contains the auth token value. [Fact] public void SmsConfigListResponse_RenderedAsJson_DoesNotContainAuthToken() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/SecuredWriteListCommandTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/SecuredWriteListCommandTests.cs index 46997686..269fdae2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/SecuredWriteListCommandTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/SecuredWriteListCommandTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// PLAN-07 Task 17 (arch-review P3): secured-write list must expose --skip +/// secured-write list must expose --skip /// / --take offset-paging options alongside the --status / --site /// filters, and map them onto . /// Omitting the paging flags preserves the historical unpaged default (skip 0, take 200). @@ -23,6 +23,7 @@ public class SecuredWriteListCommandTests private static Option? FindOption(Command command, string name) => command.Options.SingleOrDefault(o => o.Name == name); + /// Verifies that the list command exposes the skip and take options. [Fact] public void List_HasSkipAndTakeOptions() { @@ -31,6 +32,7 @@ public class SecuredWriteListCommandTests Assert.NotNull(FindOption(list, "--take")); } + /// Verifies that the list command parses skip, take and filters and forwards them to the command. [Fact] public void List_ParsesSkipTakeAndFilters_ForwardsToCommand() { @@ -47,6 +49,7 @@ public class SecuredWriteListCommandTests Assert.Equal(25, command.Take); } + /// Verifies that omitting the paging options uses the unpaged defaults. [Fact] public void List_OmittedPaging_UsesUnpagedDefaults() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/SmtpUpdateCommandTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/SmtpUpdateCommandTests.cs index 89d88fe7..8aad8e6c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/SmtpUpdateCommandTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/SmtpUpdateCommandTests.cs @@ -29,6 +29,7 @@ public class SmtpUpdateCommandTests private static ParseResult ParseUpdate(params string[] args) => SmtpUpdateCommand().Parse(args); + /// Verifies that an update with a TLS mode and credentials produces a command carrying both. [Fact] public void Update_WithTlsModeAndCredentials_ProducesCommandCarryingThem() { @@ -49,6 +50,7 @@ public class SmtpUpdateCommandTests Assert.Equal("user:pass", cmd.Credentials); } + /// Verifies that an update without a TLS mode or credentials produces a command with null values for them. [Fact] public void Update_WithoutTlsModeAndCredentials_ProducesCommandWithNulls() { @@ -64,6 +66,8 @@ public class SmtpUpdateCommandTests Assert.Null(cmd.Credentials); } + /// Verifies that the TLS mode option accepts each canonical value. + /// The TLS mode value supplied on the command line. [Theory] [InlineData("None")] [InlineData("StartTLS")] @@ -78,6 +82,8 @@ public class SmtpUpdateCommandTests Assert.Empty(parse.Errors); } + /// Verifies that the TLS mode option rejects values outside the canonical set. + /// The non-canonical TLS mode value supplied on the command line. [Theory] [InlineData("Bogus")] [InlineData("tls")] @@ -92,6 +98,7 @@ public class SmtpUpdateCommandTests Assert.NotEmpty(parse.Errors); } + /// Verifies that an update with the EWS transport produces a command carrying that transport. [Fact] public void Update_WithTransportEws_ProducesCommandCarryingIt() { @@ -106,6 +113,7 @@ public class SmtpUpdateCommandTests Assert.Equal("Ews", cmd.Transport); } + /// Verifies that an update without a transport produces a command with a null transport. [Fact] public void Update_WithoutTransport_ProducesCommandWithNullTransport() { @@ -119,6 +127,8 @@ public class SmtpUpdateCommandTests Assert.Null(cmd.Transport); } + /// Verifies that the transport option rejects values outside the canonical set. + /// The non-canonical transport value supplied on the command line. [Theory] [InlineData("Graph")] [InlineData("ews")] // AcceptOnlyFromAmong is case-sensitive: constrain to canonical spelling @@ -132,6 +142,7 @@ public class SmtpUpdateCommandTests Assert.NotEmpty(parse.Errors); } + /// Verifies that the TLS mode and credentials options are not required. [Fact] public void Update_TlsModeAndCredentials_AreNotRequired() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateAttributeListTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateAttributeListTests.cs index 11c6bacc..5b26737d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateAttributeListTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateAttributeListTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// MV-11: the template attribute add / update commands must support +/// The template attribute add / update commands must support /// structured multi-value (List) attributes — a new --element-type option, /// a JSON-array --value, client-side element-type validation, and /// / @@ -25,6 +25,7 @@ public class TemplateAttributeListTests // ---- option surface ---- + /// Verifies that the attribute add command exposes an element-type option. [Fact] public void AttributeAdd_HasElementTypeOption() { @@ -32,6 +33,7 @@ public class TemplateAttributeListTests Assert.Contains("--element-type", add.Options.Select(o => o.Name)); } + /// Verifies that the attribute update command exposes an element-type option. [Fact] public void AttributeUpdate_HasElementTypeOption() { @@ -41,6 +43,8 @@ public class TemplateAttributeListTests // ---- client-side element-type validation (both directions) ---- + /// Verifies that a List attribute with a valid scalar element type passes validation. + /// The scalar element type name under test. [Theory] [InlineData("String")] [InlineData("Int32")] @@ -55,6 +59,7 @@ public class TemplateAttributeListTests Assert.Null(error); } + /// Verifies that element-type validation for a List attribute is case-insensitive. [Fact] public void ValidateElementType_ListWithValidScalar_CaseInsensitive() { @@ -63,6 +68,7 @@ public class TemplateAttributeListTests Assert.Null(error); } + /// Verifies that a List attribute without an element type fails validation. [Fact] public void ValidateElementType_ListWithoutElementType_Error() { @@ -71,6 +77,7 @@ public class TemplateAttributeListTests Assert.NotNull(error); } + /// Verifies that a List attribute with a blank element type fails validation. [Fact] public void ValidateElementType_ListWithBlankElementType_Error() { @@ -79,6 +86,7 @@ public class TemplateAttributeListTests Assert.NotNull(error); } + /// Verifies that a List attribute with an invalid scalar element type fails validation. [Fact] public void ValidateElementType_ListWithInvalidScalar_Error() { @@ -87,6 +95,7 @@ public class TemplateAttributeListTests Assert.NotNull(error); } + /// Verifies that a List attribute with a binary element type fails validation. [Fact] public void ValidateElementType_ListWithBinaryScalar_Error() { @@ -96,6 +105,7 @@ public class TemplateAttributeListTests Assert.NotNull(error); } + /// Verifies that a scalar attribute with an element type fails validation. [Fact] public void ValidateElementType_ScalarWithElementType_Error() { @@ -104,6 +114,7 @@ public class TemplateAttributeListTests Assert.NotNull(error); } + /// Verifies that a scalar attribute without an element type passes validation. [Fact] public void ValidateElementType_ScalarWithoutElementType_Ok() { @@ -114,6 +125,7 @@ public class TemplateAttributeListTests // ---- payload wiring: the raw JSON value + ElementDataType flow into the command ---- + /// Verifies that building an add command for a List attribute carries the element type and the raw JSON value. [Fact] public void BuildAddCommand_ListAttribute_CarriesElementTypeAndRawJsonValue() { @@ -134,6 +146,7 @@ public class TemplateAttributeListTests Assert.Equal("""["WO-1","WO-2"]""", cmd.Value); } + /// Verifies that building an update command for a List attribute carries the element type and the raw JSON value. [Fact] public void BuildUpdateCommand_ListAttribute_CarriesElementTypeAndRawJsonValue() { @@ -153,6 +166,7 @@ public class TemplateAttributeListTests Assert.Equal("""["A","B"]""", cmd.Value); } + /// Verifies that building an add command for a scalar attribute leaves the element type null. [Fact] public void BuildAddCommand_ScalarAttribute_LeavesElementTypeNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateScriptTimingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateScriptTimingTests.cs index 8777c669..3b686b7e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateScriptTimingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateScriptTimingTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// #54: template script add/update must expose --min-time-between-runs +/// template script add/update must expose --min-time-between-runs /// (a duration with ms/s/min units) and --execution-timeout-seconds (an int) /// so the per-script throttle/re-fire interval and the execution-timeout override — /// previously settable only via Transport bundle import — are CLI-authorable. These @@ -24,6 +24,7 @@ public class TemplateScriptTimingTests // ---- option surface ---- + /// Verifies that template script add exposes the timing options. [Fact] public void ScriptAdd_HasTimingOptions() { @@ -33,6 +34,7 @@ public class TemplateScriptTimingTests Assert.Contains("--execution-timeout-seconds", names); } + /// Verifies that template script update exposes the timing options. [Fact] public void ScriptUpdate_HasTimingOptions() { @@ -44,6 +46,7 @@ public class TemplateScriptTimingTests // ---- --min-time-between-runs parsing ---- + /// Verifies that an absent minimum-time value parses as unset with no error. [Fact] public void MinTime_Absent_IsUnsetNoError() { @@ -52,6 +55,7 @@ public class TemplateScriptTimingTests Assert.Null(err); } + /// Verifies that a blank minimum-time value parses as unset with no error. [Fact] public void MinTime_Blank_IsUnsetNoError() { @@ -60,6 +64,9 @@ public class TemplateScriptTimingTests Assert.Null(err); } + /// Verifies that a millisecond duration string is parsed into the expected time span. + /// The duration string with a millisecond suffix. + /// The expected number of milliseconds. [Theory] [InlineData("500ms", 500)] [InlineData("250MS", 250)] @@ -70,6 +77,8 @@ public class TemplateScriptTimingTests Assert.Equal(TimeSpan.FromMilliseconds(expectedMs), d); } + /// Verifies that a seconds value, including a bare number, is parsed as seconds. + /// The duration string to parse. [Theory] [InlineData("5")] // bare number → seconds [InlineData("5s")] @@ -82,6 +91,8 @@ public class TemplateScriptTimingTests Assert.Equal(TimeSpan.FromSeconds(5), d); } + /// Verifies that a minutes duration string is parsed as minutes. + /// The duration string with a minutes suffix. [Theory] [InlineData("2m")] [InlineData("2min")] @@ -93,6 +104,8 @@ public class TemplateScriptTimingTests Assert.Equal(TimeSpan.FromMinutes(2), d); } + /// Verifies that a zero duration is treated as unset, meaning no throttle. + /// A zero duration string. [Theory] [InlineData("0")] [InlineData("0s")] @@ -105,6 +118,8 @@ public class TemplateScriptTimingTests Assert.Null(d); } + /// Verifies that an invalid duration string is rejected with an error. + /// The invalid duration string. [Theory] [InlineData("abc")] [InlineData("5h")] // hours not supported @@ -122,6 +137,7 @@ public class TemplateScriptTimingTests /// An absurdly large value (16-digit millisecond count) would overflow long/TimeSpan /// without the overflow guard. The parser must return false cleanly — not throw. /// + /// An absurdly large duration string in milliseconds, seconds or minutes. [Theory] [InlineData("9999999999999999ms")] // 16-digit ms: far exceeds TimeSpan.MaxValue (~922337203685477ms) [InlineData("9999999999999999s")] // 16-digit seconds: even larger when scaled diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateTriggerKindIgnoredTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateTriggerKindIgnoredTests.cs index e2bfd6ca..791b1507 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateTriggerKindIgnoredTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateTriggerKindIgnoredTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// #257: --trigger-kind is written into the trigger-config JSON by +/// --trigger-kind is written into the trigger-config JSON by /// , which /// returns null (dropping the kind) when no --trigger-config is supplied. Passing /// --trigger-kind without --trigger-config was therefore a silent no-op. @@ -12,18 +12,21 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// public class TemplateTriggerKindIgnoredTests { + /// Verifies that a trigger kind supplied without a trigger config is reported as ignored. [Fact] public void KindWithoutConfig_WillBeIgnored() { Assert.True(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: null, triggerKind: "strict")); } + /// Verifies that a trigger kind supplied with a blank trigger config is reported as ignored. [Fact] public void KindWithBlankConfig_WillBeIgnored() { Assert.True(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: " ", triggerKind: "strict")); } + /// Verifies that a trigger kind supplied with a trigger config is not reported as ignored. [Fact] public void KindWithConfig_NotIgnored() { @@ -31,6 +34,7 @@ public class TemplateTriggerKindIgnoredTests triggerConfig: "{\"expression\":\"x > 0\"}", triggerKind: "strict")); } + /// Verifies that omitting the trigger kind is not reported as ignored. [Fact] public void NoKind_NotIgnored() { @@ -39,6 +43,7 @@ public class TemplateTriggerKindIgnoredTests Assert.False(TemplateCommands.TriggerKindWillBeIgnored(triggerConfig: null, triggerKind: " ")); } + /// Verifies that a trigger config supplied without a trigger kind is not reported as ignored. [Fact] public void NoKindWithConfig_NotIgnored() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateTriggerKindTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateTriggerKindTests.cs index a1a6a39a..0695de52 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateTriggerKindTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateTriggerKindTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// -/// M9-T28b: template alarm add/update and template script add/update +/// template alarm add/update and template script add/update /// must expose a --trigger-kind option (advisory|strict) that writes /// "analysisKind":"Strict" into the trigger-config JSON for expression /// triggers, matching the T28a backend contract exactly. @@ -17,6 +17,7 @@ public class TemplateTriggerKindTests // alarm-update / script-add / script-update ────────────────────────────── // Inject strict into an Expression config that has no prior analysisKind + /// Verifies that injecting the Strict analysis kind adds the analysis kind key to the trigger config. [Fact] public void InjectAnalysisKind_Strict_AddsKey() { @@ -29,6 +30,7 @@ public class TemplateTriggerKindTests } // Inject advisory (null) into config that had Strict → key removed + /// Verifies that injecting the Advisory analysis kind removes the analysis kind key from the trigger config. [Fact] public void InjectAnalysisKind_Advisory_RemovesKey() { @@ -41,6 +43,7 @@ public class TemplateTriggerKindTests } // Inject strict into config that already had Strict → still Strict (idempotent) + /// Verifies that injecting the Strict analysis kind twice yields the same config as injecting it once. [Fact] public void InjectAnalysisKind_Strict_Idempotent() { @@ -53,6 +56,7 @@ public class TemplateTriggerKindTests } // Null config → returns null (no config to inject into) + /// Verifies that injecting an analysis kind into a null config returns null. [Fact] public void InjectAnalysisKind_NullConfig_ReturnsNull() { @@ -61,6 +65,7 @@ public class TemplateTriggerKindTests } // Case-insensitive: "STRICT" → canonical "Strict" + /// Verifies that the Strict analysis kind is recognized regardless of letter casing. [Fact] public void InjectAnalysisKind_Strict_CaseInsensitive() { @@ -73,6 +78,7 @@ public class TemplateTriggerKindTests } // Other keys preserved when injecting + /// Verifies that injecting an analysis kind leaves the other trigger config keys unchanged. [Fact] public void InjectAnalysisKind_OtherKeysPreserved() { @@ -88,6 +94,7 @@ public class TemplateTriggerKindTests // ── Option surface: alarm add + update have --trigger-kind ─────────────── + /// Verifies that the alarm add command exposes a trigger-kind option. [Fact] public void AlarmAdd_HasTriggerKindOption() { @@ -95,6 +102,7 @@ public class TemplateTriggerKindTests Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name)); } + /// Verifies that the alarm update command exposes a trigger-kind option. [Fact] public void AlarmUpdate_HasTriggerKindOption() { @@ -104,6 +112,7 @@ public class TemplateTriggerKindTests // ── Option surface: script add + update have --trigger-kind ───────────── + /// Verifies that the script add command exposes a trigger-kind option. [Fact] public void ScriptAdd_HasTriggerKindOption() { @@ -111,6 +120,7 @@ public class TemplateTriggerKindTests Assert.Contains("--trigger-kind", add.Options.Select(o => o.Name)); } + /// Verifies that the script update command exposes a trigger-kind option. [Fact] public void ScriptUpdate_HasTriggerKindOption() { @@ -120,6 +130,7 @@ public class TemplateTriggerKindTests // ── AlarmTriggerConfigJson.Build — analysisKind injection ──────────────── + /// Verifies that building an expression alarm config with the Strict kind injects an analysis kind of Strict. [Fact] public void AlarmConfigBuild_Expression_StrictKind_InjectsAnalysisKindStrict() { @@ -139,6 +150,7 @@ public class TemplateTriggerKindTests Assert.Equal("Strict", doc.RootElement.GetProperty("analysisKind").GetString()); } + /// Verifies that building an expression alarm config with the Advisory kind omits the analysis kind key. [Fact] public void AlarmConfigBuild_Expression_AdvisoryKind_OmitsAnalysisKindKey() { @@ -158,6 +170,7 @@ public class TemplateTriggerKindTests Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); } + /// Verifies that building an expression alarm config with no kind omits the analysis kind key. [Fact] public void AlarmConfigBuild_Expression_NullKind_OmitsAnalysisKindKey() { @@ -177,6 +190,7 @@ public class TemplateTriggerKindTests Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); } + /// Verifies that a non-expression alarm config does not emit an analysis kind even when the Strict kind is given. [Fact] public void AlarmConfigBuild_NonExpression_StrictKind_DoesNotEmitAnalysisKind() { @@ -196,6 +210,7 @@ public class TemplateTriggerKindTests Assert.False(doc.RootElement.TryGetProperty("analysisKind", out _)); } + /// Verifies that the Strict kind is recognized case-insensitively when building an expression alarm config. [Fact] public void AlarmConfigBuild_Expression_StrictKind_CaseInsensitive() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ConnectionBrowseCommandsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ConnectionBrowseCommandsTests.cs index 67d6a922..af58c097 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ConnectionBrowseCommandsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ConnectionBrowseCommandsTests.cs @@ -45,6 +45,8 @@ public class ConnectionBrowseCommandsTests return (root, captured); } + /// Verifies that the browse command builds a browse-node command addressed by site identifier. + /// A task that represents the asynchronous operation. [Fact] public async Task Browse_BuildsBrowseNodeCommand_WithSiteIdentifier() { @@ -61,6 +63,8 @@ public class ConnectionBrowseCommandsTests Assert.Equal("ns=2;s=X", cmd.ParentNodeId); } + /// Verifies that browsing without a node id starts from the address-space root. + /// A task that represents the asynchronous operation. [Fact] public async Task Browse_WithoutNodeId_BrowsesFromRoot() { @@ -76,6 +80,8 @@ public class ConnectionBrowseCommandsTests Assert.Null(cmd.ParentNodeId); } + /// Verifies that the search command builds a search-address-space command addressed by site identifier. + /// A task that represents the asynchronous operation. [Fact] public async Task Search_BuildsSearchAddressSpaceCommand_WithSiteIdentifier() { @@ -98,6 +104,8 @@ public class ConnectionBrowseCommandsTests Assert.Equal(50, cmd.MaxResults); } + /// Verifies that verify-endpoint reads the configuration JSON from a file and addresses the site by identifier. + /// A task that represents the asynchronous operation. [Fact] public async Task VerifyEndpoint_ReadsConfigJsonFromFile_WithSiteIdentifier() { @@ -128,6 +136,8 @@ public class ConnectionBrowseCommandsTests } } + /// Verifies that certs list builds a list-server-certs command addressed by site identifier. + /// A task that represents the asynchronous operation. [Fact] public async Task CertsList_BuildsListServerCertsCommand_WithSiteIdentifier() { @@ -142,6 +152,8 @@ public class ConnectionBrowseCommandsTests Assert.Equal("SITE1", cmd.SiteIdentifier); } + /// Verifies that certs trust base64-encodes the DER certificate file and addresses the site by identifier. + /// A task that represents the asynchronous operation. [Fact] public async Task CertsTrust_Base64EncodesDerFile_WithSiteIdentifier() { @@ -173,6 +185,8 @@ public class ConnectionBrowseCommandsTests } } + /// Verifies that certs remove builds a remove-server-cert command addressed by site identifier. + /// A task that represents the asynchronous operation. [Fact] public async Task CertsRemove_BuildsRemoveServerCertCommand_WithSiteIdentifier() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CredentialResolutionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CredentialResolutionTests.cs index b6dd459a..8c87e50f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CredentialResolutionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CredentialResolutionTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.CLI; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// -/// Regression tests for CLI-006 — credentials could only be supplied via the +/// Regression tests for environment-based credential supply — credentials could only be supplied via the /// --password command-line option, which leaks into process listings and /// shell history. A SCADABRIDGE_PASSWORD / SCADABRIDGE_USERNAME environment /// fallback gives CI/CD a safer alternative. @@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; [Collection("Environment")] public class CredentialResolutionTests { + /// Verifies that the password is loaded from the environment variable. [Fact] public void Load_Password_FromEnvironment() { @@ -29,6 +30,7 @@ public class CredentialResolutionTests } } + /// Verifies that the username is loaded from the environment variable. [Fact] public void Load_Username_FromEnvironment() { @@ -47,6 +49,7 @@ public class CredentialResolutionTests } } + /// Verifies that loading with no credential environment variables leaves the credentials null. [Fact] public void Load_NoCredentialEnvVars_LeavesCredentialsNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DebugStreamTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DebugStreamTests.cs index f9d0e207..d927efe2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DebugStreamTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DebugStreamTests.cs @@ -5,13 +5,14 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// /// Regression tests for the testable pieces of DebugCommands.StreamDebugAsync: -/// CLI-010 (Ctrl+C during connect misreported as a connection failure) and -/// CLI-012 (non-deterministic exit code after stream termination). +/// Ctrl+C during connect being misreported as a connection failure, and +/// non-deterministic exit codes after stream termination. /// public class DebugStreamTests { - // --- CLI-010: connect-failure classification -------------------------------------- + // --- connect-failure classification -------------------------------------- + /// Verifies that an raised after cancellation was requested is treated as a graceful cancellation. [Fact] public void ClassifyConnectFailure_OperationCanceled_IsTreatedAsCancellation() { @@ -24,6 +25,7 @@ public class DebugStreamTests Assert.Equal(0, result.ExitCode); } + /// Verifies that a raised after cancellation was requested is treated as a cancellation. [Fact] public void ClassifyConnectFailure_TaskCanceled_WhenCancelRequested_IsCancellation() { @@ -34,6 +36,7 @@ public class DebugStreamTests Assert.Equal(0, result.ExitCode); } + /// Verifies that a genuine connection exception is classified as a connection failure with exit code 1. [Fact] public void ClassifyConnectFailure_RealException_IsConnectionFailure() { @@ -44,6 +47,7 @@ public class DebugStreamTests Assert.Equal(1, result.ExitCode); } + /// Verifies that a cancellation exception without a user cancel request is classified as a connection failure. [Fact] public void ClassifyConnectFailure_CanceledExceptionButNoCancelRequested_IsConnectionFailure() { @@ -56,8 +60,10 @@ public class DebugStreamTests Assert.Equal(1, result.ExitCode); } - // --- CLI-012: deterministic exit-code resolution ---------------------------------- + // --- deterministic exit-code resolution ---------------------------------- + /// Verifies that a result already set by stream termination is preferred when resolving the exit code. + /// A task that represents the asynchronous operation. [Fact] public async Task ResolveStreamExitCodeAsync_TerminationResultSet_PrefersThatResult() { @@ -70,6 +76,8 @@ public class DebugStreamTests Assert.Equal(1, code); } + /// Verifies that the exit code resolves to zero when no termination result was ever set. + /// A task that represents the asynchronous operation. [Fact] public async Task ResolveStreamExitCodeAsync_NoResult_ReturnsZero() { @@ -81,6 +89,8 @@ public class DebugStreamTests Assert.Equal(0, code); } + /// Verifies that a result arriving during the grace period is observed when resolving the exit code. + /// A task that represents the asynchronous operation. [Fact] public async Task ResolveStreamExitCodeAsync_ResultArrivesDuringGrace_IsObserved() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeployArtifactsExitCodeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeployArtifactsExitCodeTests.cs index d283c977..e900180a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeployArtifactsExitCodeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeployArtifactsExitCodeTests.cs @@ -22,6 +22,11 @@ public class DeployArtifactsExitCodeTests "{\"siteId\":\"site-b\",\"siteName\":\"B\",\"success\":false,\"errorMessage\":\"Secret 'k' ... missing\"}]," + "\"successCount\":1,\"failureCount\":1}"; + /// Verifies that the artifact deploy command exits with a non-zero code exactly when a site failed or the response is unrecognized. + /// The deploy command group under test (site or deploy). + /// The JSON response the management endpoint returns. + /// The expected process exit code. + /// A task that represents the asynchronous operation. [Theory] [InlineData("site", AllSucceeded, 0)] [InlineData("site", OneFailed, 1)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploySiteExitCodeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploySiteExitCodeTests.cs index e024b1d0..8f61ed98 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploySiteExitCodeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploySiteExitCodeTests.cs @@ -8,6 +8,10 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; public class DeploySiteExitCodeTests { + /// Verifies that the actual deploy command returns an exit code derived from the success and failure counts in the response. + /// The JSON response body served by the fake management endpoint. + /// The expected process exit code. + /// A task that represents the asynchronous operation. [Theory] [InlineData("{\"successCount\":2,\"failureCount\":0}", 0)] [InlineData("{\"successCount\":1,\"failureCount\":1}", 1)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploymentDeadlineParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploymentDeadlineParityTests.cs index 40b38ced..2524bae3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploymentDeadlineParityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploymentDeadlineParityTests.cs @@ -10,6 +10,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; [Collection("Console")] public class DeploymentDeadlineParityTests { + /// Verifies that every single-deployment CLI alias waits for a server reply beyond the ordinary 30-second deadline. + /// A task that represents the asynchronous operation. [Fact] public async Task EverySingleDeploymentAlias_WaitsForServerBeyondOrdinaryDeadline() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploymentStatusTableProjectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploymentStatusTableProjectionTests.cs index ed2ee8a9..c0dea23d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploymentStatusTableProjectionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploymentStatusTableProjectionTests.cs @@ -35,6 +35,7 @@ public class DeploymentStatusTableProjectionTests PageSize: rowCount), new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + /// Verifies that projection: unwraps the envelope to a row array. [Fact] public void Projection_UnwrapsTheEnvelopeToARowArray() { @@ -47,6 +48,7 @@ public class DeploymentStatusTableProjectionTests Assert.Equal("Line3.Pump1", doc.RootElement[0].GetProperty("instanceUniqueName").GetString()); } + /// Verifies that projection, when page is a strict subset, appends a total note. [Fact] public void Projection_WhenPageIsAStrictSubset_AppendsATotalNote() { @@ -59,6 +61,7 @@ public class DeploymentStatusTableProjectionTests Assert.Contains("--page-size", note); } + /// Verifies that projection, when page is the whole result, emits no note column. [Fact] public void Projection_WhenPageIsTheWholeResult_EmitsNoNoteColumn() { @@ -69,6 +72,7 @@ public class DeploymentStatusTableProjectionTests e => Assert.False(e.TryGetProperty("note", out _))); } + /// Verifies that projection, when accepts a bare row array, from a pre-envelope server. [Fact] public void Projection_AcceptsABareRowArray_FromAPreEnvelopeServer() { @@ -79,6 +83,8 @@ public class DeploymentStatusTableProjectionTests .GetProperty("deploymentId").GetString()); } + /// Verifies that projection: leaves unrecognised bodies untouched. + /// The body. [Theory] [InlineData("not json at all")] [InlineData("""{"error":"boom"}""")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/EditingParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/EditingParityTests.cs index b15cbe9e..c1bc01c2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/EditingParityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/EditingParityTests.cs @@ -24,6 +24,11 @@ public class EditingParityTests return root; } + /// Verifies that each command sends the exact management envelope with the expected command name and payload. + /// The command-line arguments to run. + /// The expected management command name. + /// The expected JSON payload. + /// A task that represents the asynchronous operation. [Theory] [InlineData("template resolved-members --id 12", "GetResolvedTemplateMembers", "{\"templateId\":12}")] [InlineData("template move --id 12 --folder-id 3", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":3}")] @@ -74,6 +79,8 @@ public class EditingParityTests finally { listener.Stop(); } } + /// Verifies that missing or malformed arguments are rejected. + /// The invalid command-line arguments to run. [Theory] [InlineData("template resolved-members")] [InlineData("template move --id 12 --folder-id invalid")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ExitCodeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ExitCodeTests.cs index a7a6ce1f..799c476f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ExitCodeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ExitCodeTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// -/// Regression tests for CLI-009 — the design doc defines exit code 2 as "authorization +/// Regression tests for the exit-code contract: the design doc defines exit code 2 as "authorization /// failure". The previous implementation keyed exit 2 solely off HTTP 403, so an /// authorization failure the server signalled with an error code of /// UNAUTHORIZED/FORBIDDEN but a different HTTP status was misreported as a @@ -27,12 +27,15 @@ public class ExitCodeTests } } + /// Verifies that an HTTP 403 response returns exit code 2. [Fact] public void HandleResponse_Http403_ReturnsTwo() { Assert.Equal(2, HandleQuietly(new ManagementResponse(403, null, "Forbidden", "FORBIDDEN"))); } + /// Verifies that an authorization error code with a status other than 403 still returns exit code 2. + /// The error code returned by the server. [Theory] [InlineData("UNAUTHORIZED")] [InlineData("FORBIDDEN")] @@ -40,16 +43,18 @@ public class ExitCodeTests public void HandleResponse_AuthorizationCode_NonForbiddenStatus_ReturnsTwo(string code) { // The server signalled an authorization failure via the error code but with a - // non-403 HTTP status; per the documented exit-code table this is still exit 2. + // HTTP status other than 403 status; per the documented exit-code table this is still exit 2. Assert.Equal(2, HandleQuietly(new ManagementResponse(400, null, "Access denied", code))); } + /// Verifies that a generic error returns exit code 1. [Fact] public void HandleResponse_GenericError_ReturnsOne() { Assert.Equal(1, HandleQuietly(new ManagementResponse(400, null, "Validation failed", "INVALID_ARGUMENT"))); } + /// Verifies that an authentication failure returns exit code 1. [Fact] public void HandleResponse_AuthenticationFailure_ReturnsOne() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs index 59a56108..b0f8a053 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs @@ -25,6 +25,7 @@ public class FlowDebugCommandsTests private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + /// Verifies that building the command registers the four subcommands with their required options. [Fact] public void Build_RegistersTheFourSubcommands_WithTheirRequiredOptions() { @@ -59,6 +60,7 @@ public class FlowDebugCommandsTests Assert.Contains(replay.Options, o => o.Name == "--execution-id" && o.Required); } + /// Verifies that run-script defaults to a 45-second wait and a 120-second timeout. [Fact] public void RunScript_Defaults_WaitSeconds45_TimeoutSeconds120() { @@ -70,6 +72,8 @@ public class FlowDebugCommandsTests Assert.Equal(120, parse.GetValue((Option)cmd.Options.Single(o => o.Name == "--timeout-seconds"))); } + /// Verifies that run-script rejects a wait of zero seconds as an invalid argument. + /// A task that represents the asynchronous operation. [Fact] public async Task RunScript_WaitSecondsZero_IsRejectedWithInvalidArgument() { @@ -80,6 +84,8 @@ public class FlowDebugCommandsTests Assert.Equal(1, exitCode); } + /// Verifies that run-script rejects a timeout of zero seconds as an invalid argument. + /// A task that represents the asynchronous operation. [Fact] public async Task RunScript_TimeoutSecondsZero_IsRejectedWithInvalidArgument() { @@ -90,6 +96,7 @@ public class FlowDebugCommandsTests Assert.Equal(1, exitCode); } + /// Verifies that run-method defaults to a 120-second timeout. [Fact] public void RunMethod_TimeoutSecondsDefaultsTo120() { @@ -101,6 +108,8 @@ public class FlowDebugCommandsTests Assert.Equal(120, parse.GetValue(timeoutOption)); } + /// Verifies that run-method rejects a timeout of zero seconds as an invalid argument. + /// A task that represents the asynchronous operation. [Fact] public async Task RunMethod_TimeoutSecondsZero_IsRejectedWithInvalidArgument() { @@ -111,6 +120,7 @@ public class FlowDebugCommandsTests Assert.Equal(1, exitCode); } + /// Verifies that run-value-change defaults to a 45-second wait, matching the command record. [Fact] public void RunValueChange_WaitSecondsDefaultsTo45_MatchingTheCommandRecord() { @@ -122,6 +132,7 @@ public class FlowDebugCommandsTests Assert.Equal(new FlowDebugRunValueChangeCommand(7, "Speed", "42").WaitSeconds, parse.GetValue(waitOption)); } + /// Verifies that the flow-debug command records resolve in the management command registry. [Fact] public void FlowDebugCommandRecords_ResolveInTheManagementCommandRegistry() { @@ -133,6 +144,7 @@ public class FlowDebugCommandsTests Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugReplayCommand)))); } + /// Verifies that parsing blank parameters yields null and that a JSON object is cloned. [Fact] public void ParseParameters_Blank_IsNull_AndObject_IsCloned() { @@ -145,6 +157,8 @@ public class FlowDebugCommandsTests Assert.Equal("Z28061", parsed.Value.GetProperty("code").GetString()); } + /// Verifies that parsing parameters rejects malformed or non-object input. + /// The raw parameter text to parse. [Theory] [InlineData("not json")] [InlineData("[1,2]")] @@ -185,6 +199,7 @@ public class FlowDebugCommandsTests return JsonSerializer.Serialize(result, CamelCase); } + /// Verifies that the projection emits one row per step with its kind, lane, target, duration and status. [Fact] public void Projection_EmitsOneRowPerStep_WithKindLaneTargetDurationStatus() { @@ -218,6 +233,7 @@ public class FlowDebugCommandsTests Assert.All(rows, r => Assert.False(r.TryGetProperty("note", out _))); } + /// Verifies that the projection of an incomplete run appends a note row carrying the error code verbatim. [Fact] public void Projection_IncompleteRun_AppendsANoteRowWithTheErrorCodeVerbatim() { @@ -231,6 +247,7 @@ public class FlowDebugCommandsTests Assert.Contains("standby", note); } + /// Verifies that the projection of a truncated run notes that the audit rows remain complete. [Fact] public void Projection_TruncatedRun_NotesThatTheAuditRowsRemainComplete() { @@ -242,10 +259,11 @@ public class FlowDebugCommandsTests Assert.Contains("audit tree", last.GetProperty("note").GetString()); } + /// Verifies that the projection of a run with truncated logs notes that a replay pulls the remainder. [Fact] public void Projection_LogsTruncatedRun_NotesThatReplayPullsTheRest() { - // Final review M8: `logsTruncated` is a SEPARATE signal from `truncated` (the inline + // `logsTruncated` is a SEPARATE signal from `truncated` (the inline // collector sheds low-priority ScriptLog rows before it sheds step rows). The page shows // it; the CLI used to drop it silently. var projected = FlowDebugTableProjection.ProjectEvents( @@ -257,6 +275,7 @@ public class FlowDebugCommandsTests Assert.Equal("inline log lines capped — replay pulls the rest", rows[^1].GetProperty("note").GetString()); } + /// Verifies that the projection reports both truncation flags together. [Fact] public void Projection_BothTruncationFlags_AreReportedTogether() { @@ -269,6 +288,7 @@ public class FlowDebugCommandsTests Assert.Contains("inline log lines capped — replay pulls the rest", note); } + /// Verifies that the projection leaves response bodies that do not match the expected shape untouched. [Fact] public void Projection_LeavesNonMatchingBodiesUntouched() { @@ -277,6 +297,7 @@ public class FlowDebugCommandsTests Assert.Equal("[1]", FlowDebugTableProjection.ProjectEvents("[1]")); } + /// Verifies that the projection folds an outbound call lifecycle into a single external-lane row. [Fact] public void Projection_FoldsAnOutboundLifecycle_IntoOneExternalLaneRow() { @@ -333,7 +354,7 @@ public class FlowDebugCommandsTests Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("correlation").ValueKind); } - /// Builds a run whose script step carries two ScriptLog rows and fails with an exception detail (step-logs plan 2026-09-09, Task 14). + /// Builds a run whose script step carries two ScriptLog rows and fails with an exception detail. private static string StepLogsPayload(out DateTime log1Time, out DateTime log2Time) { var root = Guid.NewGuid(); @@ -362,6 +383,7 @@ public class FlowDebugCommandsTests return JsonSerializer.Serialize(result, CamelCase); } + /// Verifies that the logs column counts the log lines attached to a step. [Fact] public void Projection_LogsColumn_CountsAttachedLogLines() { @@ -376,6 +398,7 @@ public class FlowDebugCommandsTests Assert.All(rows, r => Assert.False(r.TryGetProperty("note", out _))); } + /// Verifies that showing logs prints indented log lines and exception detail beneath the step row. [Fact] public void Projection_ShowLogsTrue_PrintsIndentedLogLinesAndExceptionDetail_UnderTheStepRow() { @@ -400,6 +423,7 @@ public class FlowDebugCommandsTests Assert.DoesNotContain("at Z", projected); } + /// Verifies that hiding logs omits the log and exception detail lines. [Fact] public void Projection_ShowLogsFalse_OmitsLogAndExceptionDetailLines() { @@ -412,10 +436,10 @@ public class FlowDebugCommandsTests /// /// Drives the real table renderer ('s table /// branch, which is what every flow-debug command actually prints to the terminal) - /// rather than only inspecting the intermediate JSON — a code review of Task 14 found the + /// rather than only inspecting the intermediate JSON — a code review found the /// generic column-union renderer padded the log/exception note rows into a far-right /// "note" column instead of printing them as raw indented lines under their step, which the - /// JSON-level assertions above could not have caught (Task 14 review, 2026-09-09). + /// JSON-level assertions above could not have caught. /// [Fact] public void HandleResponse_TableFormat_ShowLogsTrue_PrintsLogLineRawUnderScriptRunRow_NoNoteHeader() diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FormatOptionValidationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FormatOptionValidationTests.cs index 6bcf0ba5..012037ea 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FormatOptionValidationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FormatOptionValidationTests.cs @@ -4,13 +4,15 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// -/// Regression tests for CLI-008 — the --format option previously accepted any +/// Regression tests for the --format option, which previously accepted any /// string, so a typo like --format tabel silently fell through to JSON output /// with no feedback. The option must reject values outside {json, table} with a parse /// error. /// public class FormatOptionValidationTests { + /// Verifies that the format option accepts each supported value. + /// The format value supplied to the option. [Theory] [InlineData("json")] [InlineData("table")] @@ -25,6 +27,8 @@ public class FormatOptionValidationTests Assert.Empty(result.Errors); } + /// Verifies that the format option rejects unsupported values. + /// The unsupported format value supplied to the option. [Theory] [InlineData("tabel")] [InlineData("xml")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FormatResolutionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FormatResolutionTests.cs index 4063c919..2417bb6d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FormatResolutionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FormatResolutionTests.cs @@ -14,6 +14,7 @@ public class FormatResolutionTests return (formatOption, root); } + /// Verifies that an explicit format flag overrides the configured default format. [Fact] public void ResolveFormat_ExplicitFlag_OverridesConfig() { @@ -26,10 +27,11 @@ public class FormatResolutionTests Assert.Equal("table", format); } + /// Verifies that the configured default format is used when the format flag is absent. [Fact] public void ResolveFormat_FlagAbsent_UsesConfigDefaultFormat() { - // Regression for CLI-001: when --format is not supplied, the config-file / + // Regression: when --format is not supplied, the config-file / // env-var DefaultFormat must be honoured instead of always falling back to "json". var (formatOption, root) = BuildHarness(); var result = root.Parse(Array.Empty()); @@ -40,6 +42,7 @@ public class FormatResolutionTests Assert.Equal("table", format); } + /// Verifies that the format defaults to JSON when the flag and the configuration are both absent. [Fact] public void ResolveFormat_FlagAbsent_AndNoConfig_DefaultsToJson() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportNativeAlarmSourceOverridesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportNativeAlarmSourceOverridesTests.cs index 4213cc25..3994abaa 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportNativeAlarmSourceOverridesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportNativeAlarmSourceOverridesTests.cs @@ -13,6 +13,7 @@ public class ImportNativeAlarmSourceOverridesTests { // ── error path ─────────────────────────────────────────────────────────── + /// Verifies that CSV input without a header row produces errors and no rows. [Fact] public void CsvWithMissingHeader_ProducesErrors() { @@ -22,6 +23,7 @@ public class ImportNativeAlarmSourceOverridesTests Assert.Empty(result.Rows); } + /// Verifies that a CSV row with a blank source name produces an error and no rows. [Fact] public void CsvWithBlankSourceName_ProducesError() { @@ -34,6 +36,7 @@ public class ImportNativeAlarmSourceOverridesTests // ── happy path ─────────────────────────────────────────────────────────── + /// Verifies that valid CSV input produces the expected list of override entries. [Fact] public void ValidCsv_ProducesCorrectEntryList() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportOverridesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportOverridesTests.cs index 5f939db7..77c8932e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportOverridesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportOverridesTests.cs @@ -12,6 +12,7 @@ public class ImportOverridesTests { // ── error path ─────────────────────────────────────────────────────────── + /// Verifies that a CSV with a missing header produces errors. [Fact] public void CsvWithMissingHeader_ProducesErrors() { @@ -21,6 +22,7 @@ public class ImportOverridesTests Assert.Empty(result.Rows); } + /// Verifies that a CSV row with a blank attribute name produces an error. [Fact] public void CsvWithBlankAttributeName_ProducesError() { @@ -32,6 +34,7 @@ public class ImportOverridesTests Assert.Empty(result.Rows); } + /// Verifies that a CSV row with the wrong column count produces an error. [Fact] public void CsvWithWrongColumnCount_ProducesError() { @@ -44,6 +47,7 @@ public class ImportOverridesTests // ── happy path ─────────────────────────────────────────────────────────── + /// Verifies that a valid CSV produces the expected overrides dictionary. [Fact] public void ValidCsv_ProducesCorrectOverridesDictionary() { @@ -67,6 +71,7 @@ public class ImportOverridesTests Assert.Equal("Hello", overrides["Label"]); } + /// Verifies that the element type column of a valid CSV is ignored when building the overrides dictionary. [Fact] public void ValidCsvWithElementTypeColumn_OverridesDictIgnoresElementType() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/InstanceArgumentParsingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/InstanceArgumentParsingTests.cs index 36cab3be..2c6fdc0e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/InstanceArgumentParsingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/InstanceArgumentParsingTests.cs @@ -5,11 +5,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// -/// Regression tests for CLI-005 — malformed --bindings / --overrides JSON +/// Regression tests: malformed --bindings / --overrides JSON /// previously threw unhandled exceptions instead of producing a clean validation error. /// public class InstanceArgumentParsingTests { + /// Verifies that valid bindings JSON is parsed into attribute and connection id pairs. [Fact] public void ParseBindings_ValidJson_ReturnsPairs() { @@ -23,6 +24,7 @@ public class InstanceArgumentParsingTests Assert.Equal(new ConnectionBinding("Mode", 7), bindings[1]); } + /// Verifies that malformed bindings JSON yields an error message instead of throwing an exception. [Fact] public void ParseBindings_MalformedJson_ReturnsErrorNotException() { @@ -33,6 +35,7 @@ public class InstanceArgumentParsingTests Assert.NotNull(error); } + /// Verifies that a binding pair with too few elements yields an error instead of throwing. [Fact] public void ParseBindings_ShortPair_ReturnsErrorNotException() { @@ -44,6 +47,7 @@ public class InstanceArgumentParsingTests Assert.NotNull(error); } + /// Verifies that binding elements of the wrong JSON types yield an error instead of throwing. [Fact] public void ParseBindings_WrongElementTypes_ReturnsErrorNotException() { @@ -55,6 +59,7 @@ public class InstanceArgumentParsingTests Assert.NotNull(error); } + /// Verifies that a bindings document of JSON null yields an error instead of throwing. [Fact] public void ParseBindings_JsonNull_ReturnsErrorNotException() { @@ -65,11 +70,12 @@ public class InstanceArgumentParsingTests Assert.NotNull(error); } + /// Verifies that the optional third binding element is captured as a data source reference override. [Fact] public void ParseBindings_ThreeElementForm_CapturesReferenceOverride() { // The optional 3rd element carries the per-instance data-source reference - // override (followup #4) so the CLI can SET it instead of always wiping it. + // override so the CLI can SET it instead of always wiping it. var ok = InstanceCommands.TryParseBindings( "[[\"Speed\", 5, \"ns=2;s=Spd\"], [\"Mode\", 7]]", out var bindings, out var error); @@ -82,6 +88,7 @@ public class InstanceArgumentParsingTests Assert.Null(bindings[1].DataSourceReferenceOverride); } + /// Verifies that an explicit null third element leaves the reference override unset. [Fact] public void ParseBindings_ThirdElementExplicitNull_LeavesOverrideUnset() { @@ -94,6 +101,7 @@ public class InstanceArgumentParsingTests Assert.Null(bindings![0].DataSourceReferenceOverride); } + /// Verifies that a third binding element of the wrong type yields an error instead of throwing. [Fact] public void ParseBindings_ThirdElementWrongType_ReturnsErrorNotException() { @@ -106,6 +114,7 @@ public class InstanceArgumentParsingTests Assert.NotNull(error); } + /// Verifies that a binding with four elements yields an error instead of throwing. [Fact] public void ParseBindings_FourElements_ReturnsErrorNotException() { @@ -117,6 +126,7 @@ public class InstanceArgumentParsingTests Assert.NotNull(error); } + /// Verifies that valid overrides JSON is parsed into a dictionary. [Fact] public void ParseOverrides_ValidJson_ReturnsDictionary() { @@ -130,6 +140,7 @@ public class InstanceArgumentParsingTests Assert.Null(overrides["Mode"]); } + /// Verifies that malformed overrides JSON yields an error instead of throwing. [Fact] public void ParseOverrides_MalformedJson_ReturnsErrorNotException() { @@ -140,6 +151,7 @@ public class InstanceArgumentParsingTests Assert.NotNull(error); } + /// Verifies that an overrides document of JSON null yields an error instead of throwing. [Fact] public void ParseOverrides_JsonNull_ReturnsErrorNotException() { @@ -152,6 +164,7 @@ public class InstanceArgumentParsingTests // --- instance bind-module (grouped connection bindings, 2026-08-20) --- + /// Verifies that a module rewrite changes only the named module's bindings and applies the requested connection. [Fact] public void BuildModuleRewrite_RewritesOnlyTheModule_AndKeepsConnection() { @@ -169,6 +182,7 @@ public class InstanceArgumentParsingTests Assert.DoesNotContain(rewritten, b => b.AttributeName.StartsWith("Left")); } + /// Verifies that the instance-level group matches attribute names that contain no dot. [Fact] public void BuildModuleRewrite_InstanceGroup_MatchesDotlessNames() { @@ -178,6 +192,7 @@ public class InstanceArgumentParsingTests Assert.Equal("Z28063.ChlorineFlow", b.DataSourceReferenceOverride); } + /// Verifies that an empty object path clears the data source reference override. [Fact] public void BuildModuleRewrite_EmptyObject_ClearsOverride() { @@ -188,6 +203,7 @@ public class InstanceArgumentParsingTests Assert.Equal(2, b.DataConnectionId); } + /// Verifies that surrounding whitespace and a trailing dot on the object path are trimmed. [Fact] public void BuildModuleRewrite_TrailingDotOnObject_IsTrimmed() { @@ -196,6 +212,7 @@ public class InstanceArgumentParsingTests Assert.Equal("X.A", Assert.Single(r!).DataSourceReferenceOverride); } + /// Verifies that an unknown module name produces an error listing the available modules. [Fact] public void BuildModuleRewrite_UnknownModule_ListsAvailable() { @@ -207,6 +224,7 @@ public class InstanceArgumentParsingTests Assert.Contains("(instance)", error); } + /// Verifies that existing bindings are read from camelCase instance JSON. [Fact] public void ParseExistingBindings_ReadsCamelCaseInstanceJson() { @@ -224,6 +242,7 @@ public class InstanceArgumentParsingTests Assert.Equal(new ConnectionBinding("NoOverrideProp", 5), bindings[2]); } + /// Verifies that instance JSON without a bindings array yields an empty list. [Fact] public void ParseExistingBindings_MissingArray_ReturnsEmpty() { @@ -231,6 +250,7 @@ public class InstanceArgumentParsingTests Assert.Empty(InstanceCommands.ParseExistingBindings("""{"id":7,"connectionBindings":null}""")); } + /// Verifies that the bind-module command parses its options and rejects a missing object path. [Fact] public void BindModule_ParsesOptions() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementHttpClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementHttpClientTests.cs index 389d18d1..0ee2a8e0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementHttpClientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementHttpClientTests.cs @@ -7,8 +7,8 @@ using ZB.MOM.WW.ScadaBridge.CLI; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// -/// Regression tests for CLI-013 — -/// (success, error-body parsing, connection-failure, and timeout paths) was untested. +/// Regression tests for +/// (success, error-body parsing, connection-failure, and timeout paths), which were previously untested. /// Uses a stub so no live server is required. /// public class ManagementHttpClientTests @@ -17,6 +17,9 @@ public class ManagementHttpClientTests { private readonly Func> _responder; + /// Creates a handler that always answers with the given status code and JSON body. + /// The HTTP status code to return. + /// The JSON response body to return. public StubHandler(HttpStatusCode status, string body) : this((_, _) => Task.FromResult(new HttpResponseMessage(status) { @@ -25,11 +28,14 @@ public class ManagementHttpClientTests { } + /// Creates a handler that delegates every request to the supplied responder. + /// The delegate that produces the response for each request. public StubHandler(Func> responder) { _responder = responder; } + /// protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => _responder(request, cancellationToken); @@ -38,6 +44,8 @@ public class ManagementHttpClientTests private static ManagementHttpClient ClientWith(StubHandler handler) => new(new HttpClient(handler), "http://localhost:9001", "user", "pass"); + /// Verifies that a successful response is returned as a success result carrying the JSON data. + /// A task that represents the asynchronous operation. [Fact] public async Task SendCommandAsync_Success_ReturnsJsonData() { @@ -51,6 +59,8 @@ public class ManagementHttpClientTests Assert.Null(response.ErrorCode); } + /// Verifies that an error response body is parsed into the error message and error code. + /// A task that represents the asynchronous operation. [Fact] public async Task SendCommandAsync_ErrorBody_ParsesErrorAndCode() { @@ -65,6 +75,8 @@ public class ManagementHttpClientTests Assert.Equal("INVALID_ARGUMENT", response.ErrorCode); } + /// Verifies that a non-JSON error body falls back to the raw body text as the error. + /// A task that represents the asynchronous operation. [Fact] public async Task SendCommandAsync_NonJsonErrorBody_FallsBackToRawBody() { @@ -78,6 +90,8 @@ public class ManagementHttpClientTests Assert.Null(response.ErrorCode); } + /// Verifies that a connection failure returns a result with status code zero. + /// A task that represents the asynchronous operation. [Fact] public async Task SendCommandAsync_ConnectionFailure_ReturnsStatusZero() { @@ -91,6 +105,8 @@ public class ManagementHttpClientTests Assert.Contains("connection refused", response.Error); } + /// Verifies that a timed-out call returns a 504 status. + /// A task that represents the asynchronous operation. [Fact] public async Task SendCommandAsync_Timeout_Returns504() { @@ -125,6 +141,7 @@ public class ManagementHttpClientTimeoutTests { private const string EnvVar = "SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS"; + /// Verifies that the default constructor leaves the underlying client timeout infinite. [Fact] public void DefaultConstructor_LeavesClientTimeoutInfinite() { @@ -133,6 +150,7 @@ public class ManagementHttpClientTimeoutTests Assert.Equal(Timeout.InfiniteTimeSpan, client.EffectiveTimeout); } + /// Verifies that the connect timeout defaults to thirty seconds when the environment variable is unset. [Fact] public void ConnectTimeout_DefaultsToThirtySeconds_WhenEnvVarUnset() { @@ -150,6 +168,8 @@ public class ManagementHttpClientTimeoutTests } } + /// Verifies that an invalid or non-positive environment value falls back to the default connect timeout. + /// The environment variable value to test. [Theory] [InlineData("0")] [InlineData("-5")] @@ -170,6 +190,7 @@ public class ManagementHttpClientTimeoutTests } } + /// Verifies that a positive environment variable value overrides the default connect timeout. [Fact] public void PositiveEnvValue_OverridesDefaultConnectTimeout() { @@ -195,6 +216,7 @@ public class ManagementHttpClientTimeoutTests /// real socket (not the stub handler) so the connect + send path is exercised /// end to end, and sub-second deadlines so the test stays fast. /// + /// A task that represents the asynchronous operation. [Fact] public async Task PerCallTimeoutLongerThanTheOldClientCap_IsHonored() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementSurfaceParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementSurfaceParityTests.cs index c706123a..ab4756a0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementSurfaceParityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementSurfaceParityTests.cs @@ -18,6 +18,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; [Collection("Console")] public class ManagementSurfaceParityTests { + /// Verifies that every HTTP management contract is sent by the production CLI. + /// A task that represents the asynchronous operation. [Fact] public async Task EveryHttpManagementContract_IsSentByTheProductionCli() { @@ -32,6 +34,8 @@ public class ManagementSurfaceParityTests Assert.Empty(sent.Keys.Except(registered)); } + /// Verifies that removing a production schema group is detected by the parity probe. + /// A task that represents the asynchronous operation. [Fact] public async Task RemovingProductionSchemaGroup_IsDetectedByParityProbe() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/NotificationParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/NotificationParityTests.cs index 3c5a1526..86a132a2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/NotificationParityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/NotificationParityTests.cs @@ -22,6 +22,12 @@ public class NotificationParityTests return root; } + /// Verifies that each invocation sends the dedicated command contract with the expected payload property. + /// The command-line arguments to invoke. + /// The expected command type name. + /// The payload property to check. + /// The expected value of that property. + /// A task that represents the asynchronous operation. [Theory] [InlineData("rename --id 7 --name Operators", "RenameNotificationListCommand", "notificationListId", "7")] [InlineData("recipient add --list-id 7 --name Jane --email jane@example.com", "AddNotificationRecipientCommand", "name", "Jane")] @@ -50,6 +56,9 @@ public class NotificationParityTests } } + /// Verifies that explicitly supplied SMS policy values, including zero, are forwarded. + /// The sms subcommand verb under test. + /// A task that represents the asynchronous operation. [Theory] [InlineData("create --auth-token secret")] [InlineData("update --id 8")] @@ -62,6 +71,8 @@ public class NotificationParityTests Assert.Equal(JsonValueKind.Null, payload.GetProperty("fromNumber").ValueKind); } + /// Verifies that SMTP create forwards all optional fields. + /// A task that represents the asynchronous operation. [Fact] public async Task SmtpCreateForwardsAllOptionalFields() { @@ -72,6 +83,8 @@ public class NotificationParityTests Assert.Equal("Smtp", payload.GetProperty("transport").GetString()); } + /// Verifies that omitted SMTP create options are sent as null so the server applies its defaults. + /// A task that represents the asynchronous operation. [Fact] public async Task SmtpCreateOmittedOptionsUseServerDefaults() { @@ -80,6 +93,9 @@ public class NotificationParityTests Assert.Equal(JsonValueKind.Null, payload.GetProperty(name).ValueKind); } + /// Verifies that an omitted SMS policy and omitted secrets remain null in the payload. + /// The sms subcommand verb under test. + /// A task that represents the asynchronous operation. [Theory] [InlineData("create --auth-token secret")] [InlineData("update --id 8")] @@ -91,6 +107,8 @@ public class NotificationParityTests if (verb.StartsWith("update")) Assert.Equal(JsonValueKind.Null, payload.GetProperty("authToken").ValueKind); } + /// Verifies that invalid input fails argument parsing. + /// The invalid command-line arguments. [Theory] [InlineData("sms create --account-sid AC00000000000000000000000000000000 --from-number +15551234567")] [InlineData("sms update --id 8 --account-sid AC00000000000000000000000000000000 --from-number +15551234567 --max-retries -1")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/OutputFormatterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/OutputFormatterTests.cs index c3a09b8e..fa2ac20e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/OutputFormatterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/OutputFormatterTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; [Collection("Console")] public class OutputFormatterTests { + /// Verifies that JSON output is written with indentation. [Fact] public void WriteJson_WritesIndentedJson() { @@ -20,6 +21,7 @@ public class OutputFormatterTests Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); } + /// Verifies that an error message is written to standard error. [Fact] public void WriteError_WritesToStdErr() { @@ -35,6 +37,7 @@ public class OutputFormatterTests Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); } + /// Verifies that a table render includes the headers and all rows. [Fact] public void WriteTable_RendersHeadersAndRows() { @@ -61,6 +64,7 @@ public class OutputFormatterTests Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); } + /// Verifies that a table with no rows renders only its headers. [Fact] public void WriteTable_EmptyRows_ShowsHeadersOnly() { @@ -77,6 +81,7 @@ public class OutputFormatterTests Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); } + /// Verifies that table column widths adjust to the widest cell content. [Fact] public void WriteTable_ColumnWidthsAdjustToContent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ProductionRootTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ProductionRootTests.cs index 7144a871..7f0ce2c3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ProductionRootTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ProductionRootTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; public class ProductionRootTests { + /// Verifies that the production root command exposes the shared-schema command and the secured-write lifecycle subcommands. [Fact] public void ProductionRoot_ExposesSchemaLibraryAndSecuredWriteLifecycle() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ResponseRenderingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ResponseRenderingTests.cs index d6638a75..3434bc88 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ResponseRenderingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ResponseRenderingTests.cs @@ -4,16 +4,17 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// -/// Regression tests for CLI-002 (empty success body) and CLI-003 (non-JSON success +/// Regression tests for an empty success body and a non-JSON success /// body) — both previously crashed table rendering with an unhandled exception. /// [Collection("Console")] public class ResponseRenderingTests { + /// Verifies that an empty response body in table format does not throw and yields exit code zero. [Fact] public void HandleResponse_EmptyBody_TableFormat_DoesNotThrow_ReturnsZero() { - // CLI-002: a 200/204 with an empty body must be treated as "succeeded, no output". + // A 200/204 with an empty body must be treated as "succeeded, no output". var writer = new StringWriter(); Console.SetOut(writer); @@ -30,6 +31,7 @@ public class ResponseRenderingTests } } + /// Verifies that a whitespace-only response body in JSON format does not throw and yields exit code zero. [Fact] public void HandleResponse_EmptyBody_JsonFormat_DoesNotThrow_ReturnsZero() { @@ -49,10 +51,11 @@ public class ResponseRenderingTests } } + /// Verifies that a non-JSON success body in table format falls back to the raw body and yields exit code zero. [Fact] public void HandleResponse_NonJsonBody_TableFormat_FallsBackToRaw_ReturnsZero() { - // CLI-003: a success status with a non-JSON body (e.g. proxy HTML error page) + // A success status with a non-JSON body (e.g. proxy HTML error page) // must not crash; it should print the raw body verbatim. var writer = new StringWriter(); Console.SetOut(writer); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/SchemaSecuredParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/SchemaSecuredParityTests.cs index 76e5760b..071eb733 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/SchemaSecuredParityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/SchemaSecuredParityTests.cs @@ -62,6 +62,10 @@ public class SchemaSecuredParityTests return (exit, captured.Item1, captured.headers); } + /// Verifies that schema read and delete commands use the registered contract. + /// The verb. + /// The command. + /// A task that represents the asynchronous operation. [Theory] [InlineData("list", "ListSharedSchemas")] [InlineData("get", "GetSharedSchema")] @@ -75,6 +79,11 @@ public class SchemaSecuredParityTests if (verb != "list") Assert.Equal(42, result.Envelope.GetProperty("payload").GetProperty("sharedSchemaId").GetInt32()); } + /// Verifies that schema write commands read the JSON file and preserve the contract. + /// The verb. + /// The command. + /// The include scope. + /// A task that represents the asynchronous operation. [Theory] [InlineData("create", "CreateSharedSchema", true)] [InlineData("update", "UpdateSharedSchema", true)] @@ -101,6 +110,8 @@ public class SchemaSecuredParityTests finally { File.Delete(path); } } + /// Verifies that a secured-write submit sends the value as JSON and leaves identity to the server. + /// A task that represents the asynchronous operation. [Fact] public async Task Submit_UsesValueJsonAndServerOwnedIdentity() { @@ -117,6 +128,10 @@ public class SchemaSecuredParityTests Assert.False(payload.TryGetProperty("operatorUser", out _)); } + /// Verifies that approve and reject decisions preserve the 64-bit identity and the comment. + /// The verb. + /// The command. + /// A task that represents the asynchronous operation. [Theory] [InlineData("approve", "ApproveSecuredWrite")] [InlineData("reject", "RejectSecuredWrite")] @@ -132,6 +147,12 @@ public class SchemaSecuredParityTests Assert.False(payload.TryGetProperty("verifierUser", out _)); } + /// Verifies that server denials are returned without any local approval being performed. + /// The verb. + /// The status. + /// The code. + /// The expected. + /// A task that represents the asynchronous operation. [Theory] [InlineData("submit", 403, "Forbidden", 2)] [InlineData("approve", 403, "Forbidden", 2)] @@ -145,6 +166,8 @@ public class SchemaSecuredParityTests Assert.Equal(expected, result.Exit); } + /// Verifies that the secured-write list preserves its filters and pagination. + /// A task that represents the asynchronous operation. [Fact] public async Task SecuredList_PreservesFiltersAndPagination() { @@ -158,6 +181,10 @@ public class SchemaSecuredParityTests Assert.Equal(25, payload.GetProperty("take").GetInt32()); } + /// Verifies that schema server errors map to the documented exit codes. + /// The status. + /// The expected. + /// A task that represents the asynchronous operation. [Theory] [InlineData(403, 2)] [InlineData(404, 1)] @@ -168,6 +195,8 @@ public class SchemaSecuredParityTests Assert.Equal(expected, result.Exit); } + /// Verifies that a malformed submit value returns a JSON error before any HTTP call is made. + /// A task that represents the asynchronous operation. [Fact] public async Task MalformedSubmitValue_ReturnsJsonErrorBeforeHttp() { @@ -183,6 +212,11 @@ public class SchemaSecuredParityTests finally { Console.SetError(original); } } + /// Verifies that the approve exit code is derived from the server execution outcome. + /// The status. + /// The expected. + /// The format. + /// A task that represents the asynchronous operation. [Theory] [InlineData("Executed", 0, "json")] [InlineData("Failed", 1, "json")] @@ -204,6 +238,9 @@ public class SchemaSecuredParityTests finally { Console.SetOut(original); } } + /// Verifies that an invalid server outcome in an approve response is reported as an error. + /// The response. + /// A task that represents the asynchronous operation. [Theory] [InlineData("{}")] [InlineData("{\"status\":\"Approved\"}")] @@ -216,6 +253,9 @@ public class SchemaSecuredParityTests Assert.Equal(1, result.Exit); } + /// Verifies that the parser rejects commands that omit required arguments. + /// The group. + /// The verb. [Theory] [InlineData("shared-schema", "update")] [InlineData("secured-write", "submit")] @@ -223,6 +263,9 @@ public class SchemaSecuredParityTests public void RequiredArguments_AreRejectedByParser(string group, string verb) => Assert.NotEmpty(BuildRoot().Parse([group, verb]).Errors); + /// Verifies that failures reading the schema file return an error before any HTTP call is made. + /// The malformed. + /// A task that represents the asynchronous operation. [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/TableHeaderUnionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/TableHeaderUnionTests.cs index fad445c1..599392d5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/TableHeaderUnionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/TableHeaderUnionTests.cs @@ -4,13 +4,14 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// -/// Regression tests for CLI-016 — WriteAsTable previously derived the table +/// Regression tests for the table header union: WriteAsTable previously derived the table /// header set from the first array element only, so any property unique to a later /// element was silently dropped from the rendered table. /// [Collection("Console")] public class TableHeaderUnionTests { + /// Verifies that a table rendered from a heterogeneous array includes all columns. [Fact] public void HandleResponse_TableFormat_HeterogeneousArray_IncludesAllColumns() { @@ -37,6 +38,7 @@ public class TableHeaderUnionTests } } + /// Verifies that a table rendered from a heterogeneous array preserves the first-seen column order. [Fact] public void HandleResponse_TableFormat_HeterogeneousArray_PreservesFirstSeenColumnOrder() { @@ -63,6 +65,7 @@ public class TableHeaderUnionTests } } + /// Verifies that a table still renders all rows when the first element has an extra column. [Fact] public void HandleResponse_TableFormat_FirstElementHasExtraColumn_StillRendersAllRows() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/TemplateTableProjectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/TemplateTableProjectionTests.cs index 7a18e19d..f44571ad 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/TemplateTableProjectionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/TemplateTableProjectionTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// -/// Tests for the compact template list/get table projection (followup #6): +/// Tests for the compact template list/get table projection: /// the full per-template attribute/alarm/script dumps are collapsed to counts so table /// output is usable in a terminal, while the array/object shape is preserved. /// @@ -48,6 +48,7 @@ public class TemplateTableProjectionTests ] """; + /// Verifies that projecting an array drops the per-member arrays and keeps their counts. [Fact] public void ProjectSummary_Array_DropsMemberArraysAndKeepsCounts() { @@ -82,6 +83,7 @@ public class TemplateTableProjectionTests Assert.Equal(JsonValueKind.Null, second.GetProperty("description").ValueKind); } + /// Verifies that projecting a single object produces a compact object. [Fact] public void ProjectSummary_SingleObject_ProducesCompactObject() { @@ -167,6 +169,7 @@ public class TemplateTableProjectionTests Assert.Equal(42, doc.RootElement.GetProperty("#attrs").GetInt32()); } + /// Verifies that non-JSON input is returned verbatim. [Fact] public void ProjectSummary_NonJson_ReturnedVerbatim() { @@ -174,6 +177,7 @@ public class TemplateTableProjectionTests Assert.Equal(notJson, TemplateTableProjection.ProjectSummary(notJson)); } + /// Verifies that the summary projection is substantially smaller than the full dump. [Fact] public void ProjectSummary_IsSubstantiallySmallerThanFullDump() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/UpdateCommandContractTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/UpdateCommandContractTests.cs index 680a66bd..aade40e8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/UpdateCommandContractTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/UpdateCommandContractTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// -/// Regression tests for CLI-014. The Update*Command records in Commons carry +/// Regression tests for the update commands. The Update*Command records in Commons carry /// non-nullable "core" fields (e.g. string Name, string Protocol, /// string Script) — an update is a whole-entity replace, not a sparse /// patch. The CLI must therefore mark those core flags as Required: making them @@ -37,34 +37,42 @@ public class UpdateCommandContractTests } } + /// Verifies that the template update command marks its core fields as required. [Fact] public void TemplateUpdate_CoreFieldsRequired() => AssertRequired(UpdateCommand(TemplateCommands.Build(Url, Format, Username, Password), "update"), "--name"); + /// Verifies that the template attribute update command marks its core fields as required. [Fact] public void TemplateAttributeUpdate_CoreFieldsRequired() => AssertRequired(UpdateCommand(TemplateCommands.Build(Url, Format, Username, Password), "attribute", "update"), "--name", "--data-type"); + /// Verifies that the template alarm update command marks its core fields as required. [Fact] public void TemplateAlarmUpdate_CoreFieldsRequired() => AssertRequired(UpdateCommand(TemplateCommands.Build(Url, Format, Username, Password), "alarm", "update"), "--name", "--trigger-type", "--priority"); + /// Verifies that the template script update command marks its core fields as required. [Fact] public void TemplateScriptUpdate_CoreFieldsRequired() => AssertRequired(UpdateCommand(TemplateCommands.Build(Url, Format, Username, Password), "script", "update"), "--name", "--code"); + /// Verifies that the site update command marks its core fields as required. [Fact] public void SiteUpdate_CoreFieldsRequired() => AssertRequired(UpdateCommand(SiteCommands.Build(Url, Format, Username, Password), "update"), "--name"); + /// Verifies that the data connection update command marks its core fields as required. [Fact] public void DataConnectionUpdate_CoreFieldsRequired() => AssertRequired(UpdateCommand(DataConnectionCommands.Build(Url, Format, Username, Password), "update"), "--name", "--protocol"); + /// Verifies that the external system update command marks its core fields as required. [Fact] public void ExternalSystemUpdate_CoreFieldsRequired() => AssertRequired(UpdateCommand(ExternalSystemCommands.Build(Url, Format, Username, Password), "update"), "--name", "--endpoint-url", "--auth-type"); + /// Verifies that the notification update command requires only the name and leaves the recipient options conditionally validated. [Fact] public void NotificationUpdate_CoreFieldsRequired() { @@ -83,6 +91,7 @@ public class UpdateCommandContractTests } } + /// Verifies that the SMS update command requires only the id and account SID and leaves the sender and token options optional. [Fact] public void SmsUpdate_CoreFieldsRequired() { @@ -101,10 +110,12 @@ public class UpdateCommandContractTests } } + /// Verifies that the API method update command marks its script as required. [Fact] public void ApiMethodUpdate_CoreFieldsRequired() => AssertRequired(UpdateCommand(ApiMethodCommands.Build(Url, Format, Username, Password), "update"), "--script"); + /// Verifies that the external system method update command keeps all of its fields optional because it is a sparse update. [Fact] public void ExternalSystemMethodUpdate_IsGenuinelySparse_CoreFieldsOptional() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/UrlValidationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/UrlValidationTests.cs index 7538d1bb..18475034 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/UrlValidationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/UrlValidationTests.cs @@ -3,12 +3,14 @@ using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// -/// Regression tests for CLI-004 — a malformed --url previously reached +/// Regression tests for URL validation: a malformed --url previously reached /// new Uri(...) in the constructor /// and threw an unhandled . /// public class UrlValidationTests { + /// Verifies that absolute HTTP and HTTPS URLs are accepted as management URLs. + /// The candidate management URL. [Theory] [InlineData("http://localhost:9001")] [InlineData("https://central-host:5000/")] @@ -18,6 +20,8 @@ public class UrlValidationTests Assert.True(CommandHelpers.IsValidManagementUrl(url)); } + /// Verifies that malformed or non-HTTP URLs are rejected as management URLs. + /// The candidate management URL. [Theory] [InlineData("localhost:9001")] // no scheme [InlineData("")] // empty diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs index bb63e144..3934a0bb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs @@ -32,6 +32,9 @@ public sealed class ApiKeyCrudTests : IClassFixture private readonly PlaywrightFixture _fixture; private readonly ApiSurfaceFixture _api; + /// Creates the test class with the shared Playwright and API surface fixtures. + /// The shared Playwright fixture. + /// The API surface fixture. public ApiKeyCrudTests(PlaywrightFixture fixture, ApiSurfaceFixture api) { _fixture = fixture; @@ -44,6 +47,7 @@ public sealed class ApiKeyCrudTests : IClassFixture /// adjacent "Copy" button. MUTATES — the created key is deleted via the CLI in a /// finally (resolve by name, best-effort delete) so nothing leaks. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Create_RevealsOneTimeToken() { @@ -104,6 +108,7 @@ public sealed class ApiKeyCrudTests : IClassFixture /// literal "Name is required." message in div.text-danger; no token panel /// appears, so nothing is created and there is no teardown. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Create_EmptyName_ShowsValidationError() { @@ -136,6 +141,7 @@ public sealed class ApiKeyCrudTests : IClassFixture /// div.text-danger; no token panel appears, so nothing is created and there is no /// teardown. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Create_NoMethods_ShowsValidationError() { @@ -193,6 +199,7 @@ public sealed class ApiKeyCrudTests : IClassFixture /// cell: it must appear after Disable and be gone after Enable. The CLI-created key is deleted /// in a finally (best-effort) so nothing leaks. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ToggleEnabled_TransitionsDisabledBadge() { @@ -251,6 +258,7 @@ public sealed class ApiKeyCrudTests : IClassFixture /// gone. The CLI delete in the finally is an idempotent safety net (no-op if the UI already /// removed it). /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Delete_WithConfirm_RemovesRow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiSurfaceFixture.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiSurfaceFixture.cs index 813bfb0b..af7b2107 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiSurfaceFixture.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiSurfaceFixture.cs @@ -9,10 +9,15 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Admin; /// public sealed class ApiSurfaceFixture : IAsyncLifetime { + /// The id of the provisioned inbound API method. public int MethodId { get; private set; } + /// The unique name of the provisioned inbound API method. public string MethodName { get; private set; } = string.Empty; + /// Whether the cluster was reachable and the method was provisioned. public bool Available { get; private set; } + /// Prepares the test class before the tests run. + /// A task that represents the asynchronous operation. public async Task InitializeAsync() { Available = await ClusterAvailability.IsAvailableAsync(); @@ -39,6 +44,8 @@ public sealed class ApiSurfaceFixture : IAsyncLifetime } } + /// Releases the resources held by this test class asynchronously. + /// A task that represents the asynchronous operation. public async Task DisposeAsync() { if (Available) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs index 36061d94..d382d672 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs @@ -13,11 +13,15 @@ public class LdapMappingCrudTests { private readonly PlaywrightFixture _fixture; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture providing browser and cluster access. public LdapMappingCrudTests(PlaywrightFixture fixture) { _fixture = fixture; } + /// Verifies that an LDAP group mapping can be created, edited and deleted through the UI. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateEditDelete_LdapMapping_RoundTrips() { @@ -38,10 +42,10 @@ public class LdapMappingCrudTests // The LDAP Group Name label has no `for=` attribute so GetByLabel does not // work. Locate the input that immediately follows the label text instead. await page.Locator("label:has-text('LDAP Group Name') + input.form-control.form-control-sm").FillAsync(group); - // Scope the role select to the div.mb-2 that owns the "Role" label so a + // Scope the role select to the container div that owns the "Role" label so a // second select (Site Scope) on the edit page cannot cause a strict-mode // violation. The LdapMappingForm.razor uses Blazor @bind, not
, - // so the "Role" label's parent div.mb-2 is the tightest stable scope. + // so the "Role" label's parent container div is the tightest stable scope. await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm").SelectOptionAsync("Designer"); await page.ClickAsync("button.btn.btn-success:has-text('Save')"); @@ -127,12 +131,13 @@ public class LdapMappingCrudTests /// /// The create form's SaveMapping() validates manually (no EditForm / /// DataAnnotations): a blank LDAP Group Name renders "LDAP Group Name is - /// required." in div.text-danger.small.mt-2 and returns early; a present + /// required." in div.text-danger.small and returns early; a present /// group but blank role renders "Role is required." Both branches return before /// any persistence, so nothing is created and no teardown is needed. The first /// Save is a full circuit roundtrip — proving the Blazor circuit is live — which /// makes the second-leg fill (group → blank role) safe from the prerender race. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Save_MissingGroupName_ShowsRequiredError() { @@ -176,10 +181,11 @@ public class LdapMappingCrudTests /// Attempting to create a mapping whose LDAP group already exists violates the DB /// unique index on LdapGroupName. The form has no friendly pre-check, so /// the persistence exception is caught and surfaced as "Save failed: {message}" - /// in div.text-danger.small.mt-2, keeping the user on the create page. + /// in div.text-danger.small, keeping the user on the create page. /// The duplicate is CLI-seeded (and torn down) so this asserts the save-failure /// branch without depending on the raw DB error text. ///
+ /// A task that represents the asynchronous operation. [SkippableFact] public async Task Create_DuplicateGroup_ShowsSaveFailedError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditConfigurationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditConfigurationTests.cs index 38c4e435..cba2f40b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditConfigurationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditConfigurationTests.cs @@ -16,8 +16,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit; /// rows in finally. /// /// -/// This part (Wave 3, Task 10) covers render / search-narrows / pagination. The -/// class is intentionally structured so Task 11 can APPEND modal/copy/chip facts +/// This part covers render / search-narrows / pagination. The +/// class is intentionally structured so modal/copy/chip facts can be APPENDED /// to it without restructuring: shared constants (the DB-unavailable skip reason, /// the page route) and the seeded-test gate idiom live here once. /// @@ -45,6 +45,8 @@ public class AuditConfigurationTests private readonly PlaywrightFixture _fixture; + /// Initializes the tests with the shared Playwright fixture. + /// The Playwright fixture that supplies authenticated browser pages. public AuditConfigurationTests(PlaywrightFixture fixture) { _fixture = fixture; @@ -58,6 +60,8 @@ public class AuditConfigurationTests ///
private static string NewMarker() => "zzCfgAudit-" + Guid.NewGuid().ToString("N")[..8]; + /// Verifies that the grid renders and that searching narrows the displayed rows. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Grid_RendersAndSearchNarrows() { @@ -102,6 +106,8 @@ public class AuditConfigurationTests } } + /// Verifies that Previous is disabled on page 1 and that Next advances to the following page. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Pagination_PrevDisabledOnPage1_NextPaginates() { @@ -122,7 +128,7 @@ public class AuditConfigurationTests await page.Locator("button.btn.btn-primary:has-text('Search')").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); - // Page 1 of 2: Previous disabled, Next enabled. (M10 OffsetPager summary: + // Page 1 of 2: Previous disabled, Next enabled. (OffsetPager summary: // "Page {N} of {Y} · {Total} total".) await Assertions.Expect(page.Locator("[data-test='pager-summary']")).ToContainTextAsync("Page 1 of 2"); await Assertions.Expect(page.Locator("[data-test='pager-summary']")).ToContainTextAsync("55 total"); @@ -143,6 +149,8 @@ public class AuditConfigurationTests } } + /// Verifies that a large state value opens a modal that can then be closed. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task LargeState_OpensAndClosesModal() { @@ -185,6 +193,8 @@ public class AuditConfigurationTests } } + /// Verifies that copying an entity ID shows a toast. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CopyEntityId_ShowsToast() { @@ -221,6 +231,8 @@ public class AuditConfigurationTests } } + /// Verifies that the bundle import ID drill-in applies a filter chip that narrows the grid and can be cleared. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task BundleImportId_DrillIn_ChipFiltersAndClears() { @@ -231,7 +243,7 @@ public class AuditConfigurationTests try { // 1 bulk row (EntityName "{marker}-0", BundleImportId NULL) + 2 bundle - // rows (EntityName "{marker}-bundle-0/1", BundleImportId = bundleId), all + // rows (EntityName "{marker}-bundle-" plus an index, BundleImportId = bundleId), all // EntityType = marker so the finally cleanup removes every row. await ConfigAuditDataSeeder.SeedAsync(marker, bundleId, bulkCount: 1, bundleRows: 2); @@ -245,9 +257,9 @@ public class AuditConfigurationTests // The filter chip is shown for the active bundle import. await Assertions.Expect(page.Locator("span.badge.bg-primary:has-text('Filtered by Bundle Import:')")).ToBeVisibleAsync(); - // Exactly the 2 bundle rows show (EntityName "{marker}-bundle-0/1"). The + // Exactly the 2 bundle rows show (EntityName "{marker}-bundle-" plus an index). The // bulk row (EntityName "{marker}-0", BundleImportId NULL) is filtered out. - // The "{marker}-0" token is NOT a substring of "{marker}-bundle-0", so the + // The "{marker}-0" token is NOT a substring of the "{marker}-bundle-" names, so the // two locators distinguish the bundle rows from the bulk row unambiguously. await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = $"{marker}-bundle-" })) .ToHaveCountAsync(2, new() { Timeout = 10_000 }); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs index 642d1cbf..56cca6c3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit; /// -/// Direct-SQL seeding helper for the Audit Log Playwright E2E tests (#23 M7-T16). +/// Direct-SQL seeding helper for the Audit Log Playwright E2E tests. /// /// /// The Playwright suite runs against the live Docker cluster (the same one that @@ -50,6 +50,25 @@ internal static class AuditDataSeeder /// derive automatically. All optional fields are nullable so individual tests can shape /// the row to whatever payload they need for their drawer/grid assertions. /// + /// The unique event identifier. + /// When the event occurred, in UTC. + /// The audit channel. + /// The audit kind. + /// The audit status. + /// The originating site identifier, if any. + /// The call target, if any. + /// The acting principal, if any. + /// The per-operation correlation identifier, if any. + /// The per-run execution identifier, if any. + /// The spawning execution identifier, if any. + /// The HTTP status code, if any. + /// The duration in milliseconds, if any. + /// The error message, if any. + /// The request summary, if any. + /// The response summary, if any. + /// Additional JSON details, if any. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. public static async Task InsertAuditEventAsync( Guid eventId, DateTime occurredAtUtc, @@ -135,6 +154,9 @@ VALUES /// stuck row carrying a random GUID suffix does not collide with future /// runs and tests should not fail teardown. ///
+ /// The target prefix identifying the rows to delete. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. public static async Task DeleteByTargetPrefixAsync(string targetPrefix, CancellationToken ct = default) { try @@ -158,6 +180,8 @@ VALUES /// per-test setup on this; when the cluster is down the test fails with a /// clear "MSSQL unavailable" message instead of an opaque SqlException. /// + /// A token to cancel the operation. + /// true if the database accepted a connection; otherwise false. public static async Task IsAvailableAsync(CancellationToken ct = default) { try diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs index 079d0cb4..e8c49ae5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs @@ -4,8 +4,8 @@ using Xunit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit; /// -/// End-to-end coverage for the Audit Log results-grid column UX (#23 -/// follow-ups Task 10): drag-to-resize and drag-to-reorder columns, with the +/// End-to-end coverage for the Audit Log results-grid column UX (Audit Log +/// follow-ups): drag-to-resize and drag-to-reorder columns, with the /// chosen widths + order persisted in the browser's sessionStorage. /// /// @@ -34,6 +34,8 @@ public class AuditGridColumnTests private readonly PlaywrightFixture _fixture; + /// Initializes the tests with the shared Playwright fixture. + /// The shared Playwright fixture. public AuditGridColumnTests(PlaywrightFixture fixture) { _fixture = fixture; @@ -113,6 +115,8 @@ public class AuditGridColumnTests expectedFirstKey); } + /// Verifies that dragging a resize handle widens the column and the width survives a reload. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ResizeHandle_DraggingWidensColumn_AndSurvivesReload() { @@ -171,6 +175,8 @@ public class AuditGridColumnTests } } + /// Verifies that dragging a column header reorders the columns and the order survives a reload. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ReorderDrag_MovesColumn_AndSurvivesReload() { @@ -220,6 +226,8 @@ public class AuditGridColumnTests } } + /// Verifies that column order and widths persist across a reload via session storage. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ColumnOrderAndWidths_PersistAcrossReload_ViaSessionStorage() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs index 644f655e..f98041bf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit; /// -/// End-to-end coverage for the central Audit Log surface (#23 M7-T16 / Bundle H). +/// End-to-end coverage for the central Audit Log surface (Bundle H). /// /// /// Each test seeds its own AuditLog rows directly into the running cluster's @@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit; /// /// /// -/// Scenarios covered (per the M7-T16 brief): +/// Scenarios covered (per the brief): /// /// FilterNarrowing — the channel filter narrows the results grid. /// DrilldownDrawer_JsonPrettyPrint — JSON request bodies pretty-print. @@ -50,11 +50,15 @@ public class AuditLogPageTests { private readonly PlaywrightFixture _fixture; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture. public AuditLogPageTests(PlaywrightFixture fixture) { _fixture = fixture; } + /// Verifies that applying a channel filter shrinks the results grid. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task FilterNarrowing_ChannelFilterShrinksGrid() { @@ -112,6 +116,8 @@ public class AuditLogPageTests } } + /// Verifies that combining a channel filter with a target filter narrows the grid to the matching row. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task FilterCombination_ChannelPlusTarget_NarrowsToMatch() { @@ -172,6 +178,8 @@ public class AuditLogPageTests } } + /// Verifies that applying a filter with no matches shows the empty state. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EmptyResults_AfterApply_ShowsEmptyState() { @@ -195,6 +203,8 @@ public class AuditLogPageTests .ToContainTextAsync("No audit events match the current filter."); } + /// Verifies that the drilldown drawer pretty-prints a JSON request body. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DrilldownDrawer_JsonPrettyPrintsRequestBody() { @@ -257,6 +267,8 @@ public class AuditLogPageTests } } + /// Verifies that the copy-as-curl button is visible and clickable for an inbound API row. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CopyAsCurlButton_IsVisibleAndClickableForApiInbound() { @@ -309,6 +321,8 @@ public class AuditLogPageTests } } + /// Verifies that drilling in from a correlation id lands on the Audit Log with the filter applied. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DrillInFromCorrelationId_LandsOnAuditLogWithFilterContext() { @@ -366,6 +380,8 @@ public class AuditLogPageTests } } + /// Verifies that drilling in from an execution id lands on the Audit Log with the filter applied. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DrillInFromExecutionId_LandsOnAuditLogWithFilterContext() { @@ -421,6 +437,8 @@ public class AuditLogPageTests } } + /// Verifies that drilling in from a parent execution filters the grid to the spawner execution. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DrillInFromParentExecution_FiltersGridToSpawnerExecution() { @@ -498,12 +516,14 @@ public class AuditLogPageTests } } + /// Verifies that drilling into the execution chain renders the tree and that clicking a node filters the grid. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DrillInToExecutionChain_RendersTree_AndNodeClickFiltersGrid() { Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); - // Audit Log ParentExecutionId feature, Task 10: the drawer's "View + // Audit Log ParentExecutionId feature: the drawer's "View // execution chain" action opens /audit/execution-tree?executionId={id}. // We seed a spawner row + a child row, open the child's drawer, click // "View execution chain", assert the tree renders BOTH executions, then @@ -577,12 +597,14 @@ public class AuditLogPageTests } } + /// Verifies that double-clicking a tree node opens the execution row modal. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DoubleClickTreeNode_OpensExecutionRowModal() { Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); - // Execution-Tree Node Detail Modal feature, Task 5: double-clicking a + // Execution-Tree Node Detail Modal feature: double-clicking a // node on the /audit/execution-tree page opens ExecutionDetailModal — // a modal listing that execution's audit rows, with click-through to // each row's full view. We seed ONE execution with @@ -678,6 +700,8 @@ public class AuditLogPageTests } } + /// Verifies that the Notifications page renders the audit drill-in link pattern. + /// A task that represents the asynchronous operation. [Fact] public async Task NotificationsPage_RendersAuditDrillInLinkPattern() { @@ -712,6 +736,8 @@ public class AuditLogPageTests // all we can assert without exercising the Akka query path to seed one. } + /// Verifies that the CSV export link is visible to a user with audit export permission and triggers a download. + /// A task that represents the asynchronous operation. [Fact] public async Task ExportCsv_LinkVisibleForAuditExportUserAndTriggersDownload() { @@ -742,6 +768,8 @@ public class AuditLogPageTests Assert.EndsWith(".csv", download.SuggestedFilename); } + /// Verifies that a Designer without the operational audit permission is denied access. + /// A task that represents the asynchronous operation. [Fact] public async Task PermissionGating_DesignerWithoutOperationalAudit_IsDeniedAccess() { @@ -766,6 +794,8 @@ public class AuditLogPageTests Assert.Equal(0, await page.Locator("[data-test='audit-results-grid']").CountAsync()); } + /// Verifies that the drawer for a non-API row omits the copy-as-curl button. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task NonApiRow_Drawer_OmitsCopyAsCurl() { @@ -821,6 +851,8 @@ public class AuditLogPageTests } } + /// Verifies that the drawer's close controls dismiss the drawer. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Drawer_CloseControls_DismissTheDrawer() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/ConfigAuditDataSeeder.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/ConfigAuditDataSeeder.cs index c024f405..51d90df9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/ConfigAuditDataSeeder.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/ConfigAuditDataSeeder.cs @@ -50,6 +50,7 @@ VALUES (@user, @action, @entityType, @entityId, @entityName, @afterState, @ts, @ /// "MSSQL unavailable" message instead of an opaque SqlException. Mirrors /// . /// + /// A task whose result is true when the configuration database accepts a connection; otherwise false. public static async Task IsAvailableAsync() { try @@ -77,6 +78,7 @@ VALUES (@user, @action, @entityType, @entityId, @entityName, @afterState, @ts, @ /// Bundle import id stamped on the extra bundle rows. /// Number of bulk rows (default 55 — spans two pages at 50/page). /// Number of extra rows carrying the bundle import id (default 2). + /// A task that represents the asynchronous operation. public static async Task SeedAsync(string marker, Guid bundleId, int bulkCount = 55, int bundleRows = 2) { await using var connection = new SqlConnection(ConnectionString); @@ -126,6 +128,8 @@ VALUES (@user, @action, @entityType, @entityId, @entityName, @afterState, @ts, @ /// should not fail teardown. Mirrors /// . /// + /// The unique per-test marker whose rows are deleted. + /// A task that represents the asynchronous operation. public static async Task DeleteByMarkerAsync(string marker) { try diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs index 8de0c747..36cb813d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs @@ -35,6 +35,7 @@ public static partial class CliRunner /// teardown targets. /// /// Short entity discriminator, e.g. "tmpl" or "inst". + /// The generated unique fixture name. public static string UniqueName(string kind) => $"zztest-{kind}-{Guid.NewGuid().ToString("N")[..8]}"; @@ -78,6 +79,7 @@ public static partial class CliRunner /// where DataSourceReference is non-empty, so an attribute that needs /// to appear in the Connection Bindings panel MUST be created with this set. /// + /// A task that represents the asynchronous operation. /// The CLI failed. public static async Task AddAttributeAsync( int templateId, string name, string dataType = "Double", @@ -112,6 +114,17 @@ public static partial class CliRunner /// lets one helper provision both the fixture's HiLo alarm and a ValueMatch alarm /// (--match-value) without a parallel helper. ///
+ /// Owning template id. + /// Alarm name. + /// CLI alarm trigger-type token, e.g. HiLo or ValueMatch. + /// Alarm priority. + /// Name of the monitored attribute, or null to omit it. + /// High setpoint, or null to omit it. + /// High-high setpoint, or null to omit it. + /// Low setpoint, or null to omit it. + /// Low-low setpoint, or null to omit it. + /// Value to match for a ValueMatch trigger, or null to omit it. + /// The id of the newly created alarm. public static async Task AddAlarmAsync( int templateId, string name, string triggerType = "HiLo", int priority = 500, string? attribute = null, double? hi = null, double? hiHi = null, @@ -183,6 +196,7 @@ public static partial class CliRunner /// the id, but teardown re-resolves defensively in case the test failed mid-flow). /// /// The siteIdentifier prefix to match. + /// The ids of the matching sites. public static async Task> ListSiteIdsByIdentifierPrefixAsync(string identifierPrefix) { using var doc = await RunJsonAsync("site", "list"); @@ -268,16 +282,19 @@ public static partial class CliRunner /// Deploys an instance via instance deploy. Surfaces CLI errors. /// Instance id. + /// A task that represents the asynchronous operation. /// The CLI failed. public static Task DeployInstanceAsync(int id) => RunInstanceVerbAsync("deploy", id); /// Enables an instance via instance enable. Surfaces CLI errors. /// Instance id. + /// A task that represents the asynchronous operation. /// The CLI failed. public static Task EnableInstanceAsync(int id) => RunInstanceVerbAsync("enable", id); /// Disables an instance via instance disable. Surfaces CLI errors. /// Instance id. + /// A task that represents the asynchronous operation. /// The CLI failed. public static Task DisableInstanceAsync(int id) => RunInstanceVerbAsync("disable", id); @@ -286,6 +303,7 @@ public static partial class CliRunner /// swallows any failure (the entity may already be gone). /// /// Instance id. + /// A task that represents the asynchronous operation. public static Task DeleteInstanceAsync(int id) => BestEffortAsync("instance", "delete", id); /// @@ -293,6 +311,7 @@ public static partial class CliRunner /// swallows any failure. /// /// Template id. + /// A task that represents the asynchronous operation. public static Task DeleteTemplateAsync(int id) => BestEffortAsync("template", "delete", id); /// @@ -309,6 +328,7 @@ public static partial class CliRunner /// The inline try/catch is kept here deliberately — if you need to fix teardown /// logic, update both this method and any other three-word deletes together. /// + /// A task that represents the asynchronous operation. public static async Task DeleteAreaAsync(int id) { try @@ -374,11 +394,17 @@ public static partial class CliRunner /// any failure. /// /// Site id. + /// A task that represents the asynchronous operation. public static Task DeleteSiteAsync(int id) => BestEffortAsync("site", "delete", id); /// /// Creates a data connection on a site via data-connection create and returns its new id. /// + /// Owning site id. + /// Data connection name. + /// Protocol token, e.g. OpcUa. + /// Optional primary configuration JSON. + /// The id of the newly created data connection. public static async Task CreateDataConnectionAsync(int siteId, string name, string protocol = "OpcUa", string? primaryConfig = null) { var inv = System.Globalization.CultureInfo.InvariantCulture; @@ -400,12 +426,18 @@ public static partial class CliRunner } /// Best-effort delete of a data connection via data-connection delete for teardown. + /// Data connection id. + /// A task that represents the asynchronous operation. public static Task DeleteDataConnectionAsync(int id) => BestEffortAsync("data-connection", "delete", id); /// /// Creates an inbound API method via api-method create (so it appears as a checkbox in the /// API-key form) and returns its new id. /// + /// API method name. + /// Method script body. + /// Optional parameter definitions JSON. + /// The id of the newly created API method. public static async Task CreateApiMethodAsync(string name, string script = "return null;", string? parameters = null) { var args = new List { "api-method", "create", "--name", name, "--script", script }; @@ -420,6 +452,8 @@ public static partial class CliRunner } /// Best-effort delete of an API method via api-method delete for teardown. + /// API method id. + /// A task that represents the asynchronous operation. public static Task DeleteApiMethodAsync(int id) => BestEffortAsync("api-method", "delete", id); /// @@ -452,6 +486,8 @@ public static partial class CliRunner /// Resolves an API key's opaque string keyId from its display name via /// security api-key list; returns if no key matches. /// + /// The key display name to look up. + /// The key's opaque keyId, or if no key matches. public static async Task ResolveApiKeyIdByNameAsync(string name) { using var doc = await RunJsonAsync("security", "api-key", "list"); @@ -477,6 +513,8 @@ public static partial class CliRunner /// Best-effort delete of an API key via security api-key delete --key-id for teardown. /// The key id is an opaque string, so this cannot use the int-based . /// + /// The opaque API key id. + /// A task that represents the asynchronous operation. public static async Task DeleteApiKeyAsync(string keyId) { try @@ -499,6 +537,8 @@ public static partial class CliRunner /// scalars). The caller OWNS it and MUST wrap the call in using var doc = …; the /// Document suffix is the signal that this returns a disposable resource, not plain data. ///
+ /// Instance id. + /// A task whose result is the instance document; the caller owns it and must dispose it. public static Task GetInstanceDocumentAsync(int id) => RunJsonAsync("instance", "get", "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture)); @@ -507,6 +547,7 @@ public static partial class CliRunner /// , via template list. /// /// Name prefix to filter by (ordinal comparison). + /// The ids of the matching templates. public static async Task> ListTemplateIdsByNamePrefixAsync(string prefix) { using var doc = await RunJsonAsync("template", "list"); @@ -535,6 +576,7 @@ public static partial class CliRunner /// External system name (typically from ). /// Endpoint base URL (defaults to an unreachable placeholder). /// Auth type token; one of ApiKey or BasicAuth. + /// The id of the newly created external system. /// /// The CLI failed, or the response did not carry an integer id. /// @@ -552,6 +594,7 @@ public static partial class CliRunner /// /// Notification list name (typically from ). /// Comma-separated recipient emails. + /// The id of the newly created notification list. /// /// The CLI failed, or the response did not carry an integer id. /// @@ -566,6 +609,7 @@ public static partial class CliRunner /// /// Shared script name (typically from ). /// Script body. + /// The id of the newly created shared script. /// /// The CLI failed, or the response did not carry an integer id. /// @@ -586,6 +630,7 @@ public static partial class CliRunner /// Shared script id. /// Shared script name (unchanged; the CLI requires it). /// The new script body. + /// A task that represents the asynchronous operation. /// The CLI failed. public static async Task UpdateSharedScriptCodeAsync(int id, string name, string code) { @@ -601,6 +646,7 @@ public static partial class CliRunner /// /// LDAP group name to map (typically from ). /// Role to grant members of the group; defaults to Designer. + /// The id of the newly created role mapping. /// /// The CLI failed, or the response did not carry an integer id. /// @@ -618,6 +664,7 @@ public static partial class CliRunner /// external system a test created through the UI (where the new id is never surfaced). /// /// Name prefix to filter by (ordinal comparison). + /// The ids of the matching external systems. public static async Task> ListExternalSystemIdsByNamePrefixAsync(string prefix) { using var doc = await RunJsonAsync("external-system", "list"); @@ -630,6 +677,7 @@ public static partial class CliRunner /// notification list a test created through the UI (where the new id is never surfaced). /// /// Name prefix to filter by (ordinal comparison). + /// The ids of the matching notification lists. public static async Task> ListNotificationListIdsByNamePrefixAsync(string prefix) { using var doc = await RunJsonAsync("notification", "list"); @@ -637,12 +685,18 @@ public static partial class CliRunner } /// Best-effort delete of an external system via external-system delete for teardown. + /// External system id. + /// A task that represents the asynchronous operation. public static Task DeleteExternalSystemAsync(int id) => BestEffortAsync("external-system", "delete", id); /// Best-effort delete of a notification list via notification delete for teardown. + /// Notification list id. + /// A task that represents the asynchronous operation. public static Task DeleteNotificationListAsync(int id) => BestEffortAsync("notification", "delete", id); /// Best-effort delete of a shared script via shared-script delete for teardown. + /// Shared script id. + /// A task that represents the asynchronous operation. public static Task DeleteSharedScriptAsync(int id) => BestEffortAsync("shared-script", "delete", id); /// @@ -667,6 +721,7 @@ public static partial class CliRunner /// , , and this method. /// /// + /// A task that represents the asynchronous operation. public static async Task DeleteRoleMappingAsync(int id) { try @@ -682,6 +737,9 @@ public static partial class CliRunner } /// Best-effort delete of an instance alarm override (teardown). Never throws. + /// Instance id. + /// Canonical name of the alarm whose override is removed. + /// A task that represents the asynchronous operation. public static async Task DeleteInstanceAlarmOverrideAsync(int instanceId, string alarmCanonicalName) { var inv = System.Globalization.CultureInfo.InvariantCulture; @@ -711,6 +769,7 @@ public static partial class CliRunner /// /// SourceEnvironment value stamped into the bundle manifest. /// + /// A task that represents the asynchronous operation. /// /// The template id could not be resolved to a name, or the CLI failed. /// @@ -748,6 +807,7 @@ public static partial class CliRunner /// The SiteIdentifier of the single site to export. /// Encryption passphrase for the bundle. /// SourceEnvironment value stamped into the bundle manifest. + /// A task that represents the asynchronous operation. /// The CLI failed. public static async Task BundleExportSiteAsync( string outputPath, string siteIdentifier, string passphrase, string sourceEnvironment) @@ -771,6 +831,7 @@ public static partial class CliRunner /// Name of the single shared script to export. /// Encryption passphrase for the bundle. /// SourceEnvironment value stamped into the bundle manifest. + /// A task that represents the asynchronous operation. /// /// The shared-script name contains a comma (it would split the comma-separated /// --shared-scripts selector), or the CLI failed. diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.cs index c2299f04..92239966 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.cs @@ -62,6 +62,8 @@ public static partial class CliRunner /// stdout. The connection flags (--url, --username, /// --password, --format json) are prepended automatically. /// + /// The CLI arguments to pass after the connection flags. + /// The raw standard output of the CLI invocation. /// /// The invocation exceeded the 60-second timeout; the process tree is killed. /// @@ -135,6 +137,8 @@ public static partial class CliRunner /// as a . The caller owns the returned document and /// must dispose it. /// + /// The CLI arguments to pass after the connection flags. + /// The parsed JSON output of the CLI invocation. public static async Task RunJsonAsync(params string[] args) => JsonDocument.Parse(await RunAsync(args)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs index 0fe2c7b6..3f77ec03 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs @@ -18,6 +18,7 @@ public class CliRunnerHelpersTests /// , and /// as a round-trip. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateThenDeleteTemplate_RoundTrips() { @@ -37,6 +38,7 @@ public class CliRunnerHelpersTests /// site-a seed site by its siteIdentifier and returns a /// positive id. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ResolveSiteA_ReturnsId() { @@ -49,6 +51,7 @@ public class CliRunnerHelpersTests /// deleted in teardown, exercising /// and as a round-trip. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateThenDeleteDataConnection_RoundTrips() { @@ -70,6 +73,7 @@ public class CliRunnerHelpersTests /// teardown, exercising and /// as a round-trip. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateThenDeleteApiMethod_RoundTrips() { @@ -93,6 +97,7 @@ public class CliRunnerHelpersTests /// , and /// . /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ListAreaIdsByNamePrefix_FindsCreatedArea() { @@ -118,6 +123,7 @@ public class CliRunnerHelpersTests /// , and /// as a round-trip. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateThenDeleteExternalSystem_RoundTrips() { @@ -138,6 +144,7 @@ public class CliRunnerHelpersTests /// , and /// as a round-trip. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateThenDeleteNotificationList_RoundTrips() { @@ -157,6 +164,7 @@ public class CliRunnerHelpersTests /// teardown, exercising and /// as a round-trip. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateThenDeleteSharedScript_RoundTrips() { @@ -172,6 +180,7 @@ public class CliRunnerHelpersTests /// teardown, exercising and /// as a round-trip. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateThenDeleteRoleMapping_RoundTrips() { @@ -194,6 +203,7 @@ public class CliRunnerHelpersTests /// with --hi/--hihi returns a /// positive id, confirming the server accepted the serialized trigger-config JSON. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task AddAlarmWithTypedFlags_RoundTrips() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerSmokeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerSmokeTests.cs index b1d7f5dc..81fac043 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerSmokeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerSmokeTests.cs @@ -18,6 +18,7 @@ public class CliRunnerSmokeTests /// scadabridge site list round-trips through the CLI and returns a /// JSON document (the CLI emits a JSON array of sites in --format json). /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task SiteList_ReturnsJson() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/ClusterAvailability.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/ClusterAvailability.cs index acf02473..36a43ebe 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/ClusterAvailability.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/ClusterAvailability.cs @@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; /// reachable through the CLI. State-changing E2E tests gate their setup on this so /// a downed cluster surfaces as a Skipped fact (via Skip.IfNot) rather than /// an opaque failure. The result is cached for the process; -/// is bumped each time a test would skip and is logged at suite teardown (Task 3). +/// is bumped each time a test would skip and is logged at suite teardown. /// public static class ClusterAvailability { @@ -18,7 +18,7 @@ public static class ClusterAvailability /// /// Number of times a test would have skipped because the cluster is /// unavailable. Incremented on every call that - /// returns ; logged at suite teardown (Task 3). + /// returns ; logged at suite teardown. /// public static int SkippedCount; @@ -26,6 +26,7 @@ public static class ClusterAvailability /// Returns whether the cluster is reachable, probing once (a site list /// round-trip through the CLI) and caching the result for the process. /// + /// when the cluster is reachable; otherwise . public static async Task IsAvailableAsync() { if (_cached is { } cached) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/SkipSummaryReporter.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/SkipSummaryReporter.cs index 775361e0..183f15b5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/SkipSummaryReporter.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/SkipSummaryReporter.cs @@ -7,10 +7,11 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; /// line reporting how many cluster/DB-dependent test gates skipped because the /// cluster was unavailable (see ). /// Registered once via a so it runs -/// regardless of how the test host is launched (Task 3). +/// regardless of how the test host is launched. /// internal static class SkipSummaryReporter { + /// Registers the skip-summary hook that runs when the process exits. [ModuleInitializer] internal static void Init() => AppDomain.CurrentDomain.ProcessExit += (_, _) => diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTests.cs index c2808fb1..ad1eedcd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTests.cs @@ -50,12 +50,17 @@ public class DebugViewTests : IClassFixture private readonly PlaywrightFixture _pw; private readonly DeploymentFixture _cluster; + /// Initializes the test class with the shared Playwright and cluster fixtures. + /// The shared Playwright browser fixture. + /// The shared deployment cluster fixture. public DebugViewTests(PlaywrightFixture pw, DeploymentFixture cluster) { _pw = pw; _cluster = cluster; } + /// Verifies that the Debug View controls render and the Connect button is gated on an instance selection. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DebugView_ControlsRender_ConnectGatedOnSelection() { @@ -66,7 +71,7 @@ public class DebugViewTests : IClassFixture await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await Assertions.Expect(page.Locator("h4:has-text('Debug View')")).ToBeVisibleAsync(); - // The data-test hooks from Task 4 must be served (proves the rebuild took). + // The data-test hooks must be served (proves the rebuild took). await Assertions.Expect(page.Locator("[data-test='debug-site-select']")).ToHaveCountAsync(1); await Assertions.Expect(page.Locator("[data-test='debug-instance-picker']")).ToHaveCountAsync(1); @@ -77,6 +82,8 @@ public class DebugViewTests : IClassFixture .ToBeVisibleAsync(); } + /// Verifies that a connect-enabled instance resolves and can be disconnected. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DebugView_ConnectEnabledInstance_ResolvesAndDisconnect() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTreeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTreeTests.cs index 82953cda..14b2db25 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTreeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTreeTests.cs @@ -53,12 +53,17 @@ public class DebugViewTreeTests : IClassFixture private readonly PlaywrightFixture _pw; private readonly DeploymentFixture _cluster; + /// Initializes the test class with the shared Playwright and deployment fixtures. + /// The shared Playwright fixture. + /// The shared deployment fixture. public DebugViewTreeTests(PlaywrightFixture pw, DeploymentFixture cluster) { _pw = pw; _cluster = cluster; } + /// Verifies that a connected debug view renders tabbed trees and that the alarms tab can be switched to. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DebugView_Connected_RendersTabbedTrees_AndAlarmsTabSwitches() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentActionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentActionTests.cs index 3ad366a0..abccd403 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentActionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentActionTests.cs @@ -41,12 +41,17 @@ public class DeploymentActionTests : IClassFixture private readonly PlaywrightFixture _pw; private readonly DeploymentFixture _cluster; + /// Initializes a new instance of the DeploymentActionTests class. + /// The pw. + /// The cluster. public DeploymentActionTests(PlaywrightFixture pw, DeploymentFixture cluster) { _pw = pw; _cluster = cluster; } + /// Helper: deploy instance shows outcome toast. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Deploy_Instance_ShowsOutcomeToast() { @@ -80,6 +85,8 @@ public class DeploymentActionTests : IClassFixture } } + /// Helper: disable instance shows outcome toast. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Disable_Instance_ShowsOutcomeToast() { @@ -113,6 +120,8 @@ public class DeploymentActionTests : IClassFixture } } + /// Helper: enable instance shows outcome toast. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Enable_Instance_ShowsOutcomeToast() { @@ -141,6 +150,8 @@ public class DeploymentActionTests : IClassFixture } } + /// Helper: delete instance removes from tree. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Delete_Instance_RemovesFromTree() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentFixture.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentFixture.cs index 17e76027..29c2de11 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentFixture.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentFixture.cs @@ -69,6 +69,7 @@ public sealed class DeploymentFixture : IAsyncLifetime /// is unavailable, sets to and /// returns without touching the cluster. /// + /// A task that represents the asynchronous operation. public async Task InitializeAsync() { Available = await ClusterAvailability.IsAvailableAsync(); @@ -148,6 +149,7 @@ public sealed class DeploymentFixture : IAsyncLifetime /// , then deletes the fixture's area and /// template. Swallows every error so a teardown hiccup never fails the suite. /// + /// A task that represents the asynchronous operation. public async Task DisposeAsync() { if (!Available) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentsRealtimeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentsRealtimeTests.cs index a177126d..cc229c14 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentsRealtimeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentsRealtimeTests.cs @@ -39,12 +39,17 @@ public class DeploymentsRealtimeTests : IClassFixture private readonly PlaywrightFixture _pw; private readonly DeploymentFixture _cluster; + /// Initializes the tests with the shared Playwright and deployment fixtures. + /// The shared Playwright browser fixture. + /// The fixture that provides access to the running cluster. public DeploymentsRealtimeTests(PlaywrightFixture pw, DeploymentFixture cluster) { _pw = pw; _cluster = cluster; } + /// Verifies that deploying an instance pushes its row to the deployments page without a manual refresh. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DeployingInstance_PushesRowWithoutManualRefresh() { @@ -73,6 +78,8 @@ public class DeploymentsRealtimeTests : IClassFixture } } + /// Verifies that pausing updates suppresses the pushed row and that refreshing restores it. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task PausedUpdates_SuppressPush_RefreshRestoresRow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerFixture.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerFixture.cs index 38dc3508..1457d5cc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerFixture.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerFixture.cs @@ -39,7 +39,8 @@ public sealed class FlowDebuggerFixture : IAsyncLifetime /// Name of the ephemeral inbound method (its POST /api/{name} segment). public string MethodName { get; private set; } = string.Empty; - /// + /// Probes cluster availability and, when available, creates the ephemeral inbound method used by the tests. + /// A task that represents the asynchronous operation. public async Task InitializeAsync() { Available = await ClusterAvailability.IsAvailableAsync(); @@ -69,7 +70,8 @@ public sealed class FlowDebuggerFixture : IAsyncLifetime } } - /// + /// Deletes the ephemeral inbound method if one was created. + /// A task that represents the asynchronous operation. public Task DisposeAsync() => MethodId > 0 ? CliRunner.DeleteApiMethodAsync(MethodId) : Task.CompletedTask; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs index 817880d2..0bd8edaf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs @@ -7,7 +7,7 @@ using Xunit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment; /// -/// Real-browser coverage for the Flow Debugger page (follow-ups plan 2026-09-06, Task 9): +/// Real-browser coverage for the Flow Debugger page (follow-ups plan 2026-09-06): /// a Live run of an inbound method completes and draws a graph, and the method choice survives /// a reload through the page's localStorage restore path. Both go through the same /// prediction path that used to deadlock the circuit (see ) — @@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment; /// so these tests are the browser-level guard for that fix as well. /// /// -/// The script entry (design 2026-09-08, Task 11) adds two more: a Live direct call of the rig's +/// The script entry (design 2026-09-08) adds two more: a Live direct call of the rig's /// OutboundDemo Call script on DelmiaSim01, and the write-kind Inputs card naming the /// trigger's own attribute. Unlike the method tests those drive RIG-SEEDED objects the fixture does /// not provision, so each picker step skips with a "run docker/seed-…" message when it is absent. @@ -29,12 +29,17 @@ public class FlowDebuggerTests : IClassFixture private readonly PlaywrightFixture _pw; private readonly FlowDebuggerFixture _flow; + /// Initializes a new instance of the FlowDebuggerTests class. + /// The shared Playwright fixture. + /// The Flow Debugger fixture. public FlowDebuggerTests(PlaywrightFixture pw, FlowDebuggerFixture flow) { _pw = pw; _flow = flow; } + /// Verifies that a live run in the browser completes and draws the flow graph. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task LiveRun_InBrowser_CompletesAndDrawsTheGraph() { @@ -47,7 +52,7 @@ public class FlowDebuggerTests : IClassFixture await SelectMethodAsync(page); // The prediction (a Roslyn walk + the route-target resolve) finished: the state chip flips - // to Predicted and the method's ParameterValueForm (#37: the same typed form the script + // to Predicted and the method's ParameterValueForm (the same typed form the script // pane uses, inputs addressed by field id) appears. await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Predicted", new() { Timeout = 15_000 }); var input = page.Locator("#param-Text"); @@ -72,6 +77,8 @@ public class FlowDebuggerTests : IClassFixture Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase)); } + /// Verifies that the chosen inbound API method survives a page reload through local storage. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task MethodChoice_SurvivesReload_ViaLocalStorage() { @@ -100,6 +107,8 @@ public class FlowDebuggerTests : IClassFixture Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase)); } + /// Verifies that a live run of a call script from the script entry completes in the browser. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ScriptEntry_LiveRunOfACallScript_InBrowser_Completes() { @@ -151,6 +160,8 @@ public class FlowDebuggerTests : IClassFixture Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase)); } + /// Verifies that selecting a write-kind script shows the value field for its trigger attribute. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ScriptEntry_WriteKindScript_ShowsTheValueFieldForTheTriggerAttribute() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureFixture.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureFixture.cs index 9db85182..40e1cc5e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureFixture.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureFixture.cs @@ -27,10 +27,15 @@ public sealed class InstanceConfigureFixture : IAsyncLifetime { private const string SiteAIdentifier = "site-a"; + /// Gets the database id of the site-a site resolved for the fixture. public int SiteAId { get; private set; } + /// Gets the id of the fixture template. public int TemplateId { get; private set; } + /// Gets the id of the fixture data connection. public int ConnectionId { get; private set; } + /// Gets the id of the fixture area. public int AreaId { get; private set; } + /// Gets the id of the fixture instance. public int InstanceId { get; private set; } /// The single bindable/overridable attribute name on the fixture template. @@ -57,8 +62,11 @@ public sealed class InstanceConfigureFixture : IAsyncLifetime /// The fixture data-connection name (for locating it in the bindings UI dropdown). public string ConnectionName { get; private set; } = string.Empty; + /// Gets a value indicating whether the live cluster was reachable and the fixture data was created. public bool Available { get; private set; } + /// Checks cluster availability and creates the fixture site, template, connection, area and instance. + /// A task that represents the asynchronous operation. public async Task InitializeAsync() { Available = await ClusterAvailability.IsAvailableAsync(); @@ -95,6 +103,8 @@ public sealed class InstanceConfigureFixture : IAsyncLifetime } } + /// Releases the resources held by this fixture asynchronously. + /// A task that represents the asynchronous operation. public async Task DisposeAsync() { if (!Available) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs index 9e391a5c..fcbe0dd3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs @@ -29,12 +29,17 @@ public sealed class InstanceConfigureTests : IClassFixtureInitializes the tests with the shared Playwright and instance configuration fixtures. + /// The shared Playwright browser fixture. + /// The fixture that provides the instance under test. public InstanceConfigureTests(PlaywrightFixture fixture, InstanceConfigureFixture cfg) { _fixture = fixture; _cfg = cfg; } + /// Verifies that binding all attributes through the bulk select saves and persists the bindings. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task BindAllAttributes_SavesAndPersists() { @@ -81,6 +86,7 @@ public sealed class InstanceConfigureTests : IClassFixtureinstance get /// read-back (not just the toast). /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task SaveOverride_RoundTrips() { @@ -124,6 +130,7 @@ public sealed class InstanceConfigureTests : IClassFixture + /// A task that represents the asynchronous operation. [SkippableFact] public async Task SetArea_RoundTrips() { @@ -156,6 +163,7 @@ public sealed class InstanceConfigureTests : IClassFixturedata-test='instance-error-alert') carrying the /// $"Instance #{Id} not found." message built in InstanceConfigure.OnInitializedAsync. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task NotFoundInstance_ShowsErrorAlert() { @@ -191,6 +199,7 @@ public sealed class InstanceConfigureTests : IClassFixturefinally, leaving the instance override-free as found. ///
/// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task AlarmOverride_SetPriority_ThenClear_RoundTrips() { @@ -265,6 +274,7 @@ public sealed class InstanceConfigureTests : IClassFixture /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task AlarmOverride_HiLoSetpoint_Merges() { @@ -323,6 +333,7 @@ public sealed class InstanceConfigureTests : IClassFixture + /// A task that represents the asynchronous operation. [SkippableFact] public async Task AlarmOverride_NonHiLo_WholeReplaces() { @@ -384,6 +395,7 @@ public sealed class InstanceConfigureTests : IClassFixtureSaveOverrideFromModal that fires before any server round-trip. /// /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task AlarmOverride_InvalidValue_ShowsError_StaysOpen() { @@ -431,6 +443,7 @@ public sealed class InstanceConfigureTests : IClassFixture + /// A task that represents the asynchronous operation. [SkippableFact] public async Task AlarmOverride_Cancel_DiscardsChanges() { @@ -474,6 +487,7 @@ public sealed class InstanceConfigureTests : IClassFixture). /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task AlarmOverride_ClearFromModal_RemovesOverride() { @@ -537,6 +551,7 @@ public sealed class InstanceConfigureTests : IClassFixturefinally, exactly as does. /// /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task AlarmOverride_MesReportable_RoundTrips() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/TopologyAreaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/TopologyAreaTests.cs index 3a857f7e..05966071 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/TopologyAreaTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/TopologyAreaTests.cs @@ -71,12 +71,17 @@ public class TopologyAreaTests : IClassFixture private readonly PlaywrightFixture _pw; private readonly DeploymentFixture _cluster; + /// Initializes the test class with the Playwright and deployment cluster fixtures. + /// The Playwright browser fixture. + /// The deployment cluster fixture. public TopologyAreaTests(PlaywrightFixture pw, DeploymentFixture cluster) { _pw = pw; _cluster = cluster; } + /// Verifies that an area created from the toolbar appears in the topology tree and persists. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateArea_ViaToolbar_AppearsInTreeAndPersists() { @@ -114,6 +119,8 @@ public class TopologyAreaTests : IClassFixture } } + /// Verifies that pressing Enter while renaming an area commits and persists the new name. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task RenameArea_EnterCommits_PersistsNewName() { @@ -153,6 +160,8 @@ public class TopologyAreaTests : IClassFixture } } + /// Verifies that pressing Escape while renaming an area reverts to the original label. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task RenameArea_Escape_RevertsToOriginalLabel() { @@ -183,6 +192,8 @@ public class TopologyAreaTests : IClassFixture } } + /// Verifies that moving an area under another area shows a moved toast. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task MoveArea_UnderAnotherArea_ShowsMovedToast() { @@ -221,6 +232,8 @@ public class TopologyAreaTests : IClassFixture } } + /// Verifies that moving an instance to an area shows a moved toast. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task MoveInstance_ToArea_ShowsMovedToast() { @@ -255,6 +268,8 @@ public class TopologyAreaTests : IClassFixture } } + /// Verifies that the diff action on a deployed instance opens the diff dialog. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Diff_OnDeployedInstance_OpensDialog() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ApiMethodFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ApiMethodFormTests.cs index a80a5ed4..80d7b13b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ApiMethodFormTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ApiMethodFormTests.cs @@ -9,6 +9,8 @@ public class ApiMethodFormTests { private readonly PlaywrightFixture _pw; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture. public ApiMethodFormTests(PlaywrightFixture pw) { _pw = pw; @@ -21,6 +23,7 @@ public class ApiMethodFormTests /// inline "Name and script required." error. The form does not navigate, so nothing is /// saved — this fixture name never reaches the database. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateForm_NameWithoutScript_ShowsInlineError() { @@ -33,7 +36,7 @@ public class ApiMethodFormTests await Assertions.Expect(page.Locator("h4:has-text('Add API Method')")).ToBeVisibleAsync(); // The form has several text inputs (Name + SchemaBuilder/parameter rows that also - // carry .form-control), so anchor the Name input to its labelled mb-3 wrapper and + // carry .form-control), so anchor the Name input to its labelled wrapper and // require .form-control (the peer ExternalSystemCrudTests pattern). The count guard // makes any future selector ambiguity fail loudly instead of filling the wrong field. var nameInput = page.Locator("div.mb-3:has(label:has-text('Name')) input[type=text].form-control"); @@ -57,6 +60,7 @@ public class ApiMethodFormTests /// delete round-trip is the behavior under test. A best-effort CLI delete in finally /// guarantees no zztest-method-* row leaks if the UI path fails mid-way. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CliCreated_Method_VisibleAndDeletes() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs index c2d5cb38..2c8077de 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs @@ -9,6 +9,8 @@ public class DataConnectionCrudTests { private readonly PlaywrightFixture _pw; + /// Initializes a new instance of the class. + /// The shared Playwright fixture. public DataConnectionCrudTests(PlaywrightFixture pw) { _pw = pw; @@ -22,6 +24,7 @@ public class DataConnectionCrudTests /// CLI (a minimal create needs no primary config) and only drive the delete via the UI. /// The best-effort finally is a safety net: the happy path deletes via the UI. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CliCreated_Connection_DeletesViaTree() { @@ -89,6 +92,7 @@ public class DataConnectionCrudTests /// The "+ Connection" button is gated on a tree-node selection: it starts disabled /// and only enables once a site (or connection) node is selected. Mutates nothing. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateButton_GatedOnNodeSelection() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ExternalSystemCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ExternalSystemCrudTests.cs index 489a8a86..a08dfd7e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ExternalSystemCrudTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ExternalSystemCrudTests.cs @@ -9,6 +9,8 @@ public class ExternalSystemCrudTests { private readonly PlaywrightFixture _pw; + /// Initializes the tests with the shared Playwright fixture. + /// The shared Playwright browser fixture. public ExternalSystemCrudTests(PlaywrightFixture pw) { _pw = pw; @@ -19,6 +21,7 @@ public class ExternalSystemCrudTests /// Uses CliRunner for best-effort teardown so no zztest-extsys-* definition leaks on /// failure (the happy path already deletes via the UI). /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Create_Delete_RoundTrips() { @@ -37,7 +40,7 @@ public class ExternalSystemCrudTests // ExternalSystemForm.razor has THREE input[type=text].form-control fields // (Name, Endpoint URL, and Auth Config JSON), so index-based selection is - // fragile. Anchor each fill to its own div.mb-3 wrapper via the field label + // fragile. Anchor each fill to its own form-group wrapper div via the field label // so the selectors survive field reordering and uniquely match one element. await page.Locator("div.mb-3:has(label:has-text('Name')) input[type=text].form-control") .FillAsync(name); @@ -86,6 +89,7 @@ public class ExternalSystemCrudTests /// Saving the create form with both fields blank surfaces the inline validation /// error and keeps the user on the create page. Mutates nothing. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateForm_EmptyFields_ShowsInlineError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/M9SurfaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/M9SurfaceTests.cs index b5783091..35e8a446 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/M9SurfaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/M9SurfaceTests.cs @@ -6,7 +6,7 @@ using Xunit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design; /// -/// Browser-level coverage for the M9 design surfaces, run against the live dev cluster: +/// Browser-level coverage for the design surfaces, run against the live dev cluster: /// /// the /design/templates tree search filter (TemplateFolderTree pruning), /// the /design/schema-library page (render-smoke + UI create→delete round-trip), @@ -22,6 +22,8 @@ public class M9SurfaceTests { private readonly PlaywrightFixture _pw; + /// Initializes a new instance of the test class. + /// The shared Playwright fixture. public M9SurfaceTests(PlaywrightFixture pw) { _pw = pw; @@ -34,6 +36,7 @@ public class M9SurfaceTests /// DOM. The filter rebuilds _visibleRoots from matches only — non-matches are gone, /// not CSS-hidden — so a ToHaveCountAsync(0) on the non-match is the correct assertion. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task TemplateSearch_FiltersTree() { @@ -90,6 +93,7 @@ public class M9SurfaceTests /// the SchemaBuilder editor, and the success/delete toasts all render with no circuit crash. /// The DB safety-net finally only fires if the UI delete failed mid-flow. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task SchemaLibrary_CreateDelete_RoundTrips() { @@ -157,6 +161,7 @@ public class M9SurfaceTests /// sites exist on the live cluster, so the picker (rather than the "no other site" fallback) /// renders. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task MoveConnectionDialog_OpensWithSitePicker() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/SharedScriptCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/SharedScriptCrudTests.cs index fd6ca564..1194adb8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/SharedScriptCrudTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/SharedScriptCrudTests.cs @@ -9,6 +9,8 @@ public class SharedScriptCrudTests { private readonly PlaywrightFixture _pw; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture. public SharedScriptCrudTests(PlaywrightFixture pw) { _pw = pw; @@ -21,6 +23,7 @@ public class SharedScriptCrudTests /// best-effort CLI delete in finally guarantees no zztest-script-* definition leaks /// if the UI path fails mid-way (the happy path already deletes via the UI). /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CliCreated_Script_DeletesViaCard() { @@ -72,6 +75,7 @@ public class SharedScriptCrudTests /// the Monaco editor surface. Asserts render only — does NOT click Save, Check Syntax, /// or Test Run (Test Run fires real I/O). Mutates nothing. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateForm_Renders() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs index ec3ed920..170e2539 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs @@ -16,12 +16,21 @@ public class TemplateCrudTests : IClassFixture private readonly PlaywrightFixture _fixture; private readonly DeploymentFixture _cluster; + /// Initializes the test class with the shared Playwright and cluster fixtures. + /// The fixture. + /// The cluster. + /// Initializes the test class with the shared Playwright and cluster fixtures. + /// The fixture. + /// The cluster. public TemplateCrudTests(PlaywrightFixture fixture, DeploymentFixture cluster) { _fixture = fixture; + /// Verifies that a template can be created, given an attribute and deleted again. _cluster = cluster; } + /// Verifies that a template can be created, given an attribute and deleted again. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateAddAttributeDelete_Template_RoundTrips() { @@ -126,10 +135,13 @@ public class TemplateCrudTests : IClassFixture catch { // Best-effort — swallow to avoid masking the original test failure. + /// Verifies that creating a template with a duplicate name shows an inline error. } } } + /// Verifies that creating a template with a duplicate name shows an inline error. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateTemplate_DuplicateName_ShowsInlineError() { @@ -175,11 +187,14 @@ public class TemplateCrudTests : IClassFixture } catch { + /// Verifies that cancelling template creation returns to the list without creating a template. // Best-effort — swallow to avoid masking the original test failure. } } } + /// Verifies that cancelling template creation returns to the list without creating a template. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateTemplate_Cancel_ReturnsToListWithoutCreating() { @@ -216,12 +231,15 @@ public class TemplateCrudTests : IClassFixture } } catch + /// Verifies that editing an attribute persists the change. { // Best-effort — swallow to avoid masking the original test failure. } } } + /// Verifies that editing an attribute persists the change. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EditAttribute_PersistsChange() { @@ -289,6 +307,7 @@ public class TemplateCrudTests : IClassFixture await CliRunner.DeleteTemplateAsync(leftover); } } + /// Verifies that deleting a template that has an instance is blocked. catch { // Best-effort — swallow to avoid masking the original test failure. @@ -296,6 +315,8 @@ public class TemplateCrudTests : IClassFixture } } + /// Verifies that deleting a template that has an instance is blocked. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DeleteTemplate_WithInstance_IsBlocked() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/LoginTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/LoginTests.cs index 415c4d73..a0a483a9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/LoginTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/LoginTests.cs @@ -8,11 +8,15 @@ public class LoginTests { private readonly PlaywrightFixture _fixture; + /// Initializes the tests with the shared Playwright fixture. + /// The shared Playwright fixture. public LoginTests(PlaywrightFixture fixture) { _fixture = fixture; } + /// Verifies that an unauthenticated user is redirected to the login page. + /// A task that represents the asynchronous operation. [Fact] public async Task UnauthenticatedUser_RedirectsToLogin() { @@ -30,6 +34,8 @@ public class LoginTests await Expect(page.Locator("button[type='submit']")).ToHaveTextAsync("Sign in"); } + /// Verifies that valid credentials authenticate the user successfully. + /// A task that represents the asynchronous operation. [Fact] public async Task ValidCredentials_AuthenticatesSuccessfully() { @@ -39,6 +45,8 @@ public class LoginTests Assert.DoesNotContain("/login", page.Url); } + /// Verifies that invalid credentials show an error on the login page. + /// A task that represents the asynchronous operation. [Fact] public async Task InvalidCredentials_ShowsError() { @@ -66,6 +74,8 @@ public class LoginTests Assert.Equal(200, status); // redirect followed to login page } + /// Verifies that the token endpoint returns a JWT for valid credentials. + /// A task that represents the asynchronous operation. [Fact] public async Task TokenEndpoint_ReturnsJwt() { @@ -91,6 +101,8 @@ public class LoginTests Assert.Equal("multi-role", result.GetProperty("username").GetString()); } + /// Verifies that the token endpoint returns HTTP 401 for invalid credentials. + /// A task that represents the asynchronous operation. [Fact] public async Task TokenEndpoint_InvalidCredentials_Returns401() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/LowReviewSmokeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/LowReviewSmokeTests.cs index f7bb57d9..621d7d91 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/LowReviewSmokeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/LowReviewSmokeTests.cs @@ -7,6 +7,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests; [Collection("Playwright")] public class LowReviewSmokeTests(PlaywrightFixture fixture) { + /// Verifies that CSRF login, lazy topology loading and Flow Debugger navigation work in the browser. + /// A task that represents the asynchronous operation. [Fact] public async Task LowReview_CsrfLogin_LazyTopology_AndFlowDebuggerNavigation() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/EventLogsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/EventLogsTests.cs index 7e85dee1..0906b702 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/EventLogsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/EventLogsTests.cs @@ -36,6 +36,8 @@ public class EventLogsTests private readonly PlaywrightFixture _fixture; + /// Initializes the test class with the Playwright fixture. + /// The Playwright browser fixture. public EventLogsTests(PlaywrightFixture fixture) { _fixture = fixture; @@ -46,6 +48,7 @@ public class EventLogsTests /// enabled once a site is selected from the site dropdown. Deterministic regardless /// of whether the site has any logged events. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EventLogs_SearchGatedOnSiteSelection() { @@ -76,6 +79,7 @@ public class EventLogsTests /// rows exist — a site may have zero logged events, and event logs are not seedable /// from central, so the empty-state render is itself the assertion in that case. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EventLogs_Search_RendersTableOrEmptyState() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/HealthDashboardTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/HealthDashboardTests.cs index 6f2b561c..5372816f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/HealthDashboardTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/HealthDashboardTests.cs @@ -50,6 +50,8 @@ public class HealthDashboardTests private readonly PlaywrightFixture _fixture; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture. public HealthDashboardTests(PlaywrightFixture fixture) { _fixture = fixture; @@ -66,6 +68,7 @@ public class HealthDashboardTests /// rather than using a fixed sleep, so a fast cluster will pass quickly. /// /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task KpiTiles_ResolveToValues_NotDegradePlaceholder() { @@ -83,10 +86,10 @@ public class HealthDashboardTests // // Health.razor structure: //
- //
Notification Outbox
← anchor + //
Notification Outbox
← anchor // View details → //
- //
← outboxSection (+sibling) + //
← outboxSection (+sibling) //
Queue Depth
//
Stuck
//
Parked
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/KpiTrendChartTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/KpiTrendChartTests.cs index a639ff51..170746a5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/KpiTrendChartTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/KpiTrendChartTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Monitoring; /// -/// E2E coverage for the M6 "KPI History & Trends" feature (K17's spec slice): +/// E2E coverage for the "KPI History & Trends" feature: /// proves a KpiTrendChart trend section renders in the live Central UI. /// /// @@ -51,6 +51,8 @@ public class KpiTrendChartTests private readonly PlaywrightFixture _fixture; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture that provides the browser and login helpers. public KpiTrendChartTests(PlaywrightFixture fixture) { _fixture = fixture; @@ -62,6 +64,7 @@ public class KpiTrendChartTests /// kpi-trend-* card is present, and (tolerantly) reports whether any /// polyline has been plotted yet. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task NotificationKpis_RendersTrendSection_WithKpiTrendCards() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesActionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesActionTests.cs index e47cda84..11cf0c3c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesActionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesActionTests.cs @@ -36,6 +36,8 @@ public class ParkedMessagesActionTests { private readonly PlaywrightFixture _fixture; + /// Initializes the tests with the shared Playwright fixture. + /// The Playwright fixture that supplies authenticated browser pages. public ParkedMessagesActionTests(PlaywrightFixture fixture) { _fixture = fixture; @@ -45,6 +47,7 @@ public class ParkedMessagesActionTests /// The Query button must be disabled on page load (no site selected) and become /// enabled once a site is selected from the site dropdown. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task QueryButton_DisabledUntilSiteSelected() { @@ -67,6 +70,7 @@ public class ParkedMessagesActionTests /// The Clear button must be disabled on page load (no active filters) and become /// enabled once any filter is changed. Clicking Clear must re-disable it. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ClearButton_DisabledUntilFilterSet_ThenResets() { @@ -93,6 +97,7 @@ public class ParkedMessagesActionTests /// are present (the common case in a clean test environment — parked rows are not /// seedable from central), the test exits early as a tolerated no-op. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task SelectingParkedRow_RevealsBulkRetryDiscardBar_WhenRowsPresent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesTests.cs index d5649b9a..ef90b34c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesTests.cs @@ -34,11 +34,15 @@ public class ParkedMessagesTests private readonly PlaywrightFixture _fixture; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture. public ParkedMessagesTests(PlaywrightFixture fixture) { _fixture = fixture; } + /// Verifies that querying the parked messages page for a site renders without hanging. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ParkedMessages_QueryForSite_RendersWithoutHang() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/NavCollapseTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/NavCollapseTests.cs index 132c2397..ffc12c72 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/NavCollapseTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/NavCollapseTests.cs @@ -32,11 +32,15 @@ public class NavCollapseTests private readonly PlaywrightFixture _fixture; + /// Initializes a new instance of the NavCollapseTests class. + /// The fixture. public NavCollapseTests(PlaywrightFixture fixture) { _fixture = fixture; } + /// Verifies that all nav sections are expanded by default after login. + /// A task that represents the asynchronous operation. [Fact] public async Task Sections_AreExpandedByDefault_AfterLogin() { @@ -52,6 +56,8 @@ public class NavCollapseTests await Expect(page.Locator("a.rail-link:has-text('Topology')")).ToBeVisibleAsync(); } + /// Verifies that clicking a section header toggles its items. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ClickingSectionHeader_TogglesItsItems() { @@ -76,6 +82,8 @@ public class NavCollapseTests await Expect(topology).ToBeVisibleAsync(); } + /// Verifies that the collapse state survives a page reload. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CollapseState_SurvivesPageReload() { @@ -97,6 +105,8 @@ public class NavCollapseTests await Expect(page.Locator("a.rail-link:has-text('Topology')")).Not.ToBeVisibleAsync(); } + /// Verifies that navigating into a collapsed section expands it automatically. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task NavigatingIntoCollapsedSection_AutoExpandsIt() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/NavigationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/NavigationTests.cs index fda2b8ac..0bb116c3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/NavigationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/NavigationTests.cs @@ -7,11 +7,15 @@ public class NavigationTests { private readonly PlaywrightFixture _fixture; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture that provides the browser and login helpers. public NavigationTests(PlaywrightFixture fixture) { _fixture = fixture; } + /// Verifies that the dashboard is visible after logging in. + /// A task that represents the asynchronous operation. [Fact] public async Task Dashboard_IsVisibleAfterLogin() { @@ -24,6 +28,10 @@ public class NavigationTests await Expect(page.GetByRole(AriaRole.Link, new() { Name = "Dashboard", Exact = true })).ToBeVisibleAsync(); } + /// Verifies that each Admin navigation link leads to its expected page. + /// The visible text of the navigation link to click. + /// The URL path expected after navigation. + /// A task that represents the asynchronous operation. [Theory] [InlineData("Sites", "/admin/sites")] [InlineData("API Keys", "/admin/api-keys")] @@ -34,6 +42,10 @@ public class NavigationTests await ClickNavAndWait(page, linkText, expectedPath); } + /// Verifies that each Notifications navigation link leads to its expected page. + /// The visible text of the navigation link to click. + /// The URL path expected after navigation. + /// A task that represents the asynchronous operation. [Theory] [InlineData("SMTP Configuration", "/notifications/smtp")] [InlineData("Notification Lists", "/notifications/lists")] @@ -45,6 +57,10 @@ public class NavigationTests await ClickNavAndWait(page, linkText, expectedPath); } + /// Verifies that each Design navigation link leads to its expected page. + /// The visible text of the navigation link to click. + /// The URL path expected after navigation. + /// A task that represents the asynchronous operation. [Theory] [InlineData("Templates", "/design/templates")] [InlineData("Shared Scripts", "/design/shared-scripts")] @@ -56,6 +72,10 @@ public class NavigationTests await ClickNavAndWait(page, linkText, expectedPath); } + /// Verifies that each Deployment navigation link leads to its expected page. + /// The visible text of the navigation link to click. + /// The URL path expected after navigation. + /// A task that represents the asynchronous operation. [Theory] [InlineData("Topology", "/deployment/topology")] [InlineData("Deployments", "/deployment/deployments")] @@ -65,6 +85,10 @@ public class NavigationTests await ClickNavAndWait(page, linkText, expectedPath); } + /// Verifies that each Monitoring navigation link leads to its expected page. + /// The visible text of the navigation link to click. + /// The URL path expected after navigation. + /// A task that represents the asynchronous operation. [Theory] [InlineData("Health Dashboard", "/monitoring/health")] [InlineData("Event Logs", "/monitoring/event-logs")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs index e41833b3..1bd63f8d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs @@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications; /// /// End-to-end coverage for the central Notification Report page's Retry / Discard -/// actions on parked notifications (Notification Outbox #21). +/// actions on parked notifications (Notification Outbox). /// /// /// Each test seeds its own Parked row directly into the running cluster's @@ -34,6 +34,8 @@ public class NotificationActionTests private readonly PlaywrightFixture _fixture; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture. public NotificationActionTests(PlaywrightFixture fixture) { _fixture = fixture; @@ -94,6 +96,8 @@ public class NotificationActionTests return (page, row); } + /// Verifies that retrying a parked notification shows an outcome toast. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Retry_ParkedNotification_ShowsOutcomeToast() { @@ -133,6 +137,8 @@ public class NotificationActionTests } } + /// Verifies that discarding a parked notification shows an outcome toast. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Discard_ParkedNotification_ShowsOutcomeToast() { @@ -179,6 +185,7 @@ public class NotificationActionTests /// than widening — the marker isolates the pair from ambient cluster rows, and the /// subject keyword discriminates between them. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task FilterCombination_StatusPlusList_NarrowsToMatch() { @@ -237,6 +244,7 @@ public class NotificationActionTests /// close affordances are wired to the same CloseDetail handler; this exercises /// each path independently (open → X-close → reopen → footer-close). /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DetailModal_OpenOnDblClick_CloseViaXAndFooter() { @@ -308,6 +316,7 @@ public class NotificationActionTests /// docker cluster carries no override — so a Retrying row back-dated 15 min passes both /// the repository's StuckOnly SQL predicate and the page's IsStuck derivation. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task StuckOnlyFilter_NarrowsToStuckRows() { @@ -384,6 +393,7 @@ public class NotificationActionTests /// disables both pager buttons — so the button-state assertions never race the in-flight /// query. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Pagination_PageNumberNextAndPrev_TraversesPages() { @@ -413,7 +423,7 @@ public class NotificationActionTests await page.Locator("button.btn-primary:has-text('Query')").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); - // Pager locators (M10 OffsetPager). Previous/Next are the pager's data-test + // Pager locators (OffsetPager). Previous/Next are the pager's data-test // hooks (the page header's Refresh button is also a .btn-outline-secondary.btn-sm, // so we no longer scope by text). The summary span carries the page/total readout // ("Page {N} of {Y} · {Total} total"). diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationDataSeeder.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationDataSeeder.cs index c951d558..524ee449 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationDataSeeder.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationDataSeeder.cs @@ -4,8 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications; /// -/// Direct-SQL seeding helper for the Notification Report page Playwright E2E tests -/// (Notification Outbox #21). +/// Direct-SQL seeding helper for the Notification Report page Playwright E2E tests. /// /// /// The Notification Report page reads the central Notifications table through the @@ -74,6 +73,7 @@ internal static class NotificationDataSeeder /// Retry count to display on the row. /// Optional last-error text shown beneath the subject. /// Cancellation token. + /// A task that represents the asynchronous operation. public static async Task InsertNotificationAsync( Guid notificationId, string listNameMarker, @@ -132,6 +132,7 @@ VALUES /// Retry count to display on the row. /// Optional last-error text shown beneath the subject. /// Cancellation token. + /// A task that represents the asynchronous operation. public static Task InsertParkedNotificationAsync( Guid notificationId, string listNameMarker, @@ -152,6 +153,9 @@ VALUES /// to Pending (and a subsequent dispatch sweep) does not change the ListName, /// so the marker still matches whatever terminal state the row ends in. /// + /// Unique per-run marker stored in ListName. + /// Cancellation token. + /// A task that represents the asynchronous operation. public static async Task DeleteByMarkerAsync(string listNameMarker, CancellationToken ct = default) { try @@ -175,6 +179,8 @@ VALUES /// this so a downed cluster surfaces a clear message rather than an opaque /// . /// + /// Cancellation token. + /// true if a connection could be opened; otherwise false. public static async Task IsAvailableAsync(CancellationToken ct = default) { try diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationKpisTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationKpisTests.cs index b9ee0455..2641fb58 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationKpisTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationKpisTests.cs @@ -22,6 +22,8 @@ public class NotificationKpisTests private readonly PlaywrightFixture _pw; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture. public NotificationKpisTests(PlaywrightFixture pw) { _pw = pw; @@ -32,6 +34,7 @@ public class NotificationKpisTests /// real state — EITHER all 5 KPI tile labels rendered OR the 'KPIs unavailable' /// alert is shown. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task KpisPage_RendersTilesOrError() { @@ -62,6 +65,7 @@ public class NotificationKpisTests /// that the button re-enables within 10 s — proving the refresh round-trip completed /// without hanging. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task KpisPage_RefreshReenables() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs index 47e30947..62a62255 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs @@ -23,6 +23,8 @@ public class NotificationListCrudTests private readonly PlaywrightFixture _pw; + /// Creates the test class with the shared Playwright fixture. + /// The shared Playwright fixture. public NotificationListCrudTests(PlaywrightFixture pw) { _pw = pw; @@ -33,6 +35,7 @@ public class NotificationListCrudTests /// open it for edit, add a recipient, remove that recipient, then delete the list — /// asserting the delete confirm dialog, the success toast, and the row disappearing. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Create_AddRecipient_RemoveRecipient_Delete_RoundTrips() { @@ -124,6 +127,7 @@ public class NotificationListCrudTests /// Submits the create form with a blank name and asserts the inline 'Name required.' /// validation error appears and the page does not navigate away. Mutates nothing. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateForm_EmptyName_ShowsInlineError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmsNotificationE2ETests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmsNotificationE2ETests.cs index 7eb0f822..afd2f076 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmsNotificationE2ETests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmsNotificationE2ETests.cs @@ -50,6 +50,8 @@ public class SmsNotificationE2ETests private readonly PlaywrightFixture _pw; + /// Initializes the tests with the shared Playwright fixture. + /// The shared Playwright browser fixture. public SmsNotificationE2ETests(PlaywrightFixture pw) { _pw = pw; @@ -74,6 +76,7 @@ public class SmsNotificationE2ETests /// fills, and never raise the saved toast — all of which are asserted with web-first waits. /// /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task SmsConfigPage_CreateOrRender_NeverLeaksAuthToken() { @@ -141,6 +144,7 @@ public class SmsNotificationE2ETests /// phone recipient and assert the row appears. Best-effort CLI teardown in finally. /// /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ListCreate_SmsType_ShowsPhoneInput_NotEmail() { @@ -208,6 +212,7 @@ public class SmsNotificationE2ETests /// the list page, and scopes the assertion to that list's row so it cannot match another /// list's Type cell. Best-effort CLI teardown in finally. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ListCreate_SmsType_ShowsSmsInTypeColumn() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmtpConfigTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmtpConfigTests.cs index ab36b2df..ae6fee74 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmtpConfigTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmtpConfigTests.cs @@ -40,6 +40,8 @@ public class SmtpConfigTests private readonly PlaywrightFixture _pw; + /// Initializes a new instance of the class. + /// The shared Playwright fixture. public SmtpConfigTests(PlaywrightFixture pw) { _pw = pw; @@ -51,6 +53,7 @@ public class SmtpConfigTests /// The page's Save handler returns at this guard BEFORE persisting, so this fact /// mutates nothing. Then it cancels and asserts the form closed. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task SmtpForm_MissingRequired_ShowsInlineError() { @@ -95,6 +98,7 @@ public class SmtpConfigTests /// card (Credentials value '(stored)' or '(not set)') is present, OR the empty-state /// 'No SMTP configuration set.' text is shown. Pure-read; mutates nothing. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task SmtpPage_RendersConfigOrEmptyState() { @@ -130,6 +134,7 @@ public class SmtpConfigTests /// skips (SMTP config has no delete verb, so we never create one we could not delete). /// /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task SmtpEdit_NoopSave_ShowsSavedToast() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PendingUpdatesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PendingUpdatesTests.cs index 10dca9ec..646ec7c0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PendingUpdatesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PendingUpdatesTests.cs @@ -34,12 +34,17 @@ public class PendingUpdatesTests private readonly PlaywrightFixture _fixture; private readonly ITestOutputHelper _output; + /// Initializes the test class with the Playwright fixture and the test output helper. + /// The shared Playwright fixture. + /// The xUnit test output helper. public PendingUpdatesTests(PlaywrightFixture fixture, ITestOutputHelper output) { _fixture = fixture; _output = output; } + /// Verifies that the rail footer shows the pending-updates indicator and that it settles out of the checking state. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task RailFooter_ShowsPendingUpdatesIndicator_AndSettles() { @@ -69,6 +74,8 @@ public class PendingUpdatesTests $"the fleet snapshot could not be read — indicator says: {text}"); } + /// Verifies that the Sites page shows an artifacts badge for each site and a re-check control. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task SitesPage_ShowsAnArtifactsBadgePerSite_AndARecheckControl() { @@ -115,6 +122,8 @@ public class PendingUpdatesTests Assert.Equal(0, await page.Locator("[aria-label='Artifacts: unavailable']").CountAsync()); } + /// Verifies that the navigation rail has its own scroll pane at desktop width. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task NavRail_HasAScrollPane_AtDesktopWidth() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PlaywrightFixture.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PlaywrightFixture.cs index e7581a75..c05eda45 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PlaywrightFixture.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PlaywrightFixture.cs @@ -24,7 +24,9 @@ public class PlaywrightFixture : IAsyncLifetime public const string TestUsername = "multi-role"; public const string TestPassword = "password"; + /// The Playwright driver instance created during initialization. public IPlaywright Playwright { get; private set; } = null!; + /// The browser connected to the Playwright server. public IBrowser Browser { get; private set; } = null!; /// @@ -44,12 +46,16 @@ public class PlaywrightFixture : IAsyncLifetime /// private const int MaxRetainedContexts = 4; + /// Creates the Playwright driver and connects to the remote Chromium endpoint. + /// A task that represents the asynchronous operation. public async Task InitializeAsync() { Playwright = await Microsoft.Playwright.Playwright.CreateAsync(); Browser = await Playwright.Chromium.ConnectAsync(PlaywrightWsEndpoint); } + /// Closes the browser connection and disposes the Playwright driver. + /// A task that represents the asynchronous operation. public async Task DisposeAsync() { await Browser.CloseAsync(); @@ -62,6 +68,7 @@ public class PlaywrightFixture : IAsyncLifetime /// are open, to bound the number of concurrent Blazor /// Server circuits the run holds on the Central UI. /// + /// The new page, which belongs to its own isolated browser context. public async Task NewPageAsync() { var context = await Browser.NewContextAsync(); @@ -92,6 +99,7 @@ public class PlaywrightFixture : IAsyncLifetime /// /// Create a new page and log in with the default multi-role test user. /// + /// A page that is authenticated as the default test user. public Task NewAuthenticatedPageAsync() => NewAuthenticatedPageAsync(TestUsername, TestPassword); @@ -100,6 +108,9 @@ public class PlaywrightFixture : IAsyncLifetime /// Uses JavaScript fetch() to POST to /auth/login from within the browser, /// which sets the auth cookie in the browser context. Then navigates to the dashboard. /// + /// The LDAP user name to log in with. + /// The password for . + /// A page that is authenticated as the given user. public async Task NewAuthenticatedPageAsync(string username, string password) { var page = await NewPageAsync(); @@ -144,6 +155,11 @@ public class PlaywrightFixture : IAsyncLifetime /// Blazor Server uses SignalR for client-side navigation (no full page reload), /// so standard WaitForURLAsync times out. This polls window.location instead. /// + /// The page whose location is polled. + /// A path fragment the location must contain. + /// An optional path fragment the location must not contain. + /// The maximum time to wait, in milliseconds. + /// A task that represents the asynchronous operation. public static async Task WaitForPathAsync(IPage page, string path, string? excludePath = null, int timeoutMs = 10000) { var js = excludePath != null @@ -163,6 +179,8 @@ public class PlaywrightFixture : IAsyncLifetime /// in the DOM but are hidden until the section is expanded. Call this after /// authenticating, before interacting with sectioned nav links. /// + /// The authenticated page whose navigation sections are expanded. + /// A task that represents the asynchronous operation. public static async Task ExpandAllNavSectionsAsync(IPage page) { var toggles = page.Locator("summary.rail-eyebrow-toggle"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/RoleNavigationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/RoleNavigationTests.cs index 3a50c347..dbc5ce8b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/RoleNavigationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/RoleNavigationTests.cs @@ -22,6 +22,8 @@ public class RoleNavigationTests { private readonly PlaywrightFixture _fixture; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture. public RoleNavigationTests(PlaywrightFixture fixture) { _fixture = fixture; @@ -29,6 +31,8 @@ public class RoleNavigationTests // ── Admin-only user ───────────────────────────────────────────── + /// Verifies that the admin user sees the Admin navigation section. + /// A task that represents the asynchronous operation. [Fact] public async Task AdminUser_SeesAdminSection() { @@ -41,6 +45,8 @@ public class RoleNavigationTests await AssertNavLinkVisible(page, "Audit Log"); } + /// Verifies that the admin user does not see the Design navigation section. + /// A task that represents the asynchronous operation. [Fact] public async Task AdminUser_DoesNotSeeDesignSection() { @@ -52,6 +58,8 @@ public class RoleNavigationTests await AssertNavLinkHidden(page, "External Systems"); } + /// Verifies that the admin user does not see the Deployment navigation section. + /// A task that represents the asynchronous operation. [Fact] public async Task AdminUser_DoesNotSeeDeploymentSection() { @@ -62,6 +70,8 @@ public class RoleNavigationTests await AssertNavLinkHidden(page, "Debug View"); } + /// Verifies that the admin user sees the Health Dashboard but not Deployment monitoring. + /// A task that represents the asynchronous operation. [Fact] public async Task AdminUser_SeesHealthDashboard_NotDeploymentMonitoring() { @@ -76,6 +86,8 @@ public class RoleNavigationTests // ── Design-only user ──────────────────────────────────────────── + /// Verifies that the design user sees the Design navigation section. + /// A task that represents the asynchronous operation. [Fact] public async Task DesignUser_SeesDesignSection() { @@ -87,6 +99,8 @@ public class RoleNavigationTests await AssertNavLinkVisible(page, "External Systems"); } + /// Verifies that the design user does not see the Admin navigation section. + /// A task that represents the asynchronous operation. [Fact] public async Task DesignUser_DoesNotSeeAdminSection() { @@ -99,6 +113,8 @@ public class RoleNavigationTests await AssertNavLinkHidden(page, "Audit Log"); } + /// Verifies that the design user does not see the Deployment navigation section. + /// A task that represents the asynchronous operation. [Fact] public async Task DesignUser_DoesNotSeeDeploymentSection() { @@ -109,6 +125,8 @@ public class RoleNavigationTests await AssertNavLinkHidden(page, "Debug View"); } + /// Verifies that the design user sees the Health Dashboard but not Deployment monitoring or audit links. + /// A task that represents the asynchronous operation. [Fact] public async Task DesignUser_SeesHealthDashboard_NotDeploymentMonitoringOrAudit() { @@ -124,6 +142,8 @@ public class RoleNavigationTests // ── Deployment-only user ──────────────────────────────────────── + /// Verifies that the deployment user sees the Deployment navigation section. + /// A task that represents the asynchronous operation. [Fact] public async Task DeploymentUser_SeesDeploymentSection() { @@ -134,6 +154,8 @@ public class RoleNavigationTests await AssertNavLinkVisible(page, "Debug View"); } + /// Verifies that the deployment user does not see the Admin navigation section. + /// A task that represents the asynchronous operation. [Fact] public async Task DeploymentUser_DoesNotSeeAdminSection() { @@ -146,6 +168,8 @@ public class RoleNavigationTests await AssertNavLinkHidden(page, "Audit Log"); } + /// Verifies that the deployment user does not see the Design navigation section. + /// A task that represents the asynchronous operation. [Fact] public async Task DeploymentUser_DoesNotSeeDesignSection() { @@ -157,6 +181,8 @@ public class RoleNavigationTests await AssertNavLinkHidden(page, "External Systems"); } + /// Verifies that the deployment user sees monitoring links but not the configuration audit log. + /// A task that represents the asynchronous operation. [Fact] public async Task DeploymentUser_SeesMonitoringButNotConfigurationAuditLog() { @@ -172,6 +198,8 @@ public class RoleNavigationTests // ── Multi-role user (Admin + Design + Deployment) ─────────────── + /// Verifies that the multi-role user sees every navigation section. + /// A task that represents the asynchronous operation. [Fact] public async Task MultiRoleUser_SeesAllSections() { @@ -203,6 +231,9 @@ public class RoleNavigationTests // ── All users see Dashboard ───────────────────────────────────── + /// Verifies that every user sees the Dashboard link. + /// The username to sign in with. + /// A task that represents the asynchronous operation. [Theory] [InlineData("admin")] [InlineData("designer")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SandboxWorkerDeploymentTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SandboxWorkerDeploymentTests.cs index 68d7a5fd..63a3b0d5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SandboxWorkerDeploymentTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SandboxWorkerDeploymentTests.cs @@ -6,6 +6,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests; [Collection("Playwright")] public class SandboxWorkerDeploymentTests(PlaywrightFixture fixture) { + /// Verifies that the worker runs scripts, bounds their output and recovers after a noncooperative script. + /// A task that represents the asynchronous operation. [Fact] public async Task Medium_WorkerRunsBoundsOutputAndRecoversAfterNoncooperativeScript() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SessionActivityTrustedInputTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SessionActivityTrustedInputTests.cs index 7a3ec805..a5deb4f8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SessionActivityTrustedInputTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SessionActivityTrustedInputTests.cs @@ -7,6 +7,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests; [Collection("Playwright")] public class SessionActivityTrustedInputTests(PlaywrightFixture fixture) { + /// Verifies that synthetic input and pings stay passive while a trusted key press renews the session with antiforgery. + /// A task that represents the asynchronous operation. [Fact] public async Task UI2_SyntheticInputAndPingStayPassive_TrustedKeyRenewsWithAntiforgery() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallDataSeeder.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallDataSeeder.cs index 714752d6..737e85a6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallDataSeeder.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallDataSeeder.cs @@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.SiteCalls; /// /// Direct-SQL seeding helper for the Site Calls page Playwright E2E tests -/// (Site Call Audit #22, follow-ups Task 6). +/// (Site Call Audit). /// /// /// The Site Calls page reads the central SiteCalls table through the @@ -38,6 +38,19 @@ internal static class SiteCallDataSeeder /// needs for its grid assertions. TrackedOperationId is stored as the /// 36-character GUID string the entity mapping expects. /// + /// The tracked operation id of the row. + /// The channel of the site call. + /// The target of the site call. + /// The identifier of the originating site. + /// The status of the site call. + /// The retry count. + /// The UTC creation time. + /// The UTC time of the last update. + /// The last error message, if any. + /// The HTTP status code of the last attempt, if any. + /// The UTC time the call reached a terminal status, if any. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. public static async Task InsertSiteCallAsync( Guid trackedOperationId, string channel, @@ -84,6 +97,9 @@ VALUES /// starts with . Swallows all errors — the /// prefix carries a per-run GUID so the rows are unique to this test run. /// + /// The target prefix identifying the rows to delete. + /// A token to cancel the operation. + /// A task that represents the asynchronous operation. public static async Task DeleteByTargetPrefixAsync(string targetPrefix, CancellationToken ct = default) { try @@ -107,6 +123,8 @@ VALUES /// setup on this so a downed cluster surfaces a clear message rather than an /// opaque . /// + /// A token to cancel the operation. + /// A task that resolves to true when the configuration database is reachable; otherwise false. public static async Task IsAvailableAsync(CancellationToken ct = default) { try diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs index 67bfeec6..d2e08e7e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs @@ -4,8 +4,7 @@ using Xunit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.SiteCalls; /// -/// End-to-end coverage for the central Site Calls page (Site Call Audit #22, -/// follow-ups Task 6). +/// End-to-end coverage for the central Site Calls page (Site Call Audit). /// /// /// Each test seeds its own SiteCalls rows directly into the running @@ -14,13 +13,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.SiteCalls; /// their Target prefix. The Site Calls page reads the SiteCalls /// table through the SiteCallAuditActor (a pure read-from-table mirror), /// so a directly-INSERTed row surfaces exactly as a telemetry-ingested row -/// would — the same seeding model the Audit Log E2E tests use. The pattern +/// would — the same seeding model the Audit Log end-to-end tests use. The pattern /// keeps each test self-contained without touching /// infra/mssql/seed-config.sql. /// /// /// -/// Scenarios covered (per the Task 6 brief): +/// Scenarios covered: /// /// PageLoads — the page renders for a Deployment-role user. /// FilterNarrowing — a channel filter narrows the results grid. @@ -31,7 +30,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.SiteCalls; /// RetryClickThrough — clicking Retry on a Parked row confirms /// the dialog, relays to the owning site, and surfaces an outcome toast. /// DiscardClickThrough — clicking Discard on a Parked row confirms -/// the danger dialog ("Discard" since the row-28 explicit labels), relays to the owning site, and surfaces an +/// the danger dialog ("Discard", an explicit label), relays to the owning site, and surfaces an /// outcome toast. /// /// @@ -49,6 +48,8 @@ public class SiteCallsPageTests private readonly PlaywrightFixture _fixture; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture. public SiteCallsPageTests(PlaywrightFixture fixture) { _fixture = fixture; @@ -88,6 +89,8 @@ public class SiteCallsPageTests await search.DispatchEventAsync("change"); } + /// Verifies that the Site Calls page loads for a user with the Deployment role. + /// A task that represents the asynchronous operation. [Fact] public async Task PageLoads_ForDeploymentUser() { @@ -106,6 +109,8 @@ public class SiteCallsPageTests "SiteCallDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " + "or set SCADABRIDGE_PLAYWRIGHT_DB to a reachable connection string."; + /// Verifies that applying a channel filter shrinks the results grid. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task FilterNarrowing_ChannelFilterShrinksGrid() { @@ -162,6 +167,8 @@ public class SiteCallsPageTests } } + /// Verifies that the view audit history link navigates to the Audit Log pre-filtered to the call. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DrillIn_ViewAuditHistory_NavigatesToPreFilteredAuditLog() { @@ -209,6 +216,8 @@ public class SiteCallsPageTests } } + /// Verifies that the Retry and Discard actions are visible only on parked rows. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task RetryDiscard_VisibleOnlyOnParkedRows() { @@ -266,6 +275,8 @@ public class SiteCallsPageTests } } + /// Verifies that retrying a parked row confirms the dialog, relays to the site and shows an outcome toast. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast() { @@ -336,9 +347,10 @@ public class SiteCallsPageTests /// Mirrors /// but exercises the Discard path: clicking Discard opens a danger /// confirm modal (Dialog.ConfirmAsync with danger: true), whose footer - /// button is labelled "Discard" (explicit label since row 28; not "Confirm"). Confirming relays a discard to + /// button is labelled "Discard" (an explicit label, not "Confirm"). Confirming relays a discard to /// the owning site and ShowRelayOutcome surfaces exactly one outcome toast. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task DiscardClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast() { @@ -401,6 +413,7 @@ public class SiteCallsPageTests /// FilterPermittedAsync drops rows whose source site is not permitted. /// /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task StatusFilter_NarrowsToSelectedStatus() { @@ -470,6 +483,7 @@ public class SiteCallsPageTests /// instead. /// /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EmptyState_NoMatch_ShowsEmptyCard() { @@ -513,6 +527,7 @@ public class SiteCallsPageTests /// mid-fetch — reading button state before the fetch settles would race. /// /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Pagination_KeysetNextAndPrev_TraversesPages() { @@ -546,7 +561,7 @@ public class SiteCallsPageTests await SetSearchKeywordAsync(page, sharedTarget); await page.ClickAsync("[data-test='site-calls-query']"); - // The M10 KeysetPager summary span (`Page {N} · {rows} rows`), exposed + // The KeysetPager summary span (`Page {N} · {rows} rows`), exposed // via its stable data-test hook so a future markup tweak can't make this // match ambiguous under strict mode. var pageIndicator = page.Locator("[data-test='keyset-summary']"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCrudTests.cs index ffdce383..020e3cbd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCrudTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCrudTests.cs @@ -8,11 +8,15 @@ public class SiteCrudTests { private readonly PlaywrightFixture _fixture; + /// Creates the test class with the shared Playwright fixture. + /// The shared Playwright fixture. public SiteCrudTests(PlaywrightFixture fixture) { _fixture = fixture; } + /// Verifies that the sites page shows the site management view. + /// A task that represents the asynchronous operation. [Fact] public async Task SitesPage_ShowsSiteManagement() { @@ -27,6 +31,8 @@ public class SiteCrudTests await Expect(page.Locator("button:has-text('Add Site')")).ToBeVisibleAsync(); } + /// Verifies that the add site button navigates to the create page. + /// A task that represents the asynchronous operation. [Fact] public async Task AddSiteButton_NavigatesToCreatePage() { @@ -41,6 +47,8 @@ public class SiteCrudTests Assert.True(inputCount >= 2, $"Expected at least 2 inputs, found {inputCount}"); } + /// Verifies that the back button on the create page returns to the site list. + /// A task that represents the asynchronous operation. [Fact] public async Task CreatePage_BackButton_ReturnsToList() { @@ -54,6 +62,8 @@ public class SiteCrudTests await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync(); } + /// Verifies that the cancel button on the create page returns to the site list. + /// A task that represents the asynchronous operation. [Fact] public async Task CreatePage_CancelButton_ReturnsToList() { @@ -66,6 +76,8 @@ public class SiteCrudTests await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create"); } + /// Verifies that the create page has node address subsections. + /// A task that represents the asynchronous operation. [Fact] public async Task CreatePage_HasNodeSubsections() { @@ -81,6 +93,8 @@ public class SiteCrudTests await Expect(page.Locator(".card-header:has-text('Node B')")).ToBeVisibleAsync(); } + /// Verifies that saving without a name shows an error. + /// A task that represents the asynchronous operation. [Fact] public async Task CreatePage_SaveWithoutName_ShowsError() { @@ -99,6 +113,7 @@ public class SiteCrudTests /// Full create → edit → delete round-trip for a site via the Central UI. /// Uses CliRunner for best-effort teardown so no zztest-* site leaks on failure. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task CreateEditDelete_Site_RoundTrips() { @@ -205,6 +220,7 @@ public class SiteCrudTests /// (a raw EF message) and stays on /create. The Name is deliberately distinct so /// only the SiteIdentifier unique index can trip (not the Name unique index). /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task Create_DuplicateIdentifier_ShowsSaveFailedError() { @@ -243,6 +259,7 @@ public class SiteCrudTests /// (like Back) calls GoBack() → /admin/sites with NO dirty-check and NO persistence, /// so re-opening the edit must show the ORIGINAL (empty) Description. /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task EditCancel_DiscardsChanges() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportExportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportExportTests.cs index 53325235..26d35121 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportExportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportExportTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Transport; /// -/// Happy-path end-to-end for the Transport Export wizard (Component #24, Task T21). +/// Happy-path end-to-end for the Transport Export wizard. /// /// /// Drives the full four-step wizard at /design/transport/export against the @@ -40,11 +40,15 @@ public sealed class TransportExportTests { private readonly PlaywrightFixture _fixture; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture. public TransportExportTests(PlaywrightFixture fixture) { _fixture = fixture; } + /// Verifies that exporting a throwaway template through the wizard reaches the download summary. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ExportTemplate_ReachesDownloadSummary() { @@ -57,7 +61,7 @@ public sealed class TransportExportTests { var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password"); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/export"); - // Wait for Step-1 to render (OnInitializedAsync makes 8 repo calls over SignalR + // Wait for step 1 to render (OnInitializedAsync makes 8 repo calls over SignalR // before the template tree appears — NetworkIdle is an unreliable proxy for this). await Assertions.Expect(page.Locator("[data-testid='group-templates']")) .ToBeVisibleAsync(new() { Timeout = 15_000 }); @@ -85,8 +89,8 @@ public sealed class TransportExportTests // ── STEP 2: Review ──────────────────────────────────────────────────────── // The seed group lists the picked template; Next is always enabled here. - // Scope the Next click to the Step-2 panel — identified by its unique - // #include-deps toggle — so it cannot accidentally match a Step-1 button. + // Scope the Next click to the step 2 panel — identified by its unique + // #include-deps toggle — so it cannot accidentally match a step 1 button. await Assertions.Expect(page.Locator("[data-testid='seed-group']")) .ToBeVisibleAsync(new() { Timeout = 15_000 }); await page.Locator("div:has(#include-deps) button.btn.btn-primary:has-text('Next')") diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs index 4fa50872..9155c3d2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Transport; /// -/// End-to-end round-trip for the Transport Import wizard (Component #24, Task T22). +/// End-to-end round-trip for the Transport Import wizard (Transport component). /// /// /// The test exercises the full five-step Apply path at @@ -39,17 +39,21 @@ public sealed class TransportImportTests { private readonly PlaywrightFixture _fixture; + /// Initializes the test class with the shared Playwright fixture. + /// The shared Playwright fixture providing browser and cluster access. public TransportImportTests(PlaywrightFixture fixture) { _fixture = fixture; } + /// Verifies that importing a synthetic bundle applies it and shows the audit drill-in. + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ImportSyntheticBundle_AppliesAndShowsAuditDrillIn() { Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); - // The source-environment name doubles as the Step-4 confirm phrase, so it + // The source-environment name doubles as the final-step confirm phrase, so it // must round-trip verbatim through the bundle manifest and back into the UI. var env = CliRunner.UniqueName("env"); var tmplName = CliRunner.UniqueName("imp"); @@ -84,7 +88,7 @@ public sealed class TransportImportTests // The bundle is encrypted (we exported with a passphrase), so the page // peeks the manifest with no passphrase, fails to decrypt, and shows the // encrypted-bundle notice instead of the (unencrypted-only) manifest - // summary. Either way, the Step-1 Next button only renders once the file + // summary. Either way, the first-step Next button only renders once the file // was read successfully — its appearance proves SetInputFiles worked. await Assertions.Expect(page.Locator("[data-testid='encrypted-bundle-notice']")) .ToBeVisibleAsync(new() { Timeout = 15_000 }); @@ -175,6 +179,7 @@ public sealed class TransportImportTests /// deleted before import; teardown drops it by name prefix. /// /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ImportWithWrongPassphrase_ShowsErrorAndStaysOnPassphraseStep() { @@ -242,8 +247,8 @@ public sealed class TransportImportTests } /// - /// M8 Map step (#230): drive an import whose bundle carries a Site, so the - /// preview surfaces a and the wizard's Step-3 + /// Map step: drive an import whose bundle carries a Site, so the + /// preview surfaces a and the wizard's third-step /// Map sub-section ([data-testid='map-section']) renders. /// /// @@ -265,6 +270,7 @@ public sealed class TransportImportTests /// wizard. /// /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ImportSiteBundle_RendersMapStep_AndMappingEnablesNext() { @@ -365,7 +371,7 @@ public sealed class TransportImportTests } /// - /// M8 per-line diff (#230): drive an import where a bundled shared script's + /// Per-line diff: drive an import where a bundled shared script's /// Code diverges from the destination's, so the preview classifies it /// and the row's "Field diff" carries a /// structured lineDiff — rendered by the LineDiffView component as a @@ -386,6 +392,7 @@ public sealed class TransportImportTests /// required because the line-diff block is not laid out until the disclosure opens. /// /// + /// A task that represents the asynchronous operation. [SkippableFact] public async Task ImportModifiedSharedScript_RendersPerLineDiff() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs index db21b200..90bd93a3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs @@ -13,7 +13,7 @@ using ApiKeyForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.ApiKey namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin; /// -/// Bundle D drill-in test (#23 M7-T12) for the API Keys edit page, re-wired for the +/// Drill-in test for the API Keys edit page, re-wired for the /// inbound-API-key re-arch (C3) onto the seam. The chip /// routes operators into the central Audit Log pre-filtered by Actor = key Name AND /// Channel = ApiInbound (no other channel uses the key name as actor, but the explicit @@ -25,6 +25,7 @@ public class ApiKeyFormAuditDrillinTests : BunitContext private readonly IInboundApiKeyAdmin _admin = Substitute.For(); private readonly IInboundApiRepository _repo = Substitute.For(); + /// Creates the test class and registers the stubbed services the page needs. public ApiKeyFormAuditDrillinTests() { Services.AddSingleton(_admin); @@ -50,6 +51,7 @@ public class ApiKeyFormAuditDrillinTests : BunitContext string keyId, string name, bool enabled = true, params string[] methods) => new(keyId, name, enabled, methods, DateTimeOffset.UnixEpoch, null); + /// Verifies that the edit page has a recent audit activity link filtered by actor and the inbound API channel. [Fact] public void EditPage_HasRecentAuditActivityLink_WithActorAndApiInboundChannel() { @@ -72,6 +74,7 @@ public class ApiKeyFormAuditDrillinTests : BunitContext }); } + /// Verifies that the create page has no recent audit activity link. [Fact] public void CreatePage_HasNoRecentAuditActivityLink() { @@ -86,6 +89,8 @@ public class ApiKeyFormAuditDrillinTests : BunitContext }); } + /// Verifies that creating a key with a method selected shows the one-time token and key id. + /// A task that represents the asynchronous operation. [Fact] public async Task CreatePage_WithMethodSelected_ShowsOneTimeTokenAndKeyId() { @@ -118,6 +123,8 @@ public class ApiKeyFormAuditDrillinTests : BunitContext Arg.Any()); } + /// Verifies that creating a key with no method selected shows a validation error and creates nothing. + /// A task that represents the asynchronous operation. [Fact] public async Task CreatePage_WithNoMethodSelected_ShowsValidationError_AndDoesNotCreate() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs index 49290131..9d698ba8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs @@ -19,6 +19,7 @@ public class ApiKeysListPageTests : BunitContext { private readonly IInboundApiKeyAdmin _admin = Substitute.For(); + /// Initializes the test context and registers the fake API key seam. public ApiKeysListPageTests() { Services.AddSingleton(_admin); @@ -40,6 +41,7 @@ public class ApiKeysListPageTests : BunitContext string keyId, string name, bool enabled, params string[] methods) => new(keyId, name, enabled, methods, DateTimeOffset.UnixEpoch, null); + /// Verifies that the list renders the keys from the seam and shows no key hash column. [Fact] public void List_RendersKeysFromSeam_WithNoKeyHashColumn() { @@ -76,6 +78,8 @@ public class ApiKeysListPageTests : BunitContext }); } + /// Verifies that toggling a key calls SetEnabled on the seam. + /// A task that represents the asynchronous operation. [Fact] public async Task ToggleKey_CallsSetEnabledOnSeam() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs index 27c5173e..f14e2ab9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs @@ -15,7 +15,7 @@ using SiteForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.SiteForm namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin; /// -/// Bundle D drill-in test (#23 M7-T12) for the Site edit page. The chip +/// Drill-in test for the Site edit page. The chip /// routes operators into the central Audit Log pre-filtered by SourceSiteId = /// Site.SiteIdentifier (the same string the audit pipeline stamps onto every /// site-sourced row). Create mode suppresses the link — there's no site yet. @@ -25,6 +25,7 @@ public class SiteFormAuditDrillinTests : BunitContext private readonly ISiteRepository _siteRepo = Substitute.For(); private readonly CommunicationService _comms; + /// Initializes the test context with a stubbed site repository, communication service and an administrator identity. public SiteFormAuditDrillinTests() { _comms = new CommunicationService( @@ -45,6 +46,7 @@ public class SiteFormAuditDrillinTests : BunitContext AuthorizationPolicies.AddScadaBridgeAuthorization(Services); } + /// Verifies that the edit page links to recent audit activity filtered by the site identifier. [Fact] public void EditPage_HasRecentAuditActivityLink_WithSiteEqualToSiteIdentifier() { @@ -61,6 +63,7 @@ public class SiteFormAuditDrillinTests : BunitContext }); } + /// Verifies that the create page shows no recent audit activity link. [Fact] public void CreatePage_HasNoRecentAuditActivityLink() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs index 2dc97d1d..84155d8a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs @@ -19,7 +19,7 @@ using SitesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.Sites; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin; /// -/// Regression tests for CentralUI-012. The Sites page loaded all sites and then +/// Regression tests for the Sites page N+1 query fix. The Sites page loaded all sites and then /// issued GetDataConnectionsBySiteIdAsync once per site (N+1 database /// round-trips on every load and post-delete refresh). The fix fetches all /// data connections in a single GetAllDataConnectionsAsync call and @@ -73,7 +73,10 @@ public class SitesPageTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// Initializes a new instance of the class. + /// The authentication state returned to callers. public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// public override Task GetAuthenticationStateAsync() => Task.FromResult(_state); } @@ -105,6 +108,7 @@ public class SitesPageTests : BunitContext stale, unknown); + /// Verifies that loading data fetches all connections in one query rather than one query per site. [Fact] public void LoadData_FetchesAllConnectionsInOneQuery_NoPerSiteQueries() { @@ -122,6 +126,7 @@ public class SitesPageTests : BunitContext _siteRepo.DidNotReceive().GetDataConnectionsBySiteIdAsync(Arg.Any()); } + /// Verifies that loading data groups connections by site and renders them under their site. [Fact] public void LoadData_GroupsConnectionsBySite_AndRendersThem() { @@ -138,6 +143,7 @@ public class SitesPageTests : BunitContext Assert.Contains("beta-conn", cut.Markup); } + /// Verifies that the artifact badge shows Current, Out of date or Never deployed according to the site's artifact state. [Fact] public void ArtifactBadge_ShowsCurrent_OutOfDate_NeverDeployed() { @@ -163,6 +169,7 @@ public class SitesPageTests : BunitContext }); } + /// Verifies that the instance line shows the stale count out of the deployed count. [Fact] public void InstanceLine_ShowsStaleOfDeployed() { @@ -183,6 +190,7 @@ public class SitesPageTests : BunitContext }); } + /// Verifies that the instance line uses the correct singular wording when exactly one instance is affected. [Fact] public void InstanceLine_PluralisesTheSingleInstanceCase() { @@ -204,6 +212,7 @@ public class SitesPageTests : BunitContext }); } + /// Verifies that Re-check forces a refresh of the pending-updates snapshot. [Fact] public void Recheck_ForcesRefresh() { @@ -218,6 +227,7 @@ public class SitesPageTests : BunitContext _pendingUpdates.Received().GetAsync(true, Arg.Any())); } + /// Verifies that a failure loading sites skips the fleet-wide evaluation. [Fact] public void SiteLoadFailure_SkipsTheFleetEvaluation() { @@ -232,6 +242,7 @@ public class SitesPageTests : BunitContext _pendingUpdates.DidNotReceive().GetAsync(Arg.Any(), Arg.Any()); } + /// Verifies that a failed re-check clears the stale as-of caption. [Fact] public void FailedRecheck_ClearsTheStaleAsOfCaption() { @@ -258,6 +269,7 @@ public class SitesPageTests : BunitContext }); } + /// Verifies that a cache fault shows an unavailable state while the sites still render. [Fact] public void CacheFault_ShowsUnavailable_AndSitesStillRender() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs index 648f20d2..ed7ccb60 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs @@ -24,7 +24,7 @@ using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Audit; /// -/// Endpoint-level tests for the Audit Log CSV export (#23 M7-T14 / Bundle F). +/// Endpoint-level tests for the Audit Log CSV export. /// /// /// CentralUI uses minimal-API endpoints (see AuthEndpoints / @@ -38,7 +38,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Audit; /// public class AuditExportEndpointsTests { - // C3 (Task 2.5): the export endpoint reads canonical ZB.MOM.WW.Audit.AuditEvent + // The export endpoint reads canonical ZB.MOM.WW.Audit.AuditEvent // rows straight off IAuditLogRepository.QueryAsync; build them via the factory. private static AuditEvent SampleEvent() => ScadaBridgeAuditEventFactory.Create( @@ -71,7 +71,7 @@ public class AuditExportEndpointsTests web.ConfigureServices(services => { services.AddRouting(); - // The endpoint is AuditExport-gated (#23 M7-T15 Bundle G); + // The endpoint is AuditExport-gated; // the tests run as pre-authenticated principals built by // FakeAuthHandler (everyone has the Admin role), which is // one of AuditExportRoles, so the policy succeeds. @@ -79,7 +79,7 @@ public class AuditExportEndpointsTests .AddScheme( FakeAuthHandler.SchemeName, _ => { }); // Use the real production policy wiring so the endpoint's - // updated AuditExport gate (#23 M7-T15 Bundle G) is what + // updated AuditExport gate is what // the tests exercise. The fake principal carries the // "Administrator" role, which AuditExportRoles permits. services.AddScadaBridgeAuthorization(); @@ -103,6 +103,8 @@ public class AuditExportEndpointsTests return (client, repo, host); } + /// Verifies that a GET to the export endpoint returns a CSV content type and an attachment content disposition. + /// A task that represents the asynchronous operation. [Fact] public async Task ExportEndpoint_Get_ReturnsCsvContentType_AndAttachmentDisposition() { @@ -131,6 +133,8 @@ public class AuditExportEndpointsTests } } + /// Verifies that the export endpoint parses the query string into a filter and passes it to the export service. + /// A task that represents the asynchronous operation. [Fact] public async Task ExportEndpoint_PassesFilterFromQueryString_ToService() { @@ -180,6 +184,8 @@ public class AuditExportEndpointsTests } } + /// Verifies that a request without a query string passes an empty filter to the export service. + /// A task that represents the asynchronous operation. [Fact] public async Task ExportEndpoint_NoQueryString_PassesEmptyFilter() { @@ -210,11 +216,13 @@ public class AuditExportEndpointsTests } } + /// Verifies that an unrecognized enum value in the query string is silently ignored instead of failing the export. + /// A task that represents the asynchronous operation. [Fact] public async Task ExportEndpoint_UnknownEnumValue_SilentlyIgnored() { // Defensive parsing: a junk channel value MUST NOT 500 the export — - // mirrors the page-level query-string parser (#23 M7 Bundle D) which + // mirrors the page-level query-string parser, which // silently drops unrecognised values. var (client, repo, host) = await BuildHostAsync(); using (host) @@ -230,6 +238,8 @@ public class AuditExportEndpointsTests } } + /// Verifies that an unparseable execution id in the query string is silently dropped from the filter. + /// A task that represents the asynchronous operation. [Fact] public async Task ExportEndpoint_UnparseableExecutionId_SilentlyDropped() { @@ -249,6 +259,8 @@ public class AuditExportEndpointsTests } } + /// Verifies that an unparseable parent execution id in the query string is silently dropped from the filter. + /// A task that represents the asynchronous operation. [Fact] public async Task ExportEndpoint_UnparseableParentExecutionId_SilentlyDropped() { @@ -277,12 +289,17 @@ public class AuditExportEndpointsTests { public const string SchemeName = "FakeAuth"; + /// Initializes the fake authentication handler. + /// The authentication scheme options monitor. + /// The logger factory. + /// The URL encoder. public FakeAuthHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } + /// protected override Task HandleAuthenticateAsync() { var claims = new[] @@ -297,6 +314,7 @@ public class AuditExportEndpointsTests } } + /// Verifies that the audit export route is registered as a GET endpoint. [Fact] public void ExportEndpoint_RouteIsRegistered() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/FlowDebuggerDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/FlowDebuggerDrillinTests.cs index f52ff67a..b6ebd097 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/FlowDebuggerDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/FlowDebuggerDrillinTests.cs @@ -9,13 +9,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Audit; /// -/// Flow Debugger drill-ins (design 2026-09-05 §4, plan Task 21): the audit drawer's +/// Flow Debugger drill-ins (design 2026-09-05 §4): the audit drawer's /// detail body and the Execution Chain page both offer Open in Flow Debugger, /// and the inbound-method form carries the TraceAllCalls checkbox wired to both /// save paths. /// public class FlowDebuggerDrillinTests : BunitContext { + /// Initializes the test context with the services the drill-in components need. public FlowDebuggerDrillinTests() { JSInterop.Mode = JSRuntimeMode.Loose; @@ -49,6 +50,7 @@ public class FlowDebuggerDrillinTests : BunitContext private static string Read(params string[] relative) => File.ReadAllText(Path.Combine([SrcRoot, .. relative])); + /// Verifies that an audit event with an execution id offers an Open in Flow Debugger button that navigates to the replay. [Fact] public void AuditEventDetail_WithExecutionId_OffersOpenInFlowDebugger_AndNavigatesToReplay() { @@ -61,6 +63,7 @@ public class FlowDebuggerDrillinTests : BunitContext Assert.Contains($"/deployment/flow-debugger?executionId={exec}", nav.Uri); } + /// Verifies that an audit event without an execution id hides the Open in Flow Debugger button. [Fact] public void AuditEventDetail_WithoutExecutionId_HidesTheButton() { @@ -69,6 +72,7 @@ public class FlowDebuggerDrillinTests : BunitContext Assert.DoesNotContain("data-test=\"open-flow-debugger\"", cut.Markup); } + /// Verifies that the execution tree page links to the Flow Debugger replay beside the audit log link. [Fact] public void ExecutionTreePage_LinksToTheFlowDebuggerReplay_BesideTheAuditLogLink() { @@ -81,6 +85,7 @@ public class FlowDebuggerDrillinTests : BunitContext < markup.IndexOf("open-flow-debugger", StringComparison.Ordinal)); } + /// Verifies that the API method form has a trace-all-calls checkbox wired to both the create and update save paths. [Fact] public void ApiMethodForm_HasTraceAllCallsCheckbox_WiredToBothSavePaths() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/AuthEndpointsCsrfTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/AuthEndpointsCsrfTests.cs index a2f4b17d..6e45dac7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/AuthEndpointsCsrfTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/AuthEndpointsCsrfTests.cs @@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; /// -/// Regression tests for CentralUI-017. POST /auth/logout called +/// Regression tests for the logout CSRF fix. POST /auth/logout called /// .DisableAntiforgery() and a plain GET /logout route also /// signed the user out — either could be triggered cross-site to forcibly log /// a user out. Logout is a state-changing authenticated action and must be @@ -48,6 +48,7 @@ public class AuthEndpointsCsrfTests e.RoutePattern.RawText == pattern && (e.Metadata.GetMetadata()?.HttpMethods.Contains(method) ?? false)); + /// Verifies that the logout POST route does not disable antiforgery validation. [Fact] public void PostAuthLogout_DoesNotDisableAntiforgery() { @@ -63,6 +64,7 @@ public class AuthEndpointsCsrfTests "POST /auth/logout must keep antiforgery validation enabled."); } + /// Verifies that the state-changing GET logout route has been removed. [Fact] public void GetLogout_StateChangingRoute_IsRemoved() { @@ -72,6 +74,8 @@ public class AuthEndpointsCsrfTests Assert.Null(getLogout); } + /// Verifies that a login POST with a valid antiforgery token authenticates the user and sets the session cookie. + /// A task that represents the asynchronous operation. [Fact] public async Task PostAuthLogin_ValidTokenAuthenticatesAndSetsCookie() { @@ -105,6 +109,8 @@ public class AuthEndpointsCsrfTests Assert.Contains(context.Response.Headers.SetCookie, value => value!.StartsWith(".AspNetCore.Cookies=")); } + /// Verifies that a login POST with a missing antiforgery token is rejected before any credential lookup occurs. + /// A task that represents the asynchronous operation. [Fact] public async Task PostAuthLogin_MissingTokenRejectsBeforeCredentialLookup() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/AuthPingEndpointTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/AuthPingEndpointTests.cs index 07b95467..5940e65a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/AuthPingEndpointTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/AuthPingEndpointTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; /// -/// Regression tests for CentralUI-020. The Blazor circuit's +/// Regression tests for session-expiry detection. The Blazor circuit's /// CookieAuthenticationStateProvider serves a frozen constructor-time /// principal, so SessionExpiry could never observe a server-side cookie /// expiry by polling the auth state. The fix adds GET /auth/ping, an @@ -43,6 +43,7 @@ public class AuthPingEndpointTests e.RoutePattern.RawText == pattern && (e.Metadata.GetMetadata()?.HttpMethods.Contains(method) ?? false)); + /// Verifies that the GET route for the auth ping endpoint is mapped. [Fact] public void AuthPing_GetRoute_IsMapped() { @@ -50,6 +51,8 @@ public class AuthPingEndpointTests Assert.NotNull(ping); } + /// Verifies that an anonymous user receives a 401 response from the auth ping endpoint. + /// A task that represents the asynchronous operation. [Fact] public async Task AuthPing_AnonymousUser_Returns401() { @@ -63,6 +66,8 @@ public class AuthPingEndpointTests Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); } + /// Verifies that an authenticated user receives a 200 response from the auth ping endpoint. + /// A task that represents the asynchronous operation. [Fact] public async Task AuthPing_AuthenticatedUser_Returns200() { @@ -78,6 +83,7 @@ public class AuthPingEndpointTests Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); } + /// Verifies that the auth ping endpoint returns a status code instead of redirecting through the cookie middleware. [Fact] public void AuthPing_DoesNotTriggerCookieRedirect() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CircuitRevocationRegressionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CircuitRevocationRegressionTests.cs index 875838e8..e2cc8d21 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CircuitRevocationRegressionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CircuitRevocationRegressionTests.cs @@ -14,6 +14,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; public class CircuitRevocationRegressionTests { + /// Verifies that an explicit server auto-login does not remap the synthetic cookie principal. + /// A task that represents the asynchronous operation. [Fact] public async Task ExplicitServerAutoLogin_DoesNotRemapSyntheticCookiePrincipal() { @@ -38,6 +40,8 @@ public class CircuitRevocationRegressionTests await mapper.DidNotReceive().MapAsync(Arg.Any>(), Arg.Any()); } + /// Verifies that the same circuit rechecks role mapping and site scope after a grant is revoked. + /// A task that represents the asynchronous operation. [Fact] public async Task SameCircuit_RechecksMappingAndScope_AfterGrantRevocation() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/ClaimsPrincipalExtensionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/ClaimsPrincipalExtensionsTests.cs index 913f5c98..d276c7c8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/ClaimsPrincipalExtensionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/ClaimsPrincipalExtensionsTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; /// -/// Regression tests for CentralUI-024. Ten components each copy-pasted a +/// Regression tests for the shared claim lookups. Ten components each copy-pasted a /// GetCurrentUserAsync helper using the magic string /// FindFirst("Username"), and NavMenu/Dashboard used /// FindFirst("DisplayName"). A rename of the claim type in @@ -20,6 +20,7 @@ public class ClaimsPrincipalExtensionsTests private static ClaimsPrincipal Principal(params Claim[] claims) => new(new ClaimsIdentity(claims, authenticationType: "TestCookie")); + /// Verifies that the username lookup resolves the claim the JWT token service issues. [Fact] public void GetUsername_ResolvesTheJwtTokenServiceUsernameClaim() { @@ -29,6 +30,7 @@ public class ClaimsPrincipalExtensionsTests Assert.Equal("alice", principal.GetUsername()); } + /// Verifies that the username lookup falls back to unknown when the claim is absent. [Fact] public void GetUsername_FallsBackToUnknown_WhenClaimAbsent() { @@ -37,6 +39,7 @@ public class ClaimsPrincipalExtensionsTests Assert.Equal(ClaimsPrincipalExtensions.UnknownUser, principal.GetUsername()); } + /// Verifies that the display-name lookup resolves the claim the JWT token service issues. [Fact] public void GetDisplayName_ResolvesTheJwtTokenServiceDisplayNameClaim() { @@ -46,12 +49,15 @@ public class ClaimsPrincipalExtensionsTests Assert.Equal("Alice Anderson", principal.GetDisplayName()); } + /// Verifies that the display-name lookup returns null when the claim is absent. [Fact] public void GetDisplayName_IsNull_WhenClaimAbsent() { Assert.Null(Principal().GetDisplayName()); } + /// Verifies that the current-username lookup reads the username from the authentication state. + /// A task that represents the asynchronous operation. [Fact] public async Task GetCurrentUsernameAsync_ReadsUsernameFromAuthState() { @@ -63,6 +69,7 @@ public class ClaimsPrincipalExtensionsTests Assert.Equal("bob", await provider.GetCurrentUsernameAsync()); } + /// Verifies that the username lookup follows a rename of the claim type in the JWT token service. [Fact] public void Username_LookupTracksAJwtTokenServiceRename() { @@ -81,7 +88,10 @@ public class ClaimsPrincipalExtensionsTests private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// Initializes the provider with a fixed authentication state. + /// The authentication state to return. public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// public override Task GetAuthenticationStateAsync() => Task.FromResult(_state); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CookieAuthenticationStateProviderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CookieAuthenticationStateProviderTests.cs index 75ad4528..889fcc10 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CookieAuthenticationStateProviderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CookieAuthenticationStateProviderTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; /// -/// Regression tests for CentralUI-004. The provider used to read +/// Regression tests for the cookie authentication state provider. The provider used to read /// on every call; once the Blazor /// circuit is established that context is gone, so later re-evaluations saw an /// unauthenticated principal. The provider must snapshot the principal once at @@ -21,6 +21,8 @@ public class CookieAuthenticationStateProviderTests return new ClaimsPrincipal(identity); } + /// Verifies that the provider returns the authenticated user while the HTTP context is present. + /// A task that represents the asynchronous operation. [Fact] public async Task GetAuthenticationStateAsync_ReturnsAuthenticatedUser_WhenHttpContextPresent() { @@ -36,6 +38,8 @@ public class CookieAuthenticationStateProviderTests Assert.Equal("alice", state.User.Identity?.Name); } + /// Verifies that the provider still returns the user after the HTTP context is gone. + /// A task that represents the asynchronous operation. [Fact] public async Task GetAuthenticationStateAsync_StillReturnsUser_AfterHttpContextIsGone() { @@ -57,6 +61,8 @@ public class CookieAuthenticationStateProviderTests Assert.Equal("bob", state.User.Identity?.Name); } + /// Verifies that the provider returns the same user across calls and ignores a stale foreign HTTP context. + /// A task that represents the asynchronous operation. [Fact] public async Task GetAuthenticationStateAsync_IsStableAcrossCalls_IgnoringStaleForeignContext() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/ParkedMessagesMutationRegressionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/ParkedMessagesMutationRegressionTests.cs index 58858f69..1dbcca5a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/ParkedMessagesMutationRegressionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/ParkedMessagesMutationRegressionTests.cs @@ -20,6 +20,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; public class ParkedMessagesMutationRegressionTests : BunitContext { + /// Verifies that a retry for a site with a non-numeric identifier is relayed only while the user's current site grant allows it. + /// true to revoke the user's site grant before retrying, false to keep it. + /// A task that represents the asynchronous operation. [Theory] [InlineData(false)] [InlineData(true)] @@ -69,7 +72,9 @@ public class ParkedMessagesMutationRegressionTests : BunitContext private sealed class MutableAuth(string siteId) : AuthenticationStateProvider { + /// Gets or sets the site identifier carried in the user's site-scope claim. public string SiteId { get; set; } = siteId; + /// public override Task GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState( new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.UsernameClaimType, "operator"), @@ -80,6 +85,8 @@ public class ParkedMessagesMutationRegressionTests : BunitContext private sealed class ReplyActor : ReceiveActor { + /// Initializes the actor with the queue that records relayed retry requests. + /// The queue that receives each relayed retry envelope. public ReplyActor(ConcurrentQueue dispatched) { Receive(envelope => diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/RoutePolicyCoverageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/RoutePolicyCoverageTests.cs index 52f22805..9df21df0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/RoutePolicyCoverageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/RoutePolicyCoverageTests.cs @@ -8,6 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; public class RoutePolicyCoverageTests { + /// Verifies that every routable component has its reviewed authorization policy, including the external secrets pages. [Fact] public void EveryRoutableComponent_HasItsReviewedPolicy_IncludingExternalSecrets() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SessionExpiryComponentTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SessionExpiryComponentTests.cs index 5f6b425b..73687f8b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SessionExpiryComponentTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SessionExpiryComponentTests.cs @@ -7,19 +7,21 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; /// -/// Regression tests for CentralUI-020 and CentralUI-025. SessionExpiry +/// Regression tests for the session-expiry redirect. SessionExpiry /// used to poll the Blazor AuthenticationStateProvider, which (via /// CookieAuthenticationStateProvider) serves a frozen constructor-time /// principal — so the polled state could never become "expired" and the /// idle-logout redirect never fired. The component now polls the server /// GET /auth/ping endpoint, which reflects the live cookie session: a /// 401 response triggers a redirect to /login. These tests exercise that -/// redirect path directly (CentralUI-025: the path was previously untested). +/// redirect path directly (the path was previously untested). /// public class SessionExpiryComponentTests : BunitContext { private const string ModulePath = "./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/session-expiry.js"; + /// Verifies that an expired session redirects to the login page. + /// A task that represents the asynchronous operation. [Fact] public async Task CheckSession_ExpiredSession_RedirectsToLogin() { @@ -35,6 +37,8 @@ public class SessionExpiryComponentTests : BunitContext Assert.EndsWith("/login", nav.Uri); } + /// Verifies that a live session does not redirect. + /// A task that represents the asynchronous operation. [Fact] public async Task CheckSession_LiveSession_DoesNotRedirect() { @@ -52,6 +56,8 @@ public class SessionExpiryComponentTests : BunitContext Assert.DoesNotContain("/login", nav.Uri); } + /// Verifies that a transient network failure does not redirect. + /// A task that represents the asynchronous operation. [Fact] public async Task CheckSession_TransientNetworkFailure_DoesNotRedirect() { @@ -69,6 +75,8 @@ public class SessionExpiryComponentTests : BunitContext Assert.Equal(before, nav.Uri); } + /// Verifies that checking the session on the login page neither pings the server nor redirects. + /// A task that represents the asynchronous operation. [Fact] public async Task CheckSession_OnLoginPage_DoesNotPingOrRedirect() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SessionExpiryPolicyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SessionExpiryPolicyTests.cs index 573d1e33..3ed2d44c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SessionExpiryPolicyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SessionExpiryPolicyTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; /// -/// Regression tests for CentralUI-005. AuthEndpoints previously stamped a +/// Regression tests for session expiry. AuthEndpoints previously stamped a /// fixed expires_at = UtcNow + 30 min claim and a 30-minute absolute cookie /// ExpiresUtc with no sliding refresh, contradicting the documented /// "sliding refresh, 30-minute idle timeout" policy. The login handler must now @@ -14,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; /// public class SessionExpiryPolicyTests { + /// Verifies that the sign-in properties do not impose a fixed absolute expiry. [Fact] public void BuildSignInProperties_DoesNotSetFixedAbsoluteExpiry() { @@ -25,6 +26,7 @@ public class SessionExpiryPolicyTests Assert.Null(props.ExpiresUtc); } + /// Verifies that the sign-in properties request a persistent cookie. [Fact] public void BuildSignInProperties_IsPersistent() { @@ -33,6 +35,7 @@ public class SessionExpiryPolicyTests Assert.True(props.IsPersistent); } + /// Verifies that the sign-in properties allow a sliding refresh of the cookie. [Fact] public void BuildSignInProperties_AllowsSlidingRefresh() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SiteScopeServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SiteScopeServiceTests.cs index 04cd97bc..fd40e6b9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SiteScopeServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SiteScopeServiceTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; /// -/// Regression tests for CentralUI-002. Site-scoped Deployment permissions are +/// Regression tests for site-scoped permissions. Site-scoped Deployment permissions are /// written as SiteId claims at login but were never read — Deployment /// pages listed and acted on every site. is the /// shared helper that reads those claims; these tests pin its behaviour. @@ -17,7 +17,10 @@ public class SiteScopeServiceTests private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly ClaimsPrincipal _user; + /// Initializes a new instance of the StubAuthStateProvider class. + /// The user. public StubAuthStateProvider(ClaimsPrincipal user) => _user = user; + /// public override Task GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(_user)); } @@ -34,6 +37,8 @@ public class SiteScopeServiceTests private static List Sites(params int[] ids) => ids.Select(id => new Site($"Site{id}", $"SITE-{id}") { Id = id }).ToList(); + /// Verifies that a deployer with no site claims is treated as system-wide. + /// A task that represents the asynchronous operation. [Fact] public async Task DeploymentUserWithNoSiteClaims_IsSystemWide() { @@ -43,6 +48,8 @@ public class SiteScopeServiceTests Assert.Empty(await svc.PermittedSiteIdsAsync()); } + /// Verifies that filtering sites for a system-wide user returns every site. + /// A task that represents the asynchronous operation. [Fact] public async Task SystemWideUser_FilterSites_ReturnsAllSites() { @@ -53,6 +60,8 @@ public class SiteScopeServiceTests Assert.Equal(new[] { 1, 2, 3 }, filtered.Select(s => s.Id)); } + /// Verifies that filtering sites for a scoped user returns only the permitted sites. + /// A task that represents the asynchronous operation. [Fact] public async Task ScopedUser_FilterSites_ReturnsOnlyPermittedSites() { @@ -64,6 +73,8 @@ public class SiteScopeServiceTests Assert.Equal(new[] { 1, 3 }, filtered.Select(s => s.Id).OrderBy(x => x)); } + /// Verifies that a scoped user is allowed access only to the sites they were granted. + /// A task that represents the asynchronous operation. [Fact] public async Task ScopedUser_IsSiteAllowed_OnlyForGrantedSites() { @@ -73,6 +84,8 @@ public class SiteScopeServiceTests Assert.False(await svc.IsSiteAllowedAsync(6)); } + /// Verifies that a scoped user is not system-wide and reports its permitted site ids. + /// A task that represents the asynchronous operation. [Fact] public async Task ScopedUser_IsNotSystemWide_AndReportsItsPermittedIds() { @@ -82,6 +95,8 @@ public class SiteScopeServiceTests Assert.Equal(new[] { 7, 9 }, (await svc.PermittedSiteIdsAsync()).OrderBy(x => x)); } + /// Verifies that a system-wide user is allowed access to any site. + /// A task that represents the asynchronous operation. [Fact] public async Task SystemWideUser_IsSiteAllowed_ForAnySite() { @@ -99,6 +114,8 @@ public class SiteScopeServiceTests // could act fleet-wide through the CLI/HTTP surface while the UI hid the same // sites. The UI was the side that disagreed with the documented rule. + /// Verifies that an administrator carrying site claims is still system-wide. + /// A task that represents the asynchronous operation. [Fact] public async Task AdministratorWithSiteClaims_IsSystemWide() { @@ -107,6 +124,8 @@ public class SiteScopeServiceTests Assert.True(await svc.IsSystemWideAsync()); } + /// Verifies that filtering sites for an administrator with site claims returns every site. + /// A task that represents the asynchronous operation. [Fact] public async Task AdministratorWithSiteClaims_FilterSites_ReturnsAllSites() { @@ -117,6 +136,8 @@ public class SiteScopeServiceTests Assert.Equal(new[] { 1, 2, 3 }, filtered.Select(s => s.Id)); } + /// Verifies that an administrator with site claims is allowed access to sites outside those claims. + /// A task that represents the asynchronous operation. [Fact] public async Task AdministratorWithSiteClaims_IsSiteAllowed_ForSitesOutsideTheClaims() { @@ -125,6 +146,8 @@ public class SiteScopeServiceTests Assert.True(await svc.IsSiteAllowedAsync(2)); } + /// Verifies that an administrator with site claims reports no permitted site ids. + /// A task that represents the asynchronous operation. [Fact] public async Task AdministratorWithSiteClaims_ReportsNoPermittedIds() { @@ -136,6 +159,9 @@ public class SiteScopeServiceTests Assert.Empty(await svc.PermittedSiteIdsAsync()); } + /// Verifies that the administrator role match ignores case. + /// The role. + /// A task that represents the asynchronous operation. [Theory] [InlineData("administrator")] [InlineData("ADMINISTRATOR")] @@ -150,6 +176,8 @@ public class SiteScopeServiceTests Assert.True(await svc.IsSystemWideAsync()); } + /// Verifies that a designer with site claims remains scoped to those sites. + /// A task that represents the asynchronous operation. [Fact] public async Task DesignerWithSiteClaims_IsStillScoped() { @@ -172,6 +200,8 @@ public class SiteScopeServiceTests private static ClaimsPrincipal Principal(params Claim[] claims) => new(new ClaimsIdentity(claims, authenticationType: "TestCookie")); + /// Verifies that resolving an administrator yields a system-wide scope with an empty site set. + /// The result. [Fact] public void Resolve_Administrator_IsSystemWideWithAnEmptySet() { @@ -184,6 +214,8 @@ public class SiteScopeServiceTests Assert.Empty(sites); } + /// Verifies that resolving a principal with site claims yields the scoped site set. + /// The result. [Fact] public void Resolve_SiteClaims_YieldTheScopedSet() { @@ -194,6 +226,8 @@ public class SiteScopeServiceTests Assert.Equal([1, 3], sites.OrderBy(id => id)); } + /// Verifies that resolving a principal with no site claims yields a system-wide scope. + /// The result. [Fact] public void Resolve_NoSiteClaims_IsSystemWide() { @@ -203,6 +237,8 @@ public class SiteScopeServiceTests Assert.Empty(sites); } + /// Verifies that resolving an unauthenticated principal yields a scope with no permitted sites. + /// The result. [Fact] public void Resolve_UnauthenticatedPrincipal_IsScopedToNothing() { @@ -213,6 +249,8 @@ public class SiteScopeServiceTests Assert.Empty(sites); } + /// Verifies that the synchronous resolve produces the same result as the async path. + /// A task that represents the asynchronous operation. [Fact] public async Task Resolve_AgreesWithTheAsyncPath() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/UiAuditRegressionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/UiAuditRegressionTests.cs index d7d77b9c..4d0e8340 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/UiAuditRegressionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/UiAuditRegressionTests.cs @@ -22,6 +22,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; public class UiAuditRegressionTests : BunitContext { + /// Verifies that creating an LDAP mapping from the UI persists its audit row with the generated mapping id, or rolls back when the audit write fails. + /// Whether the audit write is made to fail. + /// A task that represents the asynchronous operation. [Theory] [InlineData(false)] [InlineData(true)] @@ -90,6 +93,7 @@ public class UiAuditRegressionTests : BunitContext private sealed class StaticAuth(System.Security.Claims.ClaimsPrincipal principal) : AuthenticationStateProvider { + /// public override Task GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(principal)); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ComponentRenderingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ComponentRenderingTests.cs index fca3802c..82a3f02d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ComponentRenderingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ComponentRenderingTests.cs @@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests; /// public class ComponentRenderingTests : BunitContext { + /// Verifies that the login page renders a form with username and password fields. [Fact] public void LoginPage_RendersForm_WithUsernameAndPasswordFields() { @@ -37,6 +38,7 @@ public class ComponentRenderingTests : BunitContext Assert.Contains("Sign in", submitButton.TextContent); } + /// Verifies that the login page does not render an alert when there is no error. [Fact] public void LoginPage_WithoutError_DoesNotRenderAlert() { @@ -45,6 +47,7 @@ public class ComponentRenderingTests : BunitContext Assert.Throws(() => cut.Find("div.panel.notice.login-error")); } + /// Verifies that the dashboard page requires an authorize attribute. [Fact] public void Dashboard_RequiresAuthorizeAttribute() { @@ -53,6 +56,7 @@ public class ComponentRenderingTests : BunitContext Assert.NotEmpty(authorizeAttrs); } + /// Verifies that the templates page requires the Design authorization policy. [Fact] public void TemplateEditor_RequiresDesignPolicy() { @@ -64,6 +68,7 @@ public class ComponentRenderingTests : BunitContext Assert.Equal("RequireDesign", attr.Policy); } + /// Verifies that the login page renders the ScadaBridge title. [Fact] public void LoginPage_RendersScadaBridgeTitle() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/AlarmStateBadgesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/AlarmStateBadgesTests.cs index 35957146..d371349e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/AlarmStateBadgesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/AlarmStateBadgesTests.cs @@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; /// /// Covers the AlarmStateBadges shared component lifted out of -/// DebugView (M7-T13 prep). The badge cluster must render identically +/// DebugView. The badge cluster must render identically /// wherever it is hosted, so these tests pin the rendered markup: state badge /// class, kind label, native sub-state badges, severity, and HiLo level. /// @@ -32,6 +32,7 @@ public class AlarmStateBadgesTests : BunitContext Severity: 700), }; + /// Verifies that the state, kind, sub-state, severity and level badges are rendered. [Fact] public void Renders_StateKindSubStateSeverityAndLevel_Badges() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs index 2bf4a1e0..962f4080 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs @@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit; /// -/// bUnit tests for (#23 M7 Bundle C / M7-T4..T8). +/// bUnit tests for . /// /// The drawer is a child component opened from the Audit Log page when a grid row /// is clicked. It renders the offcanvas chrome (header, open/close) and delegates @@ -24,6 +24,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit; /// public class AuditDrilldownDrawerTests : BunitContext { + /// Initializes the test context with loose JS interop and the services the drawer needs. public AuditDrilldownDrawerTests() { // Default to Loose so the cURL clipboard call does not blow up tests @@ -70,6 +71,7 @@ public class AuditDrilldownDrawerTests : BunitContext Extra = extra, }; + /// Verifies that the drawer renders the occurred-at UTC field. [Fact] public void Drawer_RendersField_OccurredAtUtc() { @@ -86,6 +88,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains("2026-05-20T12:30:45", cut.Markup); } + /// Verifies that a JSON request summary is pretty-printed with indentation. [Fact] public void Drawer_JsonRequestSummary_PrettyPrinted_Indented() { @@ -104,6 +107,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Matches(@"\n\s+""b"":\s*""two""", cut.Markup); } + /// Verifies that a non-JSON request summary is rendered verbatim. [Fact] public void Drawer_NonJsonRequestSummary_RenderedVerbatim() { @@ -118,6 +122,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains("not really json {{}", cut.Markup); } + /// Verifies that a DbOutbound event renders its SQL block. [Fact] public void Drawer_DbOutboundChannel_RendersSqlBlock() { @@ -140,6 +145,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains("abc", cut.Markup); } + /// Verifies that an API outbound event shows the copy-as-cURL button. [Fact] public void Drawer_ApiOutbound_ShowsCopyAsCurlButton() { @@ -152,6 +158,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains("data-test=\"copy-as-curl\"", cut.Markup); } + /// Verifies that a non-API channel event hides the copy-as-cURL button. [Fact] public void Drawer_NotApiChannel_HidesCopyAsCurlButton() { @@ -165,6 +172,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.DoesNotContain("data-test=\"copy-as-curl\"", cut.Markup); } + /// Verifies that a null correlation id hides the show-all button. [Fact] public void Drawer_NullCorrelationId_HidesShowAllButton() { @@ -177,6 +185,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.DoesNotContain("data-test=\"show-all-events\"", cut.Markup); } + /// Verifies that a redacted body shows the redaction badge. [Fact] public void Drawer_RedactedBody_ShowsRedactionBadge() { @@ -191,6 +200,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains("data-test=\"redaction-badge-request\"", cut.Markup); } + /// Verifies that a non-redacted body hides the redaction badge. [Fact] public void Drawer_NonRedactedBody_HidesBadge() { @@ -203,6 +213,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.DoesNotContain("data-test=\"redaction-badge-request\"", cut.Markup); } + /// Verifies that show-all-for-operation navigates with the correlation id in the query string. [Fact] public void ShowAllForOperation_Navigates_WithCorrelationIdQueryString() { @@ -220,6 +231,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains(corr.ToString(), nav.Uri); } + /// Verifies that a null execution id hides the view-this-execution button. [Fact] public void Drawer_NullExecutionId_HidesViewThisExecutionButton() { @@ -232,6 +244,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.DoesNotContain("data-test=\"view-this-execution\"", cut.Markup); } + /// Verifies that a non-null execution id shows the view-this-execution button. [Fact] public void Drawer_NonNullExecutionId_ShowsViewThisExecutionButton() { @@ -244,6 +257,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains("data-test=\"view-this-execution\"", cut.Markup); } + /// Verifies that view-this-execution navigates with the execution id in the query string. [Fact] public void ViewThisExecution_Navigates_WithExecutionIdQueryString() { @@ -260,6 +274,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains($"/audit/log?executionId={exec}", nav.Uri); } + /// Verifies that a null parent execution id hides the view-parent-execution button. [Fact] public void Drawer_NullParentExecutionId_HidesViewParentExecutionButton() { @@ -272,6 +287,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.DoesNotContain("data-test=\"view-parent-execution\"", cut.Markup); } + /// Verifies that a non-null parent execution id shows the view-parent-execution button. [Fact] public void Drawer_NonNullParentExecutionId_ShowsViewParentExecutionButton() { @@ -284,6 +300,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains("data-test=\"view-parent-execution\"", cut.Markup); } + /// Verifies that view-parent-execution navigates with the parent execution id in the query string. [Fact] public void ViewParentExecution_Navigates_WithExecutionIdQueryString() { @@ -303,6 +320,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains($"/audit/log?executionId={parent}", nav.Uri); } + /// Verifies that a null execution id hides the view-execution-chain button. [Fact] public void Drawer_NullExecutionId_HidesViewExecutionChainButton() { @@ -315,6 +333,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.DoesNotContain("data-test=\"view-execution-chain\"", cut.Markup); } + /// Verifies that a non-null execution id shows the view-execution-chain button. [Fact] public void Drawer_NonNullExecutionId_ShowsViewExecutionChainButton() { @@ -327,6 +346,7 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup); } + /// Verifies that view-execution-chain navigates to the execution tree page. [Fact] public void ViewExecutionChain_Navigates_ToExecutionTreePage() { @@ -345,6 +365,8 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri); } + /// Verifies that copy-as-cURL invokes the clipboard with the cURL command string. + /// A task that represents the asynchronous operation. [Fact] public async Task CopyAsCurl_InvokesClipboard_WithCurlString() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs index e0ff9f06..c195525e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs @@ -10,8 +10,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit; /// /// bUnit tests for — the reusable single-row -/// detail body extracted from (Task 1 of the -/// Execution-Tree Node Detail Modal feature). +/// detail body extracted from for reuse by the +/// Execution-Tree Node Detail Modal. /// /// These tests render the detail component directly (not via the drawer) and /// pin the contract the drawer — and any future modal host — relies on: @@ -22,6 +22,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit; /// public class AuditEventDetailTests : BunitContext { + /// Initializes the test context with loose JS interop so unrelated tests do not fail on the clipboard call. public AuditEventDetailTests() { // Loose so the cURL clipboard call does not blow up tests that do not @@ -67,6 +68,7 @@ public class AuditEventDetailTests : BunitContext Extra = extra, }; + /// Verifies that the read-only field block renders the audit event fields. [Fact] public void RendersFieldBlock() { @@ -81,6 +83,7 @@ public class AuditEventDetailTests : BunitContext Assert.Contains("2026-05-20T12:30:45", cut.Markup); } + /// Verifies that the source node field renders between the site and instance fields. [Fact] public void RendersSourceNodeField_BetweenSiteAndInstance() { @@ -103,6 +106,7 @@ public class AuditEventDetailTests : BunitContext $"Expected SourceSiteId < SourceNode < SourceInstanceId; got {siteIdx}, {nodeIdx}, {instanceIdx}"); } + /// Verifies that a null source node renders as a dash. [Fact] public void RendersSourceNodeField_AsDashWhenNull() { @@ -119,6 +123,7 @@ public class AuditEventDetailTests : BunitContext Assert.Contains(">—<", cut.Markup); } + /// Verifies that the error section renders when the event carries an error. [Fact] public void ErrorSection_RendersWhenErrorPresent() { @@ -134,6 +139,7 @@ public class AuditEventDetailTests : BunitContext Assert.Contains("stack trace here", cut.Markup); } + /// Verifies that the error section is hidden when the event has no error. [Fact] public void ErrorSection_HiddenWhenNoError() { @@ -144,6 +150,7 @@ public class AuditEventDetailTests : BunitContext Assert.DoesNotContain("data-test=\"section-error\"", cut.Markup); } + /// Verifies that the request section pretty-prints a JSON payload. [Fact] public void RequestSection_PrettyPrintsJson() { @@ -156,6 +163,7 @@ public class AuditEventDetailTests : BunitContext Assert.Matches(@"\n\s+""a"":\s*1", cut.Markup); } + /// Verifies that the response section renders when a response payload is present. [Fact] public void ResponseSection_RendersWhenPresent() { @@ -167,6 +175,7 @@ public class AuditEventDetailTests : BunitContext Assert.Contains("data-test=\"response-body\"", cut.Markup); } + /// Verifies that the extra section renders when extra data is present. [Fact] public void ExtraSection_RendersWhenPresent() { @@ -177,6 +186,7 @@ public class AuditEventDetailTests : BunitContext Assert.Contains("data-test=\"section-extra\"", cut.Markup); } + /// Verifies that a redacted body shows the redaction badge. [Fact] public void RedactedBody_ShowsRedactionBadge() { @@ -187,6 +197,7 @@ public class AuditEventDetailTests : BunitContext Assert.Contains("data-test=\"redaction-badge-request\"", cut.Markup); } + /// Verifies that a body that was not redacted hides the redaction badge. [Fact] public void NonRedactedBody_HidesRedactionBadge() { @@ -197,6 +208,7 @@ public class AuditEventDetailTests : BunitContext Assert.DoesNotContain("data-test=\"redaction-badge-request\"", cut.Markup); } + /// Verifies that a database outbound event renders its SQL block. [Fact] public void DbOutboundChannel_RendersSqlBlock() { @@ -210,6 +222,7 @@ public class AuditEventDetailTests : BunitContext Assert.Contains("data-test=\"sql-parameters\"", cut.Markup); } + /// Verifies that an API channel event shows the copy-as-cURL button. [Fact] public void ApiChannel_ShowsCopyAsCurlButton() { @@ -220,6 +233,7 @@ public class AuditEventDetailTests : BunitContext Assert.Contains("data-test=\"copy-as-curl\"", cut.Markup); } + /// Verifies that a non-API channel event hides the copy-as-cURL button. [Fact] public void NonApiChannel_HidesCopyAsCurlButton() { @@ -230,6 +244,7 @@ public class AuditEventDetailTests : BunitContext Assert.DoesNotContain("data-test=\"copy-as-curl\"", cut.Markup); } + /// Verifies that a null correlation id hides the show-all-for-operation button. [Fact] public void NullCorrelationId_HidesShowAllButton() { @@ -240,6 +255,7 @@ public class AuditEventDetailTests : BunitContext Assert.DoesNotContain("data-test=\"show-all-events\"", cut.Markup); } + /// Verifies that a non-null correlation id shows the show-all-for-operation button. [Fact] public void NonNullCorrelationId_ShowsShowAllButton() { @@ -250,6 +266,7 @@ public class AuditEventDetailTests : BunitContext Assert.Contains("data-test=\"show-all-events\"", cut.Markup); } + /// Verifies that the execution buttons render only when the matching execution ids are present. [Fact] public void ExecutionButtons_ConditionalOnExecutionIds() { @@ -264,6 +281,7 @@ public class AuditEventDetailTests : BunitContext Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup); } + /// Verifies that show-all-for-operation navigates with the correlation id in the query string. [Fact] public void ShowAllForOperation_Navigates_WithCorrelationIdQueryString() { @@ -278,6 +296,7 @@ public class AuditEventDetailTests : BunitContext Assert.Contains($"/audit/log?correlationId={corr}", nav.Uri); } + /// Verifies that view-execution-chain navigates to the execution tree page. [Fact] public void ViewExecutionChain_Navigates_ToExecutionTreePage() { @@ -292,6 +311,8 @@ public class AuditEventDetailTests : BunitContext Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri); } + /// Verifies that copy-as-cURL invokes the clipboard with the generated cURL command. + /// A task that represents the asynchronous operation. [Fact] public async Task CopyAsCurl_InvokesClipboard_WithCurlString() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs index a1f6e270..7d4ac647 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs @@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit; /// -/// bUnit tests for (#23 M7-T2 / Bundle B). +/// bUnit tests for . /// /// The bar carries the 10 spec filter elements plus the Errors-only toggle. /// Channel is a single-select <select data-test="filter-channel-select">; @@ -33,6 +33,7 @@ public class AuditFilterBarTests : BunitContext private readonly ISiteRepository _siteRepo; private readonly IAuditLogQueryService _auditLogQueryService; + /// Initializes the bUnit context with substitute site and audit query services. public AuditFilterBarTests() { _siteRepo = Substitute.For(); @@ -44,7 +45,7 @@ public class AuditFilterBarTests : BunitContext })); Services.AddSingleton(_siteRepo); - // Task 15: the Node multi-select pulls its options from + // The Node multi-select pulls its options from // IAuditLogQueryService.GetDistinctSourceNodesAsync. The default stub // returns the two central nodes the cluster uses; individual tests can // override via _auditLogQueryService.GetDistinctSourceNodesAsync(...).Returns(...). @@ -56,6 +57,7 @@ public class AuditFilterBarTests : BunitContext Services.AddSingleton(_auditLogQueryService); } + /// Verifies that the filter bar renders all ten filter elements plus the errors-only toggle. [Fact] public void Render_AllTenElements_Plus_ErrorsOnlyToggle_Present() { @@ -86,6 +88,7 @@ public class AuditFilterBarTests : BunitContext } } + /// Verifies that applying the filters raises the filter-changed callback with the selected filters. [Fact] public void Apply_RaisesOnFilterChanged_WithSelectedFilters() { @@ -103,6 +106,7 @@ public class AuditFilterBarTests : BunitContext Assert.Equal("Plant-A-OPC", captured.Target); } + /// Verifies that choosing a different channel replaces the previous selection because the channel is single-select. [Fact] public void ChangingChannel_ReplacesTheSelection_SingleSelect() { @@ -125,6 +129,7 @@ public class AuditFilterBarTests : BunitContext Assert.Null(captured!.Channels); } + /// Verifies that selecting a channel narrows the available kind options. [Fact] public void Channel_Narrows_Kind_Options_When_Selected() { @@ -149,6 +154,7 @@ public class AuditFilterBarTests : BunitContext Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.InboundRequest}\"", cut.Markup); } + /// Verifies that the errors-only toggle adds the failed, parked and discarded statuses when no status is selected. [Fact] public void ErrorsOnly_Toggle_Adds_FailedParkedDiscarded_ToStatus_WhenStatusIsEmpty() { @@ -161,7 +167,7 @@ public class AuditFilterBarTests : BunitContext cut.Find("[data-test=\"filter-apply\"]").Click(); Assert.NotNull(captured); - // Task 9: Errors-only targets the full non-success set {Failed, Parked, Discarded}. + // Errors-only targets the full non-success set {Failed, Parked, Discarded}. Assert.NotNull(captured!.Statuses); Assert.Equal(3, captured.Statuses!.Count); Assert.Contains(AuditStatus.Failed, captured.Statuses); @@ -175,10 +181,11 @@ public class AuditFilterBarTests : BunitContext Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses); } + /// Verifies that the node multi-select renders its options from the query service and maps the selection through to the filter. [Fact] public void NodeMultiSelect_RendersOptions_FromQueryService_AndMapsThroughToFilter() { - // Task 15: the Node filter pulls its option set from + // The Node filter pulls its option set from // IAuditLogQueryService.GetDistinctSourceNodesAsync and threads the // chip selection into AuditLogQueryFilter.SourceNodes. AuditLogQueryFilter? captured = null; @@ -199,10 +206,11 @@ public class AuditFilterBarTests : BunitContext Assert.Equal(new[] { "central-a" }, captured.SourceNodes); } + /// Verifies that applying with several status chips selected passes every selected status to the filter. [Fact] public void Apply_WithMultipleStatusChips_PassesAllSelectedStatuses() { - // Task 9: multiple explicit Status chips all reach the filter — and they + // Multiple explicit Status chips all reach the filter — and they // win over the Errors-only default. AuditLogQueryFilter? captured = null; var cut = Render(p => p @@ -219,6 +227,7 @@ public class AuditFilterBarTests : BunitContext Assert.Contains(AuditStatus.Failed, captured.Statuses); } + /// Verifies that a pasted execution id is mapped through to the filter on apply. [Fact] public void Apply_WithPastedExecutionId_MapsThroughToFilter() { @@ -236,6 +245,7 @@ public class AuditFilterBarTests : BunitContext Assert.Equal(executionId, captured!.ExecutionId); } + /// Verifies that a blank or unparseable execution id leaves the filter execution id null. [Fact] public void Apply_WithBlankOrUnparseableExecutionId_LeavesFilterExecutionIdNull() { @@ -255,6 +265,7 @@ public class AuditFilterBarTests : BunitContext Assert.Null(captured!.ExecutionId); } + /// Verifies that a pasted parent execution id is mapped through to the filter on apply. [Fact] public void Apply_WithPastedParentExecutionId_MapsThroughToFilter() { @@ -272,6 +283,7 @@ public class AuditFilterBarTests : BunitContext Assert.Equal(parentExecutionId, captured!.ParentExecutionId); } + /// Verifies that a blank or unparseable parent execution id leaves the filter parent execution id null. [Fact] public void Apply_WithBlankOrUnparseableParentExecutionId_LeavesFilterParentExecutionIdNull() { @@ -291,6 +303,7 @@ public class AuditFilterBarTests : BunitContext Assert.Null(captured!.ParentExecutionId); } + /// Verifies that choosing the last-hour time range sets the from-UTC value to approximately one hour ago. [Fact] public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs index a9b009b3..d59ff256 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit; /// -/// bUnit tests for (#23 M7-T3 / Bundle B). The grid +/// bUnit tests for . The grid /// renders 10 columns, paginates via keyset (passing the last row's /// (OccurredAtUtc, EventId) back to the service), raises a row-click callback /// that Bundle C wires to the drilldown drawer, and styles non-success status @@ -39,6 +39,7 @@ public class AuditResultsGridTests : BunitContext ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null, }; + /// Initializes a new instance of the class. public AuditResultsGridTests() { _service = Substitute.For(); @@ -62,6 +63,7 @@ public class AuditResultsGridTests : BunitContext }); } + /// Verifies the case: render, ten Columns, from Stub Service. [Fact] public void Render_TenColumns_FromStubService() { @@ -84,6 +86,7 @@ public class AuditResultsGridTests : BunitContext } } + /// Verifies the case: click, next Page, calls Service, with Cursor, of Last Row. [Fact] public void Click_NextPage_CallsService_WithCursor_OfLastRow() { @@ -105,6 +108,7 @@ public class AuditResultsGridTests : BunitContext Assert.Equal(second.EventId, nextCall.Paging.AfterEventId); } + /// Verifies the case: click, row, raises On Row Selected. [Fact] public void Click_Row_RaisesOnRowSelected() { @@ -122,10 +126,11 @@ public class AuditResultsGridTests : BunitContext Assert.Equal(target.EventId, captured!.EventId); } + /// Verifies the case: render, includes Node Column, between Site And Channel. [Fact] public void Render_IncludesNodeColumn_BetweenSiteAndChannel() { - // Task 15: the grid surfaces SourceNode in a dedicated "Node" column + // The grid surfaces SourceNode in a dedicated "Node" column // positioned between Site and Channel. StubPage(new List { @@ -144,6 +149,7 @@ public class AuditResultsGridTests : BunitContext Assert.True(nodeIdx < channelIdx, "Node column must precede Channel."); } + /// Verifies the case: render, includes Execution Id Column. [Fact] public void Render_IncludesExecutionIdColumn() { @@ -158,6 +164,7 @@ public class AuditResultsGridTests : BunitContext Assert.Contains("data-test=\"col-header-ExecutionId\"", cut.Markup); } + /// Verifies the case: execution Id, non Null Row, renders Short Monospace Value. [Fact] public void ExecutionId_NonNullRow_RendersShortMonospaceValue() { @@ -175,6 +182,7 @@ public class AuditResultsGridTests : BunitContext Assert.Equal(executionId.ToString(), cell.GetAttribute("title")); } + /// Verifies the case: execution Id, null Row, renders Blank Placeholder, no Execution Id Cell. [Fact] public void ExecutionId_NullRow_RendersBlankPlaceholder_NoExecutionIdCell() { @@ -187,6 +195,7 @@ public class AuditResultsGridTests : BunitContext Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]")); } + /// Verifies the case: render, includes Parent Execution Id Column. [Fact] public void Render_IncludesParentExecutionIdColumn() { @@ -201,6 +210,7 @@ public class AuditResultsGridTests : BunitContext Assert.Contains("data-test=\"col-header-ParentExecutionId\"", cut.Markup); } + /// Verifies the case: parent Execution Id, non Null Row, renders Short Monospace Value. [Fact] public void ParentExecutionId_NonNullRow_RendersShortMonospaceValue() { @@ -218,6 +228,7 @@ public class AuditResultsGridTests : BunitContext Assert.Equal(parentExecutionId.ToString(), cell.GetAttribute("title")); } + /// Verifies the case: parent Execution Id, null Row, renders Blank Placeholder, no Parent Execution Id Cell. [Fact] public void ParentExecutionId_NullRow_RendersBlankPlaceholder_NoParentExecutionIdCell() { @@ -230,6 +241,7 @@ public class AuditResultsGridTests : BunitContext Assert.Empty(cut.FindAll($"[data-test=\"parent-execution-id-{row.EventId}\"]")); } + /// Verifies the case: status, failed Row, has Error Badge Class. [Fact] public void Status_FailedRow_HasErrorBadgeClass() { @@ -247,7 +259,7 @@ public class AuditResultsGridTests : BunitContext Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty); } - // --- column resize + reorder UX (#23 follow-ups Task 10) --------------- + // --- column resize + reorder UX --------------- // // The drag interaction itself is browser-side (audit-grid.js) and covered // by the Playwright suite. The bUnit tests below exercise the .NET-side @@ -265,6 +277,7 @@ public class AuditResultsGridTests : BunitContext private static int HeaderIndex(string markup, string key) => markup.IndexOf($"data-col-key=\"{key}\"", StringComparison.Ordinal); + /// Verifies the case: headers, render Resize Handle And Drag Key, for Every Column. [Fact] public void Headers_RenderResizeHandleAndDragKey_ForEveryColumn() { @@ -280,6 +293,7 @@ public class AuditResultsGridTests : BunitContext } } + /// Verifies the case: column Order Parameter, drives Header Order. [Fact] public void ColumnOrderParameter_DrivesHeaderOrder() { @@ -300,6 +314,8 @@ public class AuditResultsGridTests : BunitContext } } + /// Verifies the case: on Column Reordered, moves Column Into Target Slot, and Persists. + /// A task that represents the asynchronous operation. [Fact] public async Task OnColumnReordered_MovesColumnIntoTargetSlot_AndPersists() { @@ -318,6 +334,8 @@ public class AuditResultsGridTests : BunitContext Assert.Contains("Status", (string)save.Arguments[1]!); } + /// Verifies the case: on Column Resized, below Minimum, clamps To64px, and Persists. + /// A task that represents the asynchronous operation. [Fact] public async Task OnColumnResized_BelowMinimum_ClampsTo64px_AndPersists() { @@ -335,6 +353,7 @@ public class AuditResultsGridTests : BunitContext i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnWidths"); } + /// Verifies the case: stored Order, with Unknown Key, degrades Gracefully. [Fact] public void StoredOrder_WithUnknownKey_DegradesGracefully() { @@ -360,6 +379,7 @@ public class AuditResultsGridTests : BunitContext } } + /// Verifies the case: stored Widths, for Unknown Column, are Ignored. [Fact] public void StoredWidths_ForUnknownColumn_AreIgnored() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs index 2890924d..4bb0c0d5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs @@ -11,7 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit; /// /// bUnit tests for (Execution-Tree Node Detail -/// Modal, Task 3). The modal opens on an execution-tree node double-click: given +/// Modal). The modal opens on an execution-tree node double-click: given /// an ExecutionId it loads that execution's audit rows via /// and shows a list → per-row detail. /// @@ -24,6 +24,7 @@ public class ExecutionDetailModalTests : BunitContext private readonly IAuditLogQueryService _service; private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new(); + /// Initializes a new instance of the ExecutionDetailModalTests class. public ExecutionDetailModalTests() { _service = Substitute.For(); @@ -66,6 +67,7 @@ public class ExecutionDetailModalTests : BunitContext }); } + /// Verifies that closed modal renders nothing and does not query. [Fact] public void ClosedModal_RendersNothing_AndDoesNotQuery() { @@ -79,6 +81,7 @@ public class ExecutionDetailModalTests : BunitContext Assert.Empty(_calls); } + /// Verifies that open transition queries by execution id with page size 100. [Fact] public void OpenTransition_QueriesByExecutionId_WithPageSize100() { @@ -101,6 +104,7 @@ public class ExecutionDetailModalTests : BunitContext Assert.Equal(100, _calls[0].Paging!.PageSize); } + /// Verifies that still open non open parameter change does not requery. [Fact] public void StillOpen_NonOpenParameterChange_DoesNotRequery() { @@ -119,6 +123,7 @@ public class ExecutionDetailModalTests : BunitContext Assert.Single(_calls); } + /// Verifies that multi row renders list view with one button per row. [Fact] public void MultiRow_RendersListView_WithOneButtonPerRow() { @@ -138,6 +143,7 @@ public class ExecutionDetailModalTests : BunitContext Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]")); } + /// Verifies that multi row click row shows audit event detail. [Fact] public void MultiRow_ClickRow_ShowsAuditEventDetail() { @@ -158,6 +164,7 @@ public class ExecutionDetailModalTests : BunitContext Assert.NotNull(cut.Find("[data-test=\"execution-detail-back\"]")); } + /// Verifies that multi row back control returns to list. [Fact] public void MultiRow_BackControl_ReturnsToList() { @@ -180,6 +187,7 @@ public class ExecutionDetailModalTests : BunitContext Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]")); } + /// Verifies that single row opens straight to detail no back control. [Fact] public void SingleRow_OpensStraightToDetail_NoBackControl() { @@ -197,6 +205,7 @@ public class ExecutionDetailModalTests : BunitContext Assert.Empty(cut.FindAll("[data-test=\"execution-detail-back\"]")); } + /// Verifies that zero row shows friendly empty state. [Fact] public void ZeroRow_ShowsFriendlyEmptyState() { @@ -211,6 +220,7 @@ public class ExecutionDetailModalTests : BunitContext Assert.Contains("This execution emitted no audit rows.", empty.TextContent); } + /// Verifies that query throws shows inline error state does not rethrow. [Fact] public void QueryThrows_ShowsInlineErrorState_DoesNotRethrow() { @@ -228,6 +238,7 @@ public class ExecutionDetailModalTests : BunitContext Assert.Contains("db is down", error.TextContent); } + /// Verifies that close button raises on close. [Fact] public void CloseButton_RaisesOnClose() { @@ -245,6 +256,7 @@ public class ExecutionDetailModalTests : BunitContext Assert.True(closed); } + /// Verifies that backdrop click raises on close. [Fact] public void BackdropClick_RaisesOnClose() { @@ -262,6 +274,7 @@ public class ExecutionDetailModalTests : BunitContext Assert.True(closed); } + /// Verifies that escape key raises on close. [Fact] public void EscapeKey_RaisesOnClose() { @@ -279,6 +292,7 @@ public class ExecutionDetailModalTests : BunitContext Assert.True(closed); } + /// Verifies that header shows short execution id and row count. [Fact] public void Header_ShowsShortExecutionId_AndRowCount() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs index 1f747926..662fdd4e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs @@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit; /// /// bUnit tests for (Audit Log ParentExecutionId -/// feature, Task 10). The component takes the FLAT +/// feature). The component takes the FLAT /// list the repository returns, assembles it /// into a tree by joining to a /// parent node's , and renders it @@ -35,6 +35,7 @@ public class ExecutionTreeTests : BunitContext rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)); + /// Verifies that a single node renders as one tree node. [Fact] public void SingleNode_RendersOneTreeNode() { @@ -48,6 +49,7 @@ public class ExecutionTreeTests : BunitContext Assert.Contains($"data-test=\"tree-node-{id}\"", cut.Markup); } + /// Verifies that a multi-level tree is assembled from the flat node list. [Fact] public void MultiLevel_AssemblesTree_FromFlatList() { @@ -82,6 +84,7 @@ public class ExecutionTreeTests : BunitContext Assert.True(childIdx < grandIdx, "child must render before grandchild"); } + /// Verifies that a stub node renders with a stub marker. [Fact] public void StubNode_RendersStubMarker() { @@ -104,6 +107,7 @@ public class ExecutionTreeTests : BunitContext Assert.Contains("no audited actions", cut.Markup); } + /// Verifies that the node the user arrived from is visually highlighted. [Fact] public void ArrivedFromNode_IsVisuallyHighlighted() { @@ -128,6 +132,7 @@ public class ExecutionTreeTests : BunitContext Assert.DoesNotContain("execution-tree-node--current", other.GetAttribute("class") ?? string.Empty); } + /// Verifies that each node link points to the audit log filtered by that node's execution. [Fact] public void NodeLink_PointsTo_AuditLogFilteredByThatExecution() { @@ -154,6 +159,7 @@ public class ExecutionTreeTests : BunitContext Assert.Equal($"/audit/log?executionId={root}", rootLink.GetAttribute("href")); } + /// Verifies that an empty node list renders nothing and does not throw. [Fact] public void EmptyNodeList_RendersNothingWithoutThrowing() { @@ -164,6 +170,7 @@ public class ExecutionTreeTests : BunitContext Assert.DoesNotContain("data-test=\"tree-node-", cut.Markup); } + /// Verifies that a cyclic flat list is handled without looping forever. [Fact] public void CyclicFlatList_TerminatesWithoutInfiniteLoop() { @@ -186,6 +193,7 @@ public class ExecutionTreeTests : BunitContext Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{b}\"")); } + /// Verifies that toggling a node collapses its child subtree and toggling again re-expands it. [Fact] public void ToggleExpand_CollapsesAndReExpandsChildSubtree() { @@ -229,6 +237,7 @@ public class ExecutionTreeTests : BunitContext cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded")); } + /// Verifies that double-clicking a node raises OnNodeActivated with that node's execution id. [Fact] public void DoubleClickingNode_RaisesOnNodeActivated_WithExecutionId() { @@ -255,6 +264,7 @@ public class ExecutionTreeTests : BunitContext Assert.Equal(root, activated); } + /// Verifies that double-clicking a nested node bubbles the activation up to the root callback. [Fact] public void DoubleClickingNestedNode_BubblesOnNodeActivated_ToRoot() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/ConnectionCertificatesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/ConnectionCertificatesTests.cs index 6a5eb2f7..c1546f01 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/ConnectionCertificatesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/ConnectionCertificatesTests.cs @@ -16,7 +16,7 @@ using ConnectionCertificates = namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; /// -/// Covers the M7-B10 (T17) connection-certificates management page: an +/// Covers the connection-certificates management page: an /// Administrator-gated page that resolves a data connection's owning site, lists /// the site's trusted-peer / rejected certificates via /// , and removes a certificate via @@ -31,6 +31,7 @@ public class ConnectionCertificatesTests : BunitContext private const int ConnectionId = 7; private const string SiteIdentifier = "plant-a"; + /// Initializes the test context and registers the services the page requires. public ConnectionCertificatesTests() { Services.AddSingleton(_certs); @@ -84,6 +85,7 @@ public class ConnectionCertificatesTests : BunitContext return host.FindComponent(); } + /// Verifies that the page lists both certificates from the owning site. [Fact] public void Lists_Two_Certs_From_Owning_Site() { @@ -105,6 +107,7 @@ public class ConnectionCertificatesTests : BunitContext _certs.Received(1).ListAsync(SiteIdentifier, Arg.Any()); } + /// Verifies that removing a certificate calls RemoveAsync with the site identifier and thumbprint. [Fact] public void Remove_Calls_RemoveAsync_With_SiteIdentifier_And_Thumbprint() { @@ -119,6 +122,7 @@ public class ConnectionCertificatesTests : BunitContext _certs.Received(1).RemoveAsync(SiteIdentifier, "AAAA1111", Arg.Any()); } + /// Verifies that an empty certificate store shows the empty-state note. [Fact] public void Empty_Store_Shows_Empty_Note() { @@ -131,6 +135,7 @@ public class ConnectionCertificatesTests : BunitContext Assert.Empty(cut.FindAll("[data-test=cert-row]")); } + /// Verifies that a list failure shows a load error. [Fact] public void List_Failure_Shows_Load_Error() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Health/AuditKpiTilesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Health/AuditKpiTilesTests.cs index 26c39af1..c95e0e9c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Health/AuditKpiTilesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Health/AuditKpiTilesTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Health; /// -/// bUnit tests for (#23 M7 Bundle E / M7-T13). The +/// bUnit tests for . The /// component renders three Bootstrap-card tiles — Volume, Error Rate, Backlog — /// from a single . The tests pin: /// @@ -25,6 +25,7 @@ public class AuditKpiTilesTests : BunitContext private static AuditLogKpiSnapshot MakeSnapshot(long total, long errors, long backlog) => new(total, errors, backlog, new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)); + /// Verifies that three tiles are rendered from the snapshot. [Fact] public void Renders_ThreeTiles_FromSnapshot() { @@ -43,6 +44,7 @@ public class AuditKpiTilesTests : BunitContext Assert.Contains("7", cut.Markup); // backlog } + /// Verifies that the error rate is computed from the total and error counts. [Fact] public void ErrorRate_Computed_From_Total_AndErrors() { @@ -54,6 +56,7 @@ public class AuditKpiTilesTests : BunitContext Assert.Contains("5.0%", cut.Markup); } + /// Verifies that zero events do not cause a division by zero and render a zero percent error rate. [Fact] public void ZeroEvents_DoesNotDivideByZero_RendersZeroPercent() { @@ -70,6 +73,7 @@ public class AuditKpiTilesTests : BunitContext Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup); } + /// Verifies that an unavailable snapshot renders em dashes and an error message. [Fact] public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage() { @@ -85,6 +89,7 @@ public class AuditKpiTilesTests : BunitContext Assert.Contains("DB connection refused", cut.Markup); } + /// Verifies that clicking the error rate tile navigates to the audit log filtered by failed status. [Fact] public void ErrorRateTile_Click_NavigatesToAuditLog_WithFailedStatusFilter() { @@ -102,6 +107,7 @@ public class AuditKpiTilesTests : BunitContext Assert.Contains("/audit/log?status=Failed", nav.Uri); } + /// Verifies that clicking the volume tile navigates to the unfiltered audit log. [Fact] public void VolumeTile_Click_NavigatesToUnfilteredAuditLog() { @@ -117,6 +123,7 @@ public class AuditKpiTilesTests : BunitContext Assert.EndsWith("/audit/log", nav.Uri); } + /// Verifies that clicking the backlog tile navigates to the audit log. [Fact] public void BacklogTile_Click_NavigatesToAuditLog() { @@ -131,6 +138,7 @@ public class AuditKpiTilesTests : BunitContext Assert.EndsWith("/audit/log", nav.Uri); } + /// Verifies that a nonzero error rate below ten percent gets a warning border rather than a danger border. [Fact] public void NonzeroErrorRate_GetsWarningBorder_NotDangerBelowTenPercent() { @@ -144,6 +152,7 @@ public class AuditKpiTilesTests : BunitContext Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty); } + /// Verifies that a high error rate gets a danger border. [Fact] public void HighErrorRate_GetsDangerBorder() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Health/SiteCallKpiTilesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Health/SiteCallKpiTilesTests.cs index 6bc19f93..4a379ea3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Health/SiteCallKpiTilesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Health/SiteCallKpiTilesTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Health; /// -/// bUnit tests for (Site Call Audit #22, Task 7). +/// bUnit tests for . /// The component renders three Bootstrap-card tiles — Buffered, Stuck, Parked — /// from a single snapshot. The tests pin: /// @@ -35,6 +35,7 @@ public class SiteCallKpiTilesTests : BunitContext OldestPendingAge: null, StuckCount: stuck); + /// Verifies that the component renders three tiles from a snapshot. [Fact] public void Renders_ThreeTiles_FromSnapshot() { @@ -54,6 +55,7 @@ public class SiteCallKpiTilesTests : BunitContext Assert.Contains(">3<", cut.Markup); // parked } + /// Verifies that an unavailable snapshot renders em dashes and the error message. [Fact] public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage() { @@ -69,6 +71,7 @@ public class SiteCallKpiTilesTests : BunitContext Assert.Contains("site call repository unavailable", cut.Markup); } + /// Verifies that the parked tile gets a danger border when the parked count is above zero. [Fact] public void ParkedTile_GetsDangerBorder_WhenParkedAboveZero() { @@ -80,6 +83,7 @@ public class SiteCallKpiTilesTests : BunitContext Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty); } + /// Verifies that the parked tile has no danger border when the parked count is zero. [Fact] public void ParkedTile_NoDangerBorder_WhenParkedZero() { @@ -91,6 +95,7 @@ public class SiteCallKpiTilesTests : BunitContext Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty); } + /// Verifies that the stuck tile gets a warning border when the stuck count is above zero. [Fact] public void StuckTile_GetsWarningBorder_WhenStuckAboveZero() { @@ -104,6 +109,7 @@ public class SiteCallKpiTilesTests : BunitContext Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty); } + /// Verifies that the stuck tile has no warning border when the stuck count is zero. [Fact] public void StuckTile_NoWarningBorder_WhenStuckZero() { @@ -115,6 +121,7 @@ public class SiteCallKpiTilesTests : BunitContext Assert.DoesNotContain("border-warning", tile.GetAttribute("class") ?? string.Empty); } + /// Verifies that the buffered tile never gets a threshold border, even with a high count. [Fact] public void BufferedTile_HasNoThresholdBorder_EvenWithHighCount() { @@ -130,6 +137,7 @@ public class SiteCallKpiTilesTests : BunitContext Assert.DoesNotContain("border-warning", cls); } + /// Verifies that clicking the buffered tile navigates to the unfiltered site calls report. [Fact] public void BufferedTile_Click_NavigatesToUnfilteredSiteCallsReport() { @@ -145,6 +153,7 @@ public class SiteCallKpiTilesTests : BunitContext Assert.EndsWith("/site-calls/report", nav.Uri); } + /// Verifies that clicking the stuck tile navigates to the site calls report with the stuck filter. [Fact] public void StuckTile_Click_NavigatesToSiteCallsReport_WithStuckFilter() { @@ -160,6 +169,7 @@ public class SiteCallKpiTilesTests : BunitContext Assert.Contains("/site-calls/report?stuck=true", nav.Uri); } + /// Verifies that clicking the parked tile navigates to the site calls report with the parked status filter. [Fact] public void ParkedTile_Click_NavigatesToSiteCallsReport_WithParkedStatusFilter() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureCsvImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureCsvImportTests.cs index efd4f9cc..3263b890 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureCsvImportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureCsvImportTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; /// -/// M7-T16: the Instance Configure page accepts a CSV of attribute overrides via +/// The Instance Configure page accepts a CSV of attribute overrides via /// an <InputFile>, parses it with the shared /// , validates each row against the instance's /// overridable attributes (name must exist + not be template-locked, value must be @@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; /// /// InstanceConfigure is a heavyweight page (≈7 injected services incl. /// InstanceService + the flattening pipeline), so — consistent with the -/// MV-14 List-override and native-alarm coverage — the parse→validate→build-dict +/// list-override and native-alarm coverage — the parse→validate→build-dict /// core is extracted to an internal static helper exercised directly here, /// plus structural assertions over the component source that pin the InputFile + /// reuse-the-existing-submit-path wiring. @@ -49,6 +49,7 @@ public class InstanceConfigureCsvImportTests // ── Core: valid CSV → override dict, no errors ────────────────────────── + /// Verifies that a valid CSV builds the override dictionary with no errors. [Fact] public void ValidCsv_BuildsOverrideDict_WithNoErrors() { @@ -64,6 +65,7 @@ public class InstanceConfigureCsvImportTests Assert.Equal("Pump-A", outcome.Overrides["Name"]); } + /// Verifies that a valid list-valued CSV row builds canonical JSON that round-trips through the value codec. [Fact] public void ValidListCsv_BuildsCanonicalJson_PerCodecRoundTrip() { @@ -81,6 +83,7 @@ public class InstanceConfigureCsvImportTests // ── Core: bad rows → errors, NO dict (all-or-nothing) ─────────────────── + /// Verifies that a row naming an unknown attribute produces an error and applies nothing. [Fact] public void UnknownAttribute_ProducesError_AndAppliesNothing() { @@ -94,6 +97,7 @@ public class InstanceConfigureCsvImportTests Assert.Contains(outcome.Errors, e => e.Contains("DoesNotExist") && e.Contains("Line 2")); } + /// Verifies that a template-locked attribute is rejected, matching the manual editor. [Fact] public void TemplateLockedAttribute_IsRejected_LikeTheEditor() { @@ -109,6 +113,7 @@ public class InstanceConfigureCsvImportTests Assert.Contains(outcome.Errors, e => e.Contains("SerialNumber")); } + /// Verifies that a type-incompatible value produces an error and applies nothing. [Fact] public void TypeIncompatibleValue_ProducesError_AndAppliesNothing() { @@ -123,6 +128,7 @@ public class InstanceConfigureCsvImportTests Assert.Contains(outcome.Errors, e => e.Contains("Setpoint") && e.Contains("Line 2")); } + /// Verifies that malformed list JSON produces an error and applies nothing. [Fact] public void MalformedListJson_ProducesError_AndAppliesNothing() { @@ -136,6 +142,7 @@ public class InstanceConfigureCsvImportTests Assert.Contains(outcome.Errors, e => e.Contains("Counts")); } + /// Verifies that parser errors propagate through the helper and nothing is applied. [Fact] public void ParserErrors_PropagateThrough_AndApplyNothing() { @@ -153,6 +160,7 @@ public class InstanceConfigureCsvImportTests // ── Structural: InputFile + reuse-the-existing-submit-path wiring ─────── + /// Verifies that the page source wires up the CSV input file control with its test hooks. [Fact] public void Page_WiresCsvInputFile_WithTestHooks() { @@ -165,6 +173,7 @@ public class InstanceConfigureCsvImportTests Assert.Contains("OverrideCsvParser.Parse", markup); } + /// Verifies that the page applies the imported overrides through the existing set-attribute-override path. [Fact] public void Page_AppliesViaExistingSetAttributeOverridePath() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs index f63f3c5c..697c63da 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs @@ -55,6 +55,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests // ── Core: valid CSV → rows, no errors ─────────────────────────────────── + /// Verifies that a valid CSV builds retarget rows with no errors. [Fact] public void ValidCsv_BuildsRetargetRows_WithNoErrors() { @@ -83,6 +84,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests Assert.Null(pump.Filter); } + /// Verifies that a row with all fields blank is marked as a clear. [Fact] public void AllBlankRow_IsMarkedAsClear() { @@ -98,6 +100,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests Assert.True(row.IsClear); } + /// Verifies that sources absent from the file are left untouched because the import merges rather than replaces. [Fact] public void SourcesAbsentFromTheFile_AreUntouched_MergeNotReplace() { @@ -114,6 +117,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests // ── Core: bad rows → errors, NO rows (all-or-nothing) ─────────────────── + /// Verifies that an unknown source produces an error and nothing is applied. [Fact] public void UnknownSource_ProducesError_AndAppliesNothing() { @@ -129,6 +133,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests Assert.Contains(outcome.Errors, e => e.Contains("DoesNotExist") && e.Contains("Line 2")); } + /// Verifies that a template-locked source is rejected, matching the server's behavior. [Fact] public void TemplateLockedSource_IsRejected_LikeTheServer() { @@ -142,6 +147,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests Assert.Contains(outcome.Errors, e => e.Contains("LockedAlarms") && e.Contains("locked")); } + /// Verifies that a duplicate source is rejected and nothing is applied. [Fact] public void DuplicateSource_IsRejected_AndAppliesNothing() { @@ -157,6 +163,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests Assert.Contains(outcome.Errors, e => e.Contains("BoilerAlarms") && e.Contains("more than once")); } + /// Verifies that parser errors propagate through and nothing is applied. [Fact] public void ParserErrors_PropagateThrough_AndApplyNothing() { @@ -174,6 +181,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests // ── Structural: InputFile + reuse-the-existing-save-path wiring ───────── + /// Verifies that the page wires the native-alarm-source CSV input file with its test hooks. [Fact] public void Page_WiresNativeAlarmSourceCsvInputFile_WithTestHooks() { @@ -186,6 +194,7 @@ public class InstanceConfigureNativeAlarmCsvImportTests Assert.Contains("BuildNativeAlarmSourceCsvImport", markup); } + /// Verifies that the import applies changes through the existing repository upsert path. [Fact] public void Import_AppliesViaTheExistingRepositoryUpsertPath() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSearchTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSearchTests.cs index 1b0f6234..e92155b8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSearchTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSearchTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; /// -/// Covers the M7-B6 (T15/T16) additions to NodeBrowserDialog: the +/// Covers the search additions to NodeBrowserDialog: the /// address-space search box that renders /// matches as a flat selectable list, and that selecting a search result feeds /// the SAME selection mechanism the tree uses (so the dialog's existing @@ -20,6 +20,7 @@ public class NodeBrowserDialogSearchTests : BunitContext { private readonly IBrowseService _browse = Substitute.For(); + /// Initializes the test context with a substituted browse service that returns an empty root. public NodeBrowserDialogSearchTests() { Services.AddSingleton(_browse); @@ -63,6 +64,7 @@ public class NodeBrowserDialogSearchTests : BunitContext private Func _capturedSelection = () => null; + /// Verifies that search matches render as rows carrying the expected data-test hooks. [Fact] public void Search_RendersMatchRows_WithDataTestHooks() { @@ -84,6 +86,7 @@ public class NodeBrowserDialogSearchTests : BunitContext Assert.Contains("Double", cut.Markup); } + /// Verifies that clicking a search result raises the selection callback with the node id. [Fact] public void ClickingSearchResult_RaisesSelectionCallback_WithNodeId() { @@ -106,6 +109,7 @@ public class NodeBrowserDialogSearchTests : BunitContext Assert.Equal("ns=2;s=Pump1.Speed", _capturedSelection()); } + /// Verifies that submitting a blank query clears the displayed search results. [Fact] public void BlankQuery_ClearsResults() { @@ -125,6 +129,7 @@ public class NodeBrowserDialogSearchTests : BunitContext Assert.Empty(cut.FindAll("[data-test=node-search-result]")); } + /// Verifies that a blank query after a failed search clears the stale failure alert. [Fact] public void BlankQueryAfterFailure_ClearsStaleFailureAlert() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs index 481df833..3ea76eaa 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs @@ -13,6 +13,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext { private readonly IBrowseService _browse = Substitute.For(); + /// Initializes the test context with a substituted browse service. public NodeBrowserDialogSelectionTests() { Services.AddSingleton(_browse); @@ -36,6 +37,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext CapReached: false, Failure: null)); } + /// Verifies that confirming a selection emits the full node including its data type through the node-selected callback. [Fact] public void Confirm_emits_full_node_with_datatype_on_OnNodeSelected() { @@ -57,6 +59,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext Assert.Equal("Double", captured.DataType); } + /// Verifies that setting ShowSearch to false hides the search box. [Fact] public void ShowSearch_false_hides_search_box() { @@ -74,6 +77,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext // ONLY user-reachable selection path in production is the tree (Select(TreeNode) // → _selectedNode = node.Source). This guards that path end-to-end: a tree-node // click must carry the node's DataType through Confirm → OnNodeSelected. + /// Verifies that selecting a node in the tree emits the full node including its data type through the node-selected callback. [Fact] public void TreeSelect_emits_full_node_with_datatype_on_OnNodeSelected() { @@ -107,6 +111,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext // SelectableNodeClasses (design 2026-08-21): the create page needs to pick a // module's CONTAINER object, not just a leaf tag. Default stays Variable-only // so every existing caller is unaffected. + /// Verifies that an Object node is not selectable by default. [Fact] public void Object_node_is_not_selectable_by_default() { @@ -122,6 +127,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext Assert.Single(cut.FindAll("[data-test=node-search-result] button.btn-link")); } + /// Verifies that an Object node is selectable when its class is listed as selectable. [Fact] public void Object_node_is_selectable_when_listed() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/OpcUaEndpointVerifyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/OpcUaEndpointVerifyTests.cs index ef4fe2ef..4e1ade88 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/OpcUaEndpointVerifyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/OpcUaEndpointVerifyTests.cs @@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; /// -/// Covers the M7-B8 (T17) "Verify endpoint" affordance added to +/// Covers the "Verify endpoint" affordance added to /// : a button that calls the injected /// with the current endpoint config and /// renders the typed — green on success, a red @@ -21,7 +21,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; /// with a captured cert. /// /// -/// Also covers the M7-B10 (T17) "Trust certificate" affordance on the untrusted-cert +/// Also covers the "Trust certificate" affordance on the untrusted-cert /// panel: an Administrator-gated button that calls /// and re-runs Verify on success. /// @@ -34,6 +34,7 @@ public class OpcUaEndpointVerifyTests : BunitContext private readonly ICertManagementService _certs = Substitute.For(); + /// Registers the substitute verification and certificate-management services with the test container. public OpcUaEndpointVerifyTests() { Services.AddSingleton(_verify); @@ -86,6 +87,7 @@ public class OpcUaEndpointVerifyTests : BunitContext return host.FindComponent(); } + /// Verifies that a successful endpoint verification shows a success message. [Fact] public void Verify_Success_ShowsSuccessMessage() { @@ -102,6 +104,7 @@ public class OpcUaEndpointVerifyTests : BunitContext Assert.Empty(cut.FindAll("[data-test=verify-cert-panel]")); } + /// Verifies that an untrusted server certificate shows the certificate panel with its thumbprint. [Fact] public void Verify_UntrustedCertificate_ShowsCertPanelWithThumbprint() { @@ -128,6 +131,7 @@ public class OpcUaEndpointVerifyTests : BunitContext Assert.NotEmpty(cut.FindAll("[data-test=verify-failure]")); } + /// Verifies that a generic verification failure shows a failure message. [Fact] public void Verify_GenericFailure_ShowsFailureMessage() { @@ -161,6 +165,7 @@ public class OpcUaEndpointVerifyTests : BunitContext .Returns(new VerifyEndpointResult( false, VerifyFailureKind.UntrustedCertificate, "Untrusted certificate.", cert)); + /// Verifies that an administrator sees the trust button and that clicking it calls the trust operation. [Fact] public void TrustButton_Admin_SeesButton_AndClick_CallsTrustAsync() { @@ -183,6 +188,7 @@ public class OpcUaEndpointVerifyTests : BunitContext "plant-a", "PLC-OPC", cert.DerBase64, cert.Thumbprint, Arg.Any()); } + /// Verifies that a user with only the Designer role does not see the trust button. [Fact] public void TrustButton_NonAdmin_DesignerOnly_DoesNotSeeButton() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWriteDataTypeMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWriteDataTypeMapperTests.cs index f43f5b77..8b1cccc3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWriteDataTypeMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWriteDataTypeMapperTests.cs @@ -5,6 +5,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; public class SecuredWriteDataTypeMapperTests { + /// Verifies that known Galaxy data type names map to the expected data type. + /// The Galaxy data type name. + /// The expected mapped data type. [Theory] [InlineData("Boolean", DataType.Boolean)] [InlineData("bool", DataType.Boolean)] @@ -22,6 +25,8 @@ public class SecuredWriteDataTypeMapperTests Assert.Equal(expected, dt); } + /// Verifies that unknown, null or blank Galaxy type names fail to map. + /// The Galaxy data type name, possibly null or blank. [Theory] [InlineData(null)] [InlineData("")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs index ea11aabe..a960fe7b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs @@ -17,7 +17,7 @@ using SecuredWritesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Opera namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; /// -/// bUnit rendering + interaction tests for the Secured Writes page (M7 T14b / C5). +/// bUnit rendering + interaction tests for the Secured Writes page. /// The page is the UI for the two-person MxGateway write workflow: an Operator submits, /// a different Verifier approves/rejects, and everyone sees the history. The page only /// SUBMITS commands through — the server enforces @@ -37,6 +37,7 @@ public class SecuredWritesTests : BunitContext // even though these tests never drive the browser. private readonly IBrowseService _browse = Substitute.For(); + /// Registers the substitute services and default stubs the Secured Writes page needs when it renders. public SecuredWritesTests() { Services.AddSingleton(_service); @@ -48,7 +49,7 @@ public class SecuredWritesTests : BunitContext // BOTH overloads must be stubbed: Reject takes the bool form, Approve takes the // DialogSeverity form (register row 75). An unstubbed NSubstitute call returns a // null Task, so missing one here surfaces as an NRE inside the click handler - // rather than as anything that names the dialog — the row-28 gotcha. + // rather than as anything that names the dialog — a known gotcha. _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) @@ -130,6 +131,7 @@ public class SecuredWritesTests : BunitContext // ── (a) Operator: submit form present + Submit calls SubmitAsync with the values ── + /// Verifies that a user with the Operator role sees the submit form. [Fact] public void Operator_SeesSubmitForm() { @@ -145,6 +147,7 @@ public class SecuredWritesTests : BunitContext }); } + /// Verifies that submitting as an Operator calls the service with the entered values. [Fact] public void Operator_Submit_CallsServiceWithEnteredValues() { @@ -175,6 +178,7 @@ public class SecuredWritesTests : BunitContext // ── (b) Verifier: pending row renders with Approve/Reject; own-row actions disabled ── + /// Verifies that a Verifier sees enabled approve and reject actions on a pending row submitted by another operator. [Fact] public void Verifier_PendingRow_HasEnabledActions_ForOtherOperatorsRow() { @@ -194,6 +198,7 @@ public class SecuredWritesTests : BunitContext }); } + /// Verifies that a Verifier's own submission has its approve and reject actions disabled. [Fact] public void Verifier_OwnSubmission_HasApproveAndRejectDisabled() { @@ -216,6 +221,7 @@ public class SecuredWritesTests : BunitContext }); } + /// Verifies that approving a pending row as a Verifier calls ApproveAsync on the service. [Fact] public void Verifier_Approve_CallsApproveAsync() { @@ -233,6 +239,7 @@ public class SecuredWritesTests : BunitContext // ── (c) History: terminal rows render ── + /// Verifies that the history section renders rows in terminal states. [Fact] public void History_RendersTerminalRows() { @@ -263,6 +270,7 @@ public class SecuredWritesTests : BunitContext // ── (d) History pager: page-size 50, driven by TotalCount ── + /// Verifies that the history pager requests the second page using the total count when Next is clicked. [Fact] public void History_Pager_NextRequestsSecondPage_UsingTotalCount() { @@ -285,13 +293,14 @@ public class SecuredWritesTests : BunitContext cut.Find("[data-test='secured-write-history-next']").Click(); - // The history page-2 fetch offsets by one page (skip: 50). + // The history second-page fetch offsets by one page (skip: 50). _service.Received().ListAsync( Arg.Any(), Arg.Any(), 50, Arg.Any(), Arg.Any()); } // ── (e) Approve dialog surfaces submission age (stale-setpoint scenario) ── + /// Verifies that the approve confirmation dialog shows the age of the submission. [Fact] public void Approve_ConfirmDialog_ShowsSubmissionAge() { @@ -316,6 +325,7 @@ public class SecuredWritesTests : BunitContext // ── (f) Approve/Reject confirm-button severity (register row 75) ── + /// Verifies that the approve confirmation dialog uses caution styling rather than destructive styling. [Fact] public void Approve_ConfirmDialog_IsCautionNotDestructive() { @@ -343,6 +353,7 @@ public class SecuredWritesTests : BunitContext Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } + /// Verifies that the reject confirmation dialog keeps its destructive styling. [Fact] public void Reject_ConfirmDialog_KeepsDestructiveStyling() { @@ -364,6 +375,7 @@ public class SecuredWritesTests : BunitContext Arg.Any(), Arg.Any(), danger: true, confirmLabel: "Reject"); } + /// Verifies that a Viewer does not see the Operator or Verifier regions. [Fact] public void Viewer_DoesNotSeeOperatorOrVerifierRegions() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Shared/KpiTrendChartTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Shared/KpiTrendChartTests.cs index e60a1e23..3d253644 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Shared/KpiTrendChartTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Shared/KpiTrendChartTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Shared; /// -/// bUnit tests for (M6 K12) — the reusable +/// bUnit tests for — the reusable /// dependency-free SVG trend chart. Coverage pins the three render states /// (multi-point chart, unavailable placeholder, single-sample note), the stable /// data-test slug, and the coordinate-math edge guards (all-equal @@ -22,6 +22,7 @@ public class KpiTrendChartTests : BunitContext new KpiSeriesPoint(Base.AddMinutes(10), 2.0), }; + /// Verifies that an available series with three points renders a polyline, a title and a data-test attribute. [Fact] public void Available_WithThreePoints_RendersPolylineTitleAndDataTest() { @@ -38,6 +39,7 @@ public class KpiTrendChartTests : BunitContext Assert.Contains("", markup); } + /// <summary>Verifies that a unit is appended to the rendered value labels.</summary> [Fact] public void Available_WithUnit_AppendsUnitToValueLabels() { @@ -51,6 +53,7 @@ public class KpiTrendChartTests : BunitContext Assert.Contains("max 4 s", cut.Markup); } + /// <summary>Verifies that an unavailable series renders an em dash placeholder and the error text without a polyline.</summary> [Fact] public void Unavailable_RendersEmDashPlaceholderAndError_NoPolyline() { @@ -66,6 +69,7 @@ public class KpiTrendChartTests : BunitContext Assert.DoesNotContain("<polyline", markup); } + /// <summary>Verifies that null points render the placeholder without a polyline.</summary> [Fact] public void NullPoints_RendersPlaceholder_NoPolyline() { @@ -79,6 +83,7 @@ public class KpiTrendChartTests : BunitContext Assert.DoesNotContain("<polyline", markup); } + /// <summary>Verifies that a single point renders the single-sample state without a polyline and does not throw.</summary> [Fact] public void SinglePoint_RendersSingleSampleState_NoPolyline_DoesNotThrow() { @@ -92,6 +97,7 @@ public class KpiTrendChartTests : BunitContext Assert.DoesNotContain("<polyline", markup); } + /// <summary>Verifies that points with identical timestamps render a polyline without throwing.</summary> [Fact] public void AllEqualTimestamps_DoesNotThrow_AndRendersPolyline() { @@ -114,6 +120,7 @@ public class KpiTrendChartTests : BunitContext Assert.DoesNotContain("Infinity", markup); } + /// <summary>Verifies that all-zero values render a flat polyline without throwing.</summary> [Fact] public void AllZeroValues_DoesNotThrow_AndRendersFlatPolyline() { @@ -136,6 +143,9 @@ public class KpiTrendChartTests : BunitContext Assert.DoesNotContain("Infinity", markup); } + /// <summary>Verifies that a chart title is converted to a stable data-test slug.</summary> + /// <param name="title">The chart title to slugify.</param> + /// <param name="expectedDataTest">The expected data-test slug.</param> [Theory] [InlineData("Queue Depth", "kpi-trend-queue-depth")] [InlineData("Oldest Pending (s)", "kpi-trend-oldest-pending-s")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ConfigurationMutationTestExtensions.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ConfigurationMutationTestExtensions.cs index 6444d8d6..1444e913 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ConfigurationMutationTestExtensions.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ConfigurationMutationTestExtensions.cs @@ -7,6 +7,9 @@ namespace Microsoft.Extensions.DependencyInjection; internal static class ConfigurationMutationTestExtensions { + /// <summary>Registers the configuration command services with test doubles for auditing and transactions.</summary> + /// <param name="services">The service collection to add the registrations to.</param> + /// <returns>The same service collection, for chaining.</returns> public static IServiceCollection AddTestConfigurationMutations(this IServiceCollection services) { services.TryAddScoped<ConfigurationCommandService>(); @@ -17,6 +20,7 @@ internal static class ConfigurationMutationTestExtensions } private sealed class RepositoryTransaction(IServiceScopeFactory scopes) : IConfigurationCommandTransaction { + /// <inheritdoc /> public async Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation) { using var scope = scopes.CreateScope(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs index 6ca73961..ac15fb9a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs @@ -17,6 +17,7 @@ public class DataConnectionFormTests : BunitContext { private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>(); + /// <summary>Initializes the test context and registers the substitute services the form requires.</summary> public DataConnectionFormTests() { Services.AddSingleton(_siteRepo); @@ -51,6 +52,7 @@ public class DataConnectionFormTests : BunitContext return Render<DataConnectionForm>(); } + /// <summary>Verifies that the protocol dropdown is rendered on create and offers both protocols.</summary> [Fact] public void ProtocolDropdown_IsRendered_OnCreate_WithBothProtocols() { @@ -64,6 +66,8 @@ public class DataConnectionFormTests : BunitContext Assert.Contains("MxGateway", optionTexts); } + /// <summary>Verifies that saving an MxGateway connection persists typed JSON with the MxGateway protocol.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Save_MxGateway_PersistsTypedJsonAndProtocolMxGateway() { @@ -103,6 +107,8 @@ public class DataConnectionFormTests : BunitContext doc.RootElement.GetProperty("endpoint").GetString()); } + /// <summary>Verifies that saving an invalid primary URL does not call the repository.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Save_InvalidPrimaryUrl_DoesNotCallRepo() { @@ -126,6 +132,8 @@ public class DataConnectionFormTests : BunitContext Assert.Contains("Endpoint URL must be a valid", cut.Markup); } + /// <summary>Verifies that saving a valid configuration persists typed JSON with the OPC UA protocol.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Save_ValidConfig_PersistsTypedJsonAndProtocolOpcUa() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs index d00773fc..3d8e75be 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs @@ -21,6 +21,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests; /// </summary> public class DataConnectionsPageTests : BunitContext { + /// <summary>Verifies the case: storage Save Fixture, completes Invocation Immediately.</summary> [Fact] public void StorageSaveFixture_CompletesInvocationImmediately() { @@ -29,17 +30,18 @@ public class DataConnectionsPageTests : BunitContext } private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>(); - // M9-T25: the design page now injects IConnectionHealthQueryService to render + // The design page now injects IConnectionHealthQueryService to render // per-connection live health badges. A substitute defaulting to an empty map // keeps every existing test green (no badge / unknown state when no report). private readonly IConnectionHealthQueryService _healthQuery = Substitute.For<IConnectionHealthQueryService>(); - // M9-T24b: the page now opens a Move-to-Site dialog that dispatches the move + // The page now opens a Move-to-Site dialog that dispatches the move // through IDataConnectionMoveService (the guard-running ManagementActor path). // Substituted so the page renders without a real ManagementActor in scope. private readonly IDataConnectionMoveService _moveService = Substitute.For<IDataConnectionMoveService>(); + /// <summary>Initializes a new instance of the <see cref="DataConnectionsPageTests"/> class.</summary> public DataConnectionsPageTests() { Services.AddSingleton(_siteRepo); @@ -48,7 +50,7 @@ public class DataConnectionsPageTests : BunitContext // Satisfy the page's [Inject] IDialogService — the host that actually // renders the dialog lives in MainLayout, not in bUnit's render scope. Services.AddScoped<IDialogService, DialogService>(); - // arch-review P6-4: the page now filters its site/connection tree through + // The page now filters its site/connection tree through // SiteScopeService, so it must be resolvable in every render test. Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>(); AddTestAuth(); @@ -90,6 +92,7 @@ public class DataConnectionsPageTests : BunitContext .FirstOrDefault(row => row.QuerySelector(".tv-label")?.TextContent == label) ?.QuerySelector(".tv-toggle"); + /// <summary>Verifies the case: renders, empty State, when No Sites.</summary> [Fact] public void Renders_EmptyState_WhenNoSites() { @@ -100,6 +103,7 @@ public class DataConnectionsPageTests : BunitContext Assert.Contains("No sites configured", cut.Markup); } + /// <summary>Verifies the case: renders, empty Site, as Top Level Node.</summary> [Fact] public void Renders_EmptySite_AsTopLevelNode() { @@ -112,6 +116,7 @@ public class DataConnectionsPageTests : BunitContext Assert.Contains("Plant-A", cut.Markup); } + /// <summary>Verifies the case: renders, site Connection, nesting.</summary> [Fact] public void Renders_SiteConnection_Nesting() { @@ -129,6 +134,7 @@ public class DataConnectionsPageTests : BunitContext Assert.Contains("OpcUa", cut.Markup); } + /// <summary>Verifies the case: search, dims Non Matches, preserves Shape.</summary> [Fact] public void Search_DimsNonMatches_PreservesShape() { @@ -156,6 +162,7 @@ public class DataConnectionsPageTests : BunitContext Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Plant-B")); } + /// <summary>Verifies the case: add Connection Button, disabled Until Site Selected.</summary> [Fact] public void AddConnectionButton_DisabledUntilSiteSelected() { @@ -178,6 +185,7 @@ public class DataConnectionsPageTests : BunitContext Assert.False(addBtnAfter.HasAttribute("disabled")); } + /// <summary>Verifies the case: data Connections Routes, are Declared On List Page.</summary> [Fact] public void DataConnectionsRoutes_AreDeclaredOnListPage() { @@ -194,10 +202,11 @@ public class DataConnectionsPageTests : BunitContext Assert.Contains("/design/data-connections", routes); } + /// <summary>Verifies the case: connection Node, renders Live Health Badge, matching Status.</summary> [Fact] public void ConnectionNode_RendersLiveHealthBadge_MatchingStatus() { - // M9-T25: a connection whose live health is Connected gets the success + // A connection whose live health is Connected gets the success // health badge next to its node; a Disconnected one gets the danger badge. SeedRepos( sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, @@ -228,10 +237,11 @@ public class DataConnectionsPageTests : BunitContext Assert.Contains("Disconnected", disconnectedBadge.TextContent); } + /// <summary>Verifies the case: connection Node, has, move To Site Action.</summary> [Fact] public void ConnectionNode_Has_MoveToSiteAction() { - // M9-T24b: each connection node exposes a "Move to Site…" action alongside + // Each connection node exposes a "Move to Site…" action alongside // Edit/Delete. SeedRepos( sites: new[] @@ -248,10 +258,11 @@ public class DataConnectionsPageTests : BunitContext b => b.TextContent.Contains("Move to Site")); } + /// <summary>Verifies the case: move To Site, opens Dialog, excluding Current Site.</summary> [Fact] public void MoveToSite_OpensDialog_ExcludingCurrentSite() { - // M9-T24b: clicking "Move to Site…" opens the move dialog whose target-site + // Clicking "Move to Site…" opens the move dialog whose target-site // picker omits the connection's current site (Plant-A) and lists the others. SeedRepos( sites: new[] @@ -284,10 +295,11 @@ public class DataConnectionsPageTests : BunitContext Assert.DoesNotContain(optionLabels, l => l.Contains("Plant-A")); } - // ── arch-review P6-4: site scoping. This page reads ISiteRepository directly, + // ── site scoping. This page reads ISiteRepository directly, // bypassing ManagementActor's scope gate, and Designer is NOT exempt from // site scope — so a scoped Designer used to see every site's connections. ── + /// <summary>Verifies the case: site Scoping, scoped Designer, sees Only Permitted Sites And Connections.</summary> [Fact] public void SiteScoping_ScopedDesigner_SeesOnlyPermittedSitesAndConnections() { @@ -321,6 +333,7 @@ public class DataConnectionsPageTests : BunitContext Assert.DoesNotContain("PLC-B", cut.Markup); } + /// <summary>Verifies the case: site Scoping, system Wide User, sees Every Connection.</summary> [Fact] public void SiteScoping_SystemWideUser_SeesEveryConnection() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs index ffac838f..6436037c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs @@ -14,6 +14,7 @@ public class DebugTreeBuilderTests private static AttributeValueChanged Attr(string name, object? value = null, string quality = "Good") => new("Inst", name, name, value, quality, DateTimeOffset.UtcNow); + /// <summary>Verifies that a root-level attribute becomes a single leaf.</summary> [Fact] public void RootLevelAttribute_BecomesSingleLeaf() { @@ -27,6 +28,7 @@ public class DebugTreeBuilderTests Assert.Equal("Speed", node.Attribute!.AttributeName); } + /// <summary>Verifies two sibling leaves share single branch sorted by segment.</summary> [Fact] public void TwoSiblingLeaves_ShareSingleBranch_SortedBySegment() { @@ -47,6 +49,7 @@ public class DebugTreeBuilderTests Assert.Equal("Temp", branch.Children[1].Segment); } + /// <summary>Verifies that a deep path nests branches by accumulated prefix.</summary> [Fact] public void DeepPath_NestsBranchesByAccumulatedPrefix() { @@ -70,6 +73,7 @@ public class DebugTreeBuilderTests Assert.NotNull(pump.Attribute); } + /// <summary>Verifies that branches are deduplicated across deep paths sharing a prefix.</summary> [Fact] public void BranchDedupe_SharedPrefixAcrossDeepPaths() { @@ -95,6 +99,7 @@ public class DebugTreeBuilderTests Assert.Equal("Motor1.Compressor.Valve", compressor.Children[1].Key); } + /// <summary>Verifies that a bad-quality descendant marks all ancestor branches as bad.</summary> [Fact] public void RollUp_BadQualityDescendant_MarksAllAncestorBranches() { @@ -113,6 +118,7 @@ public class DebugTreeBuilderTests Assert.True(compressor.HasBadQuality); } + /// <summary>Verifies that a branch whose leaves are all good is not marked as bad quality.</summary> [Fact] public void RollUp_AllGood_LeavesBranchHasBadQualityFalse() { @@ -123,6 +129,8 @@ public class DebugTreeBuilderTests Assert.False(motor.HasBadQuality); } + /// <summary>Verifies that any non-good quality counts as bad when rolling up.</summary> + /// <param name="quality">The test input.</param> [Theory] [InlineData("Uncertain")] [InlineData("Bad")] @@ -134,6 +142,7 @@ public class DebugTreeBuilderTests Assert.True(Assert.Single(tree).HasBadQuality); } + /// <summary>Verifies that filtering keeps only the matching leaves and their ancestors.</summary> [Fact] public void Filter_KeepsOnlyMatchingLeaves_AndTheirAncestors() { @@ -153,6 +162,7 @@ public class DebugTreeBuilderTests Assert.Equal("Motor1.Temp", leaf.Key); } + /// <summary>Verifies that filtering is case insensitive.</summary> [Fact] public void Filter_IsCaseInsensitive() { @@ -163,6 +173,8 @@ public class DebugTreeBuilderTests Assert.Equal("Motor1.Temp", Assert.Single(motor.Children).Key); } + /// <summary>Verifies that an empty or whitespace filter returns the full forest.</summary> + /// <param name="filter">The test input.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -177,6 +189,7 @@ public class DebugTreeBuilderTests Assert.Equal("Motor2", tree[1].Key); } + /// <summary>Verifies that a filter with no matches returns an empty forest.</summary> [Fact] public void Filter_NoMatches_ReturnsEmptyForest() { @@ -186,6 +199,7 @@ public class DebugTreeBuilderTests Assert.Empty(tree); } + /// <summary>Verifies that the top-level forest is sorted by segment.</summary> [Fact] public void TopLevelForest_SortedBySegment() { @@ -198,6 +212,7 @@ public class DebugTreeBuilderTests Assert.Equal("Zeta", tree[2].Key); } + /// <summary>Verifies that empty input returns an empty forest.</summary> [Fact] public void EmptyInput_ReturnsEmptyForest() { @@ -205,6 +220,7 @@ public class DebugTreeBuilderTests Assert.Empty(tree); } + /// <summary>Verifies that bad quality four levels deep rolls up to the root.</summary> [Fact] public void RollUp_FourLevelDeepBadQuality_ReachesRoot() { @@ -221,6 +237,7 @@ public class DebugTreeBuilderTests Assert.True(d.HasBadQuality); } + /// <summary>Verifies that a filter matching a deep leaf retains all its ancestor branches.</summary> [Fact] public void Filter_DeepLeafMatch_RetainsAllAncestorBranches() { @@ -235,7 +252,7 @@ public class DebugTreeBuilderTests } // --------------------------------------------------------------------- - // DV-4: BuildAlarmTree — computed leaves, native condition groupings, + // BuildAlarmTree — computed leaves, native condition groupings, // configured-placeholder binding nodes, roll-up, filter. // --------------------------------------------------------------------- @@ -261,6 +278,7 @@ public class DebugTreeBuilderTests IsConfiguredPlaceholder = placeholder, }; + /// <summary>Verifies that a computed alarm with a path-qualified name becomes a leaf under a branch.</summary> [Fact] public void Computed_PathQualifiedName_BecomesLeafUnderBranch() { @@ -282,6 +300,7 @@ public class DebugTreeBuilderTests Assert.Equal("Motor1.HighTemp", leaf.Alarm!.AlarmName); } + /// <summary>Verifies that a computed alarm with a root-level name becomes a single leaf.</summary> [Fact] public void Computed_RootLevelName_BecomesSingleLeaf() { @@ -294,6 +313,7 @@ public class DebugTreeBuilderTests Assert.NotNull(node.Alarm); } + /// <summary>Verifies that a native condition nests under its binding node with the source reference segment.</summary> [Fact] public void Native_Condition_NestsUnderBindingNode_WithSourceReferenceSegment() { @@ -318,6 +338,7 @@ public class DebugTreeBuilderTests Assert.Equal("Tank1.Level.HiHi", condition.Alarm!.SourceReference); } + /// <summary>Verifies that two native conditions share the same binding node.</summary> [Fact] public void Native_TwoConditions_ShareSameBindingNode() { @@ -340,6 +361,7 @@ public class DebugTreeBuilderTests Assert.Equal("Tank1.Levels::Tank1.Level.LoLo", binding.Children[1].Key); } + /// <summary>Verifies that a native placeholder yields a binding node without children.</summary> [Fact] public void Native_Placeholder_YieldsChildlessBindingNode() { @@ -354,10 +376,11 @@ public class DebugTreeBuilderTests Assert.Empty(binding.Children); } + /// <summary>Verifies that real conditions win over a placeholder under the same native binding.</summary> [Fact] public void Native_PlaceholderAndRealConditions_RealConditionsWin() { - // DV-2 shouldn't emit both, but be safe: placeholder adds nothing. + // Shouldn't emit both, but be safe: placeholder adds nothing. var tree = DebugTreeBuilder.BuildAlarmTree( new[] { @@ -373,6 +396,7 @@ public class DebugTreeBuilderTests Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", condition.Key); } + /// <summary>Verifies that an active descendant sets the worst state to active and is counted when rolling up.</summary> [Fact] public void RollUp_ActiveDescendant_SetsWorstStateActiveAndCounts() { @@ -394,6 +418,7 @@ public class DebugTreeBuilderTests Assert.Equal(1, binding.ActiveCount); } + /// <summary>Verifies that when every alarm is normal the worst state is normal with a zero active count.</summary> [Fact] public void RollUp_AllNormal_WorstStateNormalAndZeroCount() { @@ -405,6 +430,7 @@ public class DebugTreeBuilderTests Assert.Equal(0, tank.ActiveCount); } + /// <summary>Verifies that a placeholder never counts as active when rolling up.</summary> [Fact] public void RollUp_Placeholder_NeverCountsAsActive() { @@ -423,6 +449,7 @@ public class DebugTreeBuilderTests Assert.Equal(0, binding.ActiveCount); } + /// <summary>Verifies that filtering on the source reference keeps the condition under its binding and ancestors.</summary> [Fact] public void Filter_OnSourceReference_KeepsConditionUnderBindingAndAncestors() { @@ -443,6 +470,7 @@ public class DebugTreeBuilderTests Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", condition.Key); } + /// <summary>Verifies that filtering on a binding canonical name keeps the binding node.</summary> [Fact] public void Filter_OnBindingCanonicalName_KeepsBindingNode() { @@ -463,6 +491,7 @@ public class DebugTreeBuilderTests Assert.Single(binding.Children); } + /// <summary>Verifies that filtering on a binding name that matches a placeholder keeps the binding node.</summary> [Fact] public void Filter_OnBindingName_MatchingPlaceholder_KeepsBindingNode() { @@ -477,6 +506,7 @@ public class DebugTreeBuilderTests Assert.False(binding.HasChildren); } + /// <summary>Verifies that filtering on a computed alarm name is case insensitive.</summary> [Fact] public void Filter_OnComputedAlarmName_IsCaseInsensitive() { @@ -488,6 +518,8 @@ public class DebugTreeBuilderTests Assert.Equal("Motor1.HighTemp", Assert.Single(motor.Children).Key); } + /// <summary>Verifies that an empty or whitespace filter returns the full alarm forest.</summary> + /// <param name="filter">The test input.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -503,6 +535,7 @@ public class DebugTreeBuilderTests Assert.Equal("Tank1", tree[1].Key); } + /// <summary>Verifies that a filter with no matches returns an empty alarm forest.</summary> [Fact] public void Filter_NoMatches_ReturnsEmptyAlarmForest() { @@ -512,6 +545,7 @@ public class DebugTreeBuilderTests Assert.Empty(tree); } + /// <summary>Verifies that mixed computed and native alarms produce both subtrees.</summary> [Fact] public void MixedComputedAndNative_ProduceBothSubtrees() { @@ -536,6 +570,7 @@ public class DebugTreeBuilderTests Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", Assert.Single(binding.Children).Key); } + /// <summary>Verifies that empty alarm input returns an empty forest.</summary> [Fact] public void EmptyAlarmInput_ReturnsEmptyForest() { @@ -543,6 +578,7 @@ public class DebugTreeBuilderTests Assert.Empty(tree); } + /// <summary>Verifies that computed and native alarms sharing a prefix keep distinct binding and leaf nodes.</summary> [Fact] public void Native_ComputedAndNativeSharePrefix_DistinctBindingAndLeaf() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewAlarmTableTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewAlarmTableTests.cs index 82a5a243..93a907e6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewAlarmTableTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewAlarmTableTests.cs @@ -20,7 +20,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// <summary> -/// DV-5: the Debug View renders a tabbed (Attributes / Alarms) layout, each tab +/// The Debug View renders a tabbed (Attributes / Alarms) layout, each tab /// hosting a composition <c>TreeView</c>. Computed alarms and attributes nest as /// leaves under their composition-member branches; native conditions nest under /// their source-binding node. Branch nodes surface a roll-up (active-count / @@ -62,7 +62,10 @@ public class DebugViewAlarmTableTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Initializes the provider with the fixed authentication state to return.</summary> + /// <param name="state">The authentication state returned to every caller.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } @@ -98,6 +101,7 @@ public class DebugViewAlarmTableTests : BunitContext cut.Render(); } + /// <summary>Verifies that both tabs are present, the Attributes tab is the default, and it shows the attribute tree.</summary> [Fact] public void BothTabsPresent_AttributesTabIsDefault_AndShowsAttributeTree() { @@ -132,6 +136,7 @@ public class DebugViewAlarmTableTests : BunitContext Assert.NotNull(motor1.QuerySelector("ul[role='group'] li[role='treeitem']")); } + /// <summary>Verifies that the Alarms tab groups a computed alarm under its branch with an active roll-up badge.</summary> [Fact] public void AlarmsTab_GroupsComputedAlarmUnderBranch_WithActiveRollUpBadge() { @@ -168,6 +173,7 @@ public class DebugViewAlarmTableTests : BunitContext Assert.NotNull(highTemp.QuerySelector(".badge.bg-danger")); // Active state badge } + /// <summary>Verifies that the Alarms tab renders a native condition with enriched badges under its binding node.</summary> [Fact] public void AlarmsTab_RendersNativeCondition_WithEnrichedBadgesUnderBindingNode() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewConnectDisposeRaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewConnectDisposeRaceTests.cs index 2bd9f17c..5dcf5c17 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewConnectDisposeRaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewConnectDisposeRaceTests.cs @@ -18,7 +18,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// <summary> -/// Regression tests for arch-review 2026-08-19 P6-6. <c>Connect()</c> awaits +/// Regression tests for a dispose race in the debug view. <c>Connect()</c> awaits /// <c>StartStreamAsync</c> — which can take up to 30s — and then publishes the result into /// <c>_session</c>. If the circuit was torn down inside that window, <c>Dispose()</c> had /// already run, found <c>_session</c> still null and stopped nothing, so the just-created @@ -40,12 +40,17 @@ public class DebugViewConnectDisposeRaceTests : BunitContext private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly List<string> _stopped = new(); + /// <summary>Creates a debug stream service whose stream start is held until released.</summary> + /// <param name="comms">The communication service.</param> + /// <param name="provider">The service provider.</param> + /// <param name="factory">The gRPC client factory.</param> public GatedDebugStreamService( CommunicationService comms, IServiceProvider provider, SiteStreamGrpcClientFactory factory) : base(comms, provider, factory, NullLogger<DebugStreamService>.Instance) { } + /// <summary>The id of the session this service hands out.</summary> public string SessionId => "session-under-test"; /// <summary>Completes once <see cref="StartStreamAsync"/> has been entered.</summary> @@ -57,8 +62,10 @@ public class DebugViewConnectDisposeRaceTests : BunitContext get { lock (_stopped) { return _stopped.ToList(); } } } + /// <summary>Releases the held stream start so it can complete.</summary> public void ReleaseStart() => _gate.TrySetResult(); + /// <inheritdoc /> public override async Task<DebugStreamSession> StartStreamAsync( int instanceId, Action<object> onEvent, Action onTerminated, CancellationToken ct = default) { @@ -73,6 +80,7 @@ public class DebugViewConnectDisposeRaceTests : BunitContext DateTimeOffset.UtcNow)); } + /// <inheritdoc /> public override void StopStream(string sessionId) { lock (_stopped) { _stopped.Add(sessionId); } @@ -82,7 +90,10 @@ public class DebugViewConnectDisposeRaceTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Creates a provider that always returns the given authentication state.</summary> + /// <param name="state">The authentication state to return.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } @@ -141,6 +152,8 @@ public class DebugViewConnectDisposeRaceTests : BunitContext return task!; } + /// <summary>Verifies that disposing during a pending connect stops the just-created session.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Connect_DisposedMidStart_StopsTheJustCreatedSession() { @@ -162,6 +175,8 @@ public class DebugViewConnectDisposeRaceTests : BunitContext Assert.Null(GetField(cut.Instance, "_session")); } + /// <summary>Verifies that connecting and then disposing stops the session exactly once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Connect_ThenDispose_StopsTheSessionExactlyOnce() { @@ -186,6 +201,8 @@ public class DebugViewConnectDisposeRaceTests : BunitContext Assert.Equal(new[] { stream.SessionId }, stream.Stopped); } + /// <summary>Verifies that disconnecting after a connect stops the session once and clears the session slot.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Disconnect_AfterConnect_StopsTheSessionOnceAndClearsTheSlot() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewDisposalTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewDisposalTests.cs index 94e764f5..88303dbe 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewDisposalTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewDisposalTests.cs @@ -16,7 +16,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// <summary> -/// Regression tests for CentralUI-009. The <c>DebugView</c> stream callbacks +/// Regression tests for the debug view disposal guard. The <c>DebugView</c> stream callbacks /// (<c>onEvent</c>/<c>onTerminated</c>) run on an Akka/gRPC thread and capture /// <c>this</c> and <c>_toast</c>. If the user navigates away, an in-flight /// callback could still call <c>_toast.ShowError(...)</c> / @@ -69,11 +69,15 @@ public class DebugViewDisposalTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Creates a provider that always returns the given authentication state.</summary> + /// <param name="state">The authentication state to return.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } + /// <summary>Verifies that the debug view exposes a boolean disposal guard field.</summary> [Fact] public void DebugView_HasDisposalGuardField() { @@ -86,6 +90,7 @@ public class DebugViewDisposalTests : BunitContext Assert.Equal(typeof(bool), field!.FieldType); } + /// <summary>Verifies that disposing the debug view sets the disposal guard and that disposing twice does not throw.</summary> [Fact] public void DebugView_Dispose_SetsDisposedFlag_AndIsIdempotent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs index b7273e81..c82b9728 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs @@ -81,7 +81,10 @@ public class DebugViewInstancePickerTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Initializes the provider with a fixed authentication state.</summary> + /// <param name="state">The state.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } @@ -101,6 +104,7 @@ public class DebugViewInstancePickerTests : BunitContext .First(r => r.TextContent.Trim() == text) .Click(); + /// <summary>Verifies that the instance selector is the picker component rather than a flat select.</summary> [Fact] public void InstanceSelector_IsThePicker_NotAFlatSelect() { @@ -111,6 +115,7 @@ public class DebugViewInstancePickerTests : BunitContext Assert.Empty(cut.FindAll("select[data-test='debug-instance-select']")); } + /// <summary>Verifies that opening the picker shows only the root area.</summary> [Fact] public void OpeningThePicker_ShowsOnlyTheRootArea() { @@ -122,6 +127,7 @@ public class DebugViewInstancePickerTests : BunitContext Assert.False(rows[0].HasAttribute("data-instance-id")); } + /// <summary>Verifies that drilling down reveals only the enabled instance.</summary> [Fact] public void DrillingDown_RevealsTheEnabledInstanceOnly() { @@ -139,6 +145,7 @@ public class DebugViewInstancePickerTests : BunitContext r => r.TextContent.Contains("Off01")); } + /// <summary>Verifies that picking an instance fills the field and enables the Connect button.</summary> [Fact] public void PickingAnInstance_FillsTheField_AndEnablesConnect() { @@ -211,6 +218,7 @@ public class DebugViewInstancePickerTests : BunitContext JSInterop.VerifyInvoke("localStorage.removeItem"); } + /// <summary>Verifies that the picker is disabled while the view is connected.</summary> [Fact] public void WhileConnected_ThePickerIsDisabled() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewRenderCoalescingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewRenderCoalescingTests.cs index 86678da3..6f73d899 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewRenderCoalescingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewRenderCoalescingTests.cs @@ -18,7 +18,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// <summary> -/// Regression tests for the Debug View render coalescing (arch-review WP2.4). Every streamed +/// Regression tests for the Debug View render coalescing. Every streamed /// event used to marshal onto the circuit dispatcher and call <c>StateHasChanged</c> on its /// own, so a chatty instance drove one full render — and two full composition-tree rebuilds — /// per value change. Events are now buffered and applied once per ~250 ms window: a burst @@ -60,7 +60,10 @@ public class DebugViewRenderCoalescingTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Initializes the provider with the authentication state to return.</summary> + /// <param name="state">The authentication state to return.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } @@ -78,6 +81,7 @@ public class DebugViewRenderCoalescingTests : BunitContext new AttributeValueChanged("Inst-1", name, name, value, "Good", DateTimeOffset.UtcNow), }); + /// <summary>Verifies that a burst of events triggers a single render while every event is still applied.</summary> [Fact] public void BurstOfEvents_RendersOnce_ButAppliesEveryEvent() { @@ -99,6 +103,7 @@ public class DebugViewRenderCoalescingTests : BunitContext Assert.InRange(renders, 1, 5); } + /// <summary>Verifies that repeated updates to one tag collapse to the latest value.</summary> [Fact] public void RepeatedUpdatesToOneTag_CollapseToTheLatestValue() { @@ -115,6 +120,7 @@ public class DebugViewRenderCoalescingTests : BunitContext Assert.Equal(49, applied.Value); } + /// <summary>Verifies that an event arriving after a flush still arms a new coalescing window.</summary> [Fact] public void EventArrivingAfterAFlush_StillArmsANewWindow() { @@ -130,6 +136,7 @@ public class DebugViewRenderCoalescingTests : BunitContext cut.WaitForState(() => dict.Count == 2, TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that events arriving after disposal are dropped without throwing.</summary> [Fact] public void EventsAfterDispose_AreDroppedWithoutThrowing() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewStreamRaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewStreamRaceTests.cs index 6024ee07..46ce3828 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewStreamRaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewStreamRaceTests.cs @@ -18,7 +18,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// <summary> -/// Regression tests for CentralUI-021. The <c>DebugView</c> stream callback runs +/// Regression tests for the DebugView stream race. The <c>DebugView</c> stream callback runs /// on an Akka/gRPC thread; it used to call <c>UpsertWithCap</c> directly on that /// thread, mutating the <c>_attributeValues</c>/<c>_alarmStates</c> /// <see cref="Dictionary{TKey,TValue}"/> while the render thread enumerated the @@ -67,7 +67,10 @@ public class DebugViewStreamRaceTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Initializes a new instance of the <c>StubAuthStateProvider</c> class.</summary> + /// <param name="state">The authentication state the provider returns.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } @@ -83,6 +86,7 @@ public class DebugViewStreamRaceTests : BunitContext typeof(DebugViewPage).GetProperty("FilteredAttributeValues", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!; + /// <summary>Verifies that a stream event is applied once the dispatcher runs its queued work.</summary> [Fact] public void HandleStreamEvent_AppliesUpdate_OnceDispatcherRuns() { @@ -98,10 +102,12 @@ public class DebugViewStreamRaceTests : BunitContext Assert.True(dict.Contains("Pump.Speed")); } + /// <summary>Verifies that stream events raised from background threads do not fault concurrent dispatcher reads.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HandleStreamEvent_OffThreadEvents_DoNotFaultDispatcherReads() { - // CentralUI-021 reproduction. Writers fire stream events from background + // Stream race reproduction. Writers fire stream events from background // threads (the Akka/gRPC callback threads). The reader enumerates // FilteredAttributeValues *through the renderer's dispatcher* — exactly // as the real render thread does. Pre-fix the writers mutated the diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsPushUpdateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsPushUpdateTests.cs index d04ecdfd..21b8f0ec 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsPushUpdateTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsPushUpdateTests.cs @@ -15,7 +15,7 @@ using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploym namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// <summary> -/// Regression tests for CentralUI-006. Component-CentralUI "Real-Time Updates" +/// Regression tests for deployment status push updates. Component-CentralUI "Real-Time Updates" /// states deployment status transitions push to the UI immediately via SignalR /// with no polling. The page previously ran a 10-second <c>Timer</c> that /// reloaded every deployment record + instance map per tick. The fix removes @@ -61,11 +61,15 @@ public class DeploymentsPushUpdateTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Initializes the provider with the authentication state it returns.</summary> + /// <param name="state">The authentication state to return.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } + /// <summary>Verifies that the Deployments page does not poll and has no refresh timer.</summary> [Fact] public void Deployments_DoesNotPoll_HasNoRefreshTimer() { @@ -76,6 +80,7 @@ public class DeploymentsPushUpdateTests : BunitContext Assert.Null(timerField); } + /// <summary>Verifies that a deployment status change notification triggers a reload of the page.</summary> [Fact] public void Deployments_StatusChange_TriggersReload() { @@ -95,6 +100,7 @@ public class DeploymentsPushUpdateTests : BunitContext Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())); } + /// <summary>Verifies that disposing the page unsubscribes it from the status notifier.</summary> [Fact] public void Deployments_Dispose_UnsubscribesFromNotifier() { @@ -225,7 +231,7 @@ public class DeploymentsPushUpdateTests : BunitContext } /// <summary> - /// Regression test for CentralUI-022. The notifier is a process singleton: + /// Regression test for the dispose race. The notifier is a process singleton: /// it can read its subscriber list and begin invoking /// <c>OnDeploymentStatusChanged</c> on the DeploymentManager thread an /// instant before the component is disposed. The handler must no-op against @@ -242,6 +248,7 @@ public class DeploymentsPushUpdateTests : BunitContext Assert.Equal(typeof(bool), field!.FieldType); } + /// <summary>Verifies that a status change delivered after disposal neither throws nor reloads the page.</summary> [Fact] public void Deployments_StatusChangeAfterDispose_DoesNotThrowOrReload() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsReloadDebounceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsReloadDebounceTests.cs index 92ca8645..71262344 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsReloadDebounceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsReloadDebounceTests.cs @@ -14,7 +14,7 @@ using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploym namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// <summary> -/// Regression tests for the Deployment Status push coalescing (arch-review WP2.4). The +/// Regression tests for the Deployment Status push coalescing. The /// notifier fires per status write, so a multi-instance deploy produced a stampede of /// reloads per circuit. The reload is leading-edge debounced: the first push after an /// idle gap is still immediate, and a burst behind it collapses into one trailing reload. @@ -57,7 +57,10 @@ public class DeploymentsReloadDebounceTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Initializes a new instance of the <see cref="StubAuthStateProvider"/> class with a fixed authentication state.</summary> + /// <param name="state">The authentication state to return.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } @@ -67,6 +70,7 @@ public class DeploymentsReloadDebounceTests : BunitContext _deployRepo.Received().QueryDeploymentListPageAsync( Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())); + /// <summary>Verifies that a burst of status writes collapses into far fewer page reloads.</summary> [Fact] public void BurstOfStatusWrites_CollapsesIntoFarFewerReloads() { @@ -93,6 +97,7 @@ public class DeploymentsReloadDebounceTests : BunitContext Assert.InRange(reloads, 1, 4); } + /// <summary>Verifies that the first push after an idle period reloads the page immediately.</summary> [Fact] public void FirstPushAfterIdle_ReloadsImmediately() { @@ -112,6 +117,7 @@ public class DeploymentsReloadDebounceTests : BunitContext TimeSpan.FromMilliseconds(400)); } + /// <summary>Verifies that disposing the page during a coalesce window does not trigger a reload.</summary> [Fact] public void DisposeDuringACoalesceWindow_DoesNotReload() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsRequestGenerationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsRequestGenerationTests.cs index da3c3ec2..cef8be4a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsRequestGenerationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsRequestGenerationTests.cs @@ -15,7 +15,7 @@ using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploym namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// <summary> -/// Regression tests for arch-review 2026-08-19 P6-11. Three independent initiators — +/// Regression tests for stale request generations on the Deployment Status page. Three independent initiators — /// search, paging, and the push/coalesce reload — can each have a /// <c>LoadDataAsync</c> flight in flight on the Deployment Status page at once. Every /// flight awaits its own round-trip to central MS SQL, so COMPLETION order is not @@ -92,7 +92,10 @@ public class DeploymentsRequestGenerationTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Initializes the provider with the authentication state it will return.</summary> + /// <param name="state">The authentication state to return.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } @@ -146,18 +149,20 @@ public class DeploymentsRequestGenerationTests : BunitContext .GetField("_currentPage", BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(page)!; + /// <summary>Verifies that a stale page 2 flight arriving after a newer flight does not overwrite the grid.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaleFlightArrivingAfterANewerOne_DoesNotOverwriteTheGrid() { RegisterServices(); var initialRows = Enumerable.Range(1, 25).Select(i => Row(i, $"Init-{i}")).ToList(); - _pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // call #1: initial render + _pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // first call: initial render - var stale = Gate(); // call #2: page-2 flight — held open, the SLOW, STALE flight + var stale = Gate(); // second call: page 2 flight — held open, the SLOW, STALE flight _pageResponses.Add(stale); - // call #3: status-filter flight — resolves immediately, the FAST, NEWER flight + // third call: status-filter flight — resolves immediately, the FAST, NEWER flight _pageResponses.Add(CompletedGate( new DeploymentListPage(new[] { Row(999, "TileFilteredFreshRow") }, TotalCount: 1))); @@ -182,7 +187,7 @@ public class DeploymentsRequestGenerationTests : BunitContext stale.SetResult(new DeploymentListPage(new[] { Row(2, "PageTwoStaleRow") }, TotalCount: 100)); await flightA.WaitAsync(TimeSpan.FromSeconds(5)); - // Pre-fix, flight A's late arrival clobbers _rows with the stale page-2 data. + // Pre-fix, flight A's late arrival clobbers _rows with the stale page 2 data. // Post-fix, its generation is no longer current, so it discards its own // results and the newer grid survives untouched. Assert.DoesNotContain(GetRows(cut.Instance), r => r.InstanceUniqueName == "PageTwoStaleRow"); @@ -190,7 +195,7 @@ public class DeploymentsRequestGenerationTests : BunitContext } /// <summary> - /// The "does the timer refresh reset the page mid-search" half of P6-11. The + /// The "does the timer refresh reset the page mid-search" half of that regression. The /// filter-debounce ("timer") reload fires first, as if 500ms had elapsed after a /// keystroke, and is held open. While it is still in flight the operator /// navigates to page 2 by other means (a fresh, faster flight). The debounce @@ -204,18 +209,19 @@ public class DeploymentsRequestGenerationTests : BunitContext /// deterministically instead of racing bUnit's render pump. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaleFilterDebounceFlight_DoesNotResetThePageAfterANewerNavigation() { RegisterServices(); var initialRows = Enumerable.Range(1, 25).Select(i => Row(i, $"Init-{i}")).ToList(); - _pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // call #1: initial render + _pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // first call: initial render - var staleSearch = Gate(); // call #2: the debounce-triggered ("timer") flight — held open, STALE + var staleSearch = Gate(); // second call: the debounce-triggered ("timer") flight — held open, STALE _pageResponses.Add(staleSearch); - // call #3: page-2 click — resolves immediately, NEWER. + // third call: page 2 click — resolves immediately, NEWER. _pageResponses.Add(CompletedGate( new DeploymentListPage(new[] { Row(2, "PageTwoFreshRow") }, TotalCount: 100))); @@ -243,7 +249,7 @@ public class DeploymentsRequestGenerationTests : BunitContext await staleFlight.WaitAsync(TimeSpan.FromSeconds(5)); // Pre-fix, the stale flight unconditionally wrote _currentPage = 1 up front - // and then overwrote the grid with page-1 data once it resolved — resetting + // and then overwrote the grid with page 1 data once it resolved — resetting // the operator's page mid an already-completed navigation. Post-fix, its // generation is no longer current, so both the page and the grid are left // untouched. diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsServerPagingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsServerPagingTests.cs index 3bcd1396..41ef574d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsServerPagingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsServerPagingTests.cs @@ -79,7 +79,10 @@ public class DeploymentsServerPagingTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Initializes the provider with the authentication state it will return.</summary> + /// <param name="state">The authentication state returned to callers.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } @@ -100,6 +103,7 @@ public class DeploymentsServerPagingTests : BunitContext // ── Paging ──────────────────────────────────────────────────────────────── + /// <summary>Verifies that the initial load requests page one from the server and never reads every instance.</summary> [Fact] public void InitialLoad_RequestsPageOneFromTheServer_AndNeverReadsEveryInstance() { @@ -115,6 +119,7 @@ public class DeploymentsServerPagingTests : BunitContext _templateRepo.DidNotReceiveWithAnyArgs().GetAllInstancesAsync(); } + /// <summary>Verifies that the server-reported total count drives the pager rather than the rows on screen.</summary> [Fact] public void TotalCountFromServer_DrivesThePager_NotTheRowsOnScreen() { @@ -133,6 +138,7 @@ public class DeploymentsServerPagingTests : BunitContext Assert.Contains("6", cut.Find("ul.pagination").TextContent); } + /// <summary>Verifies that moving to the next page re-queries the server for page two.</summary> [Fact] public void NextPage_RequeriesTheServerForPageTwo() { @@ -148,6 +154,7 @@ public class DeploymentsServerPagingTests : BunitContext Assert.Equal(2, call.Page); } + /// <summary>Verifies that a single-page result renders no pager.</summary> [Fact] public void SinglePageResult_RendersNoPager() { @@ -160,6 +167,7 @@ public class DeploymentsServerPagingTests : BunitContext // ── Status tiles ────────────────────────────────────────────────────────── + /// <summary>Verifies that the status tiles show server-computed counts rather than counts of the visible page.</summary> [Fact] public void Tiles_RenderServerComputedCounts_NotCountsOfTheVisiblePage() { @@ -179,6 +187,7 @@ public class DeploymentsServerPagingTests : BunitContext Assert.Contains("400", cut.Find("[data-test=\"deploy-tile-all\"]").TextContent); } + /// <summary>Verifies that clicking a status tile applies the filter on the server and resets to page one.</summary> [Fact] public void ClickingAStatusTile_AppliesTheFilterServerSide_AndResetsToPageOne() { @@ -197,6 +206,7 @@ public class DeploymentsServerPagingTests : BunitContext Assert.Equal("true", cut.Find("[data-test=\"deploy-tile-failed\"]").GetAttribute("aria-pressed")); } + /// <summary>Verifies that clicking the active status tile again clears the status filter.</summary> [Fact] public void ClickingTheActiveTileAgain_ClearsTheStatusFilter() { @@ -214,6 +224,7 @@ public class DeploymentsServerPagingTests : BunitContext // ── Site scoping ────────────────────────────────────────────────────────── + /// <summary>Verifies that a system-wide user queries with no site scope.</summary> [Fact] public void SystemWideUser_QueriesWithNoSiteScope() { @@ -224,6 +235,7 @@ public class DeploymentsServerPagingTests : BunitContext Assert.Null(Assert.Single(_pageCalls).Filter.SiteIdScope); } + /// <summary>Verifies that a site-scoped user's permitted site ids are pushed into the query.</summary> [Fact] public void SiteScopedUser_PushesThePermittedSiteIdsIntoTheQuery() { @@ -238,6 +250,7 @@ public class DeploymentsServerPagingTests : BunitContext // ── Filter debounce ─────────────────────────────────────────────────────── + /// <summary>Verifies that search input is debounced so a burst of keystrokes issues a single query.</summary> [Fact] public void SearchInput_IsDebounced_SoABurstOfKeystrokesIssuesOneQuery() { @@ -263,6 +276,7 @@ public class DeploymentsServerPagingTests : BunitContext Assert.Equal(1, call.Page); } + /// <summary>Verifies that clearing the filters resets the search text and status and re-queries the server.</summary> [Fact] public void ClearFilters_ResetsSearchAndStatus_AndRequeries() { @@ -280,6 +294,7 @@ public class DeploymentsServerPagingTests : BunitContext Assert.Equal(1, call.Page); } + /// <summary>Verifies that the clear-filters button is enabled as soon as the operator types, not only once the debounce fires.</summary> [Fact] public void ClearFilters_IsEnabledAsSoonAsTheOperatorTypes_NotOnlyOnceTheDebounceFires() { @@ -294,6 +309,7 @@ public class DeploymentsServerPagingTests : BunitContext Assert.False(cut.Find("[data-test=\"deploy-clear-filters\"]").HasAttribute("disabled")); } + /// <summary>Verifies that disposing the page with an armed filter timer does not issue a query.</summary> [Fact] public void DisposeWithAnArmedFilterTimer_DoesNotQuery() { @@ -311,6 +327,7 @@ public class DeploymentsServerPagingTests : BunitContext // ── Rendering ───────────────────────────────────────────────────────────── + /// <summary>Verifies that the instance name comes from the joined server row rather than a client-side lookup table.</summary> [Fact] public void InstanceName_ComesFromTheJoinedRow_NotAClientSideLookupTable() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs index 23ed81b4..1b6904ce 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs @@ -45,7 +45,7 @@ public class FlowDebuggerPageTests : BunitContext }; /// <summary> - /// #37: the MesMoveOut shape — a REQUIRED array of objects — which the old flat text-box pane + /// The MesMoveOut shape — a REQUIRED array of objects — which the old flat text-box pane /// could not take at all (the array reached the site empty). /// </summary> private static readonly ApiMethod MesMoveOut = new("MesMoveOut", "return 1;") @@ -69,6 +69,7 @@ public class FlowDebuggerPageTests : BunitContext ], [new("n1", "n2", false), new("n2", "n3", false)]); + /// <summary>Verifies that a refresh merge keeps the replay row order and then appends rows that exist only in the live run.</summary> [Fact] public void RefreshMerge_UsesReplayOrderThenLiveOnlyRows() { @@ -140,10 +141,14 @@ public class FlowDebuggerPageTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Initializes the provider with the authentication state to return.</summary> + /// <param name="state">The authentication state to return.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } + /// <summary>Verifies that the page renders the toolbar and an empty flow for a deployer.</summary> [Fact] public void Renders_ForDeployer_WithToolbarAndEmptyFlow() { @@ -164,6 +169,7 @@ public class FlowDebuggerPageTests : BunitContext // ---- F3: Replay opens to the OperationalAudit roles; Live stays Deployer-only ---------------- + /// <summary>Verifies that the page renders replay-only for a viewer, with no live run controls.</summary> [Fact] public void Renders_ForViewer_ReplayOnly_WithNoLiveControls() { @@ -186,6 +192,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Empty(cut.FindAll("select[data-test='flow-method-select']")); } + /// <summary>Verifies that a viewer can load a replay but gets no retry or discard actions on a parked node.</summary> [Fact] public void Viewer_LoadsAReplay_ButGetsNoRetryOrDiscardOnAParkedNode() { @@ -209,6 +216,7 @@ public class FlowDebuggerPageTests : BunitContext _flow.Received(1).ReplayAsync(root, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a viewer drilling in with a method id does not open the live run surface.</summary> [Fact] public void Viewer_DrillInWithAMethodId_DoesNotOpenTheLiveRunSurface() { @@ -225,6 +233,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.NotNull(cut.Find("input[data-test='flow-execution-id']")); } + /// <summary>Verifies that choosing a method shows the predicted graph and the inputs form.</summary> [Fact] public void ChoosingAMethod_ShowsThePredictedGraph_AndTheInputsForm() { @@ -236,7 +245,7 @@ public class FlowDebuggerPageTests : BunitContext cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("g[data-test='flow-node']").Count)); Assert.Equal("Predicted", cut.Find("[data-test='flow-state']").TextContent.Trim()); Assert.NotNull(cut.Find("[data-test='flow-predicted-note']")); - // #37: the method pane is the shared ParameterValueForm (typed inputs by field id), and + // The method pane is the shared ParameterValueForm (typed inputs by field id), and // Run is gated on its validity — MachineCode is required, so Run stays off until it is set. Assert.NotEmpty(cut.FindAll("[data-test='flow-method-params']")); Assert.NotNull(cut.Find("#param-MachineCode")); @@ -246,6 +255,7 @@ public class FlowDebuggerPageTests : BunitContext cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"))); } + /// <summary>Verifies that a run sends the typed inputs and renders the returned graph.</summary> [Fact] public void Run_SendsTypedInputs_AndRendersTheReturnedGraph() { @@ -274,6 +284,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Contains("POST /api/DelmiaRecipeDownload", cut.Find("[data-test='flow-step-title']").TextContent); } + /// <summary>Verifies that a standby node refusal is shown verbatim in an inline alert.</summary> [Fact] public void Run_StandbyNodeRefusal_ShowsInlineAlertVerbatim() { @@ -293,6 +304,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Equal("Failed", cut.Find("[data-test='flow-state']").TextContent.Trim()); } + /// <summary>Verifies that an execution id query parameter triggers a replay on the first render.</summary> [Fact] public void ExecutionIdQueryParameter_TriggersReplayOnFirstRender() { @@ -310,6 +322,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Contains("btn-primary", cut.Find("button[data-test='flow-mode-replay']").ClassName); } + /// <summary>Verifies that a run with no site steps and no child execution id shows the site-not-traced label.</summary> [Fact] public void Run_WithNoSiteStepsAndNoChildId_ShowsSiteNotTracedLabel() { @@ -325,7 +338,7 @@ public class FlowDebuggerPageTests : BunitContext cut.WaitForElement("[data-test='flow-site-not-traced']"); } - // ---- Node actions (follow-ups plan 2026-09-06, Task 8): Retry / Discard on a Parked node ------- + // ---- Node actions (follow-ups plan 2026-09-06): Retry / Discard on a Parked node ------- /// <summary>Same events as <see cref="OutboundOutcome"/> but the cached call ends Parked; the second /// outcome shares the event objects (stable ids, as real audit rows are) and adds a Delivered resolve.</summary> @@ -379,6 +392,7 @@ public class FlowDebuggerPageTests : BunitContext return cut; } + /// <summary>Verifies that retrying a parked node confirms, relays the retry and re-polls until the node is no longer parked.</summary> [Fact] public void ParkedNode_Retry_ConfirmsRelaysAndRepollsUntilNotParked() { @@ -401,6 +415,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Empty(cut.FindAll("button[data-test='flow-step-retry']")); // the selected node is no longer parked } + /// <summary>Verifies that discarding a parked node uses a danger confirmation and does not poll when the confirmation is refused.</summary> [Fact] public void ParkedNode_Discard_UsesDangerConfirm_AndDoesNotPollWhenRefused() { @@ -420,6 +435,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.NotNull(cut.Find("button[data-test='flow-step-retry']")); } + /// <summary>Verifies that declining the confirmation for a parked node never relays the action.</summary> [Fact] public void ParkedNode_ConfirmDeclined_NeverRelays() { @@ -436,6 +452,7 @@ public class FlowDebuggerPageTests : BunitContext _flow.DidNotReceive().ReplayAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that an action on a parked node at an out-of-scope site is refused before any confirmation.</summary> [Fact] public void ParkedNode_OutOfScopeSite_RefusedBeforeConfirm() { @@ -452,7 +469,7 @@ public class FlowDebuggerPageTests : BunitContext _flow.DidNotReceive().RetryOutboundAsync(Arg.Any<FlowStep>(), Arg.Any<CancellationToken>()); } - // ---- Outbound calls (2026-09-06 plan, Task 9): pending refresh after a Live run ---------- + // ---- Outbound calls (2026-09-06 plan): pending refresh after a Live run ---------- /// <summary> /// Inbound → route → site run → cached API call; the call is Pending unless <paramref name="resolved"/>. @@ -498,6 +515,7 @@ public class FlowDebuggerPageTests : BunitContext }; } + /// <summary>Verifies that a live run with a pending outbound call polls the replay until the call reaches a terminal state.</summary> [Fact] public void LiveRun_WithPendingOutbound_PollsReplayUntilTerminal() { @@ -560,6 +578,7 @@ public class FlowDebuggerPageTests : BunitContext return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null); } + /// <summary>Verifies that a pending refresh keeps the steps the live run already had when the replay lags behind telemetry.</summary> [Fact] public void PendingRefresh_KeepsStepsTheLiveRunAlreadyHad_WhenReplayLagsBehindTelemetry() { @@ -593,6 +612,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Contains(cut.FindAll("[data-test='flow-node-pill']"), p => p.TextContent.Trim() == "Ok"); } + /// <summary>Verifies that a live run with no pending step never polls.</summary> [Fact] public void LiveRun_WithNoPendingStep_NeverPolls() { @@ -611,6 +631,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Empty(cut.FindAll("[data-test='flow-pending-count']")); } + /// <summary>Verifies that replay mode never polls but the refresh button replays once.</summary> [Fact] public void ReplayMode_NeverPolls_ButRefreshButtonReplaysOnce() { @@ -665,6 +686,7 @@ public class FlowDebuggerPageTests : BunitContext _flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction()); } + /// <summary>Verifies that the script entry lists scripts with their trigger labels once a site and instance are chosen.</summary> [Fact] public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance() { @@ -679,6 +701,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']")); } + /// <summary>Verifies that a write-kind script shows a value field for the trigger attribute and gates Run on it.</summary> [Fact] public void ScriptEntry_WriteKind_ShowsTheValueField_ForTheTriggerAttribute_AndGatesRunOnIt() { @@ -697,6 +720,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); } + /// <summary>Verifies that a call-kind script shows the parameter form and runs with the entered parameters.</summary> [Fact] public void ScriptEntry_CallKind_ShowsTheParameterForm_AndRunsWithParameters() { @@ -717,6 +741,7 @@ public class FlowDebuggerPageTests : BunitContext _ = _flow.Received(1).RunScriptAsync(7, "Manual", Arg.Is<JsonElement?>(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a call-kind script without parameters says so and can be run.</summary> [Fact] public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun() { @@ -731,6 +756,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); } + /// <summary>Verifies that a deep link with an instance and a script preselects both and shows the prediction.</summary> [Fact] public void DeepLink_InstanceAndScript_PreselectsAndPredicts() { @@ -774,6 +800,7 @@ public class FlowDebuggerPageTests : BunitContext return cut; } + /// <summary>Verifies that switching between call-kind scripts resets the parameter form and its validity.</summary> [Fact] public void ScriptEntry_SwitchingBetweenCallKindScripts_ResetsTheFormAndValidity() { @@ -792,6 +819,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); } + /// <summary>Verifies that switching from an invalid form to a script without parameters enables Run.</summary> [Fact] public void ScriptEntry_SwitchingFromAnInvalidFormToANoParameterScript_EnablesRun() { @@ -805,6 +833,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); } + /// <summary>Verifies that the instance picker shows areas collapsed until they are expanded.</summary> [Fact] public void ScriptEntry_InstancePicker_ShowsAreasCollapsed_UntilExpanded() { @@ -838,6 +867,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options); } + /// <summary>Verifies that a deep link with an instance id shows the instance name in the picker.</summary> [Fact] public void ScriptEntry_DeepLink_InstanceId_ShowsItsNameInThePicker() { @@ -913,6 +943,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.False(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled"))); } + /// <summary>Verifies that changing the site clears the picked instance.</summary> [Fact] public void ScriptEntry_SiteChange_ClearsThePickedInstance() { @@ -940,7 +971,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Equal(["— choose —"], cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList()); } - // ---- Step logs (plan 2026-09-09 D5/D7, Task 13): status-strip counts, truncation notes, cross-step hint ---- + // ---- Step logs (plan 2026-09-09 D5/D7): status-strip counts, truncation notes, cross-step hint ---- /// <summary>One <c>ScriptTrace.ScriptLog</c> row — a line the builder folds onto a step, never a node of its own.</summary> private static AuditEvent LogLine(Guid execution, string level, int ms, Guid? stepEventId = null, string message = "hello") => @@ -995,6 +1026,7 @@ public class FlowDebuggerPageTests : BunitContext return cut; } + /// <summary>Verifies that the status strip shows log counts by level.</summary> [Fact] public void StatusStrip_ShowsLogCountsByLevel() { @@ -1005,6 +1037,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Equal("1 errors · 2 warnings · 3 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); } + /// <summary>Verifies that the status strip omits log counts when the run logged nothing.</summary> [Fact] public void StatusStrip_OmitsLogCounts_WhenTheRunLoggedNothing() { @@ -1017,6 +1050,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Empty(cut.FindAll("[data-test='flow-unattached-logs']")); } + /// <summary>Verifies that the status strip shows the inline log truncation badge.</summary> [Fact] public void StatusStrip_ShowsTheInlineLogTruncationBadge() { @@ -1028,6 +1062,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Contains("inline log lines capped", cut.Find("[data-test='flow-logs-truncated']").TextContent); } + /// <summary>Verifies that the status strip notes log lines that could not be placed on a step.</summary> [Fact] public void StatusStrip_NotesLinesThatCouldNotBePlacedOnAStep() { @@ -1040,6 +1075,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Equal("0 errors · 0 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); } + /// <summary>Verifies that the state chip stays completed when the only errors are log lines.</summary> [Fact] public void StateChip_StaysCompleted_WhenTheOnlyErrorsAreLogLines() { @@ -1052,6 +1088,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Equal("2 errors · 0 warnings · 0 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); } + /// <summary>Verifies that the step detail log hint selects the sibling step that carries the other log lines.</summary> [Fact] public void StepDetail_LogHint_SelectsTheSiblingStepCarryingTheOtherLines() { @@ -1134,6 +1171,7 @@ public class FlowDebuggerPageTests : BunitContext return (run, refreshed, root); } + /// <summary>Verifies that refreshing outcomes clears the inline cap badge once it merges in more log rows.</summary> [Fact] public void RefreshOutcomes_ClearsTheInlineCapBadge_OnceItMergesInMoreLogRows() { @@ -1150,6 +1188,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Equal("0 errors · 1 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); } + /// <summary>Verifies that refreshing outcomes keeps the inline cap badge when the re-read brings back no new lines.</summary> [Fact] public void RefreshOutcomes_KeepsTheInlineCapBadge_WhenTheReReadBringsBackNoNewLines() { @@ -1170,6 +1209,7 @@ public class FlowDebuggerPageTests : BunitContext // ---- CountLogs: the level buckets, straight --------------------------------------------------- + /// <summary>Verifies that log counting buckets lines by level case-insensitively and counts everything else as info.</summary> [Fact] public void CountLogs_BucketsByLevel_CaseInsensitively_WithEverythingElseAsInfo() { @@ -1189,6 +1229,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Equal((1, 1, 3, 5), FlowDebuggerPage.CountLogs(graph)); } + /// <summary>Verifies that counting logs for a null graph yields all zeroes.</summary> [Fact] public void CountLogs_NullGraph_IsAllZeroes() { @@ -1197,7 +1238,7 @@ public class FlowDebuggerPageTests : BunitContext /// <summary> /// Waits for the method pane's <c>ParameterValueForm</c> and fills the fixture method's one - /// required field, so Run is enabled (#37 gates Run on the form's validity). + /// required field, so Run is enabled (Run is gated on the form's validity). /// </summary> private static void FillRequiredMethodInput(IRenderedComponent<FlowDebuggerPage> cut) { @@ -1205,8 +1246,9 @@ public class FlowDebuggerPageTests : BunitContext cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"))); } - // ── #37: structured method parameters ─────────────────────────────────────────────────── + // ── Structured method parameters ─────────────────────────────────────────────────── + /// <summary>Verifies that an array of objects entered in the item editors round-trips into the run payload.</summary> [Fact] public void MethodEntry_ArrayOfObjects_RoundTripsFromItemEditorsToTheRunPayload() { @@ -1241,6 +1283,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Equal("WO-2", orders[1].GetProperty("WorkOrderNumber").GetString()); } + /// <summary>Verifies that leaving a required list item field empty keeps Run disabled.</summary> [Fact] public void MethodEntry_RequiredListItemField_LeftEmpty_KeepsRunDisabled() { @@ -1258,6 +1301,8 @@ public class FlowDebuggerPageTests : BunitContext cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"))); } + /// <summary>Verifies that an invalid raw JSON document blocks Run and shows the error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MethodEntry_RawJson_InvalidDocument_BlocksRun_AndShowsTheError() { @@ -1289,6 +1334,8 @@ public class FlowDebuggerPageTests : BunitContext _ = _flow.DidNotReceive().RunMethodAsync(Arg.Any<int>(), Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a valid raw JSON document is sent verbatim and survives switching back to the form.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MethodEntry_RawJson_ValidDocument_IsSentVerbatim_AndSurvivesTheSwitchBackToForm() { @@ -1322,6 +1369,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Equal("P-1", sent.Value.GetProperty("WorkOrders")[0].GetProperty("PartNumber").GetString()); } + /// <summary>Verifies that switching methods resets the form and its validity.</summary> [Fact] public void MethodEntry_SwitchingMethods_ResetsTheFormAndItsValidity() { @@ -1339,6 +1387,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); } + /// <summary>Verifies that a persisted value tree is restored into the form.</summary> [Fact] public void MethodEntry_PersistedValueTree_IsRestoredIntoTheForm() { @@ -1353,11 +1402,12 @@ public class FlowDebuggerPageTests : BunitContext cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"))); } + /// <summary>Verifies that a legacy flat string entry that no longer validates is dropped.</summary> [Fact] public void MethodEntry_LegacyFlatStringEntry_ThatNoLongerValidates_IsDropped() { RegisterServices(); - // Pre-#37 entries stored every field as a string; "7" is not an Integer, so the whole + // Earlier entries stored every field as a string; "7" is not an Integer, so the whole // entry is discarded rather than painted into the form as an error. JSInterop.Setup<string?>("localStorage.getItem", "flowDebugger.inputs.4") .SetResult("{\"MachineCode\":\"Z9\",\"Qty\":\"7\"}"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs index 38a60bc7..27287e3a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs @@ -5,16 +5,16 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// <summary> -/// Flow Debugger outbound-calls plan (2026-09-06) Task 8: the step detail panel's Lifecycle tab, -/// outbound "Where this ran" fields and kind-specific Input tab labels. Follow-ups plan (2026-09-06) -/// Task 6: report deep links from a correlated outbound step and Retry/Discard on a parked one (D2/D5). -/// Step-logs plan (2026-09-09) Task 12: the Log tab — exception block, level-filtered lines and the -/// cross-step hint (design D8). +/// Tests for the Flow Debugger step detail panel: the Lifecycle tab, outbound "Where this ran" fields +/// and kind-specific Input tab labels; report deep links from a correlated outbound step and +/// Retry/Discard on a parked one (D2/D5); and the Log tab — exception block, level-filtered lines and +/// the cross-step hint (design D8). /// </summary> public class FlowStepDetailTests : BunitContext { private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero); + /// <summary>Initializes the test context with loose JS interop so the Log tab's clipboard calls are accepted.</summary> public FlowStepDetailTests() { // The Log tab's Copy buttons call navigator.clipboard through IJSRuntime. @@ -59,6 +59,7 @@ public class FlowStepDetailTests : BunitContext new(Guid.NewGuid(), "CachedResolve", terminalStatus, T0.AddMilliseconds(4100), "node-b", null, null), ]; + /// <summary>Verifies that the Lifecycle tab renders its rows in time order and shows who delivered the operation.</summary> [Fact] public void LifecycleTab_RendersRowsInTimeOrder_AndDeliveredBy() { @@ -84,6 +85,7 @@ public class FlowStepDetailTests : BunitContext Assert.Empty(cut.FindAll("[data-test=flow-step-parked-hint]")); } + /// <summary>Verifies that a step without lifecycle rows hides the Lifecycle tab.</summary> [Fact] public void NoLifecycle_HidesTheTab() { @@ -93,6 +95,7 @@ public class FlowStepDetailTests : BunitContext Assert.Equal("Input", cut.Find("[data-test=flow-step-tab-input]").TextContent.Trim()); } + /// <summary>Verifies that a parked notification step shows the hint, the list label and a parked badge.</summary> [Fact] public void ParkedNotify_ShowsHint_AndListLabel_AndParkedBadge() { @@ -115,6 +118,10 @@ public class FlowStepDetailTests : BunitContext Assert.Contains("Site Calls / Notification", cut.Find("[data-test=flow-step-parked-hint]").TextContent); } + /// <summary>Verifies that each outbound step kind labels its Input tab and target field appropriately.</summary> + /// <param name="kind">The outbound step kind under test.</param> + /// <param name="inputLabel">The expected label of the Input tab.</param> + /// <param name="targetLabel">The expected label of the target field.</param> [Theory] [InlineData(FlowStepKind.ApiCall, "Request", "System")] [InlineData(FlowStepKind.DbCall, "SQL + parameters", "Connection")] @@ -127,6 +134,7 @@ public class FlowStepDetailTests : BunitContext Assert.Contains(targetLabel, cut.Find("[data-test=flow-step-where]").TextContent); Assert.Equal("Pending", cut.Find("[data-test=flow-step-outcome]").TextContent.Trim()); } + /// <summary>Verifies that an outbound step with a correlation id links to its report page.</summary> [Fact] public void OutboundStepWithCorrelation_LinksToTheReportPage() { @@ -149,6 +157,7 @@ public class FlowStepDetailTests : BunitContext Assert.Empty(cut.FindAll("[data-test=open-report]")); } + /// <summary>Verifies that a parked step with callbacks shows Retry and Discard actions.</summary> [Fact] public void ParkedStep_WithCallbacks_ShowsRetryAndDiscard() { @@ -187,6 +196,7 @@ public class FlowStepDetailTests : BunitContext Assert.True(cut.Find("button[data-test=flow-step-discard]").HasAttribute("disabled")); } + /// <summary>Verifies that a parked step without callbacks shows only the hint link.</summary> [Fact] public void ParkedStep_WithoutCallbacks_ShowsOnlyTheHintLink() { @@ -205,6 +215,7 @@ public class FlowStepDetailTests : BunitContext Assert.Equal($"/notifications/report?correlationId={c:N}", hintLink!.GetAttribute("href")); } + /// <summary>Verifies that a successful step never shows Retry or Discard actions.</summary> [Fact] public void OkStep_NeverShowsActions() { @@ -218,6 +229,7 @@ public class FlowStepDetailTests : BunitContext Assert.Empty(cut.FindAll("[data-test=flow-step-discard]")); } + /// <summary>Verifies that a step with no log lines and no error detail hides the Log tab.</summary> [Fact] public void NoLinesAndNoDetail_HidesTheLogTab() { @@ -225,6 +237,7 @@ public class FlowStepDetailTests : BunitContext Assert.Empty(cut.FindAll("[data-test=flow-step-tab-log]")); } + /// <summary>Verifies that the Log tab shows a count of its lines.</summary> [Fact] public void LogTab_CountsItsLines() { @@ -239,6 +252,7 @@ public class FlowStepDetailTests : BunitContext Assert.Equal("Log (3)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim()); } + /// <summary>Verifies that a failed step with error detail opens on the Log tab and renders the exception block.</summary> [Fact] public void FailedStepWithDetail_OpensOnLog_AndRendersTheExceptionBlock() { @@ -254,10 +268,11 @@ public class FlowStepDetailTests : BunitContext Assert.NotNull(cut.Find("[data-test=flow-step-exception-copy]")); } + /// <summary>Verifies that the exception block header names the innermost exception type in full for a wrapped exception.</summary> [Fact] public void WrappedException_TheBlockHeaderNamesTheINNERMOSTType_InFull() { - // Final review M1: the panel used to parse the OUTERMOST exception of a " ---> " chain + // The panel used to parse the OUTERMOST exception of a " ---> " chain // while the graph node's subtitle showed the innermost — the two now share // ExceptionHeader, which always follows the chain to its end. The panel still shows the // type with its namespace (only the node badge shortens it), and the raw detail is @@ -275,6 +290,7 @@ public class FlowStepDetailTests : BunitContext Assert.Contains("System.AggregateException", exc.QuerySelector("pre")!.TextContent); } + /// <summary>Verifies that a successful step with log lines opens on the Input tab.</summary> [Fact] public void OkStepWithLines_OpensOnInput() { @@ -286,6 +302,7 @@ public class FlowStepDetailTests : BunitContext Assert.Empty(cut.FindAll("[data-test=flow-step-exception]")); } + /// <summary>Verifies that the warning filter leaves only warning and error rows.</summary> [Fact] public void WarnFilter_LeavesOnlyWarnAndErrorRows() { @@ -311,6 +328,7 @@ public class FlowStepDetailTests : BunitContext Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count); } + /// <summary>Verifies that the hint names another step of the same execution and selects it when clicked.</summary> [Fact] public void Hint_NamesAnotherStepOfTheSameExecution_AndSelectsIt() { @@ -336,6 +354,7 @@ public class FlowStepDetailTests : BunitContext Assert.Same(other, picked); } + /// <summary>Verifies that a hint without a select callback is rendered as plain text.</summary> [Fact] public void Hint_WithoutASelectCallback_IsPlainText() { @@ -352,6 +371,7 @@ public class FlowStepDetailTests : BunitContext Assert.Null(hint.QuerySelector("button")); } + /// <summary>Verifies that the see-exception link in the output alert switches to the Log tab.</summary> [Fact] public void OutputAlert_SeeExceptionLink_SwitchesToTheLogTab() { @@ -367,6 +387,7 @@ public class FlowStepDetailTests : BunitContext Assert.NotNull(cut.Find("[data-test=flow-step-exception]")); } + /// <summary>Verifies that a runtime-sourced log line shows its source and any attached exception.</summary> [Fact] public void RuntimeSourcedLine_ShowsItsSource_AndAnAttachedException() { @@ -384,6 +405,7 @@ public class FlowStepDetailTests : BunitContext Assert.Contains("System.TimeoutException", row.QuerySelector("details pre")!.TextContent); } + /// <summary>Verifies that switching steps keeps the selected tab when it is still valid for the new step.</summary> [Fact] public void SwitchingSteps_KeepsAStillValidTab() { @@ -400,6 +422,7 @@ public class FlowStepDetailTests : BunitContext Assert.DoesNotContain("active", cut.Find("[data-test=flow-step-tab-input]").ClassName); } + /// <summary>Verifies that refreshing the same step keeps the selected tab and the level filter.</summary> [Fact] public void RefreshingTheSameStep_KeepsTheTabAndTheLevelFilter() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs index c6bb637a..9019546e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs @@ -19,7 +19,7 @@ using InstanceConfigurePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.D namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// <summary> -/// Bundle D drill-in test (#23 M7-T12) for the Instance Configure page. The +/// Drill-in test for the Instance Configure page. The /// chip routes operators into the central Audit Log pre-filtered by /// <c>?instance={Instance.UniqueName}</c>. Instance is UI-only on the filter /// bar (the repository filter contract has no instance column), so the page @@ -32,6 +32,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext Substitute.For<ITemplateEngineRepository>(); private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>(); + /// <summary>Initializes the test context and registers the services the Instance Configure page depends on.</summary> public InstanceConfigureAuditDrillinTests() { // Loose JS interop because shared components on the page render @@ -65,6 +66,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext AuthorizationPolicies.AddScadaBridgeAuthorization(Services); } + /// <summary>Verifies that the page renders a recent audit activity link carrying the instance unique name.</summary> [Fact] public void Page_HasRecentAuditActivityLink_WithInstanceUniqueName() { @@ -114,6 +116,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext /// protocol == "OpcUa". Previously BuildTestableRows filtered to OPC UA /// only, leaving the button greyed for MxGateway bindings. /// </summary> + /// <param name="protocol">The data connection protocol of the bound attribute.</param> [Theory] [InlineData("MxGateway")] [InlineData("OpcUa")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingBehaviourTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingBehaviourTests.cs index f3919a9a..6b2886d2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingBehaviourTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingBehaviourTests.cs @@ -33,6 +33,7 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>(); private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>(); + /// <summary>Creates the test class and registers the stubbed services the page needs.</summary> public InstanceConfigureBindingBehaviourTests() { JSInterop.Mode = JSRuntimeMode.Loose; @@ -85,6 +86,7 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext new() { CanonicalName = canonical, DataType = "Boolean", DataSourceReference = effective ?? dflt, DefaultDataSourceReference = dflt, BoundDataConnectionId = conn }; + /// <summary>Verifies that binding rows come from the flattened configuration with composed members grouped.</summary> [Fact] public void Rows_ComeFromFlattenedConfiguration_ComposedMembersGrouped() { @@ -110,6 +112,7 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext }); } + /// <summary>Verifies that a flatten failure falls back to the direct attributes and shows a banner.</summary> [Fact] public void FlattenFailure_FallsBackToDirectAttributes_WithBanner() { @@ -128,6 +131,8 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext }); } + /// <summary>Verifies that applying a connection to a mixed group keeps each member connection and that save posts the composed rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyOnMixedConnectionGroup_KeepsEachMembersConnection_AndSavePostsComposedRows() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingGroupsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingGroupsTests.cs index 86d4fa60..f4d703f7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingGroupsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingGroupsTests.cs @@ -9,6 +9,7 @@ public class InstanceConfigureBindingGroupsTests { private static string Markup => InstanceConfigureBindingRowsTests.ReadMarkup(); + /// <summary>Verifies that a group header has a connection selector, an object selector and an Apply button.</summary> [Fact] public void GroupHeader_HasConnectionObjectAndApply() { @@ -21,6 +22,7 @@ public class InstanceConfigureBindingGroupsTests Assert.Contains("BindingGroupBuilder.ApplyObject(", m); } + /// <summary>Verifies that a group header shows unbound and mixed status pills.</summary> [Fact] public void GroupHeader_ShowsUnboundAndMixedPills() { @@ -30,6 +32,7 @@ public class InstanceConfigureBindingGroupsTests Assert.Contains("data-test=\"binding-total-unbound\"", m); } + /// <summary>Verifies that the toolbar offers an unbound-only filter and an expand-all control.</summary> [Fact] public void Toolbar_HasUnboundFilterAndExpandAll() { @@ -39,18 +42,21 @@ public class InstanceConfigureBindingGroupsTests Assert.Contains("data-test=\"binding-bulk-select\"", m); // existing all-groups connection select retained } + /// <summary>Verifies that a composed member with a bare default binding is flagged.</summary> [Fact] public void ComposedBareDefault_IsFlagged() { Assert.Contains("data-test=\"binding-bare-default\"", Markup); } + /// <summary>Verifies that the old flat bindings table is no longer rendered.</summary> [Fact] public void OldFlatTable_IsGone() { Assert.DoesNotContain("_bindingDataSourceAttrs", Markup); } + /// <summary>Verifies that the per-row Browse button is retained.</summary> [Fact] public void PerRowBrowseButton_IsRetained() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingRowsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingRowsTests.cs index 1b4bc591..488ce227 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingRowsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingRowsTests.cs @@ -8,6 +8,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// </summary> public class InstanceConfigureBindingRowsTests { + /// <summary>Reads the InstanceConfigure page markup from the source tree.</summary> + /// <returns>The full text of <c>InstanceConfigure.razor</c>.</returns> internal static string ReadMarkup() { var dir = AppContext.BaseDirectory; @@ -18,6 +20,7 @@ public class InstanceConfigureBindingRowsTests private static string Markup => ReadMarkup(); + /// <summary>Verifies that binding rows come from the flattened configuration rather than the template attributes.</summary> [Fact] public void BindingRows_ComeFromFlattenedConfiguration_NotTemplateAttributes() { @@ -29,6 +32,7 @@ public class InstanceConfigureBindingRowsTests Assert.Contains("DefaultDataSourceReference", m); } + /// <summary>Verifies that binding rows fall back to direct attributes with a warning banner when flattening fails.</summary> [Fact] public void BindingRows_FallBackToDirectAttributes_WithBanner_WhenFlattenFails() { @@ -37,6 +41,7 @@ public class InstanceConfigureBindingRowsTests Assert.Contains("data-test=\"binding-source-warning\"", m); } + /// <summary>Verifies that save and test cover every binding row.</summary> [Fact] public void SaveAndTest_CoverEveryRow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureListOverrideTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureListOverrideTests.cs index 93fad749..5ed44b31 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureListOverrideTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureListOverrideTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// <summary> -/// MV-14: the Instance Configure attribute-override panel uses the shared +/// The Instance Configure attribute-override panel uses the shared /// <c>AttributeListEditor</c> for a List attribute (whole-list replacement; the /// element type is fixed by the base attribute, so the type select is hidden via /// <c>ShowElementType="false"</c>). Loading an existing override decodes its JSON @@ -30,6 +30,7 @@ public class InstanceConfigureListOverrideTests } } + /// <summary>Verifies that list override, reveals shared editor, with element type hidden.</summary> [Fact] public void ListOverride_RevealsSharedEditor_WithElementTypeHidden() { @@ -45,6 +46,7 @@ public class InstanceConfigureListOverrideTests Assert.Contains("RowsChanged=\"@(r => OnListRowsChanged(attr.Name, r))\"", markup); } + /// <summary>Verifies that list override, decodes on load, and encodes on save with guard.</summary> [Fact] public void ListOverride_DecodesOnLoad_AndEncodesOnSaveWithGuard() { @@ -59,6 +61,7 @@ public class InstanceConfigureListOverrideTests Assert.Contains("_overrideErrors", markup); } + /// <summary>Verifies that list override, clear removes the override row.</summary> [Fact] public void ListOverride_ClearRemovesTheOverrideRow() { @@ -70,6 +73,7 @@ public class InstanceConfigureListOverrideTests Assert.Contains("HasOverrideRow", markup); } + /// <summary>Verifies that non list override, keeps single input ux.</summary> [Fact] public void NonListOverride_KeepsSingleInputUx() { @@ -79,6 +83,7 @@ public class InstanceConfigureListOverrideTests Assert.Contains("OnOverrideChanged(attr.Name, e)", markup); } + /// <summary>Verifies that encoded rows, round trip through codec, as the page does.</summary> [Fact] public void EncodedRows_RoundTripThroughCodec_AsThePageDoes() { @@ -103,6 +108,7 @@ public class InstanceConfigureListOverrideTests Assert.Equal(json, reEncoded); } + /// <summary>Verifies that malformed list element, surfaces format exception, for inline error.</summary> [Fact] public void MalformedListElement_SurfacesFormatException_ForInlineError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureMesReportableTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureMesReportableTests.cs index a5ce1365..497ff25e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureMesReportableTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureMesReportableTests.cs @@ -34,6 +34,7 @@ public class InstanceConfigureMesReportableTests private static string InstanceConfigureMarkup => File.ReadAllText(Path.Combine(DeploymentComponentsRoot, "InstanceConfigure.razor")); + /// <summary>Verifies that the override modal has a tri-state MES-reportable select.</summary> [Fact] public void OverrideModal_HasTriStateMesReportableSelect() { @@ -57,6 +58,10 @@ public class InstanceConfigureMesReportableTests Assert.Contains("value=\"false\"", selectBody); } + /// <summary>Verifies that setting only the MES-reportable flag counts as an override delta.</summary> + /// <param name="overrideJson">The override json.</param> + /// <param name="priority">The priority.</param> + /// <param name="mesReportable">The mes reportable.</param> [Theory] // The new term: a reportable-only override is a real delta in BOTH directions // (false is not "no override" — it pins a reportable template alarm off). @@ -66,6 +71,10 @@ public class InstanceConfigureMesReportableTests string? overrideJson, int? priority, bool? mesReportable) => Assert.True(InstanceConfigure.HasOverrideDelta(overrideJson, priority, mesReportable)); + /// <summary>Verifies that an override with nothing set is not a delta.</summary> + /// <param name="overrideJson">The override json.</param> + /// <param name="priority">The priority.</param> + /// <param name="mesReportable">The mes reportable.</param> [Theory] [InlineData(null, null, null)] [InlineData("", null, null)] @@ -74,6 +83,10 @@ public class InstanceConfigureMesReportableTests string? overrideJson, int? priority, bool? mesReportable) => Assert.False(InstanceConfigure.HasOverrideDelta(overrideJson, priority, mesReportable)); + /// <summary>Verifies that the existing override terms still count as a delta.</summary> + /// <param name="overrideJson">The override json.</param> + /// <param name="priority">The priority.</param> + /// <param name="mesReportable">The mes reportable.</param> [Theory] [InlineData("{\"hi\":70}", null, null)] [InlineData(null, 750, null)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureNativeAlarmTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureNativeAlarmTests.cs index 3e521965..9d097855 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureNativeAlarmTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureNativeAlarmTests.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// <summary> -/// Task 25: Instance Configure exposes a "Native Alarm Source Overrides" card — +/// Instance Configure exposes a "Native Alarm Source Overrides" card — /// per-instance retarget of an inherited native alarm source binding (connection / /// source reference / filter; blank = inherited). <c>InstanceConfigure</c> is a /// heavyweight page (7 injected services incl. <c>InstanceService</c> and the @@ -24,6 +24,7 @@ public class InstanceConfigureNativeAlarmTests } } + /// <summary>Verifies that the Instance Configure page has a native alarm source overrides card.</summary> [Fact] public void InstanceConfigure_HasNativeAlarmSourceOverridesCard() { @@ -33,6 +34,7 @@ public class InstanceConfigureNativeAlarmTests Assert.Contains("HasNativeOverride", markup); } + /// <summary>Verifies that a native override row has connection, source reference and filter editors.</summary> [Fact] public void NativeOverrideRow_HasConnectionSourceRefAndFilterEditors() { @@ -44,6 +46,7 @@ public class InstanceConfigureNativeAlarmTests Assert.Contains("(inherited)", markup); } + /// <summary>Verifies that a native override wires the repository upsert and clear operations.</summary> [Fact] public void NativeOverride_WiresRepositoryUpsertAndClear() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceCreateTemplateFieldTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceCreateTemplateFieldTests.cs index 30e7f6d7..e4478fa8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceCreateTemplateFieldTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceCreateTemplateFieldTests.cs @@ -40,6 +40,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext private readonly IAuditService _audit = Substitute.For<IAuditService>(); private readonly DialogService _dialog = new(); + /// <summary>Initializes a new instance of the <c>InstanceCreateTemplateFieldTests</c> class.</summary> public InstanceCreateTemplateFieldTests() { Services.AddSingleton(_repo); @@ -144,6 +145,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext /// <summary>Wrapper that renders InstanceCreate next to a DialogHost, as MainLayout does.</summary> public sealed class TestHostWrapper : ComponentBase { + /// <inheritdoc /> protected override void BuildRenderTree(RenderTreeBuilder builder) { builder.OpenComponent<InstanceCreatePage>(0); @@ -158,6 +160,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext .First(li => li.QuerySelector(".tv-label")!.TextContent.Trim() == label) .QuerySelector(".tv-toggle")!.Click(); + /// <summary>Verifies that template field is read only with choose button and placeholder.</summary> [Fact] public void TemplateField_IsReadOnlyWithChooseButton_AndPlaceholder() { @@ -169,6 +172,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext Assert.NotNull(cut.Find("button[data-test='create-template-choose']")); } + /// <summary>Verifies that template id query prefills field with folder path.</summary> [Fact] public void TemplateIdQuery_PrefillsFieldWithFolderPath() { @@ -177,6 +181,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext Assert.Equal("A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value")); } + /// <summary>Verifies that unknown template id query leaves field empty and shows hint.</summary> [Fact] public void UnknownTemplateIdQuery_LeavesFieldEmpty_AndShowsHint() { @@ -186,6 +191,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext Assert.Contains("Template 999 no longer exists", cut.Markup); } + /// <summary>Verifies that no templates disables choose and links to templates page.</summary> [Fact] public void NoTemplates_DisablesChoose_AndLinksToTemplatesPage() { @@ -195,6 +201,8 @@ public class InstanceCreateTemplateFieldTests : BunitContext Assert.Contains("/design/templates", cut.Markup); } + /// <summary>Verifies that choosing in picker sets template and re previews bindings when site set.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ChoosingInPicker_SetsTemplate_AndRePreviewsBindingsWhenSiteSet() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/TopologyDisableConfirmLabelTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/TopologyDisableConfirmLabelTests.cs index 28b96f2b..1c348e25 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/TopologyDisableConfirmLabelTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/TopologyDisableConfirmLabelTests.cs @@ -41,6 +41,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext private readonly IAuditService _audit = Substitute.For<IAuditService>(); private readonly DialogService _dialog = new(); + /// <summary>Initializes the test host with stubbed services and the dialog service.</summary> public TopologyDisableConfirmLabelTests() { Services.AddSingleton(_repo); @@ -111,6 +112,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext /// <summary>Wrapper so the page and the dialog host share one render tree.</summary> public sealed class TestHostWrapper : ComponentBase { + /// <inheritdoc /> protected override void BuildRenderTree(RenderTreeBuilder builder) { builder.OpenComponent<TopologyPage>(0); @@ -151,6 +153,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext return (cut, cut.FindAll("div.modal-footer button").ToList()); } + /// <summary>Verifies that the disable confirmation button is labelled Disable rather than Delete.</summary> [Fact] public void Disable_confirm_button_says_disable_not_delete() { @@ -162,6 +165,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext Assert.Contains("Cancel", footer.Select(b => b.TextContent.Trim())); } + /// <summary>Verifies that the disable confirmation body names the instance being disabled.</summary> [Fact] public void Disable_confirm_body_names_the_instance() { @@ -169,6 +173,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext Assert.Contains("Disable instance 'Pump-001'?", cut.Find(".modal-body").TextContent); } + /// <summary>Verifies that the delete confirmation button still says Delete.</summary> [Fact] public void Delete_confirm_button_still_says_delete() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/AttributeListEditorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/AttributeListEditorTests.cs index b72a51a9..ba11b754 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/AttributeListEditorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/AttributeListEditorTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; /// <summary> -/// MV-13: the shared <c>AttributeListEditor</c> reveals an element-type select +/// The shared <c>AttributeListEditor</c> reveals an element-type select /// plus a repeatable list-value editor for structured List attributes. These are /// real bUnit rendering/interaction tests over the self-contained component, plus /// structural assertions pinning the TemplateEdit attribute-form wiring (the page @@ -29,12 +29,13 @@ public class AttributeListEditorTests : BunitContext private static string TemplateEditMarkup => File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateEdit.razor")); - // M10 residual: the attribute authoring form moved out of the page into the + // The attribute authoring form moved out of the page into the // dialog body component hosted by IDialogService.ShowAsync. Submit-side // encoding stays on the page (it owns TemplateService). private static string AttributeDialogMarkup => File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateAttributeDialog.razor")); + /// <summary>Verifies that the editor renders an element type select offering the six valid scalar types.</summary> [Fact] public void Editor_RendersElementTypeSelect_WithSixValidScalars() { @@ -56,6 +57,7 @@ public class AttributeListEditorTests : BunitContext Assert.DoesNotContain("List", texts); } + /// <summary>Verifies that turning off the element type option hides the select.</summary> [Fact] public void ShowElementType_False_HidesTheSelect() { @@ -66,6 +68,7 @@ public class AttributeListEditorTests : BunitContext Assert.Throws<ElementNotFoundException>(() => cut.Find("select.form-select")); } + /// <summary>Verifies that the editor renders one input per row.</summary> [Fact] public void Editor_RendersOneInputPerRow() { @@ -79,6 +82,7 @@ public class AttributeListEditorTests : BunitContext Assert.Equal("3", inputs[2].GetAttribute("value")); } + /// <summary>Verifies that adding an element appends a row and raises the rows-changed callback.</summary> [Fact] public void AddElement_AppendsRow_AndRaisesRowsChanged() { @@ -95,6 +99,7 @@ public class AttributeListEditorTests : BunitContext Assert.Equal("", changed[1]); } + /// <summary>Verifies that removing an element drops its row and raises the rows-changed callback.</summary> [Fact] public void RemoveElement_DropsRow_AndRaisesRowsChanged() { @@ -112,6 +117,7 @@ public class AttributeListEditorTests : BunitContext Assert.Equal("b", changed![0]); } + /// <summary>Verifies that encoded rows round-trip through the codec.</summary> [Fact] public void EncodedRows_RoundTripThroughCodec() { @@ -123,6 +129,7 @@ public class AttributeListEditorTests : BunitContext Assert.Equal(new[] { 10, 20 }, list); } + /// <summary>Verifies that the template edit form reveals the list editor and sends the element type.</summary> [Fact] public void TemplateEdit_RevealsListEditor_AndSendsElementType() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs index f4443665..b4181c23 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs @@ -13,7 +13,7 @@ using ExternalSystemForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Desi namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; /// <summary> -/// Bundle D drill-in test (#23 M7-T12) for the External Systems edit page. +/// Drill-in test for the External Systems edit page. /// The page-header chip routes operators into the central Audit Log /// pre-filtered by Target = external-system name. Create mode has nothing /// to drill into yet, so the link is suppressed. @@ -22,6 +22,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext { private readonly IExternalSystemRepository _repo = Substitute.For<IExternalSystemRepository>(); + /// <summary>Initializes the test context with a substitute repository and test authentication.</summary> public ExternalSystemFormAuditDrillinTests() { Services.AddSingleton(_repo); @@ -38,6 +39,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext AuthorizationPolicies.AddScadaBridgeAuthorization(Services); } + /// <summary>Verifies that the edit page shows a recent audit activity link whose target equals the system name.</summary> [Fact] public void EditPage_HasRecentAuditActivityLink_WithTargetEqualToSystemName() { @@ -57,6 +59,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext }); } + /// <summary>Verifies that the create page shows no recent audit activity link.</summary> [Fact] public void CreatePage_HasNoRecentAuditActivityLink() { @@ -70,10 +73,11 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext }); } + /// <summary>Verifies that the form renders a timeout seconds input with the gateway default helper text.</summary> [Fact] public void Form_RendersTimeoutSecondsInput_WithGatewayDefaultHelperText() { - // PLAN-06 Task 12: the per-system TimeoutSeconds column (Task 8) needs a + // The per-system TimeoutSeconds column needs a // Central UI form field so Design users can actually set it. var cut = Render<ExternalSystemForm>(); @@ -84,6 +88,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext }); } + /// <summary>Verifies that saving a new system passes its timeout seconds to the repository.</summary> [Fact] public void SavingNewSystem_WithTimeoutSeconds_PassesTimeoutSecondsToRepository() { @@ -105,6 +110,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext }); } + /// <summary>Verifies that saving an edited system updates the timeout seconds on the existing record.</summary> [Fact] public void SavingEditedSystem_UpdatesTimeoutSecondsOnExisting() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemsConnectionStringHidingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemsConnectionStringHidingTests.cs index 51408327..de78131b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemsConnectionStringHidingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemsConnectionStringHidingTests.cs @@ -25,6 +25,7 @@ public class ExternalSystemsConnectionStringHidingTests : BunitContext private readonly IInboundApiRepository _inbound = Substitute.For<IInboundApiRepository>(); private readonly IDialogService _dialog = Substitute.For<IDialogService>(); + /// <summary>Initializes the test context and registers the services the External Systems page depends on.</summary> public ExternalSystemsConnectionStringHidingTests() { Services.AddSingleton(_repo); @@ -43,6 +44,7 @@ public class ExternalSystemsConnectionStringHidingTests : BunitContext AuthorizationPolicies.AddScadaBridgeAuthorization(Services); } + /// <summary>Verifies that the database connections tab does not render the connection string.</summary> [Fact] public void DbConnectionsTab_DoesNotRenderConnectionString() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormMonacoSchemaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormMonacoSchemaTests.cs index 5f758ab9..b7a10a11 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormMonacoSchemaTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormMonacoSchemaTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Services; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; /// <summary> -/// M9-T31 (+#261 follow-up): the raw-JSON escape hatch in +/// The raw-JSON escape hatch in /// <see cref="ParameterValueForm"/> (the fallback for an unresolved <c>$ref</c> / /// unknown-type node) is a Monaco <c>json</c> editor wired with THAT FIELD's own /// sub-schema — not the whole root object schema — so each field's editor @@ -22,6 +22,7 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext { private readonly ISchemaLibraryQueryService _library = Substitute.For<ISchemaLibraryQueryService>(); + /// <summary>Initializes a new instance of the <see cref="ParameterValueFormMonacoSchemaTests"/> class.</summary> public ParameterValueFormMonacoSchemaTests() { // The escape-hatch surface renders a MonacoEditor whose JS interop is not @@ -33,6 +34,7 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext Services.AddSingleton(_library); } + /// <summary>Verifies the case: escape Hatch, renders Monaco Json Editor, not Plain Textarea.</summary> [Fact] public void EscapeHatch_RendersMonacoJsonEditor_NotPlainTextarea() { @@ -57,6 +59,7 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext Assert.Empty(cut.FindAll("textarea")); } + /// <summary>Verifies the case: escape Hatch, monaco Editor, receives Field Sub Schema, not Root Schema.</summary> [Fact] public void EscapeHatch_MonacoEditor_ReceivesFieldSubSchema_NotRootSchema() { @@ -106,6 +109,7 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext Assert.DoesNotContain("$ref", jsonSchema!); } + /// <summary>Verifies the case: escape Hatch, with Resolvable Sibling Object, scopes Field Editor To Its Own Fragment.</summary> [Fact] public void EscapeHatch_WithResolvableSiblingObject_ScopesFieldEditorToItsOwnFragment() { @@ -143,6 +147,7 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext Assert.DoesNotContain("\"id\"", jsonSchema!); } + /// <summary>Verifies the case: multiple Escape Hatch Fields, each Editor Receives Its Own Field Fragment, not Root Schema.</summary> [Fact] public void MultipleEscapeHatchFields_EachEditorReceivesItsOwnFieldFragment_NotRootSchema() { @@ -182,6 +187,7 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext } } + /// <summary>Verifies the case: multiple Escape Hatch Fields, do Not Leak AResolvable Siblings Shape.</summary> [Fact] public void MultipleEscapeHatchFields_DoNotLeakAResolvableSiblingsShape() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs index c7b1f419..815caaf9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Services; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; /// <summary> -/// M9-T30: <c>ParameterValueForm</c> renders typed nested inputs for object and +/// <c>ParameterValueForm</c> renders typed nested inputs for object and /// list parameters (replacing the raw-JSON textarea fallback), driven by the /// declared schema (with <c>{"$ref":"lib:Name"}</c> resolved via /// <see cref="ISchemaLibraryQueryService"/>), with per-field validation, and @@ -19,6 +19,7 @@ public class ParameterValueFormTests : BunitContext { private readonly ISchemaLibraryQueryService _library = Substitute.For<ISchemaLibraryQueryService>(); + /// <summary>Initializes a new instance of the <c>ParameterValueFormTests</c> class.</summary> public ParameterValueFormTests() { // Default: empty library (no $ref entries). Individual tests override. @@ -56,6 +57,7 @@ public class ParameterValueFormTests : BunitContext } """; + /// <summary>Verifies that an object parameter renders one input per field instead of a raw textarea.</summary> [Fact] public void ObjectParameter_RendersPerFieldInputs_NotRawTextarea() { @@ -78,6 +80,7 @@ public class ParameterValueFormTests : BunitContext Assert.Contains("name", cut.Markup); } + /// <summary>Verifies that an object parameter round-trips to the canonical nested JSON shape.</summary> [Fact] public void ObjectParameter_RoundTripsToCanonicalNestedJsonShape() { @@ -101,6 +104,7 @@ public class ParameterValueFormTests : BunitContext Assert.Equal("widget", order["name"]); } + /// <summary>Verifies that a list parameter renders typed item rows with add and remove controls.</summary> [Fact] public void ListParameter_RendersAddRemoveTypedItemRows() { @@ -131,6 +135,7 @@ public class ParameterValueFormTests : BunitContext Assert.Empty(tagsAfter); } + /// <summary>Verifies that a parameter containing a reference renders the resolved shape.</summary> [Fact] public void RefBearingParameter_RendersResolvedShape() { @@ -169,6 +174,7 @@ public class ParameterValueFormTests : BunitContext Assert.Equal(2, cut.FindAll("input[type=text]").Count); } + /// <summary>Verifies that a per-field validation error is shown inline.</summary> [Fact] public void PerFieldValidationError_SurfacesInline() { @@ -185,7 +191,8 @@ public class ParameterValueFormTests : BunitContext Assert.NotEmpty(cut.FindAll(".text-danger")); } - // M1 — top-level $ref: empty-state guard must key off resolved fields, not _shapes + // Top-level $ref: empty-state guard must key off resolved fields, not _shapes + /// <summary>Verifies that a top-level reference resolving to an object renders its fields rather than an empty state.</summary> [Fact] public void TopLevelRef_ResolvesToObject_RendersFields_NotEmptyState() { @@ -228,6 +235,7 @@ public class ParameterValueFormTests : BunitContext // is cleared on invalid input, not the containing dict). This is the scenario the // ShiftDict fix is designed for: "rows[1].qty" → "rows[0].qty" when rows[0] is // removed, so the error/raw-text follows the item rather than being wiped. + /// <summary>Verifies that removing a list item shifts the nested raw text of later items instead of wiping it.</summary> [Fact] public void ListItemRemoval_ShiftsNestedRawTextOfLaterItems_NotWiped() { @@ -283,6 +291,7 @@ public class ParameterValueFormTests : BunitContext Assert.NotEmpty(cut.FindAll(".text-danger")); } + /// <summary>Verifies that the valid-changed callback reports false on a parse error and true once it is fixed.</summary> [Fact] public void ValidChanged_ReportsFalseOnAParseError_AndTrueOnceFixed() { @@ -298,6 +307,7 @@ public class ParameterValueFormTests : BunitContext Assert.True(valid.Last()); } + /// <summary>Verifies that the valid-changed callback reports false on first render when a required field is empty.</summary> [Fact] public void ValidChanged_ReportsFalseOnFirstRender_WhenARequiredFieldIsEmpty() { @@ -315,6 +325,7 @@ public class ParameterValueFormTests : BunitContext Assert.Empty(cut.FindAll(".text-danger")); } + /// <summary>Verifies that the valid-changed callback reports true on first render when nothing is required.</summary> [Fact] public void ValidChanged_ReportsTrueOnFirstRender_WhenNothingIsRequired() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryPageTests.cs index 97a1a5e8..35fe7d49 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryPageTests.cs @@ -12,7 +12,7 @@ using SchemaLibraryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Desig namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; /// <summary> -/// bUnit tests for the Schema Library page (M9-T32c). The page lists the reusable named +/// bUnit tests for the Schema Library page. The page lists the reusable named /// JSON-Schema library entries, authors create/edit via the shared <c>SchemaBuilder</c> /// component, and deletes with a confirm — every mutation dispatched through /// <see cref="ISchemaLibraryService"/> (the guard-running ManagementActor path), never a @@ -23,6 +23,7 @@ public class SchemaLibraryPageTests : BunitContext { private readonly ISchemaLibraryService _service = Substitute.For<ISchemaLibraryService>(); + /// <summary>Initializes the test context with a substituted schema library service and an auto-confirming dialog service.</summary> public SchemaLibraryPageTests() { Services.AddSingleton(_service); @@ -51,6 +52,7 @@ public class SchemaLibraryPageTests : BunitContext _service.ListAsync(Arg.Any<CancellationToken>()) .Returns(Task.FromResult<IReadOnlyList<SharedSchema>>(schemas.ToList())); + /// <summary>Verifies that the page renders the empty state when there are no schemas.</summary> [Fact] public void Renders_EmptyState_WhenNoSchemas() { @@ -60,6 +62,7 @@ public class SchemaLibraryPageTests : BunitContext Assert.Contains("No library schemas defined", cut.Markup)); } + /// <summary>Verifies that existing schemas are listed together with their library reference.</summary> [Fact] public void Lists_ExistingSchemas_WithLibReference() { @@ -78,6 +81,7 @@ public class SchemaLibraryPageTests : BunitContext }); } + /// <summary>Verifies that choosing New Schema opens the editor with the schema builder.</summary> [Fact] public void NewSchema_OpensEditor_WithSchemaBuilder() { @@ -92,6 +96,7 @@ public class SchemaLibraryPageTests : BunitContext Assert.NotNull(cut.FindComponent<SchemaBuilder>()); } + /// <summary>Verifies that creating a schema dispatches a create command carrying the name and schema.</summary> [Fact] public void Create_DispatchesCreateCommand_WithNameAndSchema() { @@ -112,6 +117,7 @@ public class SchemaLibraryPageTests : BunitContext "Address", Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>())); } + /// <summary>Verifies that a guard error from a create is shown inline and the editor stays open.</summary> [Fact] public void Create_GuardError_IsShownInline_AndEditorStaysOpen() { @@ -133,6 +139,7 @@ public class SchemaLibraryPageTests : BunitContext }); } + /// <summary>Verifies that saving with an empty name dispatches no command.</summary> [Fact] public void EmptyName_DoesNotDispatch() { @@ -148,6 +155,7 @@ public class SchemaLibraryPageTests : BunitContext Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that deleting a schema confirms first, dispatches a delete command and reloads the list.</summary> [Fact] public void Delete_ConfirmsThenDispatchesDeleteCommand_AndReloads() { @@ -168,6 +176,7 @@ public class SchemaLibraryPageTests : BunitContext }); } + /// <summary>Verifies that editing a schema opens the editor and dispatches an update command.</summary> [Fact] public void Edit_OpensEditor_AndDispatchesUpdateCommand() { @@ -188,10 +197,11 @@ public class SchemaLibraryPageTests : BunitContext 9, "Address", Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>())); } + /// <summary>Verifies that row actions are disabled while the editor is open so the row under edit cannot be deleted.</summary> [Fact] public void Editing_DisablesRowActions_SoTheRowUnderEditCannotBeDeleted() { - // #260: while the edit form is open the list still shows the row, but its + // While the edit form is open the list still shows the row, but its // Edit/Delete actions must be disabled so the row under edit (or a sibling) // can't be deleted out from under the form. SeedSchemas(new SharedSchema { Id = 9, Name = "Address", Scope = "us", SchemaJson = "{\"type\":\"object\"}" }); @@ -211,19 +221,28 @@ public class SchemaLibraryPageTests : BunitContext /// <summary>A dialog service that auto-confirms, so the delete path runs end-to-end.</summary> private sealed class AlwaysConfirmDialogService : IDialogService { + /// <inheritdoc /> public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); // Register row 75 added a DialogSeverity overload. This stub auto-confirms // regardless of severity — it exists to let action paths run end to end, not to // assert anything about styling. + /// <inheritdoc /> public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null) => Task.FromResult(true); + /// <inheritdoc /> public Task<string?> PromptAsync( string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult<string?>(null); + /// <summary>Returns the default result without showing a dialog.</summary> + /// <typeparam name="TResult">The type of result the dialog would produce.</typeparam> + /// <param name="title">The dialog title.</param> + /// <param name="body">The dialog body fragment.</param> + /// <param name="size">The optional dialog size.</param> + /// <returns>A task whose result is the default value of <typeparamref name="TResult"/>.</returns> public Task<TResult?> ShowAsync<TResult>( string title, Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body, diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryQueryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryQueryServiceTests.cs index 0763b610..ec6f2fb3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryQueryServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryQueryServiceTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; /// <summary> -/// Tests for the schema-library read-accessor (M9-T32c) that T30's schema-driven +/// Tests for the schema-library read-accessor that the schema-driven /// value-entry forms reuse to resolve <c>{"$ref":"lib:Name"}</c> pointers. It projects /// <see cref="ISharedSchemaRepository"/> onto a name → schema-JSON map (and a single-name /// lookup) over a fresh DI scope. The repository is substituted and registered scoped so @@ -25,6 +25,8 @@ public class SchemaLibraryQueryServiceTests return (service, repo); } + /// <summary>Verifies that the schema map associates each library name with its schema JSON.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSchemaMapAsync_MapsNameToSchemaJson() { @@ -42,6 +44,8 @@ public class SchemaLibraryQueryServiceTests Assert.Equal("{\"type\":\"array\"}", map["GeoPoint"]); } + /// <summary>Verifies that an empty library yields an empty schema map.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSchemaMapAsync_EmptyLibrary_ReturnsEmptyMap() { @@ -53,6 +57,8 @@ public class SchemaLibraryQueryServiceTests Assert.Empty(map); } + /// <summary>Verifies that resolving a known name returns its schema JSON.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResolveAsync_KnownName_ReturnsSchemaJson() { @@ -65,6 +71,8 @@ public class SchemaLibraryQueryServiceTests Assert.Equal("{\"type\":\"object\"}", json); } + /// <summary>Verifies that resolving an unknown name returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResolveAsync_UnknownName_ReturnsNull() { @@ -74,6 +82,8 @@ public class SchemaLibraryQueryServiceTests Assert.Null(await service.ResolveAsync("Missing")); } + /// <summary>Verifies that resolving an empty name returns null without querying the repository.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResolveAsync_EmptyName_ReturnsNull_WithoutHittingRepo() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ScriptFormSandboxDisposalTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ScriptFormSandboxDisposalTests.cs index 9b32fd66..57c1cbd1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ScriptFormSandboxDisposalTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ScriptFormSandboxDisposalTests.cs @@ -17,7 +17,7 @@ using SharedScriptFormPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.De namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; /// <summary> -/// Regression tests for arch-review P6-12. <c>ApiMethodForm</c> and +/// Regression tests for the sandbox-run cancellation defect. <c>ApiMethodForm</c> and /// <c>SharedScriptForm</c> run a Test Run sandbox script via /// <c>AnalysisService.RunInSandboxAsync(request, _runCts.Token)</c> but — unlike /// <c>TemplateScriptDialog</c>, which cancels/disposes <c>_runCts</c> from an @@ -36,7 +36,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; /// requires <c>ScriptAnalysisService.RunInSandboxAsync</c> to be <c>virtual</c> — /// a test-only seam added alongside this fix (mirroring the pre-existing /// <c>virtual</c> on <c>DebugStreamService.StartStreamAsync</c>/<c>StopStream</c>), -/// not itself part of the P6-12 defect or its fix. +/// not itself part of the defect or its fix. /// </para> /// <para> /// <b>Proof of failure:</b> stashing only the two <c>.razor</c> changes (leaving @@ -51,6 +51,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; /// </summary> public class ApiMethodFormSandboxDisposalTests : BunitContext { + /// <summary>Initializes a new instance of <c>GatedScriptAnalysisService</c>.</summary> private (IRenderedComponent<ApiMethodFormPage> Cut, GatedScriptAnalysisService Sandbox) RenderPage() { // ApiMethodForm mounts a MonacoEditor unconditionally; let bUnit answer its @@ -99,6 +100,8 @@ public class ApiMethodFormSandboxDisposalTests : BunitContext typeof(ApiMethodFormPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(page); + /// <summary>Verifies that dispose mid run cancels the sandbox run token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Dispose_MidRun_CancelsTheSandboxRunToken() { @@ -124,6 +127,8 @@ public class ApiMethodFormSandboxDisposalTests : BunitContext await run.WaitAsync(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that dispose mid run continuation is a no op.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Dispose_MidRun_ContinuationIsANoOp() { @@ -198,6 +203,8 @@ public class SharedScriptFormSandboxDisposalTests : BunitContext typeof(SharedScriptFormPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(page); + /// <summary>Verifies that dispose mid run cancels the sandbox run token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Dispose_MidRun_CancelsTheSandboxRunToken() { @@ -220,6 +227,8 @@ public class SharedScriptFormSandboxDisposalTests : BunitContext await run.WaitAsync(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that dispose mid run continuation is a no op.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Dispose_MidRun_ContinuationIsANoOp() { @@ -260,6 +269,7 @@ internal sealed class GatedScriptAnalysisService : ScriptAnalysisService private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// <summary>Initializes a new instance of <c>GatedScriptAnalysisService</c>.</summary> public GatedScriptAnalysisService() : base( Substitute.For<ISharedScriptCatalog>(), @@ -278,6 +288,7 @@ internal sealed class GatedScriptAnalysisService : ScriptAnalysisService public void ReleaseRun() => _gate.TrySetResult( new SandboxRunResult(true, "1", "int", "", null, SandboxErrorKind.None, 1, null)); + /// <inheritdoc /> public override async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct) { LastToken = ct; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateAlarmDialogMesReportableTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateAlarmDialogMesReportableTests.cs index d4351251..5b563931 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateAlarmDialogMesReportableTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateAlarmDialogMesReportableTests.cs @@ -30,6 +30,7 @@ public class TemplateAlarmDialogMesReportableTests private static string TemplateEditMarkup => File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateEdit.razor")); + /// <summary>Verifies that the alarm dialog has a MES-reportable checkbox.</summary> [Fact] public void AlarmDialog_HasMesReportableCheckbox() { @@ -39,6 +40,7 @@ public class TemplateAlarmDialogMesReportableTests Assert.Contains("MES reportable", markup); } + /// <summary>Verifies that the alarm dialog threads the MES-reportable flag through its parameter and draft.</summary> [Fact] public void AlarmDialog_ThreadsMesReportableThroughParameterAndDraft() { @@ -48,6 +50,7 @@ public class TemplateAlarmDialogMesReportableTests Assert.Contains("bool IsMesReportable", markup); } + /// <summary>Verifies that the alarms tab has a MES column.</summary> [Fact] public void AlarmsTab_HasMesColumn() { @@ -57,6 +60,7 @@ public class TemplateAlarmDialogMesReportableTests Assert.Contains("alarm.IsMesReportable", markup); } + /// <summary>Verifies that the alarms tab passes the MES-reportable flag into and out of the dialog.</summary> [Fact] public void AlarmsTab_PassesMesReportableIntoAndOutOfTheDialog() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateEditInheritedSetTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateEditInheritedSetTests.cs index 939d0b6d..2bb21b45 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateEditInheritedSetTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateEditInheritedSetTests.cs @@ -20,7 +20,7 @@ using TemplateEditPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; /// <summary> -/// bUnit tests for the template editor's READ-ONLY inheritance preview (M9-T26b): +/// bUnit tests for the template editor's READ-ONLY inheritance preview: /// rendering the FULL transitively-resolved inherited member set (not just the /// immediate base) annotated with origin, plus a read-only base-changed staleness /// banner. The resolve query is dispatched through @@ -41,6 +41,7 @@ public class TemplateEditInheritedSetTests : BunitContext private const int DerivedId = 30; private const int BaseId = 20; + /// <summary>Initializes the bUnit context and registers the fakes and services the template editor needs.</summary> public TemplateEditInheritedSetTests() { Services.AddSingleton(_repo); @@ -121,6 +122,7 @@ public class TemplateEditInheritedSetTests : BunitContext _resolve.ResolveAsync(DerivedId, Arg.Any<CancellationToken>()) .Returns(Task.FromResult<ResolvedTemplateMembers?>(resolved)); + /// <summary>Verifies that transitively inherited members are rendered with an origin annotation.</summary> [Fact] public void Renders_TransitivelyInheritedMembers_WithOriginAnnotation() { @@ -167,6 +169,7 @@ public class TemplateEditInheritedSetTests : BunitContext }); } + /// <summary>Verifies that the effective alarm trigger configuration is rendered from the resolved set.</summary> [Fact] public void Renders_EffectiveAlarmTriggerConfiguration_FromResolvedSet() { @@ -203,6 +206,7 @@ public class TemplateEditInheritedSetTests : BunitContext }); } + /// <summary>Verifies that the staleness banner is shown when the resolved set is stale.</summary> [Fact] public void Shows_StalenessBanner_WhenResolvedSetIsStale() { @@ -235,6 +239,7 @@ public class TemplateEditInheritedSetTests : BunitContext }); } + /// <summary>Verifies that no staleness banner is shown when the resolved set is not stale.</summary> [Fact] public void NoStalenessBanner_WhenResolvedSetIsNotStale() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateNativeAlarmSourceEditorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateNativeAlarmSourceEditorTests.cs index 3c6852b6..df20056d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateNativeAlarmSourceEditorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateNativeAlarmSourceEditorTests.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; /// <summary> -/// Task 24: the template editor exposes a Native Alarm Sources subsection (a new +/// The template editor exposes a Native Alarm Sources subsection (a new /// tab) for authoring read-only native alarm bindings. <c>TemplateEdit</c> is a /// heavyweight page (~10 injected services with their own graphs); like the other /// TemplateEdit coverage in this suite (see <c>TestRunWarningTests</c>), these are @@ -28,13 +28,14 @@ public class TemplateNativeAlarmSourceEditorTests private static string TemplateEditMarkup => File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateEdit.razor")); - // M10 residual: the authoring form moved out of the page into the dialog body + // The authoring form moved out of the page into the dialog body // component hosted by IDialogService.ShowAsync. The field-level assertions // below follow it there; the tab, the connection filtering, and the CRUD // wiring all still live on the page. private static string NativeSourceDialogMarkup => File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateNativeAlarmSourceDialog.razor")); + /// <summary>Verifies that the template editor has a Native Alarms tab.</summary> [Fact] public void TemplateEditor_HasNativeAlarmsTab() { @@ -45,6 +46,7 @@ public class TemplateNativeAlarmSourceEditorTests Assert.Contains("Native Alarm Sources", markup); } + /// <summary>Verifies that the native alarms form has connection, source filter and lock fields.</summary> [Fact] public void NativeAlarmsForm_HasConnectionSourceFilterAndLockFields() { @@ -67,10 +69,11 @@ public class TemplateNativeAlarmSourceEditorTests Assert.Contains("@bind=\"_nasIsLocked\"", dialogMarkup); } + /// <summary>Verifies that the native alarms form is hosted by the dialog service rather than embedded in the page.</summary> [Fact] public void NativeAlarmsForm_IsHostedByTheDialogService() { - // M10 residual: the page-embedded modal is gone — the form opens through + // The page-embedded modal is gone — the form opens through // IDialogService.ShowAsync so the host owns the backdrop, focus trap, // Escape, and focus restoration. var pageMarkup = TemplateEditMarkup; @@ -83,6 +86,7 @@ public class TemplateNativeAlarmSourceEditorTests Assert.Contains("DialogContext<bool> Context", dialogMarkup); } + /// <summary>Verifies that native alarm add, update and delete are wired to the repository followed by a save.</summary> [Fact] public void NativeAlarmsCrud_WiresRepositoryAddUpdateDeleteWithSave() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TestRunWarningTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TestRunWarningTests.cs index 8d51263f..9831c9da 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TestRunWarningTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TestRunWarningTests.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; /// <summary> -/// Regression tests for CentralUI-014. Test Run wires <c>External</c>, +/// Regression tests for the Test Run side-effect warning. Test Run wires <c>External</c>, /// <c>Database</c>, and <c>Notify</c> to central's real services, so a Test Run /// has production-equivalent side effects. The finding asked, at minimum, that /// this blast radius be surfaced to the user. The Test Run panels in @@ -29,9 +29,11 @@ public class TestRunWarningTests private static string Read(string fileName) => File.ReadAllText(Path.Combine(SrcRoot, fileName)); - // M10 residual: the template Test Run panel moved out of TemplateEdit.razor + // The template Test Run panel moved out of TemplateEdit.razor // into TemplateScriptDialog.razor when the page-embedded script modal was // migrated to the IDialogService host. The warning travelled with it. + /// <summary>Verifies that a Test Run panel with a real I/O surface shows the real I/O badge and warning.</summary> + /// <param name="razorFile">The Razor file containing the Test Run panel.</param> [Theory] [InlineData("SharedScriptForm.razor")] [InlineData("TemplateScriptDialog.razor")] @@ -47,6 +49,7 @@ public class TestRunWarningTests Assert.Contains("Side effects are permanent", markup); } + /// <summary>Verifies that the API method form Test Run shows no real I/O badge because the inbound API has no side-effect surface.</summary> [Fact] public void ApiMethodForm_TestRun_HasNoRealIoBadge_BecauseInboundApiHasNoSideEffectSurface() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Forms/OpcUaEndpointEditorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Forms/OpcUaEndpointEditorTests.cs index 2cd1def9..0df1ec68 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Forms/OpcUaEndpointEditorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Forms/OpcUaEndpointEditorTests.cs @@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Forms; public class OpcUaEndpointEditorTests : BunitContext { + /// <summary>Initializes the bUnit context with no-op endpoint verification and certificate management services.</summary> public OpcUaEndpointEditorTests() { // OpcUaEndpointEditor injects IEndpointVerificationService for its Verify-endpoint @@ -21,6 +22,7 @@ public class OpcUaEndpointEditorTests : BunitContext Services.AddSingleton<ICertManagementService>(Substitute.For<ICertManagementService>()); } + /// <summary>Verifies that the editor renders all four section labels.</summary> [Fact] public void Renders_All_Four_Section_Labels() { @@ -35,6 +37,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.Contains("Heartbeat", cut.Markup); } + /// <summary>Verifies that editing a field mutates the configuration instance passed to the editor.</summary> [Fact] public void Binding_MutatesPassedConfigInstance() { @@ -46,6 +49,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.Equal("opc.tcp://new-host:4840", config.EndpointUrl); } + /// <summary>Verifies that enabling the heartbeat creates the heartbeat sub-object.</summary> [Fact] public void EnableHeartbeat_CreatesSubObject() { @@ -58,6 +62,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.NotNull(config.Heartbeat); } + /// <summary>Verifies that removing the heartbeat sets the heartbeat sub-object to null.</summary> [Fact] public void RemoveHeartbeat_NullsSubObject() { @@ -72,6 +77,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.Null(config.Heartbeat); } + /// <summary>Verifies that the errors parameter renders red error text beside the affected fields.</summary> [Fact] public void Errors_Parameter_RendersPerFieldRedText() { @@ -85,6 +91,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.Contains("text-danger", cut.Markup); } + /// <summary>Verifies that a legacy configuration renders the warning banner.</summary> [Fact] public void IsLegacy_True_RendersWarningBanner() { @@ -98,6 +105,7 @@ public class OpcUaEndpointEditorTests : BunitContext // ── Layer E: new editor sections ── + /// <summary>Verifies that the editor renders the authentication section label.</summary> [Fact] public void Renders_Authentication_Section_Label() { @@ -105,6 +113,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.Contains("Authentication", cut.Markup); } + /// <summary>Verifies that enabling authentication creates the user identity sub-object.</summary> [Fact] public void EnableAuthentication_CreatesUserIdentitySubObject() { @@ -118,6 +127,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.Equal(OpcUaUserTokenType.Anonymous, config.UserIdentity!.TokenType); } + /// <summary>Verifies that removing authentication sets the user identity to null.</summary> [Fact] public void RemoveAuthentication_NullsUserIdentity() { @@ -137,6 +147,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.Null(config.UserIdentity); } + /// <summary>Verifies that the username/password token type renders username and password inputs.</summary> [Fact] public void UsernamePassword_RendersUsernameAndPasswordInputs() { @@ -155,6 +166,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.DoesNotContain(">Certificate path<", cut.Markup); } + /// <summary>Verifies that the X.509 certificate token type renders the certificate fields.</summary> [Fact] public void X509Certificate_RendersCertificateFields() { @@ -172,6 +184,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.DoesNotContain(">Username<", cut.Markup); } + /// <summary>Verifies that the anonymous token type shows no extra credential fields.</summary> [Fact] public void AnonymousTokenType_ShowsNoExtraFields() { @@ -185,6 +198,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.DoesNotContain(">Certificate path<", cut.Markup); } + /// <summary>Verifies that enabling the deadband creates the deadband sub-object.</summary> [Fact] public void EnableDeadband_CreatesDeadbandSubObject() { @@ -197,6 +211,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.NotNull(config.Deadband); } + /// <summary>Verifies that removing the deadband sets the deadband sub-object to null.</summary> [Fact] public void RemoveDeadband_NullsDeadband() { @@ -211,6 +226,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.Null(config.Deadband); } + /// <summary>Verifies that the advanced subscription section renders.</summary> [Fact] public void AdvancedSubscription_Section_Renders() { @@ -223,6 +239,7 @@ public class OpcUaEndpointEditorTests : BunitContext Assert.Contains("Timestamps to return", cut.Markup); } + /// <summary>Verifies that a user identity error renders under the username field.</summary> [Fact] public void UserIdentityError_RendersPerFieldUnderUsername() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs index 8f66b005..cb7d18e8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs @@ -12,7 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Layout; /// <summary> /// bUnit rendering tests for the rail <see cref="NavMenu"/>. After the -/// ZB.MOM.WW.Theme adoption (Task 2.4) the nav is composed of the kit's +/// ZB.MOM.WW.Theme adoption the nav is composed of the kit's /// <c>NavRailSection</c> (a native <c><details></c> disclosure, expanded /// by default — no JS interop, works in static SSR) and <c>NavRailItem</c> /// links. These tests verify the structure (sections render as @@ -74,6 +74,7 @@ public class NavMenuTests : BunitContext return host.FindComponent<NavMenu>(); } + /// <summary>Verifies that a Viewer sees the Flow Debugger link and no empty Notifications section.</summary> [Fact] public void Viewer_HasFlowDebuggerAndNoEmptyNotificationsSection() { @@ -85,6 +86,7 @@ public class NavMenuTests : BunitContext }); } + /// <summary>Verifies that nav sections render as kit details disclosures that are expanded by default.</summary> [Fact] public void Sections_RenderAsKitDetailsDisclosures_ExpandedByDefault() { @@ -108,6 +110,7 @@ public class NavMenuTests : BunitContext }); } + /// <summary>Verifies that the Deployment section renders all of its items for a Deployer.</summary> [Fact] public void DeploymentSection_RendersAllItems_ForDeployer() { @@ -121,6 +124,7 @@ public class NavMenuTests : BunitContext }); } + /// <summary>Verifies that the Notifications section shows all of its items for a user with multiple roles.</summary> [Fact] public void NotificationsSection_ShowsAllItems_ForMultiRoleUser() { @@ -136,6 +140,7 @@ public class NavMenuTests : BunitContext }); } + /// <summary>Verifies that an Administrator-only user sees only the SMTP item in the Notifications section.</summary> [Fact] public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp() { @@ -150,6 +155,7 @@ public class NavMenuTests : BunitContext }); } + /// <summary>Verifies that the old routes are no longer linked from the nav.</summary> [Fact] public void OldRoutes_AreNoLongerLinked() { @@ -162,6 +168,7 @@ public class NavMenuTests : BunitContext }); } + /// <summary>Verifies that the Secrets link in the Admin section is visible to an Administrator.</summary> [Fact] public void AdminSection_SecretsLink_VisibleForAdministrator() { @@ -174,6 +181,7 @@ public class NavMenuTests : BunitContext }); } + /// <summary>Verifies that the Secrets link in the Admin section is absent for a non-Administrator.</summary> [Fact] public void AdminSection_SecretsLink_AbsentForNonAdministrator() { @@ -193,6 +201,7 @@ public class NavMenuTests : BunitContext // is the witness — its heading lives inside the policy block, so a denied // user must not even see the eyebrow. + /// <summary>Verifies that a policy-gated section is visible to an authorized principal.</summary> [Fact] public void PolicyGatedSection_IsVisible_ForAuthorizedPrincipal() { @@ -207,6 +216,7 @@ public class NavMenuTests : BunitContext }); } + /// <summary>Verifies that a policy-gated section is absent for an unauthorized principal.</summary> [Fact] public void PolicyGatedSection_IsAbsent_ForUnauthorizedPrincipal() { @@ -221,6 +231,7 @@ public class NavMenuTests : BunitContext }); } + /// <summary>Verifies that all policy-gated sections are absent for an anonymous principal.</summary> [Fact] public void PolicyGatedSections_AllAbsent_ForAnonymousPrincipal() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/PendingUpdatesIndicatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/PendingUpdatesIndicatorTests.cs index a2c52217..814b8bdb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/PendingUpdatesIndicatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/PendingUpdatesIndicatorTests.cs @@ -86,10 +86,14 @@ public class PendingUpdatesIndicatorTests : BunitContext { private readonly ClaimsPrincipal _user; + /// <summary>Initializes a new instance of the <c>CountingAuthStateProvider</c> class.</summary> + /// <param name="user">The user whose claims the provider exposes.</param> public CountingAuthStateProvider(ClaimsPrincipal user) => _user = user; + /// <summary>Gets the calls recorded by this fake.</summary> public int Calls { get; private set; } + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() { Calls++; @@ -122,6 +126,7 @@ public class PendingUpdatesIndicatorTests : BunitContext return host.FindComponent<PendingUpdatesIndicator>(); } + /// <summary>Verifies that a user with only the Viewer role sees no pending-updates indicator.</summary> [Fact] public void Viewer_RendersNothing() { @@ -130,6 +135,7 @@ public class PendingUpdatesIndicatorTests : BunitContext Assert.True(string.IsNullOrWhiteSpace(cut.Markup)); } + /// <summary>Verifies that a Deployer with nothing pending sees the up-to-date state.</summary> [Fact] public void Deployer_WithNothingPending_ShowsUpToDate() { @@ -145,6 +151,7 @@ public class PendingUpdatesIndicatorTests : BunitContext }); } + /// <summary>Verifies that the snapshot is captioned with the instant at which it was computed.</summary> [Fact] public void Snapshot_IsCaptionedWithItsComputedInstant() { @@ -160,6 +167,7 @@ public class PendingUpdatesIndicatorTests : BunitContext }); } + /// <summary>Verifies that a user who is both Deployer and Administrator sees the total pending count and both links.</summary> [Fact] public void DeployerAndAdministrator_WithPendingWork_ShowsTotalAndBothLinks() { @@ -188,6 +196,7 @@ public class PendingUpdatesIndicatorTests : BunitContext }); } + /// <summary>Verifies that a Deployer without the Administrator role sees the site count as plain text.</summary> [Fact] public void DeployerWithoutAdministrator_ShowsSiteCountAsPlainText() { @@ -210,6 +219,7 @@ public class PendingUpdatesIndicatorTests : BunitContext }); } + /// <summary>Verifies that an Administrator without the Deployer role sees the instance count as plain text.</summary> [Fact] public void AdministratorWithoutDeployer_ShowsInstanceCountAsPlainText() { @@ -227,6 +237,7 @@ public class PendingUpdatesIndicatorTests : BunitContext }); } + /// <summary>Verifies that a cache fault shows an unavailable state with the fault message as a tooltip.</summary> [Fact] public void CacheFault_ShowsUnavailableWithTheMessageAsTooltip() { @@ -241,6 +252,7 @@ public class PendingUpdatesIndicatorTests : BunitContext }); } + /// <summary>Verifies that a site-scoped Deployer is shown counts for the permitted sites only.</summary> [Fact] public void SiteScopedDeployer_CountsOnlyPermittedSites() { @@ -264,6 +276,7 @@ public class PendingUpdatesIndicatorTests : BunitContext }); } + /// <summary>Verifies that disposing the component after it has rendered does not throw.</summary> [Fact] public void Dispose_AfterRender_DoesNotThrow() { @@ -278,6 +291,7 @@ public class PendingUpdatesIndicatorTests : BunitContext instance.Dispose(); } + /// <summary>Verifies that a cascaded authentication state is used instead of asking the authentication state provider.</summary> [Fact] public void CascadedAuthenticationState_IsUsedInsteadOfAskingTheProvider() { @@ -296,6 +310,8 @@ public class PendingUpdatesIndicatorTests : BunitContext Assert.Equal(0, anonymous.Calls); } + /// <summary>Verifies that polling does not resolve any further authentication state.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Polling_ResolvesNoFurtherAuthenticationState() { @@ -331,6 +347,7 @@ public class PendingUpdatesIndicatorTests : BunitContext Assert.Contains("2 instances", cut.Markup); } + /// <summary>Verifies that an authentication state change hides the indicator when the role is revoked.</summary> [Fact] public void AuthenticationStateChange_HidesTheBlockWhenTheRoleIsRevoked() { @@ -358,11 +375,16 @@ public class PendingUpdatesIndicatorTests : BunitContext { private ClaimsPrincipal _user; + /// <summary>Initializes a new instance of the <c>MutableAuthStateProvider</c> class.</summary> + /// <param name="user">The user whose claims the provider exposes.</param> public MutableAuthStateProvider(ClaimsPrincipal user) => _user = user; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(_user)); + /// <summary>Replaces the user returned by the provider and notifies listeners of the authentication state change.</summary> + /// <param name="user">The user whose claims the provider exposes.</param> public void SetUser(ClaimsPrincipal user) { _user = user; @@ -370,6 +392,7 @@ public class PendingUpdatesIndicatorTests : BunitContext } } + /// <summary>Verifies that polling starts once an asynchronous authentication resolve completes.</summary> [Fact] public void Polling_StartsAfterAnAsynchronousAuthResolveCompletes() { @@ -408,8 +431,11 @@ public class PendingUpdatesIndicatorTests : BunitContext { private readonly Task<AuthenticationState> _state; + /// <summary>Initializes a new instance of the <c>DeferredAuthStateProvider</c> class.</summary> + /// <param name="state">The authentication state the provider returns.</param> public DeferredAuthStateProvider(Task<AuthenticationState> state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => _state; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs index bdf11d79..36a23e71 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs @@ -29,6 +29,7 @@ public class AlarmSummaryRenderTests : BunitContext private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>(); private readonly FakeSiteAlarmLiveCache _liveCache = new(); + /// <summary>Initializes the test class and configures the faked services and the bUnit context.</summary> public AlarmSummaryRenderTests() { // Two rows whose default sort (severity DESC) orders Zeta (900) before @@ -41,7 +42,7 @@ public class AlarmSummaryRenderTests : BunitContext _summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()) .Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty<string>()))); - // BuildFromLiveAlarms (Task 5): flatten the pushed live snapshot the same way + // BuildFromLiveAlarms: flatten the pushed live snapshot the same way // the real service does, so the live-path render tests exercise real behavior. _summary.BuildFromLiveAlarms(Arg.Any<IReadOnlyList<AlarmStateChanged>>()) .Returns(ci => @@ -90,6 +91,7 @@ public class AlarmSummaryRenderTests : BunitContext return cut; } + /// <summary>Verifies that sortable headers carry a tabindex and an aria-sort attribute.</summary> [Fact] public void SortableHeaders_CarryTabindexAndAriaSort() { @@ -113,6 +115,7 @@ public class AlarmSummaryRenderTests : BunitContext Assert.Null(stateKind.GetAttribute("aria-sort")); } + /// <summary>Verifies that pressing Enter on the instance header flips the row order.</summary> [Fact] public void EnterOnInstanceHeader_FlipsRowOrder() { @@ -133,8 +136,9 @@ public class AlarmSummaryRenderTests : BunitContext cut.FindAll("tr[data-test='alarm-summary-row']")[0] .QuerySelector("td")!.TextContent.Trim(); - // ── plan #10 Task 5: live-cache-first with poll fallback ─────────────────── + // ── live-cache-first with poll fallback ─────────────────── + /// <summary>Verifies that selecting a site subscribes to the live cache.</summary> [Fact] public void SelectingSite_SubscribesToTheLiveCache() { @@ -144,6 +148,7 @@ public class AlarmSummaryRenderTests : BunitContext Assert.Equal(1, _liveCache.LastSubscribedSite); } + /// <summary>Verifies that the poll fallback renders when the live cache is not live yet.</summary> [Fact] public void PollFallbackRenders_WhenCacheNotLiveYet() { @@ -157,6 +162,7 @@ public class AlarmSummaryRenderTests : BunitContext Assert.Equal("Zeta", FirstRowInstance(cut)); // severity-desc default } + /// <summary>Verifies that a change notification rebuilds the rendered rows from the live snapshot.</summary> [Fact] public void OnChangedDelta_RebuildsRenderedRowsFromLiveSnapshot() { @@ -176,6 +182,7 @@ public class AlarmSummaryRenderTests : BunitContext }); } + /// <summary>Verifies that leaving a site unsubscribes from the live cache.</summary> [Fact] public void LeavingSite_UnsubscribesFromTheLiveCache() { @@ -188,6 +195,7 @@ public class AlarmSummaryRenderTests : BunitContext Assert.Equal(1, _liveCache.DisposeCount); } + /// <summary>Verifies that changing the site resubscribes to the new site.</summary> [Fact] public void ChangingSite_ResubscribesToTheNewSite() { @@ -200,6 +208,7 @@ public class AlarmSummaryRenderTests : BunitContext Assert.Equal(2, _liveCache.LastSubscribedSite); } + /// <summary>Verifies that disposing the component unsubscribes from the live cache.</summary> [Fact] public void DisposingComponent_UnsubscribesFromTheLiveCache() { @@ -212,6 +221,7 @@ public class AlarmSummaryRenderTests : BunitContext // ── arch-review R2 N4: stale-site poll guard ─────────────────────────────── + /// <summary>Verifies that an in-flight poll for the previous site does not overwrite the new site's rows.</summary> [Fact] public void InFlightPollForPreviousSite_DoesNotOverwriteNewSitesRows() { @@ -231,7 +241,7 @@ public class AlarmSummaryRenderTests : BunitContext cut.Find("[data-test='alarm-summary-site']").Change("2"); // site 2 renders cut.WaitForAssertion(() => Assert.Equal("Site2Instance", FirstRowInstance(cut))); - // The stale site-1 fan-out now completes — it must be dropped, not rendered. + // The stale first-site fan-out now completes — it must be dropped, not rendered. site1Tcs.SetResult(new AlarmSummaryResult( new List<AlarmSummaryRow> { @@ -248,6 +258,7 @@ public class AlarmSummaryRenderTests : BunitContext // ── arch-review R2 N5: while live, the poll updates only _notReporting ────── + /// <summary>Verifies that a poll while live updates only the not-reporting list and does not regress the live rows.</summary> [Fact] public void PollWhileLive_UpdatesNotReportingOnly_DoesNotRegressLiveRows() { @@ -280,9 +291,10 @@ public class AlarmSummaryRenderTests : BunitContext }); } - // ── arch-review P6-2: site scoping (a scoped Deployer must not watch another + // ── site scoping (a scoped Deployer must not watch another // site's live alarms — the page was missed by the ad0100f2 scoping survey) ── + /// <summary>Verifies that a site-scoped deployment user sees only the permitted site in the picker.</summary> [Fact] public void SiteScoping_ScopedDeploymentUser_SeesOnlyPermittedSiteInPicker() { @@ -302,6 +314,7 @@ public class AlarmSummaryRenderTests : BunitContext Assert.DoesNotContain("Site 2", cut.Markup); } + /// <summary>Verifies that a forged out-of-scope site id is denied and never fetched.</summary> [Fact] public void SiteScoping_ForgedOutOfScopeSiteId_IsDeniedNotFetched() { @@ -329,6 +342,7 @@ public class AlarmSummaryRenderTests : BunitContext // ── arch-review R2 N7: disposal guard on the live callback ───────────────── + /// <summary>Verifies that a live callback racing with disposal is dropped without a rebuild or an exception.</summary> [Fact] public void LiveCallbackRacingDispose_IsDropped_NoRebuildNoThrow() { @@ -363,11 +377,16 @@ public class AlarmSummaryRenderTests : BunitContext private IReadOnlyList<AlarmStateChanged> _current = Array.Empty<AlarmStateChanged>(); private bool _live; + /// <summary>Gets the number of subscribe calls received.</summary> public int SubscribeCount { get; private set; } + /// <summary>Gets the number of subscriptions disposed.</summary> public int DisposeCount { get; private set; } + /// <summary>Gets the site id of the most recent subscribe call.</summary> public int? LastSubscribedSite { get; private set; } + /// <summary>Gets the change callback captured by the most recent subscribe call.</summary> public Action? CapturedCallback => _onChanged; + /// <inheritdoc /> public IDisposable Subscribe(int siteId, Action onChanged) { SubscribeCount++; @@ -376,8 +395,10 @@ public class AlarmSummaryRenderTests : BunitContext return new Subscription(this); } + /// <inheritdoc /> public IReadOnlyList<AlarmStateChanged> GetCurrentAlarms(int siteId) => _current; + /// <inheritdoc /> public bool IsLive(int siteId) => _live; /// <summary> @@ -386,8 +407,11 @@ public class AlarmSummaryRenderTests : BunitContext /// </summary> public IReadOnlyList<string> NotReporting { get; set; } = Array.Empty<string>(); + /// <inheritdoc /> public IReadOnlyList<string> GetNotReportingInstances(int siteId) => NotReporting; + /// <summary>Replaces the current snapshot, marks the cache live and raises the change callback.</summary> + /// <param name="alarms">The alarm snapshot to publish.</param> public void PushAlarms(IReadOnlyList<AlarmStateChanged> alarms) { _current = alarms; @@ -400,8 +424,11 @@ public class AlarmSummaryRenderTests : BunitContext private readonly FakeSiteAlarmLiveCache _owner; private bool _disposed; + /// <summary>Initializes the subscription for the given fake cache.</summary> + /// <param name="owner">The fake cache that issued the subscription.</param> public Subscription(FakeSiteAlarmLiveCache owner) => _owner = owner; + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { if (_disposed) return; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryVirtualizeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryVirtualizeTests.cs index 1f81c2da..d802b0cf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryVirtualizeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryVirtualizeTests.cs @@ -15,7 +15,7 @@ using AlarmSummaryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monito namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring; /// <summary> -/// Covers the Alarm Summary flat table's two rendering paths (arch-review WP2.4). A short +/// Covers the Alarm Summary flat table's two rendering paths. A short /// list renders every row with a plain foreach; past the threshold the same row template is /// handed to <c>Virtualize</c> so a site with thousands of alarms stops materialising the /// whole table into the circuit's render tree on every delta. Row markup — including the @@ -69,6 +69,7 @@ public class AlarmSummaryVirtualizeTests : BunitContext return cut; } + /// <summary>Verifies that a short alarm list renders every row inline.</summary> [Fact] public void ShortList_RendersEveryRowInline() { @@ -82,6 +83,7 @@ public class AlarmSummaryVirtualizeTests : BunitContext Assert.Contains("Showing 20 of 20", cut.Markup); } + /// <summary>Verifies that a long alarm list is virtualized and does not render every row.</summary> [Fact] public void LongList_VirtualizesWithoutRenderingEveryRow() { @@ -100,17 +102,22 @@ public class AlarmSummaryVirtualizeTests : BunitContext /// <summary>Never goes live, so the page keeps its poll snapshot for these tests.</summary> private sealed class InertLiveCache : ISiteAlarmLiveCache { + /// <inheritdoc /> public IDisposable Subscribe(int siteId, Action onChanged) => new NoOp(); + /// <inheritdoc /> public IReadOnlyList<AlarmStateChanged> GetCurrentAlarms(int siteId) => Array.Empty<AlarmStateChanged>(); + /// <inheritdoc /> public bool IsLive(int siteId) => false; + /// <inheritdoc /> public IReadOnlyList<string> GetNotReportingInstances(int siteId) => Array.Empty<string>(); private sealed class NoOp : IDisposable { + /// <summary>Does nothing; the no-op subscription holds no resources.</summary> public void Dispose() { } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/BrowserTimeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/BrowserTimeTests.cs index 44b22e9c..79452c3b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/BrowserTimeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/BrowserTimeTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring; /// <summary> -/// Regression tests for CentralUI-008. <c><input type="datetime-local"></c> +/// Regression tests for the datetime-local conversion. <c><input type="datetime-local"></c> /// yields the value the user typed in their <i>browser-local</i> time zone. The /// audit-log filter converted it with <c>new DateTimeOffset(value, TimeSpan.Zero)</c> /// — relabelling the local wall-clock value as UTC, shifting the query window by @@ -12,12 +12,14 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring; /// </summary> public class BrowserTimeTests { + /// <summary>Verifies that local input to utc, when null, returns null.</summary> [Fact] public void LocalInputToUtc_Null_ReturnsNull() { Assert.Null(BrowserTime.LocalInputToUtc(null, 0)); } + /// <summary>Verifies that local input to utc, when utc browser, leaves time unchanged.</summary> [Fact] public void LocalInputToUtc_UtcBrowser_LeavesTimeUnchanged() { @@ -29,6 +31,7 @@ public class BrowserTimeTests Assert.Equal(new DateTimeOffset(2026, 5, 16, 9, 30, 0, TimeSpan.Zero), utc); } + /// <summary>Verifies that local input to utc, when positive utc offset browser, subtracts offset.</summary> [Fact] public void LocalInputToUtc_PositiveUtcOffsetBrowser_SubtractsOffset() { @@ -41,10 +44,11 @@ public class BrowserTimeTests Assert.Equal(new DateTimeOffset(2026, 5, 16, 7, 30, 0, TimeSpan.Zero), utc); } + /// <summary>Verifies that local input to utc, when negative utc offset browser, adds offset.</summary> [Fact] public void LocalInputToUtc_NegativeUtcOffsetBrowser_AddsOffset() { - // A browser at UTC-5 (US Eastern, standard time) reports getTimezoneOffset() == 300. + // A browser at UTC minus 5 hours (US Eastern, standard time) reports getTimezoneOffset() == 300. // The user typing 09:30 local means 14:30 UTC. var local = new DateTime(2026, 5, 16, 9, 30, 0); @@ -53,6 +57,7 @@ public class BrowserTimeTests Assert.Equal(new DateTimeOffset(2026, 5, 16, 14, 30, 0, TimeSpan.Zero), utc); } + /// <summary>Verifies that local input to utc, when non utc browser, does not equal naive relabelling.</summary> [Fact] public void LocalInputToUtc_NonUtcBrowser_DoesNotEqualNaiveRelabelling() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs index 84542bee..49480979 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs @@ -30,6 +30,7 @@ public class FailoverConfirmLabelTests : BunitContext private readonly IManualFailoverService _failover = Substitute.For<IManualFailoverService>(); private readonly DialogService _dialog = new(); + /// <summary>Initializes the bUnit context with an authenticated administrator and substitute failover service.</summary> public FailoverConfirmLabelTests() { var claims = new[] @@ -97,6 +98,8 @@ public class FailoverConfirmLabelTests : BunitContext return host.FindAll("div.modal-footer button").ToList(); } + /// <summary>Verifies that the failover confirm button reads "Fail over" rather than "Delete".</summary> + /// <param name="siteId">The site id to fail over, or <see langword="null"/> for the central cluster.</param> [Theory] [InlineData(null)] // central pair [InlineData("SiteA")] // site pair @@ -111,6 +114,7 @@ public class FailoverConfirmLabelTests : BunitContext Assert.Contains("Cancel", footerButtons); } + /// <summary>Verifies that the failover confirm button keeps its destructive styling.</summary> [Fact] public void Failover_confirm_button_keeps_destructive_styling() { @@ -122,12 +126,14 @@ public class FailoverConfirmLabelTests : BunitContext Assert.Contains("btn-danger", confirm.ClassList); } + /// <summary>Verifies that a danger confirmation without a custom label still falls back to "Delete".</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task An_unlabelled_danger_confirm_still_falls_back_to_delete() { // The fallback is retained deliberately: ~20 genuine delete call sites rely // on it (and the Playwright suites click the literal "Delete"). This pins that - // the row-28 fix is additive and did not change the default. + // the fail-over label fix is additive and did not change the default. var cut = Render<DialogHost>(); await cut.InvokeAsync(() => { _ = _dialog.ConfirmAsync("Delete thing", "Sure?", danger: true); }); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs index ee741ef1..20cff0be 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs @@ -61,6 +61,7 @@ public class HealthFailoverButtonTests : BunitContext return host.FindComponent<CentralFailoverControl>(); } + /// <summary>Verifies that the failover button is hidden for a non-admin user.</summary> [Fact] public void Button_is_hidden_for_a_non_admin() { @@ -71,6 +72,7 @@ public class HealthFailoverButtonTests : BunitContext Assert.Empty(cut.FindAll("button")); } + /// <summary>Verifies that the failover button is rendered and enabled for an admin when a standby node exists.</summary> [Fact] public void Button_is_rendered_and_enabled_for_an_admin_with_a_standby() { @@ -83,6 +85,7 @@ public class HealthFailoverButtonTests : BunitContext Assert.Contains("failover", button.TextContent, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that the failover button is disabled with an explanatory title when there is no standby.</summary> [Fact] public void Button_is_disabled_with_an_explanatory_title_when_there_is_no_standby() { @@ -96,6 +99,8 @@ public class HealthFailoverButtonTests : BunitContext Assert.Contains("outage", button.GetAttribute("title"), StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that confirming the dialog triggers the failover exactly once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Confirming_triggers_the_failover_exactly_once() { @@ -110,6 +115,8 @@ public class HealthFailoverButtonTests : BunitContext await _failover.Received(1).FailOverCentralAsync("tester"); } + /// <summary>Verifies that cancelling the confirmation does not trigger a failover.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Cancelling_the_confirmation_does_not_trigger_a_failover() { @@ -122,6 +129,8 @@ public class HealthFailoverButtonTests : BunitContext await _failover.DidNotReceive().FailOverCentralAsync(Arg.Any<string>()); } + /// <summary>Verifies that the confirmation warns that this page will disconnect.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Confirmation_warns_that_this_page_will_disconnect() { @@ -141,6 +150,8 @@ public class HealthFailoverButtonTests : BunitContext Arg.Any<string?>()); } + /// <summary>Verifies that a refused failover surfaces the refusal instead of claiming success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task A_refused_failover_surfaces_the_refusal_instead_of_claiming_success() { @@ -156,7 +167,7 @@ public class HealthFailoverButtonTests : BunitContext Assert.Contains("no standby", cut.Markup, StringComparison.OrdinalIgnoreCase); } - // ---- Site-pair failover (Task 10) ------------------------------------------ + // ---- Site-pair failover ------------------------------------------ // Same control, SiteId set. The differences that matter: it calls the site path, // and its confirmation must NOT claim the admin's own page will drop (it won't — // the site is a different cluster). @@ -175,6 +186,8 @@ public class HealthFailoverButtonTests : BunitContext return host.FindComponent<CentralFailoverControl>(); } + /// <summary>Verifies that confirming on the site card triggers the site failover path.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Site_card_confirming_triggers_the_site_failover_path() { @@ -190,6 +203,8 @@ public class HealthFailoverButtonTests : BunitContext await _failover.DidNotReceive().FailOverCentralAsync(Arg.Any<string>()); } + /// <summary>Verifies that the site card confirmation does not claim that this page will disconnect.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Site_card_confirmation_does_not_claim_this_page_disconnects() { @@ -210,6 +225,8 @@ public class HealthFailoverButtonTests : BunitContext Arg.Any<string?>()); } + /// <summary>Verifies that the site card surfaces a refusal from the site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Site_card_surfaces_a_refusal_from_the_site() { @@ -225,6 +242,8 @@ public class HealthFailoverButtonTests : BunitContext Assert.Contains("No standby available", cut.Markup); } + /// <summary>Verifies that the site card reports an unreachable site distinctly from a refusal.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Site_card_surfaces_an_unreachable_site_distinctly_from_a_refusal() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/MonitoringAuthorizationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/MonitoringAuthorizationTests.cs index ce42b247..88f66832 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/MonitoringAuthorizationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/MonitoringAuthorizationTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring; /// <summary> -/// Regression tests for CentralUI-007. The design doc classifies the Site Event +/// Regression tests for page authorization. The design doc classifies the Site Event /// Log Viewer and Parked Message Management as <b>Deployment Role</b>, but both /// pages were annotated only <c>[Authorize]</c> (any authenticated user) — a /// non-Deployment user who followed the nav link could query event logs and @@ -20,6 +20,7 @@ public class MonitoringAuthorizationTests .Cast<AuthorizeAttribute>() .FirstOrDefault(); + /// <summary>Verifies that the event logs page requires the Deployment authorization policy.</summary> [Fact] public void EventLogsPage_RequiresDeploymentPolicy() { @@ -29,6 +30,7 @@ public class MonitoringAuthorizationTests Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy); } + /// <summary>Verifies that the parked messages page requires the Deployment authorization policy.</summary> [Fact] public void ParkedMessagesPage_RequiresDeploymentPolicy() { @@ -38,6 +40,7 @@ public class MonitoringAuthorizationTests Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy); } + /// <summary>Verifies that the health dashboard is intentionally open to all authenticated roles.</summary> [Fact] public void HealthDashboard_IsIntentionallyAllAuthenticatedRoles() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs index 0a633ce3..79fb3c4d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs @@ -6,8 +6,7 @@ using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.Audi namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// <summary> -/// Unit tests for <see cref="AuditLogPage.BuildExportUrl"/> (#23 M7-T14 / -/// Bundle F). Builds the <c>?...</c> querystring the Export-CSV link points +/// Unit tests for <see cref="AuditLogPage.BuildExportUrl"/> (audit log export). Builds the <c>?...</c> querystring the Export-CSV link points /// at; the same conversion is round-tripped on the server side by /// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Audit.AuditExportEndpoints.ParseFilter"/>. /// These tests pin the no-filter base path + the round-trip back through @@ -15,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// </summary> public class AuditLogPageExportUrlTests { + /// <summary>Verifies that a null filter yields the base export path.</summary> [Fact] public void BuildExportUrl_NullFilter_ReturnsBasePath() { @@ -22,6 +22,7 @@ public class AuditLogPageExportUrlTests Assert.Equal("/api/centralui/audit/export", url); } + /// <summary>Verifies that an empty filter yields the base export path.</summary> [Fact] public void BuildExportUrl_EmptyFilter_ReturnsBasePath() { @@ -31,6 +32,7 @@ public class AuditLogPageExportUrlTests Assert.Equal("/api/centralui/audit/export", url); } + /// <summary>Verifies that a filter with every field set round-trips through the export URL.</summary> [Fact] public void BuildExportUrl_AllFiltersSet_RoundTrips() { @@ -62,6 +64,7 @@ public class AuditLogPageExportUrlTests Assert.Equal("2026-05-20T23:59:59.0000000Z", query["to"]); } + /// <summary>Verifies that a filter with only a channel set omits all other query parameters.</summary> [Fact] public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams() { @@ -75,6 +78,7 @@ public class AuditLogPageExportUrlTests Assert.Equal("Notification", query["channel"]); } + /// <summary>Verifies that setting an execution id emits the execution id query parameter.</summary> [Fact] public void BuildExportUrl_ExecutionIdSet_EmitsExecutionIdParam() { @@ -89,6 +93,7 @@ public class AuditLogPageExportUrlTests Assert.Equal(exec.ToString(), query["executionId"]); } + /// <summary>Verifies that setting a parent execution id emits the parent execution id query parameter.</summary> [Fact] public void BuildExportUrl_ParentExecutionIdSet_EmitsParentExecutionIdParam() { @@ -103,10 +108,11 @@ public class AuditLogPageExportUrlTests Assert.Equal(parent.ToString(), query["parentExecutionId"]); } + /// <summary>Verifies that multi-value dimensions emit repeated query parameters.</summary> [Fact] public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams() { - // Task 9: each multi-value dimension emits one repeated query-string key + // Each multi-value dimension emits one repeated query-string key // per selected value so the export endpoint's ParseFilter sees them all. var filter = new AuditLogQueryFilter( Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs index be8ffe8b..14ed2226 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs @@ -28,7 +28,7 @@ using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.Audi namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// <summary> -/// Permission-gating tests for the Audit Log surface (#23 M7-T15 / Bundle G). +/// Permission-gating tests for the Audit Log surface (Bundle G). /// /// <para> /// Bundle G introduces two new policies: @@ -38,7 +38,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// <item><c>AuditExport</c> — additional gate on the Export-CSV button and /// the streaming export endpoint.</item> /// </list> -/// Post Task 1.7: <c>AuditExport</c> is satisfied only by the +/// After the role consolidation, <c>AuditExport</c> is satisfied only by the /// <c>Administrator</c> role (which absorbed the former <c>Audit</c> role); /// <c>OperationalAudit</c> is additionally satisfied by the <c>Viewer</c> role /// (the home of the former <c>AuditReadOnly</c> role) — Viewer reads but cannot @@ -48,6 +48,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// </summary> public class AuditLogPagePermissionTests : BunitContext { + /// <summary>Initializes the test context and registers the fake authentication and service stand-ins.</summary> public AuditLogPagePermissionTests() { // The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the @@ -71,7 +72,7 @@ public class AuditLogPagePermissionTests : BunitContext // a permitted render is exercised end-to-end. Services.AddSingleton(Substitute.For<ISiteRepository>()); Services.AddSingleton(Substitute.For<IAuditLogQueryService>()); - // M6 (K15): the page now injects IKpiHistoryQueryService for the Trends + // The page now injects IKpiHistoryQueryService for the Trends // panel — register a stand-in so a permitted render is exercised end-to-end. Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>()); } @@ -109,6 +110,8 @@ public class AuditLogPagePermissionTests : BunitContext // so we verify the attribute is present + the policy denies a principal // that holds none of the permitting roles. + /// <summary>Verifies that a user without the OperationalAudit permission is denied by the policy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WithoutOperationalAudit_PolicyDenies() { @@ -127,6 +130,7 @@ public class AuditLogPagePermissionTests : BunitContext Assert.False(result.Succeeded); } + /// <summary>Verifies that the Audit Log page carries the OperationalAudit authorize attribute.</summary> [Fact] public void AuditLogPage_HasOperationalAuditAuthorizeAttribute() { @@ -140,6 +144,7 @@ public class AuditLogPagePermissionTests : BunitContext Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit); } + /// <summary>Verifies that the Configuration Audit Log page carries the OperationalAudit authorize attribute.</summary> [Fact] public void ConfigurationAuditLogPage_HasOperationalAuditAuthorizeAttribute() { @@ -159,13 +164,14 @@ public class AuditLogPagePermissionTests : BunitContext // Test 2 + 3: Export button visibility split. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a user with OperationalAudit but without AuditExport sees the page with the export button hidden.</summary> [Fact] public void WithOperationalAudit_NoAuditExport_PageRenders_ExportButtonHidden() { // The "Administrator" role grants OperationalAudit + AuditExport, so we // test the split by handing the user ONLY the "Viewer" role, which maps // to OperationalAudit (read) but NOT AuditExport — the preserved - // half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse + // half-SoD after the AuditReadOnly→Viewer collapse // (see AuthorizationPolicies). var cut = RenderAuditLogPage("Viewer"); @@ -178,6 +184,7 @@ public class AuditLogPagePermissionTests : BunitContext }); } + /// <summary>Verifies that a user with both OperationalAudit and AuditExport sees the page with the export button visible.</summary> [Fact] public void WithOperationalAudit_AndAuditExport_PageRenders_ExportButtonVisible() { @@ -190,6 +197,7 @@ public class AuditLogPagePermissionTests : BunitContext }); } + /// <summary>Verifies that an Administrator sees the page and the export button.</summary> [Fact] public void AdminUser_SeesPage_AndExportButton() { @@ -208,6 +216,8 @@ public class AuditLogPagePermissionTests : BunitContext // Test 4 + 5: Endpoint-level enforcement. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that the export endpoint returns 403 for a caller without AuditExport.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditExportEndpoint_WithoutAuditExport_Returns403() { @@ -221,6 +231,8 @@ public class AuditLogPagePermissionTests : BunitContext } } + /// <summary>Verifies that the export endpoint returns 200 for a caller with AuditExport.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditExportEndpoint_WithAuditExport_Returns200() { @@ -232,6 +244,8 @@ public class AuditLogPagePermissionTests : BunitContext } } + /// <summary>Verifies that the export endpoint returns 200 for an Administrator alone.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditExportEndpoint_AdminAlone_Returns200() { @@ -245,6 +259,8 @@ public class AuditLogPagePermissionTests : BunitContext } } + /// <summary>Verifies that the export endpoint returns 403 for a Viewer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditExportEndpoint_Viewer_Returns403() { @@ -313,12 +329,17 @@ public class AuditLogPagePermissionTests : BunitContext { public const string SchemeName = "FakeAuth"; + /// <summary>Initializes the handler with the standard authentication handler dependencies.</summary> + /// <param name="options">The options monitor for the fake handler.</param> + /// <param name="logger">The logger factory.</param> + /// <param name="encoder">The URL encoder.</param> public FakeAuthHandler( IOptionsMonitor<FakeAuthHandlerOptions> options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } + /// <inheritdoc /> protected override Task<AuthenticateResult> HandleAuthenticateAsync() { var claims = new List<Claim> { new(ClaimTypes.Name, "test-user") }; @@ -335,6 +356,7 @@ public class AuditLogPagePermissionTests : BunitContext private sealed class FakeAuthHandlerOptions : AuthenticationSchemeOptions { + /// <summary>Gets or sets the roles granted to the fake authenticated user.</summary> public string[] Roles { get; set; } = Array.Empty<string>(); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index d53108ce..d62136ab 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -16,9 +16,8 @@ using NavMenu = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout.NavMenu; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// <summary> -/// Scaffold tests for the new Audit Log page (#23 M7-T1) and the Audit -/// nav group that hosts both it and the renamed Configuration Audit Log -/// (#23 M7 Bundle A). +/// Scaffold tests for the new Audit Log page and the Audit +/// nav group that hosts both it and the renamed Configuration Audit Log. /// /// These are render-only smoke tests — the filter bar and results grid /// are intentional placeholders that Bundle B fills in. The tests pin @@ -27,6 +26,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// </summary> public class AuditLogPageScaffoldTests : BunitContext { + /// <summary>Configures bUnit's JS interop in loose mode so unconfigured grid interop calls are no-ops.</summary> public AuditLogPageScaffoldTests() { // The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the @@ -63,7 +63,7 @@ public class AuditLogPageScaffoldTests : BunitContext // Provide stand-ins so the scaffold smoke tests still render the page. Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ISiteRepository>()); Services.AddSingleton(_queryService); - // M6 (K15): the page now injects IKpiHistoryQueryService for the Trends + // The page now injects IKpiHistoryQueryService for the Trends // panel. Provide a stand-in so the scaffold/drill-in renders still work. Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>()); @@ -73,7 +73,7 @@ public class AuditLogPageScaffoldTests : BunitContext nav.NavigateTo($"/audit/log?{query}"); } - // Bundle G (#23 M7-T15): the page now hosts an in-component + // The page now hosts an in-component // AuthorizeView around the Export-CSV button, so the page MUST // render inside a CascadingAuthenticationState. The router supplies // this in production; bUnit hosts the page directly so we wrap it @@ -109,7 +109,7 @@ public class AuditLogPageScaffoldTests : BunitContext /// <summary> /// Asserts the named rail section is present. After the ZB.MOM.WW.Theme - /// adoption (Task 2.4) nav sections are the kit's <c>NavRailSection</c> — + /// adoption nav sections are the kit's <c>NavRailSection</c> — /// native <c><details class="rail-section"></c> disclosures that are /// open by default, so a section's items are already in the DOM (no click /// needed, no JS). This helper just confirms the section's @@ -122,6 +122,7 @@ public class AuditLogPageScaffoldTests : BunitContext s => s.TextContent.Contains(title, StringComparison.Ordinal)); } + /// <summary>Verifies that the Audit Log page renders its page heading.</summary> [Fact] public void AuditLogPage_Renders_PageHeading() { @@ -136,6 +137,7 @@ public class AuditLogPageScaffoldTests : BunitContext }); } + /// <summary>Verifies that the nav menu contains an Audit group with a link to the Audit Log page.</summary> [Fact] public void NavMenu_Contains_AuditGroup_With_AuditLog_Link() { @@ -149,6 +151,7 @@ public class AuditLogPageScaffoldTests : BunitContext }); } + /// <summary>Verifies that the nav menu lists the Configuration Audit Log link under the Audit group.</summary> [Fact] public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup() { @@ -174,9 +177,10 @@ public class AuditLogPageScaffoldTests : BunitContext } // ───────────────────────────────────────────────────────────────────────── - // Bundle D — query-string drill-in parsing (#23 M7-T10..T12) + // Query-string drill-in parsing // ───────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that navigating with a correlation id applies the filter and loads results automatically.</summary> [Fact] public void NavigateWithCorrelationId_AppliesFilter_AndAutoLoads() { @@ -197,6 +201,7 @@ public class AuditLogPageScaffoldTests : BunitContext }); } + /// <summary>Verifies that navigating with an execution id applies the filter and loads results automatically.</summary> [Fact] public void NavigateWithExecutionIdParam_AppliesFilter_AndAutoLoads() { @@ -219,6 +224,7 @@ public class AuditLogPageScaffoldTests : BunitContext }); } + /// <summary>Verifies that an unparseable execution id is silently dropped and nothing loads automatically.</summary> [Fact] public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad() { @@ -235,6 +241,7 @@ public class AuditLogPageScaffoldTests : BunitContext Arg.Any<CancellationToken>()); } + /// <summary>Verifies that navigating with a parent execution id applies the filter and loads results automatically.</summary> [Fact] public void NavigateWithParentExecutionIdParam_AppliesFilter_AndAutoLoads() { @@ -257,6 +264,7 @@ public class AuditLogPageScaffoldTests : BunitContext }); } + /// <summary>Verifies that an unparseable parent execution id is silently dropped and nothing loads automatically.</summary> [Fact] public void NavigateWithUnparseableParentExecutionIdParam_IsSilentlyDropped_NoAutoLoad() { @@ -274,6 +282,7 @@ public class AuditLogPageScaffoldTests : BunitContext Arg.Any<CancellationToken>()); } + /// <summary>Verifies that navigating with a target parameter applies the target filter.</summary> [Fact] public void NavigateWithTargetParam_AppliesTargetFilter() { @@ -292,6 +301,7 @@ public class AuditLogPageScaffoldTests : BunitContext }); } + /// <summary>Verifies that navigating with a site parameter applies the site filter.</summary> [Fact] public void NavigateWithSiteParam_AppliesSiteFilter() { @@ -311,10 +321,11 @@ public class AuditLogPageScaffoldTests : BunitContext }); } + /// <summary>Verifies that navigating with a status parameter applies the status filter.</summary> [Fact] public void NavigateWithStatusParam_AppliesStatusFilter() { - // Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills + // The Health-dashboard Audit error-rate tile drills // in with ?status=Failed. The page parses the enum (case-insensitive), // builds an AuditLogQueryFilter with Status set, and auto-loads. _queryService = Substitute.For<IAuditLogQueryService>(); @@ -333,6 +344,7 @@ public class AuditLogPageScaffoldTests : BunitContext }); } + /// <summary>Verifies that an unknown status value is silently dropped and nothing loads automatically.</summary> [Fact] public void NavigateWithUnknownStatusParam_IsSilentlyDropped_NoAutoLoad() { @@ -350,6 +362,7 @@ public class AuditLogPageScaffoldTests : BunitContext Arg.Any<CancellationToken>()); } + /// <summary>Verifies that navigating without parameters leaves the filter empty and does not load automatically.</summary> [Fact] public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageTrendTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageTrendTests.cs index 7bb6987a..44ac11e3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageTrendTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageTrendTests.cs @@ -15,7 +15,7 @@ using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.Audi namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// <summary> -/// Tests for the M6 (K15) Trends panel added to the Audit Log page — one +/// Tests for the Trends panel added to the Audit Log page — one /// <c>KpiTrendChart</c> per AuditLog global metric, over a 24h/7d window. The /// panel is best-effort: a failing <see cref="IKpiHistoryQueryService"/> must /// degrade the affected charts without breaking the audit query UI (the filter @@ -37,6 +37,7 @@ public class AuditLogPageTrendTests : BunitContext { private static readonly DateTime Base = new(2026, 6, 15, 10, 0, 0, DateTimeKind.Utc); + /// <summary>Initializes a new instance of the <see cref="AuditLogPageTrendTests"/> class.</summary> public AuditLogPageTrendTests() { // AuditResultsGrid's OnAfterRenderAsync wires its column resize/reorder UX @@ -84,6 +85,7 @@ public class AuditLogPageTrendTests : BunitContext return host.FindComponent<AuditLogPage>(); } + /// <summary>Verifies the case: trends Panel, with Series, renders Charts With Polyline.</summary> [Fact] public void TrendsPanel_WithSeries_RendersChartsWithPolyline() { @@ -118,11 +120,12 @@ public class AuditLogPageTrendTests : BunitContext Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies the case: trends Panel, long Range Windows, render And Refetch With Matching Span.</summary> [Fact] public void TrendsPanel_LongRangeWindows_RenderAndRefetchWithMatchingSpan() { // The 30d (720h) / 90d (2160h) buttons added for the hourly-rollup path - // (plan #22 T7) must render alongside 24h/7d and, when clicked, re-fetch + // must render alongside 24h/7d and, when clicked, re-fetch // every series over the corresponding range — the query service then // transparently routes the long windows to hourly rollups. var ranges = new List<(DateTime From, DateTime To)>(); @@ -156,6 +159,7 @@ public class AuditLogPageTrendTests : BunitContext Assert.Contains(ranges, r => Math.Abs((r.To - r.From).TotalHours - 2160) < 0.5)); } + /// <summary>Verifies the case: trends Panel, when Query Service Throws, page Still Renders.</summary> [Fact] public void TrendsPanel_WhenQueryServiceThrows_PageStillRenders() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs index ba51bf6c..b07b6260 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs @@ -23,7 +23,7 @@ using TransportExportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Des namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design; /// <summary> -/// bUnit + logic tests for the TransportExport wizard (Component #24, Task T21). +/// bUnit + logic tests for the TransportExport wizard (Transport component). /// /// <para> /// Covers the four contract points the design plan calls out: @@ -54,6 +54,7 @@ public class TransportExportPageTests : BunitContext private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>(); private readonly IBundleExporter _exporter = Substitute.For<IBundleExporter>(); + /// <summary>Initializes a new instance of <c>TransportExportPageTests</c>.</summary> public TransportExportPageTests() { JSInterop.Mode = JSRuntimeMode.Loose; @@ -76,7 +77,7 @@ public class TransportExportPageTests : BunitContext .Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>())); _inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>()) .Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>())); - // Empty site/instance defaults — the M8 site/instance picker calls these on load + // Empty site/instance defaults — the site/instance picker calls these on load // and the DependencyResolver fans out over sites on resolve. _siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()) .Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>())); @@ -92,7 +93,7 @@ public class TransportExportPageTests : BunitContext Services.AddSingleton(_siteRepo); Services.AddSingleton(_exporter); // DependencyResolver is sealed but its dependencies are the repositories above - // (template/external/notification/inbound + ISiteRepository for M8 site/instance + // (template/external/notification/inbound + ISiteRepository for site/instance // export) — registering the concrete type is enough. Services.AddSingleton<DependencyResolver>(); Services.AddSingleton<IOptions<TransportOptions>>( @@ -117,6 +118,7 @@ public class TransportExportPageTests : BunitContext // ───────────────────────────────────────────────────────────────────── // Test 1: Step 1 renders the template tree and every flat artifact group. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that renders step 1 with template tree and artifact checkboxes.</summary> [Fact] public void Renders_step1_with_template_tree_and_artifact_checkboxes() { @@ -201,6 +203,8 @@ public class TransportExportPageTests : BunitContext // Test 2: Step 2 shows resolved dependencies — auto-included templates pulled // in because a seed template composes them. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that step 2 shows resolved dependencies after clicking next.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Step2_shows_resolved_dependencies_after_clicking_next() { @@ -252,6 +256,8 @@ public class TransportExportPageTests : BunitContext // Test 3: Walks the wizard end-to-end and verifies BundleExporter.ExportAsync // is invoked with the user-selected ids and the authenticated identity. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that step 4 triggers export async with selected artifacts and user identity.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Step4_triggers_ExportAsync_with_selected_artifacts_and_user_identity() { @@ -318,6 +324,8 @@ public class TransportExportPageTests : BunitContext // doesn't model routing, so we verify the policy itself denies the // principal (the same gate the router consults). // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that page returns unauthorized for user without design role.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Page_returns_unauthorized_for_user_without_Design_role() { @@ -336,9 +344,10 @@ public class TransportExportPageTests : BunitContext } // ───────────────────────────────────────────────────────────────────── - // Test 5 (M8 E1): Step 1 renders the Sites & Instances group, listing each + // Test 5: Step 1 renders the Sites & Instances group, listing each // site and (when expanded) its instances. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that renders step 1 sites group with sites and instances.</summary> [Fact] public void Renders_step1_sites_group_with_sites_and_instances() { @@ -372,10 +381,12 @@ public class TransportExportPageTests : BunitContext } // ───────────────────────────────────────────────────────────────────── - // Test 6 (M8 E1): Selecting a site flows its id into ExportSelection.SiteIds; + // Test 6: Selecting a site flows its id into ExportSelection.SiteIds; // selecting an individual instance flows its id into InstanceIds. Verified at // the resolver/exporter boundary (the same contract DependencyResolver reads). // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that step 4 export carries selected site and instance ids.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Step4_export_carries_selected_site_and_instance_ids() { @@ -441,6 +452,8 @@ public class TransportExportPageTests : BunitContext // AccountSid (never AuthToken), and ticking one flows its entity id into // ExportSelection.SmsConfigurationIds — mirroring the SMTP-by-Host contract. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that step 4 export carries selected sms configuration ids.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Step4_export_carries_selected_sms_configuration_ids() { @@ -494,6 +507,7 @@ public class TransportExportPageTests : BunitContext // Test 9 (S10c): CountSecrets counts a populated SMS AuthToken, mirroring // the SMTP Credentials contribution to the Step 3 secrets banner. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that count secrets includes sms configuration auth token.</summary> [Fact] public void CountSecrets_includes_sms_configuration_auth_token() { @@ -523,6 +537,7 @@ public class TransportExportPageTests : BunitContext // Static helpers — exercised directly so the file-naming + secret-count // contract is unit-pinned independently of the rendering surface. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that build filename produces pattern and sanitises source environment.</summary> [Fact] public void BuildFilename_produces_pattern_and_sanitises_source_environment() { @@ -532,9 +547,10 @@ public class TransportExportPageTests : BunitContext } // ───────────────────────────────────────────────────────────────────── - // Test 7 (M8 C2-SECRET-1): CountSecrets includes site data-connection + // Test 7: CountSecrets includes site data-connection // PrimaryConfiguration and BackupConfiguration fields in the banner total. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that count secrets includes data connection primary and backup configurations.</summary> [Fact] public void CountSecrets_includes_data_connection_primary_and_backup_configurations() { @@ -560,6 +576,7 @@ public class TransportExportPageTests : BunitContext Assert.Equal(2, TransportExportPage.CountSecrets(resolved)); } + /// <summary>Verifies that count secrets counts only non empty data connection configurations.</summary> [Fact] public void CountSecrets_counts_only_non_empty_data_connection_configurations() { @@ -589,6 +606,7 @@ public class TransportExportPageTests : BunitContext Assert.Equal(1, TransportExportPage.CountSecrets(resolved)); } + /// <summary>Verifies that count secrets returns zero when no data connections.</summary> [Fact] public void CountSecrets_returns_zero_when_no_data_connections() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs index 82099aee..febab7a8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs @@ -24,7 +24,7 @@ using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Des namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design; /// <summary> -/// bUnit + logic tests for the TransportImport wizard (Component #24, Task T22). +/// bUnit + logic tests for the TransportImport wizard. /// /// <para> /// The wizard has five steps (Upload / Passphrase / Diff / Confirm / Result). @@ -41,7 +41,7 @@ public class TransportImportPageTests : BunitContext { private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>(); private readonly IAuditService _auditService = Substitute.For<IAuditService>(); - // M8 E2: the Map step injects ISiteRepository to populate target-site + + // The Map step injects ISiteRepository to populate target-site + // target-connection dropdowns. Register a substitute so every test (not just // the Map tests) can render the wizard without a missing-service failure. private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>(); @@ -49,6 +49,7 @@ public class TransportImportPageTests : BunitContext /// <summary>Client address the wizard reports for the per-IP unlock throttle (register row 44).</summary> private const string TestClientIp = "203.0.113.7"; + /// <summary>Initializes the test context with stubbed importer, audit, repository and authentication services.</summary> public TransportImportPageTests() { JSInterop.Mode = JSRuntimeMode.Loose; @@ -124,6 +125,7 @@ public class TransportImportPageTests : BunitContext // ───────────────────────────────────────────────────────────────────── // Test 1: Step 1 renders the InputFile upload control. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that the wizard renders the step 1 upload input.</summary> [Fact] public void Renders_step1_upload_input() { @@ -138,6 +140,8 @@ public class TransportImportPageTests : BunitContext // Test 2: Wrong passphrase increments the failure counter without // advancing past Step 2. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a decryption failure increments the passphrase attempt counter.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Decryption_failure_increments_attempt_counter() { @@ -169,6 +173,8 @@ public class TransportImportPageTests : BunitContext // Test 3: After MaxUnlockAttemptsPerSession failures the wizard returns // to Step 1 with an explanatory error. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that three failed unlock attempts force the user to upload the bundle again.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Three_failed_unlocks_force_reupload() { @@ -202,6 +208,8 @@ public class TransportImportPageTests : BunitContext // Test 4: Confirm step requires an exact match (case-sensitive) on the // source environment name before Apply is enabled. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that the confirm step requires an exact environment name match.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Confirm_step_requires_exact_environment_name_match() { @@ -250,6 +258,8 @@ public class TransportImportPageTests : BunitContext // Test 5: ApplyAsync is invoked with the chosen resolutions and the // authenticated user identity. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that the apply step calls the bundle importer's <c>ApplyAsync</c> with the chosen resolutions.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Apply_step_invokes_BundleImporter_ApplyAsync_with_resolutions() { @@ -314,6 +324,8 @@ public class TransportImportPageTests : BunitContext // doesn't model routing, so we verify the policy itself denies the // principal. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that the page is unauthorized for a user without the Admin role.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Page_returns_unauthorized_for_user_without_Admin_role() { @@ -335,6 +347,7 @@ public class TransportImportPageTests : BunitContext // Test 7 (helper coverage): BuildDefaultResolutions maps each kind to the // expected default ResolutionAction. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that default resolutions map each conflict kind to the expected action.</summary> [Fact] public void BuildDefaultResolutions_maps_kinds_to_actions() { @@ -355,10 +368,12 @@ public class TransportImportPageTests : BunitContext } // ───────────────────────────────────────────────────────────────────── - // Test 8 (M8 E2): the Map sub-section renders one row per required site / + // Test 8: the Map sub-section renders one row per required site / // connection mapping with the auto-match defaults pre-selected, and the // section is hidden entirely when the preview carries no required mappings. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that the map section renders the required rows with auto-match defaults selected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Map_section_renders_required_rows_with_automatch_defaults() { @@ -398,6 +413,8 @@ public class TransportImportPageTests : BunitContext Assert.Equal("opc-main", selectedConnOpt.GetAttribute("value")); } + /// <summary>Verifies that the map section is hidden when no mappings are required.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Map_section_hidden_when_no_required_mappings() { @@ -418,10 +435,12 @@ public class TransportImportPageTests : BunitContext } // ───────────────────────────────────────────────────────────────────── - // Test 9 (M8 E2): the operator's Map choices fold into the BundleNameMap + // Test 9: the operator's Map choices fold into the BundleNameMap // passed to ApplyAsync — a chosen target → MapToExisting, "Create new" → // CreateNew. Captured via the substituted IBundleImporter. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that apply passes a <c>BundleNameMap</c> built from the operator's map choices.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Apply_passes_BundleNameMap_built_from_map_choices() { @@ -479,9 +498,11 @@ public class TransportImportPageTests : BunitContext } // ───────────────────────────────────────────────────────────────────── - // Test 10 (M8 E2): a Modified row whose FieldDiffJson carries a code + // Test 10: a Modified row whose FieldDiffJson carries a code // lineDiff renders +/- lines via LineDiffView. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a modified row with a code line diff renders added and removed lines.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Modified_row_with_code_lineDiff_renders_add_and_remove_lines() { @@ -533,8 +554,10 @@ public class TransportImportPageTests : BunitContext } // ───────────────────────────────────────────────────────────────────── - // Test 11 (M8 E2): truncation marker shows when the lineDiff is truncated. + // Test 11: truncation marker shows when the lineDiff is truncated. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a modified row with a truncated line diff shows the truncation marker.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Modified_row_with_truncated_lineDiff_shows_truncation_marker() { @@ -579,11 +602,13 @@ public class TransportImportPageTests : BunitContext } // ───────────────────────────────────────────────────────────────────── - // Test 12 (M8 E2 Fix 1): changing a source site's dropdown to a target + // Test 12: changing a source site's dropdown to a target // that lacks the previously auto-matched connection name resets that // connection choice to CreateNew (""), and BuildNameMap therefore emits // a CreateNew entry — not a MapToExisting for an absent connection. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that changing a site choice resets connection choices the new target site lacks.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OnSiteChoiceChanged_resets_stale_connection_choices_when_new_target_lacks_connection() { @@ -639,9 +664,11 @@ public class TransportImportPageTests : BunitContext } // ───────────────────────────────────────────────────────────────────── - // Test 13 (M8 E2 Fix 2): BackToUpload clears session/preview/Map state + // Test 13: BackToUpload clears session/preview/Map state // so a subsequent re-upload flow cannot inherit stale data. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that going back to upload clears the session, preview and map state.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BackToUpload_clears_session_and_preview_state() { @@ -715,12 +742,17 @@ public class TransportImportPageTests : BunitContext /// </summary> private sealed class StoreBackedImporter : IBundleImporter { + /// <summary>Gets the backing bundle session store.</summary> public BundleSessionStore Store { get; } = new( Microsoft.Extensions.Options.Options.Create(new TransportOptions()), TimeProvider.System); + /// <summary>Gets the number of times <c>Abandon</c> has been called.</summary> public int AbandonCalls { get; private set; } + /// <summary>Seeds an encrypted session directly into the store.</summary> + /// <param name="sourceEnv">The source environment name recorded on the session manifest.</param> + /// <returns>The opened session.</returns> public BundleSession OpenSession(string sourceEnv = "prod-cluster") { var seed = BuildEncryptedSession(sourceEnv); @@ -735,13 +767,16 @@ public class TransportImportPageTests : BunitContext return Store.Open(session); } + /// <inheritdoc /> public Task<BundleSession> LoadAsync( Stream bundleStream, string? passphrase, CancellationToken ct = default, string? clientKey = null) => throw new NotSupportedException("These tests seed sessions via OpenSession()."); + /// <inheritdoc /> public Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default) => Task.FromResult(new ImportPreview(sessionId, new List<ImportPreviewItem>())); + /// <inheritdoc /> public Task<ImportResult> ApplyAsync( Guid sessionId, IReadOnlyList<ImportResolution> resolutions, @@ -760,6 +795,7 @@ public class TransportImportPageTests : BunitContext AuditEventCorrelation: Guid.NewGuid().ToString())); } + /// <inheritdoc /> public void Abandon(Guid sessionId) { AbandonCalls++; @@ -777,6 +813,8 @@ public class TransportImportPageTests : BunitContext } } + /// <summary>Verifies that disposing the wizard releases its bundle session.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Disposing_the_wizard_releases_its_bundle_session() { @@ -798,6 +836,8 @@ public class TransportImportPageTests : BunitContext Assert.Null(GetField<object?>(cut.Instance, "_session")); } + /// <summary>Verifies that going back to upload releases the bundle session.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BackToUpload_releases_the_bundle_session() { @@ -827,6 +867,8 @@ public class TransportImportPageTests : BunitContext Assert.All(content, b => Assert.Equal(0, b)); } + /// <summary>Verifies that applying then disposing releases the session exactly once without throwing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Apply_then_dispose_releases_the_session_once_without_throwing() { @@ -862,6 +904,8 @@ public class TransportImportPageTests : BunitContext Assert.Null(importer.Store.Get(session.SessionId)); } + /// <summary>Verifies that disposing one wizard leaves a concurrent circuit's session open.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Disposing_one_wizard_leaves_a_concurrent_circuits_session_open() { @@ -942,8 +986,8 @@ public class TransportImportPageTests : BunitContext /// <summary> /// Seeds the wizard at Step 2 (Passphrase) with a staged bundle file — the /// shape after an encrypted-bundle upload completed Step 1's peek and - /// surfaced an ArgumentException ("passphrase required"). CentralUI-031: - /// the wizard now stages the upload to a temp file and only retains the + /// surfaced an ArgumentException ("passphrase required"). The + /// wizard now stages the upload to a temp file and only retains the /// path on the component, so the test helper writes the bytes to a per- /// test temp file and sets the path field instead of the byte[] field. /// </summary> diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportSessionDisposeRaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportSessionDisposeRaceTests.cs index fbdf6361..2c938462 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportSessionDisposeRaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportSessionDisposeRaceTests.cs @@ -22,7 +22,7 @@ using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Des namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design; /// <summary> -/// Regression tests for register row 103 (the P6-6 lesser instance in +/// Regression tests for register row 103 (the lesser instance in /// <c>TransportImport</c>). <c>TryLoadAsync</c> awaits /// <see cref="IBundleImporter.LoadAsync"/>, then used to publish the result into /// <c>_session</c> unconditionally. If the circuit was torn down inside that await @@ -31,7 +31,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design; /// <c>BundleSession</c> stayed registered, stranding one of /// <c>TransportOptions.MaxConcurrentImportSessions</c> (register row 43) for the full /// session TTL. The fix publishes the session and re-checks disposal under one lock -/// (<c>_sessionLock</c>), mirroring <c>DebugView</c>'s P6-6 fix, so exactly one side +/// (<c>_sessionLock</c>), mirroring <c>DebugView</c>'s fix, so exactly one side /// (this method or <c>Dispose</c>) abandons it. /// </summary> public class TransportImportSessionDisposeRaceTests : BunitContext @@ -48,6 +48,7 @@ public class TransportImportSessionDisposeRaceTests : BunitContext private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly List<Guid> _abandoned = new(); + /// <summary>Gets the session store used by the test.</summary> public BundleSessionStore Store { get; } = new( Options.Create(new TransportOptions()), TimeProvider.System); @@ -60,8 +61,10 @@ public class TransportImportSessionDisposeRaceTests : BunitContext get { lock (_abandoned) { return _abandoned.ToList(); } } } + /// <summary>Releases the load that is blocked waiting for the test to let it finish.</summary> public void ReleaseLoad() => _gate.TrySetResult(); + /// <inheritdoc /> public async Task<BundleSession> LoadAsync( Stream bundleStream, string? passphrase, CancellationToken ct = default, string? clientKey = null) { @@ -89,9 +92,11 @@ public class TransportImportSessionDisposeRaceTests : BunitContext return Store.Open(session); } + /// <inheritdoc /> public Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default) => throw new NotSupportedException("Not exercised by these tests."); + /// <inheritdoc /> public Task<ImportResult> ApplyAsync( Guid sessionId, IReadOnlyList<ImportResolution> resolutions, @@ -100,6 +105,7 @@ public class TransportImportSessionDisposeRaceTests : BunitContext BundleNameMap? nameMap = null) => throw new NotSupportedException("Not exercised by these tests."); + /// <inheritdoc /> public void Abandon(Guid sessionId) { lock (_abandoned) { _abandoned.Add(sessionId); } @@ -116,6 +122,7 @@ public class TransportImportSessionDisposeRaceTests : BunitContext private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>(); private const string TestClientIp = "203.0.113.7"; + /// <summary>Initializes the test class and its fake session service.</summary> public TransportImportSessionDisposeRaceTests() { JSInterop.Mode = JSRuntimeMode.Loose; @@ -181,6 +188,8 @@ public class TransportImportSessionDisposeRaceTests : BunitContext return task!; } + /// <summary>Verifies that a component disposed mid-load abandons the session that was just opened.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TryLoadAsync_DisposedMidLoad_AbandonsTheJustOpenedSession() { @@ -203,6 +212,8 @@ public class TransportImportSessionDisposeRaceTests : BunitContext Assert.Null(_importer.Store.Get(_importer.Abandoned[0])); } + /// <summary>Verifies that loading and then disposing abandons the session exactly once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TryLoadAsync_ThenDispose_AbandonsTheSessionExactlyOnce() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs index 820713a7..0d244e03 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs @@ -16,7 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// <summary> /// bUnit tests for <see cref="ExecutionTreePage"/> (Audit Log ParentExecutionId -/// feature, Task 10). The page is reached via the "View execution chain" +/// feature). The page is reached via the "View execution chain" /// drill-in at <c>/audit/execution-tree?executionId={guid}</c>. It parses the /// query-string id, calls <see cref="IAuditLogQueryService.GetExecutionTreeAsync"/>, /// and hands the flat node list to the <c>ExecutionTree</c> component. @@ -68,6 +68,7 @@ public class ExecutionTreePageTests : BunitContext rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)); + /// <summary>Verifies that navigating with an execution id calls the service and renders the tree.</summary> [Fact] public void NavigateWithExecutionId_CallsService_AndRendersTree() { @@ -91,6 +92,7 @@ public class ExecutionTreePageTests : BunitContext }); } + /// <summary>Verifies that navigating without an execution id renders the guidance prompt and makes no service call.</summary> [Fact] public void NavigateWithoutExecutionId_RendersGuidancePrompt_NoServiceCall() { @@ -102,6 +104,7 @@ public class ExecutionTreePageTests : BunitContext _queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that navigating with an unparseable execution id renders the guidance prompt and makes no service call.</summary> [Fact] public void NavigateWithUnparseableExecutionId_RendersGuidancePrompt_NoServiceCall() { @@ -113,6 +116,7 @@ public class ExecutionTreePageTests : BunitContext _queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that double-clicking a tree node opens the execution detail modal.</summary> [Fact] public void DoubleClickTreeNode_OpensExecutionDetailModal() { @@ -147,6 +151,7 @@ public class ExecutionTreePageTests : BunitContext Arg.Any<CancellationToken>()); } + /// <summary>Verifies that closing the execution detail modal hides it.</summary> [Fact] public void ClosingExecutionDetailModal_HidesIt() { @@ -175,6 +180,7 @@ public class ExecutionTreePageTests : BunitContext Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]"))); } + /// <summary>Verifies that the page carries the operational-audit authorize attribute.</summary> [Fact] public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs index f141eb2c..74908f13 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs @@ -26,7 +26,7 @@ using HealthPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.H namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// <summary> -/// bUnit rendering tests for the Health Monitoring dashboard (Task 24). +/// bUnit rendering tests for the Health Monitoring dashboard. /// /// Scope: the Notification Outbox KPI tile row added to the Health dashboard. /// <see cref="ICentralHealthAggregator"/> is an interface (mockable), but @@ -45,13 +45,14 @@ public class HealthPageTests : BunitContext new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3, DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6)); - // Site Call Audit (#22) Task 7 — mutable scripted Site Call KPI reply. Tests + // Mutable scripted Site Call KPI reply. Tests // that target the Site Call tiles override this before rendering. private SiteCallKpiResponse _siteCallKpiReply = new("k", true, null, BufferedCount: 9, ParkedCount: 2, FailedLastInterval: 1, DeliveredLastInterval: 40, OldestPendingAge: TimeSpan.FromMinutes(3), StuckCount: 5); + /// <summary>Initializes the test context and registers the services and actor stand-ins the Health page requires.</summary> public HealthPageTests() { _comms = new CommunicationService( @@ -65,7 +66,7 @@ public class HealthPageTests : BunitContext _comms.SetSiteCallAudit(siteCallAudit); Services.AddSingleton(_comms); - // arch-review WP2.4 — the page no longer queries CommunicationService / + // The page no longer queries CommunicationService / // IAuditLogQueryService per circuit; it reads the process-level memoized // KPI cache. The real cache is registered here (not a substitute) so the // scripted-actor seam above is still what actually answers. @@ -78,7 +79,7 @@ public class HealthPageTests : BunitContext .Returns(new Dictionary<string, SiteHealthState>()); Services.AddSingleton(aggregator); - // M6 K16 — the Health page now injects IKpiHistoryQueryService to feed the + // The Health page now injects IKpiHistoryQueryService to feed the // per-site Site Health Trends panel. Stub it with a known non-empty series // so the page resolves the dependency and the trend charts have data; the // dedicated trend tests below seed sites / override behaviour. @@ -94,12 +95,12 @@ public class HealthPageTests : BunitContext .Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>())); Services.AddSingleton(siteRepo); - // PLAN-01 Task 14 — the Health page now shows the metrics-stale badge with + // The Health page now shows the metrics-stale badge with // a tooltip naming the configured MetricsStaleTimeout, so it injects the // health-monitoring options. Services.AddSingleton(Options.Create(new HealthMonitoringOptions())); - // Audit Log (#23) M7 Bundle E — the Health page now also fetches the + // The Health page now also fetches the // Audit KPI snapshot. Stub it with an empty point-in-time reading so // the existing assertions (Notification Outbox tiles, Online/Offline // counts) keep passing; tests that target the Audit tiles set their @@ -125,7 +126,7 @@ public class HealthPageTests : BunitContext // policy is evaluated; force the real one (same note as NavMenuTests). Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>(); - // The cluster cards embed CentralFailoverControl (Task 10), which injects these two. + // The cluster cards embed CentralFailoverControl, which injects these two. // The page's DI graph genuinely requires them in production, so the test supplies // them rather than the component making its dependencies optional. Behaviour of the // control itself is covered by HealthFailoverButtonTests. @@ -136,7 +137,7 @@ public class HealthPageTests : BunitContext /// <summary> /// Renders the dashboard the way the app does — inside a /// <see cref="CascadingAuthenticationState"/>, which the real layout supplies. The - /// cluster cards contain an AuthorizeView (the Task 10 failover control), and + /// cluster cards contain an AuthorizeView (the failover control), and /// AuthorizeView throws without that cascading value. /// </summary> private IRenderedComponent<HealthPage> RenderHealthPage() @@ -151,6 +152,7 @@ public class HealthPageTests : BunitContext return host.FindComponent<HealthPage>(); } + /// <summary>Verifies that the Notification Outbox KPI tiles render with the supplied values.</summary> [Fact] public void Renders_OutboxKpiTiles_WithValues() { @@ -170,6 +172,7 @@ public class HealthPageTests : BunitContext }); } + /// <summary>Verifies that the dashboard renders a link to the notification KPIs page.</summary> [Fact] public void RendersLinkToTheNotificationKpisPage() { @@ -178,6 +181,7 @@ public class HealthPageTests : BunitContext Assert.Contains("View details", link.TextContent); } + /// <summary>Verifies that the audit KPI tiles render with the supplied values.</summary> [Fact] public void Renders_AuditKpiTiles_WithValues() { @@ -207,6 +211,7 @@ public class HealthPageTests : BunitContext }); } + /// <summary>Verifies that the Site Call KPI tiles render with the supplied values.</summary> [Fact] public void Renders_SiteCallKpiTiles_WithValues() { @@ -227,6 +232,7 @@ public class HealthPageTests : BunitContext }); } + /// <summary>Verifies that the dashboard renders a link to the Site Calls report page.</summary> [Fact] public void RendersLinkToTheSiteCallsReportPage() { @@ -235,6 +241,7 @@ public class HealthPageTests : BunitContext Assert.Contains("View details", link.TextContent); } + /// <summary>Verifies that a Site Call KPI failure shows a graceful fallback.</summary> [Fact] public void SiteCallKpiFailure_ShowsGracefulFallback() { @@ -254,6 +261,7 @@ public class HealthPageTests : BunitContext }); } + /// <summary>Verifies that a Notification Outbox KPI failure shows a graceful fallback.</summary> [Fact] public void OutboxKpiFailure_ShowsGracefulFallback() { @@ -271,6 +279,7 @@ public class HealthPageTests : BunitContext }); } + /// <summary>Verifies that the site health trends panel and chart render for the selected site.</summary> [Fact] public void Renders_SiteHealthTrends_PanelAndChart_ForSelectedSite() { @@ -297,6 +306,7 @@ public class HealthPageTests : BunitContext }); } + /// <summary>Verifies that a site health trends failure does not break the dashboard.</summary> [Fact] public void SiteHealthTrendsFailure_DoesNotBreakDashboard() { @@ -324,10 +334,11 @@ public class HealthPageTests : BunitContext }); } + /// <summary>Verifies that the metrics-stale badge renders for an online site whose metrics are stale.</summary> [Fact] public void Renders_MetricsStaleBadge_ForOnlineButStaleSite() { - // PLAN-01 Task 14: a site that is online (heartbeating) but whose report + // A site that is online (heartbeating) but whose report // pipeline died shows a distinct "Metrics stale" warning badge. SeedStates(new SiteHealthState { @@ -352,6 +363,7 @@ public class HealthPageTests : BunitContext // Sustained LocalDb replication backlog badge (register row 33) // ------------------------------------------------------------------ + /// <summary>Verifies that the replication backlog badge renders when the backlog is sustained past the dwell.</summary> [Fact] public void Renders_ReplicationBacklogBadge_WhenBacklogSustainedPastTheDwell() { @@ -373,6 +385,7 @@ public class HealthPageTests : BunitContext }); } + /// <summary>Verifies that the replication backlog badge is not rendered during a purge burst.</summary> [Fact] public void DoesNotRender_ReplicationBacklogBadge_DuringAPurgeBurst() { @@ -394,6 +407,7 @@ public class HealthPageTests : BunitContext Assert.DoesNotContain("data-test=\"localdb-backlog-badge\"", cut.Markup); } + /// <summary>Verifies that the replication backlog badge is not rendered for an unreplicated site.</summary> [Fact] public void DoesNotRender_ReplicationBacklogBadge_ForAnUnreplicatedSite() { @@ -413,6 +427,7 @@ public class HealthPageTests : BunitContext Assert.DoesNotContain("data-test=\"localdb-backlog-badge\"", cut.Markup); } + /// <summary>Verifies that the replication backlog badge is not rendered for an offline site.</summary> [Fact] public void DoesNotRender_ReplicationBacklogBadge_ForAnOfflineSite() { @@ -432,6 +447,7 @@ public class HealthPageTests : BunitContext Assert.DoesNotContain("data-test=\"localdb-backlog-badge\"", cut.Markup); } + /// <summary>Verifies that the LocalDb backlog trend chart renders.</summary> [Fact] public void Renders_LocalDbBacklogTrendChart() { @@ -448,9 +464,10 @@ public class HealthPageTests : BunitContext // ----- Register row 73: the "No self-heal" badge ----------------------------------- // // Tri-state, and only the explicit false is badged. Null means "not reported" — the - // central self-report row, and every site node on a pre-row-73 build — so badging null + // central self-report row, and every site node on an older build — so badging null // would light up the whole fleet the moment central is upgraded ahead of the sites. + /// <summary>Verifies that the startup reconciler badge renders when the site reports it is missing.</summary> [Fact] public void Renders_StartupReconcilerBadge_WhenTheSiteReportsItIsMissing() { @@ -468,6 +485,7 @@ public class HealthPageTests : BunitContext Assert.Contains("data-test=\"startup-reconciler-badge\"", cut.Markup)); } + /// <summary>Verifies that the startup reconciler badge is not rendered when the reconciler is running.</summary> [Fact] public void DoesNotRender_StartupReconcilerBadge_WhenTheReconcilerIsRunning() { @@ -485,6 +503,7 @@ public class HealthPageTests : BunitContext Assert.DoesNotContain("data-test=\"startup-reconciler-badge\"", cut.Markup); } + /// <summary>Verifies that the startup reconciler badge is not rendered when the status is not reported.</summary> [Fact] public void DoesNotRender_StartupReconcilerBadge_WhenNotReported() { @@ -504,6 +523,7 @@ public class HealthPageTests : BunitContext Assert.DoesNotContain("data-test=\"startup-reconciler-badge\"", cut.Markup); } + /// <summary>Verifies that the startup reconciler badge is not rendered when the site has no report yet.</summary> [Fact] public void DoesNotRender_StartupReconcilerBadge_WhenTheSiteHasNoReportYet() { @@ -521,6 +541,7 @@ public class HealthPageTests : BunitContext Assert.DoesNotContain("data-test=\"startup-reconciler-badge\"", cut.Markup); } + /// <summary>Verifies that the startup reconciler badge is not rendered for an offline site.</summary> [Fact] public void DoesNotRender_StartupReconcilerBadge_ForAnOfflineSite() { @@ -559,11 +580,12 @@ public class HealthPageTests : BunitContext StartupReconcilerActive = active, }; - // ----- Review finding M1: per-node deployed-secrets mirror failures ------------------ + // ----- Per-node deployed-secrets mirror failures ------------------ // // The standby's failures ride the active node's report; the card must name the node, // the secret and the message. Null ("no data") and all-healthy nodes render nothing. + /// <summary>Verifies that secret mirror failures render labelled by node.</summary> [Fact] public void Renders_SecretMirrorFailures_LabelledByNode() { @@ -598,8 +620,10 @@ public class HealthPageTests : BunitContext }); } + /// <summary>Verifies that no secret mirror warning renders when no node is failing.</summary> + /// <param name="reported">Whether the site reports secret mirror status at all.</param> [Theory] - [InlineData(false)] // null = "no data" (central self-report, pre-M1 site) + [InlineData(false)] // null = "no data" (central self-report, older site) [InlineData(true)] // every node healthy public void DoesNotRender_SecretMirrorWarning_WhenNoNodeIsFailing(bool reported) { @@ -623,6 +647,7 @@ public class HealthPageTests : BunitContext Assert.DoesNotContain("data-test=\"secret-mirror-failures\"", cut.Markup); } + /// <summary>Verifies that the offline-since time renders for an offline site with a status change.</summary> [Fact] public void Renders_OfflineSince_ForOfflineSiteWithStatusChange() { @@ -688,6 +713,7 @@ public class HealthPageTests : BunitContext }; } + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) @@ -703,6 +729,8 @@ public class HealthPageTests : BunitContext /// </summary> private sealed class ScriptedOutboxActor : ReceiveActor { + /// <summary>Initializes the actor with the test whose scripted reply it returns.</summary> + /// <param name="test">The owning test that holds the scripted reply.</param> public ScriptedOutboxActor(HealthPageTests test) { Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply)); @@ -712,11 +740,13 @@ public class HealthPageTests : BunitContext /// <summary> /// Stand-in for the Site Call Audit actor. Replies to the KPI request with /// the test's currently-scripted response. Also handles the per-node KPI - /// request (T6: M5.2) with an empty-nodes success reply so the Health page + /// request with an empty-nodes success reply so the Health page /// can complete initialization without a 30-second Ask timeout. /// </summary> private sealed class ScriptedSiteCallAuditActor : ReceiveActor { + /// <summary>Initializes the actor with the test whose scripted replies it returns.</summary> + /// <param name="test">The owning test that holds the scripted replies.</param> public ScriptedSiteCallAuditActor(HealthPageTests test) { Receive<SiteCallKpiRequest>(_ => Sender.Tell(test._siteCallKpiReply)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs index f641006d..b7be9539 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs @@ -48,7 +48,7 @@ public class NotificationKpisPageTests : BunitContext DeliveredLastInterval: 9, OldestPendingAge: TimeSpan.FromMinutes(7)), }); - // KPI-history trend service (K13 / T11). Default substitute returns a + // KPI-history trend service Default substitute returns a // 3-point series for any (source, metric, scope, …) tuple so every chart // renders a polyline; individual tests can re-stub it (e.g. to throw). private readonly IKpiHistoryQueryService _kpiHistory = Substitute.For<IKpiHistoryQueryService>(); @@ -60,6 +60,7 @@ public class NotificationKpisPageTests : BunitContext new(DateTime.UtcNow.AddHours(-1), 4), }; + /// <summary>Initializes a new instance of the <c>NotificationKpisPageTests</c> class.</summary> public NotificationKpisPageTests() { _comms = new CommunicationService( @@ -71,7 +72,7 @@ public class NotificationKpisPageTests : BunitContext Services.AddSingleton(_comms); - // arch-review WP2.4 — the page reads the process-level memoized KPI cache + // The page reads the process-level memoized KPI cache // rather than querying CommunicationService per circuit. The real cache is // registered so the scripted-actor seam above still answers the queries. Services.AddSingleton<IKpiSnapshotCache>(sp => new KpiSnapshotCache( @@ -104,6 +105,8 @@ public class NotificationKpisPageTests : BunitContext Services.AddTestConfigurationMutations(); } + /// <summary>Verifies that the page requires the deployment authorization policy.</summary> + /// <returns>The result.</returns> [Fact] public void Page_RequiresDeploymentPolicy() { @@ -116,6 +119,8 @@ public class NotificationKpisPageTests : BunitContext Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy); } + /// <summary>Verifies that the page renders the global KPI tiles and the per-site rows.</summary> + /// <returns>The result.</returns> [Fact] public void RendersGlobalTilesAndPerSiteRows() { @@ -130,6 +135,8 @@ public class NotificationKpisPageTests : BunitContext }); } + /// <summary>Verifies that the page shows an error when the global KPI query fails.</summary> + /// <returns>The result.</returns> [Fact] public void ShowsKpiError_WhenGlobalKpiQueryFails() { @@ -141,6 +148,8 @@ public class NotificationKpisPageTests : BunitContext cut.WaitForAssertion(() => Assert.Contains("kpi down", cut.Markup)); } + /// <summary>Verifies that the page shows a per-site error, without the global error, when the per-site KPI query fails.</summary> + /// <returns>The result.</returns> [Fact] public void ShowsPerSiteError_WhenPerSiteKpiQueryFails() { @@ -159,6 +168,8 @@ public class NotificationKpisPageTests : BunitContext }); } + /// <summary>Verifies that the page shows the per-site empty state when there are no sites.</summary> + /// <returns>The result.</returns> [Fact] public void ShowsPerSiteEmptyState_WhenNoSites() { @@ -170,6 +181,8 @@ public class NotificationKpisPageTests : BunitContext cut.WaitForAssertion(() => Assert.Contains("No per-site activity", cut.Markup)); } + /// <summary>Verifies that the page renders trend charts when history series are available.</summary> + /// <returns>The result.</returns> [Fact] public void RendersTrendCharts_WhenSeriesAvailable() { @@ -186,6 +199,8 @@ public class NotificationKpisPageTests : BunitContext }); } + /// <summary>Verifies that a trend query failure for one metric degrades only that metric's chart.</summary> + /// <returns>The result.</returns> [Fact] public void PartialTrendFailure_IsolatesToTheFailingMetric() { @@ -224,6 +239,8 @@ public class NotificationKpisPageTests : BunitContext }); } + /// <summary>Verifies that a failing trend query does not break the page.</summary> + /// <returns>The result.</returns> [Fact] public void TrendQueryFailure_DoesNotBreakPage() { @@ -249,6 +266,7 @@ public class NotificationKpisPageTests : BunitContext }); } + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) @@ -261,11 +279,13 @@ public class NotificationKpisPageTests : BunitContext /// <summary> /// Stand-in for the notification-outbox actor. Replies to each KPI message /// type with the test's currently-scripted response. Also handles the per-node - /// KPI request (T6: M5.2) with an empty-nodes success reply so the page can + /// KPI request with an empty-nodes success reply so the page can /// complete initialization without a 30-second Ask timeout. /// </summary> private sealed class ScriptedOutboxActor : ReceiveActor { + /// <summary>Initializes a new instance of the <c>ScriptedOutboxActor</c> class.</summary> + /// <param name="test">The test.</param> public ScriptedOutboxActor(NotificationKpisPageTests test) { Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListFormTests.cs index 8bd57e8a..7f64e974 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListFormTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListFormTests.cs @@ -20,6 +20,7 @@ public class NotificationListFormTests : BunitContext { private readonly INotificationRepository _repo = Substitute.For<INotificationRepository>(); + /// <summary>Initializes the test context with a substitute repository, a channel catalog and test authentication.</summary> public NotificationListFormTests() { Services.AddSingleton(_repo); @@ -46,6 +47,7 @@ public class NotificationListFormTests : BunitContext Services.AddTestConfigurationMutations(); } + /// <summary>Verifies that the type selector renders the registered channels.</summary> [Fact] public void TypeSelector_RendersRegisteredChannels() { @@ -61,6 +63,7 @@ public class NotificationListFormTests : BunitContext }); } + /// <summary>Verifies that the type selector is enabled when creating a list.</summary> [Fact] public void TypeSelector_IsEnabled_OnCreate() { @@ -73,6 +76,7 @@ public class NotificationListFormTests : BunitContext }); } + /// <summary>Verifies that selecting the email type shows the email input and not the phone input.</summary> [Fact] public void SelectingEmail_ShowsEmailInput_NotPhone() { @@ -93,6 +97,7 @@ public class NotificationListFormTests : BunitContext }); } + /// <summary>Verifies that selecting the SMS type shows the phone input and not the email input.</summary> [Fact] public void SelectingSms_ShowsPhoneInput_NotEmail() { @@ -111,6 +116,7 @@ public class NotificationListFormTests : BunitContext }); } + /// <summary>Verifies that the type selector is disabled when editing an existing list.</summary> [Fact] public void TypeSelector_IsDisabled_OnEdit() { @@ -128,6 +134,7 @@ public class NotificationListFormTests : BunitContext }); } + /// <summary>Verifies that creating an SMS list persists the SMS type.</summary> [Fact] public void CreatingSmsList_PersistsTypeSms() { @@ -150,6 +157,7 @@ public class NotificationListFormTests : BunitContext }); } + /// <summary>Verifies that adding an SMS recipient creates an SMS recipient with its phone number set.</summary> [Fact] public void AddingSmsRecipient_CreatesForSmsRecipient_WithPhoneSet() { @@ -179,6 +187,7 @@ public class NotificationListFormTests : BunitContext }); } + /// <summary>Verifies that adding an SMS recipient with an invalid phone number is rejected.</summary> [Fact] public void AddingSmsRecipient_RejectsInvalidPhone() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs index 312b5032..372c5262 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs @@ -13,7 +13,7 @@ using NotificationListsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.N namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// <summary> -/// bUnit rendering tests for the standalone Notification Lists page (Task 7). +/// bUnit rendering tests for the standalone Notification Lists page. /// </summary> public class NotificationListsPageTests : BunitContext { @@ -32,6 +32,7 @@ public class NotificationListsPageTests : BunitContext Services.AddTestConfigurationMutations(); } + /// <summary>Verifies that the page renders a row for each notification list.</summary> [Fact] public void RendersNotificationListRows() { @@ -54,6 +55,7 @@ public class NotificationListsPageTests : BunitContext }); } + /// <summary>Verifies that the page shows an empty state when there are no lists.</summary> [Fact] public void ShowsEmptyState_WhenNoLists() { @@ -70,6 +72,7 @@ public class NotificationListsPageTests : BunitContext Assert.Contains("No notification lists", cut.Markup)); } + /// <summary>Verifies that deleting a list confirms, deletes it and reloads the list.</summary> [Fact] public void DeleteList_ConfirmsThenDeletesAndReloads() { @@ -100,6 +103,7 @@ public class NotificationListsPageTests : BunitContext }); } + /// <summary>Verifies that the type column renders SMS for an SMS list.</summary> [Fact] public void RendersTypeColumn_Sms() { @@ -125,6 +129,7 @@ public class NotificationListsPageTests : BunitContext }); } + /// <summary>Verifies that an SMS list recipient badge shows the phone number instead of empty angle brackets.</summary> [Fact] public void SmsListRecipientBadge_ShowsPhoneNumber_NotEmptyAngleBrackets() { @@ -154,6 +159,7 @@ public class NotificationListsPageTests : BunitContext }); } + /// <summary>Verifies that the type column renders Email for an email list.</summary> [Fact] public void RendersTypeColumn_Email() { @@ -192,19 +198,28 @@ public class NotificationListsPageTests : BunitContext /// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary> private sealed class AlwaysConfirmDialogService : IDialogService { + /// <inheritdoc /> public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); // Register row 75 added a DialogSeverity overload. This stub auto-confirms // regardless of severity — it exists to let action paths run end to end, not to // assert anything about styling. + /// <inheritdoc /> public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null) => Task.FromResult(true); + /// <inheritdoc /> public Task<string?> PromptAsync( string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult<string?>(null); + /// <summary>Returns a completed task with the default result without showing a dialog.</summary> + /// <typeparam name="TResult">The dialog result type.</typeparam> + /// <param name="title">The dialog title.</param> + /// <param name="body">The dialog body fragment.</param> + /// <param name="size">The optional dialog size.</param> + /// <returns>A task that resolves to the default result.</returns> public Task<TResult?> ShowAsync<TResult>( string title, Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body, diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs index f792357b..50b6cf43 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs @@ -65,6 +65,7 @@ public class NotificationReportDetailModalTests : BunitContext DeliveredAt: DateTimeOffset.UtcNow.AddHours(-2), IsStuck: false), }, TotalCount: 2); + /// <summary>Initializes the test context, registering the fake outbox actor and dialog service.</summary> public NotificationReportDetailModalTests() { _comms = new CommunicationService( @@ -98,6 +99,7 @@ public class NotificationReportDetailModalTests : BunitContext Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>(); } + /// <summary>Verifies that double click row, opens detail modal.</summary> [Fact] public void DoubleClickRow_OpensDetailModal() { @@ -119,6 +121,7 @@ public class NotificationReportDetailModalTests : BunitContext }); } + /// <summary>Verifies that modal, shows full notification id, not truncated.</summary> [Fact] public void Modal_ShowsFullNotificationId_NotTruncated() { @@ -138,6 +141,7 @@ public class NotificationReportDetailModalTests : BunitContext }); } + /// <summary>Verifies that close button, dismisses modal.</summary> [Fact] public void CloseButton_DismissesModal() { @@ -156,6 +160,7 @@ public class NotificationReportDetailModalTests : BunitContext cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".modal.show"))); } + /// <summary>Verifies that modal, shows last error, when present.</summary> [Fact] public void Modal_ShowsLastError_WhenPresent() { @@ -173,6 +178,7 @@ public class NotificationReportDetailModalTests : BunitContext }); } + /// <summary>Verifies that modal, fetches and shows body.</summary> [Fact] public void Modal_FetchesAndShowsBody() { @@ -192,6 +198,7 @@ public class NotificationReportDetailModalTests : BunitContext }); } + /// <summary>Verifies that modal, shows recipients, from resolved targets.</summary> [Fact] public void Modal_ShowsRecipients_FromResolvedTargets() { @@ -210,6 +217,7 @@ public class NotificationReportDetailModalTests : BunitContext }); } + /// <summary>Verifies that modal, shows list fallback, when resolved targets null.</summary> [Fact] public void Modal_ShowsListFallback_WhenResolvedTargetsNull() { @@ -234,6 +242,7 @@ public class NotificationReportDetailModalTests : BunitContext }); } + /// <summary>Verifies that modal, shows error, when detail fetch fails.</summary> [Fact] public void Modal_ShowsError_WhenDetailFetchFails() { @@ -310,6 +319,7 @@ public class NotificationReportDetailModalTests : BunitContext cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".modal.show"))); } + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) @@ -321,6 +331,8 @@ public class NotificationReportDetailModalTests : BunitContext private sealed class ScriptedOutboxActor : ReceiveActor { + /// <summary>Creates the scripted outbox actor bound to the owning test class.</summary> + /// <param name="test">The owning test whose scripted responses the actor replays.</param> public ScriptedOutboxActor(NotificationReportDetailModalTests test) { Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply)); @@ -337,19 +349,28 @@ public class NotificationReportDetailModalTests : BunitContext private sealed class AlwaysConfirmDialogService : IDialogService { + /// <inheritdoc /> public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); // Register row 75 added a DialogSeverity overload. This stub auto-confirms // regardless of severity — it exists to let action paths run end to end, not to // assert anything about styling. + /// <inheritdoc /> public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null) => Task.FromResult(true); + /// <inheritdoc /> public Task<string?> PromptAsync( string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult<string?>(null); + /// <summary>Returns the default result without showing a dialog.</summary> + /// <typeparam name="TResult">The type of the dialog result.</typeparam> + /// <param name="title">The dialog title (ignored).</param> + /// <param name="body">The dialog body (ignored).</param> + /// <param name="size">The dialog size (ignored).</param> + /// <returns>A task whose result is the default value.</returns> public Task<TResult?> ShowAsync<TResult>( string title, Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body, diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs index e72cd27c..c7f5ca0e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs @@ -54,6 +54,7 @@ public class NotificationReportPageTests : BunitContext private readonly List<RetryNotificationRequest> _retryRequests = new(); private readonly List<DiscardNotificationRequest> _discardRequests = new(); + /// <summary>Initializes the test class and registers the services the page needs.</summary> public NotificationReportPageTests() { _comms = new CommunicationService( @@ -84,12 +85,13 @@ public class NotificationReportPageTests : BunitContext Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); Services.AddTestConfigurationMutations(); - // CentralUI-028: the page now injects SiteScopeService — the test user + // The page now injects SiteScopeService — the test user // has no SiteId claims, so this resolves to system-wide and the // pre-existing test expectations hold. Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>(); } + /// <summary>Verifies that the page requires the Deployment policy.</summary> [Fact] public void Page_RequiresDeploymentPolicy() { @@ -102,6 +104,7 @@ public class NotificationReportPageTests : BunitContext Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy); } + /// <summary>Verifies that the page renders the notification rows.</summary> [Fact] public void Renders_NotificationRows() { @@ -115,6 +118,7 @@ public class NotificationReportPageTests : BunitContext }); } + /// <summary>Verifies that a stuck row is badged.</summary> [Fact] public void StuckRow_IsBadged() { @@ -131,6 +135,7 @@ public class NotificationReportPageTests : BunitContext }); } + /// <summary>Verifies that clicking Retry on a parked row calls the retry notification command.</summary> [Fact] public void ClickRetry_OnParkedRow_CallsRetryNotification() { @@ -152,6 +157,7 @@ public class NotificationReportPageTests : BunitContext }); } + /// <summary>Verifies that clicking Discard on a parked row calls the discard notification command.</summary> [Fact] public void ClickDiscard_OnParkedRow_CallsDiscardNotification() { @@ -173,6 +179,7 @@ public class NotificationReportPageTests : BunitContext }); } + /// <summary>Verifies that a query failure shows an error message.</summary> [Fact] public void QueryFailure_ShowsErrorMessage() { @@ -187,10 +194,11 @@ public class NotificationReportPageTests : BunitContext } // ───────────────────────────────────────────────────────────────────────── - // Bundle D drill-in (#23 M7-T10) — every notification row carries a + // Audit drill-in — every notification row carries a // "View audit history" link to /audit/log?correlationId={NotificationId}. // ───────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that the View audit history link on a notification row has the correct href.</summary> [Fact] public void NotificationRow_ViewAuditHistory_Link_HasCorrectHref() { @@ -220,6 +228,7 @@ public class NotificationReportPageTests : BunitContext }); } + /// <summary>Verifies that clicking the audit history link navigates to the Audit Log with the correlation id.</summary> [Fact] public void Click_NavigatesTo_AuditLog_WithCorrelationId() { @@ -249,6 +258,7 @@ public class NotificationReportPageTests : BunitContext // with that id comes back. // ───────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a dashed GUID in the correlation id query is normalised to the stored N form.</summary> [Fact] public void NotificationReport_CorrelationIdQuery_DashedGuid_IsNormalisedToTheStoredNForm() { @@ -268,6 +278,7 @@ public class NotificationReportPageTests : BunitContext }); } + /// <summary>Verifies that the correlation id query seeds the notification filter and automatically opens the detail view.</summary> [Fact] public void NotificationReport_CorrelationIdQuery_SeedsTheNotificationFilterAndAutoOpensDetail() { @@ -303,6 +314,7 @@ public class NotificationReportPageTests : BunitContext }); } + /// <summary>Verifies that clearing the filters drops the notification filter.</summary> [Fact] public void ClearFilters_DropsTheNotificationFilter() { @@ -326,6 +338,7 @@ public class NotificationReportPageTests : BunitContext }); } + /// <summary>Verifies that clearing the notification filter pill drops the filter and re-queries.</summary> [Fact] public void NotificationFilterPill_Clear_DropsTheFilterAndRequeries() { @@ -346,6 +359,7 @@ public class NotificationReportPageTests : BunitContext }); } + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) @@ -361,6 +375,8 @@ public class NotificationReportPageTests : BunitContext /// </summary> private sealed class ScriptedOutboxActor : ReceiveActor { + /// <summary>Initializes the actor with the test that owns it.</summary> + /// <param name="test">The test.</param> public ScriptedOutboxActor(NotificationReportPageTests test) { Receive<NotificationOutboxQueryRequest>(req => @@ -397,19 +413,28 @@ public class NotificationReportPageTests : BunitContext /// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary> private sealed class AlwaysConfirmDialogService : IDialogService { + /// <inheritdoc /> public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); // Register row 75 added a DialogSeverity overload. This stub auto-confirms // regardless of severity — it exists to let action paths run end to end, not to // assert anything about styling. + /// <inheritdoc /> public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null) => Task.FromResult(true); + /// <inheritdoc /> public Task<string?> PromptAsync( string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult<string?>(null); + /// <summary>Returns the default result without showing a dialog.</summary> + /// <typeparam name="TResult">The dialog result type.</typeparam> + /// <param name="title">The dialog title.</param> + /// <param name="body">The dialog body fragment.</param> + /// <param name="size">The optional dialog size.</param> + /// <returns>A task that resolves to the default result.</returns> public Task<TResult?> ShowAsync<TResult>( string title, Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body, diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/PageScopedDetailStateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/PageScopedDetailStateTests.cs index 0a9532e8..cec8abf5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/PageScopedDetailStateTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/PageScopedDetailStateTests.cs @@ -66,6 +66,9 @@ public class PageScopedDetailStateTests : BunitContext JSInterop.Mode = JSRuntimeMode.Loose; } + /// <summary>Verifies that paging the Parked Messages list closes the open detail drawer.</summary> + /// <param name="pagingMethod">The name of the paging method to invoke.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("PrevPage")] [InlineData("NextPage")] @@ -94,6 +97,8 @@ public class PageScopedDetailStateTests : BunitContext Assert.Null(GetPrivate(cut.Instance, "_drawerMessageId")); } + /// <summary>Verifies that paging the configuration audit log closes the open state modal.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConfigurationAuditLog_Paging_ClosesTheStateModal() { @@ -113,7 +118,10 @@ public class PageScopedDetailStateTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Initializes the stub with the authentication state it returns.</summary> + /// <param name="state">The authentication state to return.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs index bbb42a8d..43940329 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs @@ -31,7 +31,7 @@ using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Des namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// <summary> -/// CentralUI-033: tests for the drill-in / query-string code paths on the two +/// Tests for the drill-in / query-string code paths on the two /// newest pages (TransportImport + SiteCallsReport). The base happy-path cases /// (Parked, stuck=true, no params) live next to the rest of the page's tests in /// <c>SiteCallsReportPageTests</c> / <c>TransportImportPageTests</c>; this file @@ -40,12 +40,12 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// </summary> public sealed class QueryStringDrillInTests { - // STM: CentralUI-033-QueryStringDrillIn marker — used by grep verification. // ----------------------------------------------------------------- // SiteCallsReport — ?status= // ----------------------------------------------------------------- + /// <summary>Verifies that a case-insensitive status query parameter on the site calls report normalises to the canonical casing.</summary> [Fact] public void SiteCallsReport_StatusParam_CaseInsensitiveMatch_NormalisesToCanonicalCasing() { @@ -68,6 +68,7 @@ public sealed class QueryStringDrillInTests }); } + /// <summary>Verifies that an unrecognised status query parameter on the site calls report is silently dropped.</summary> [Fact] public void SiteCallsReport_StatusParam_Unrecognised_IsSilentlyDropped() { @@ -88,6 +89,7 @@ public sealed class QueryStringDrillInTests }); } + /// <summary>Verifies that a non-boolean stuck query parameter on the site calls report is silently dropped.</summary> [Fact] public void SiteCallsReport_StuckParam_NonBoolean_IsSilentlyDropped() { @@ -110,6 +112,7 @@ public sealed class QueryStringDrillInTests // SiteCallsReport — ?correlationId= (Flow Debugger / Audit Log deep link, D2) // ----------------------------------------------------------------- + /// <summary>Verifies that the correlation id query parameter seeds the operation filter on the site calls report.</summary> [Fact] public void SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilter() { @@ -131,6 +134,7 @@ public sealed class QueryStringDrillInTests }); } + /// <summary>Verifies that a malformed correlation id query parameter on the site calls report is ignored.</summary> [Fact] public void SiteCallsReport_CorrelationIdQuery_Malformed_IsIgnored() { @@ -155,6 +159,7 @@ public sealed class QueryStringDrillInTests // NotificationReport — ?correlationId= (Flow Debugger follow-ups, D2) // ----------------------------------------------------------------- + /// <summary>Verifies that a blank correlation id query parameter on the notification report is ignored.</summary> [Fact] public void NotificationReport_CorrelationIdQuery_Blank_IsIgnored() { @@ -180,7 +185,7 @@ public sealed class QueryStringDrillInTests // ----------------------------------------------------------------- /// <summary> - /// CentralUI-033: TransportImport.razor declares no <c>[Parameter]</c> / + /// TransportImport.razor declares no <c>[Parameter]</c> / /// <c>SupplyParameterFromQuery</c> bindings — the wizard's initial state is /// purely <c>ImportWizardStep.Upload</c> regardless of the query-string. This /// test pins that contract: navigating with an unrecognised query-string @@ -204,7 +209,7 @@ public sealed class QueryStringDrillInTests .GetValue(cut.Instance)!; Assert.Equal(TransportImportPage.ImportWizardStep.Upload, step); - // And the Step-1 InputFile control is rendered — the page came up clean. + // And the first-step InputFile control is rendered — the page came up clean. Assert.NotNull(cut.Find("input[type='file']")); } @@ -222,6 +227,7 @@ public sealed class QueryStringDrillInTests public readonly CommunicationService Comms; public readonly List<SiteCallQueryRequest> QueryRequests = new(); + /// <summary>Initializes a new instance of the <c>SiteCallsReportFixture</c> class.</summary> public SiteCallsReportFixture() { Comms = new CommunicationService( @@ -233,7 +239,7 @@ public sealed class QueryStringDrillInTests Services.AddSingleton(Comms); Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService()); - // M6 (K14): the SiteCallsReport page now injects IKpiHistoryQueryService + // The SiteCallsReport page now injects IKpiHistoryQueryService // for the Trends section. Provide a stand-in so these drill-in tests render. Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>()); @@ -257,6 +263,7 @@ public sealed class QueryStringDrillInTests Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>(); } + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) @@ -269,6 +276,8 @@ public sealed class QueryStringDrillInTests private sealed class ScriptedSiteCallAuditActor : ReceiveActor { + /// <summary>Initializes a new instance of the <c>ScriptedSiteCallAuditActor</c> class.</summary> + /// <param name="fixture">The fixture supplying the scripted replies.</param> public ScriptedSiteCallAuditActor(SiteCallsReportFixture fixture) { Receive<SiteCallQueryRequest>(req => @@ -287,6 +296,7 @@ public sealed class QueryStringDrillInTests public readonly CommunicationService Comms; public readonly List<NotificationOutboxQueryRequest> QueryRequests = new(); + /// <summary>Initializes a new instance of the <c>NotificationReportFixture</c> class.</summary> public NotificationReportFixture() { Comms = new CommunicationService( @@ -319,6 +329,7 @@ public sealed class QueryStringDrillInTests Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>(); } + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) @@ -331,6 +342,8 @@ public sealed class QueryStringDrillInTests private sealed class ScriptedNotificationOutboxActor : ReceiveActor { + /// <summary>Initializes a new instance of the <c>ScriptedNotificationOutboxActor</c> class.</summary> + /// <param name="fixture">The fixture supplying the scripted replies.</param> public ScriptedNotificationOutboxActor(NotificationReportFixture fixture) { Receive<NotificationOutboxQueryRequest>(req => @@ -345,17 +358,26 @@ public sealed class QueryStringDrillInTests private sealed class AlwaysConfirmDialogService : IDialogService { + /// <inheritdoc /> public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); // Register row 75 added a DialogSeverity overload. This stub auto-confirms // regardless of severity — it exists to let action paths run end to end, not to // assert anything about styling. + /// <inheritdoc /> public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null) => Task.FromResult(true); + /// <inheritdoc /> public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult<string?>(null); + /// <summary>Returns the default result without showing a dialog.</summary> + /// <typeparam name="TResult">The dialog result type.</typeparam> + /// <param name="title">The dialog title.</param> + /// <param name="body">The dialog body fragment.</param> + /// <param name="size">The optional dialog size.</param> + /// <returns>A task that resolves to the default result.</returns> public Task<TResult?> ShowAsync<TResult>( string title, Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body, @@ -365,6 +387,7 @@ public sealed class QueryStringDrillInTests private sealed class TransportImportFixture : BunitContext { + /// <summary>Initializes a new instance of the <c>TransportImportFixture</c> class.</summary> public TransportImportFixture() { JSInterop.Mode = JSRuntimeMode.Loose; @@ -372,7 +395,7 @@ public sealed class QueryStringDrillInTests var importer = Substitute.For<IBundleImporter>(); Services.AddSingleton(importer); Services.AddSingleton(Substitute.For<IAuditService>()); - // M8 E2: TransportImport's Map step injects ISiteRepository. + // TransportImport's Map step injects ISiteRepository. Services.AddSingleton(Substitute.For<ISiteRepository>()); // Register row 44: the wizard reads the circuit's client IP for the // per-IP unlock throttle. No HTTP context under bUnit — use the seam. diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs index 3b90d5e4..f90b1266 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs @@ -22,7 +22,7 @@ using SiteCallsReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Sit namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// <summary> -/// bUnit rendering tests for the Site Calls report page (Site Call Audit #22). +/// bUnit rendering tests for the Site Calls report page. /// /// Testability note: <see cref="CommunicationService"/> is a concrete class with /// non-virtual methods, so NSubstitute cannot intercept it. The page's calls all @@ -82,6 +82,7 @@ public class SiteCallsReportPageTests : BunitContext private DiscardSiteCallResponse _discardReply = new("q", SiteCallRelayOutcome.Applied, true, true, null); + /// <summary>Initializes the bUnit context with the fakes and services the Site Calls report page depends on.</summary> public SiteCallsReportPageTests() { _comms = new CommunicationService( @@ -129,6 +130,7 @@ public class SiteCallsReportPageTests : BunitContext Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>(); } + /// <summary>Verifies that the page requires the Deployment authorization policy.</summary> [Fact] public void Page_RequiresDeploymentPolicy() { @@ -141,6 +143,7 @@ public class SiteCallsReportPageTests : BunitContext Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy); } + /// <summary>Verifies that the page renders a row for each site call returned by the query.</summary> [Fact] public void Renders_SiteCallRows() { @@ -153,6 +156,7 @@ public class SiteCallsReportPageTests : BunitContext }); } + /// <summary>Verifies that a stuck row is shown with a stuck badge.</summary> [Fact] public void StuckRow_IsBadged() { @@ -167,6 +171,7 @@ public class SiteCallsReportPageTests : BunitContext }); } + /// <summary>Verifies that the Retry and Discard buttons are shown only on parked rows.</summary> [Fact] public void RetryDiscardButtons_ShownOnlyOnParkedRows() { @@ -192,6 +197,7 @@ public class SiteCallsReportPageTests : BunitContext b => b.TextContent.Contains("Discard")); } + /// <summary>Verifies that clicking Retry on a parked row relays the retry to the owning site.</summary> [Fact] public void ClickRetry_OnParkedRow_RelaysRetryToOwningSite() { @@ -215,6 +221,7 @@ public class SiteCallsReportPageTests : BunitContext }); } + /// <summary>Verifies that clicking Discard on a parked row relays the discard to the owning site.</summary> [Fact] public void ClickDiscard_OnParkedRow_RelaysDiscardToOwningSite() { @@ -237,6 +244,7 @@ public class SiteCallsReportPageTests : BunitContext }); } + /// <summary>Verifies that a retry relay to an unreachable site shows a distinct message.</summary> [Fact] public void RetryRelay_SiteUnreachable_ShowsDistinctMessage() { @@ -259,6 +267,7 @@ public class SiteCallsReportPageTests : BunitContext Assert.Contains("offline", cut.Markup)); } + /// <summary>Verifies that a failed query shows an error message.</summary> [Fact] public void QueryFailure_ShowsErrorMessage() { @@ -277,6 +286,7 @@ public class SiteCallsReportPageTests : BunitContext // /audit/log?correlationId={TrackedOperationId}. // ───────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that the View Audit History link on a row has the correct href.</summary> [Fact] public void SiteCallRow_ViewAuditHistory_Link_HasCorrectHref() { @@ -310,6 +320,7 @@ public class SiteCallsReportPageTests : BunitContext // page numbers; the request echoes the cursor back to the actor. // ───────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that the Next button is hidden when there is no further page.</summary> [Fact] public void Paging_NextButton_HiddenWhenNoFurtherPage() { @@ -324,6 +335,7 @@ public class SiteCallsReportPageTests : BunitContext Assert.True(prev.HasAttribute("disabled")); } + /// <summary>Verifies that the Next button advances using the keyset cursor.</summary> [Fact] public void Paging_NextButton_AdvancesUsingKeysetCursor() { @@ -364,6 +376,7 @@ public class SiteCallsReportPageTests : BunitContext }); } + /// <summary>Verifies that the Previous button pops the back stack and refetches using the prior cursor.</summary> [Fact] public void Paging_PrevButton_PopsBackStackAndRefetchesPriorCursor() { @@ -407,7 +420,7 @@ public class SiteCallsReportPageTests : BunitContext cut.WaitForAssertion(() => { - // Query 3 is the Previous refetch — the back-stack popped the page-1 + // Query 3 is the Previous refetch — the back-stack popped the page 1 // cursor, which is the empty (null, null) first-page cursor. Assert.Equal(3, _queryRequests.Count); Assert.Null(_queryRequests[2].AfterCreatedAtUtc); @@ -417,6 +430,7 @@ public class SiteCallsReportPageTests : BunitContext }); } + /// <summary>Verifies that retrying a row that is no longer parked shows an info message and exactly one toast.</summary> [Fact] public void RetryRelay_NotParked_ShowsInfoMessage_AndExactlyOneToast() { @@ -453,6 +467,7 @@ public class SiteCallsReportPageTests : BunitContext // is already filtered, and the filter card controls must reflect the values. // ───────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that navigating with the parked status query parameter loads the grid pre-filtered to parked rows.</summary> [Fact] public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked() { @@ -476,6 +491,7 @@ public class SiteCallsReportPageTests : BunitContext }); } + /// <summary>Verifies that navigating with the stuck query parameter set to true loads the grid with the stuck filter applied.</summary> [Fact] public void NavigateWithStuckTrueParam_LoadsGridWithStuckFilterApplied() { @@ -497,6 +513,7 @@ public class SiteCallsReportPageTests : BunitContext }); } + /// <summary>Verifies that navigating without query parameters loads the grid unfiltered.</summary> [Fact] public void NavigateWithNoQueryParams_LoadsGridUnfiltered() { @@ -521,9 +538,10 @@ public class SiteCallsReportPageTests : BunitContext // Flow Debugger / Audit Log deep link: ?correlationId=<TrackedOperationId> // seeds a read-only "Operation" pill filter, rides on the query as // TrackedOperationIdFilter, and auto-opens the detail modal when exactly - // one matching row comes back (follow-ups plan D2 / Task 4). + // one matching row comes back. // ───────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a correlation ID query parameter seeds the operation filter and automatically opens the detail view.</summary> [Fact] public void SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilterAndAutoOpensDetail() { @@ -558,6 +576,7 @@ public class SiteCallsReportPageTests : BunitContext }); } + /// <summary>Verifies that a correlation ID query parameter with no matching row does not open the detail view.</summary> [Fact] public void SiteCallsReport_CorrelationIdQuery_NoMatchingRow_DoesNotOpenDetail() { @@ -580,6 +599,7 @@ public class SiteCallsReportPageTests : BunitContext Assert.Empty(_detailRequests); } + /// <summary>Verifies that clearing the filters drops the operation filter.</summary> [Fact] public void ClearFilters_DropsTheOperationFilter() { @@ -597,6 +617,7 @@ public class SiteCallsReportPageTests : BunitContext cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']"))); } + /// <summary>Verifies that the operation pill's clear button drops the filter and requeries.</summary> [Fact] public void OperationPill_ClearButton_DropsTheFilterAndRequeries() { @@ -616,10 +637,11 @@ public class SiteCallsReportPageTests : BunitContext }); } + /// <summary>Verifies that a Deployment user scoped to one site does not see rows from out-of-scope sites.</summary> [Fact] public void SiteScoping_ScopedDeploymentUser_HidesOutOfScopeRows() { - // CentralUI-028: a Deployment user scoped to Plant A only must not see + // A Deployment user scoped to Plant A only must not see // Plant B rows in the grid, even though the query response carried both. // Last AuthenticationStateProvider registration wins on resolution. var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] @@ -647,6 +669,7 @@ public class SiteCallsReportPageTests : BunitContext // section is collapsed on init; expanding it lazy-loads the series. // ───────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that the trends section container always renders.</summary> [Fact] public void TrendsSection_Container_AlwaysRenders() { @@ -658,6 +681,7 @@ public class SiteCallsReportPageTests : BunitContext Assert.NotNull(cut.Find("[data-test='site-calls-trends']"))); } + /// <summary>Verifies that an expanded trends section renders charts from the known series.</summary> [Fact] public void TrendsSection_WhenExpanded_RendersChartsFromKnownSeries() { @@ -680,6 +704,7 @@ public class SiteCallsReportPageTests : BunitContext }); } + /// <summary>Verifies that toggling the trends window reloads the series.</summary> [Fact] public void TrendsSection_WindowToggle_ReloadsSeries() { @@ -700,10 +725,11 @@ public class SiteCallsReportPageTests : BunitContext Assert.True(_kpiHistory.ReceivedCalls().Count() > afterExpand)); } + /// <summary>Verifies that the long-range trend windows render and reload the series.</summary> [Fact] public void TrendsSection_LongRangeWindows_RenderAndReloadSeries() { - // The 30d/90d buttons (plan #22 T7) render alongside 24h/7d and clicking + // The 30d/90d buttons render alongside 24h/7d and clicking // one re-loads the series — the query service then routes the long window // to hourly rollups transparently. var cut = Render<SiteCallsReportPage>(); @@ -725,6 +751,7 @@ public class SiteCallsReportPageTests : BunitContext Assert.True(_kpiHistory.ReceivedCalls().Count() > afterExpand)); } + /// <summary>Verifies that the page still renders when the KPI history query throws.</summary> [Fact] public void TrendsSection_WhenKpiHistoryThrows_PageStillRenders() { @@ -751,6 +778,7 @@ public class SiteCallsReportPageTests : BunitContext }); } + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) @@ -766,6 +794,8 @@ public class SiteCallsReportPageTests : BunitContext /// </summary> private sealed class ScriptedSiteCallAuditActor : ReceiveActor { + /// <summary>Creates the stand-in actor, wiring each request type to the owning test's recorded requests and scripted replies.</summary> + /// <param name="test">The owning test whose recorded requests and scripted replies the actor uses.</param> public ScriptedSiteCallAuditActor(SiteCallsReportPageTests test) { Receive<SiteCallQueryRequest>(r => @@ -794,19 +824,28 @@ public class SiteCallsReportPageTests : BunitContext /// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary> private sealed class AlwaysConfirmDialogService : IDialogService { + /// <inheritdoc /> public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); // Register row 75 added a DialogSeverity overload. This stub auto-confirms // regardless of severity — it exists to let action paths run end to end, not to // assert anything about styling. + /// <inheritdoc /> public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null) => Task.FromResult(true); + /// <inheritdoc /> public Task<string?> PromptAsync( string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult<string?>(null); + /// <summary>Returns a null result without rendering the dialog.</summary> + /// <typeparam name="TResult">The dialog result type.</typeparam> + /// <param name="title">The dialog title, ignored.</param> + /// <param name="body">The dialog body fragment, ignored.</param> + /// <param name="size">The dialog size, ignored.</param> + /// <returns>A task that completes with a null result.</returns> public Task<TResult?> ShowAsync<TResult>( string title, Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body, diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmsConfigurationPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmsConfigurationPageTests.cs index 77fb5efb..4c980c45 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmsConfigurationPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmsConfigurationPageTests.cs @@ -52,6 +52,7 @@ public class SmsConfigurationPageTests : BunitContext return repo; } + /// <summary>Verifies that the read-only view shows the config row but never renders the auth token value.</summary> [Fact] public void ReadOnlyView_ShowsConfigRow_ButNeverRendersAuthTokenValue() { @@ -74,6 +75,7 @@ public class SmsConfigurationPageTests : BunitContext }); } + /// <summary>Verifies that the edit form does not prefill the auth token but does prefill the messaging service SID.</summary> [Fact] public void EditForm_DoesNotPrefillAuthToken_ButPrefillsMessagingServiceSid() { @@ -96,6 +98,7 @@ public class SmsConfigurationPageTests : BunitContext }); } + /// <summary>Verifies that saving a new configuration calls Add and SaveChanges.</summary> [Fact] public void SavingNewConfig_CallsAddAndSaveChanges() { @@ -127,10 +130,11 @@ public class SmsConfigurationPageTests : BunitContext }); } + /// <summary>Verifies that a new configuration with only a messaging service SID and no from number saves successfully.</summary> [Fact] public void SavingNewConfig_MessagingServiceSidOnly_NoFromNumber_Saves() { - // UI-Med-2: a Twilio Messaging-Service-only config is valid with no From number. + // A Twilio Messaging-Service-only config is valid with no From number. // The either-or validation must accept it and persist a null FromNumber. var repo = RepoWith(); Services.AddSingleton(repo); @@ -160,6 +164,7 @@ public class SmsConfigurationPageTests : BunitContext }); } + /// <summary>Verifies that saving an edit with a blank auth token preserves the existing token.</summary> [Fact] public void SavingEdit_WithBlankAuthToken_PreservesExistingToken() { @@ -185,6 +190,7 @@ public class SmsConfigurationPageTests : BunitContext }); } + /// <summary>Verifies that saving an edit with a new auth token overwrites the token.</summary> [Fact] public void SavingEdit_WithNewAuthToken_OverwritesToken() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs index 50005387..72c8d291 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs @@ -89,6 +89,7 @@ public class SmtpConfigurationPageTests : BunitContext => cut.FindAll("select") .Any(s => s.QuerySelectorAll("option").Any(o => o.TextContent == optionText)); + /// <summary>Verifies that the edit form renders the TLS mode select with all three modes.</summary> [Fact] public void EditForm_RendersTlsModeSelectWithAllThreeModes() { @@ -114,6 +115,7 @@ public class SmtpConfigurationPageTests : BunitContext }); } + /// <summary>Verifies that the read-only view shows the TLS mode.</summary> [Fact] public void ReadOnlyView_ShowsTlsMode() { @@ -133,6 +135,7 @@ public class SmtpConfigurationPageTests : BunitContext }); } + /// <summary>Verifies that saving an edit persists the chosen TLS mode.</summary> [Fact] public void SavingEdit_PersistsChosenTlsMode() { @@ -168,6 +171,7 @@ public class SmtpConfigurationPageTests : BunitContext // Transport selector (EWS) — form state machine // ======================================================================== + /// <summary>Verifies that the edit form with the EWS transport hides the port, TLS and auth type and forces basic auth.</summary> [Fact] public void EditForm_WithEwsTransport_HidesPortTlsAndAuthTypeAndForcesBasicAuth() { @@ -196,6 +200,7 @@ public class SmtpConfigurationPageTests : BunitContext && c.Host == "https://mail.example.com/ews/exchange.asmx"))); } + /// <summary>Verifies that switching the transport from EWS back to SMTP restores the auth type and shows the port and TLS fields again.</summary> [Fact] public void EditForm_SwitchingEwsBackToSmtp_RestoresAuthTypeAndReshowsPortAndTls() { @@ -222,6 +227,7 @@ public class SmtpConfigurationPageTests : BunitContext && c.TlsMode == "StartTLS"))); } + /// <summary>Verifies that saving with the EWS transport and a non-HTTPS host shows a form error and does not persist.</summary> [Fact] public void Save_WithEwsTransportAndNonHttpsHost_ShowsFormErrorAndDoesNotPersist() { @@ -238,6 +244,7 @@ public class SmtpConfigurationPageTests : BunitContext repo.DidNotReceive().SaveChangesAsync(); } + /// <summary>Verifies that saving with the EWS transport and credentials without a colon shows a form error and does not persist.</summary> [Fact] public void Save_WithEwsTransportAndCredentialsWithoutColon_ShowsFormErrorAndDoesNotPersist() { @@ -257,6 +264,7 @@ public class SmtpConfigurationPageTests : BunitContext repo.DidNotReceive().SaveChangesAsync(); } + /// <summary>Verifies that saving with the EWS transport and domain-qualified credentials persists.</summary> [Fact] public void Save_WithEwsTransportAndDomainQualifiedCredentials_Persists() { @@ -279,6 +287,7 @@ public class SmtpConfigurationPageTests : BunitContext }); } + /// <summary>Verifies that the read-only view shows the transport badge and hides the port and TLS for EWS rows.</summary> [Fact] public void ReadOnlyView_ShowsTransportBadgeAndHidesPortAndTlsForEwsRows() { @@ -294,6 +303,7 @@ public class SmtpConfigurationPageTests : BunitContext }); } + /// <summary>Verifies that a legacy row with a null transport shows the SMTP badge and keeps the port and TLS fields.</summary> [Fact] public void ReadOnlyView_LegacyRowWithNullTransport_ShowsSmtpBadgeAndKeepsPortAndTls() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/PollGateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/PollGateTests.cs index 9b251f70..cc905683 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/PollGateTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/PollGateTests.cs @@ -5,6 +5,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests; public class PollGateTests { + /// <summary>Verifies that a rejected renderer dispatch releases the gate and reports the fault.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RejectedRendererDispatch_ReleasesGateAndObservesFault() { @@ -15,6 +17,7 @@ public class PollGateTests Assert.True(gate.TryEnter()); } + /// <summary>Verifies that a second entry attempt while the gate is held returns false.</summary> [Fact] public void TryEnter_SecondCallWhileHeld_ReturnsFalse() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/BoundedSandboxOutputTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/BoundedSandboxOutputTests.cs index 5dd11fc4..f94b32f5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/BoundedSandboxOutputTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/BoundedSandboxOutputTests.cs @@ -4,6 +4,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis; [Collection("Sandbox worker processes")] public class BoundedSandboxOutputTests { + /// <summary>Verifies that oversized console output and return values are explicitly truncated.</summary> + /// <param name="returnExpression">The script expression whose value is returned.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("block")] [InlineData("new { Data = block }")] @@ -21,6 +24,7 @@ public class BoundedSandboxOutputTests Assert.True(System.Text.Encoding.UTF8.GetByteCount(result.ReturnValueJson!) < 32000); } + /// <summary>Verifies that concurrent large writes retain only the configured character budget.</summary> [Fact] public void ConcurrentRepeatedLargeWrites_RetainOnlyBudget() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/JsonSchemaShapeParserTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/JsonSchemaShapeParserTests.cs index 6904652e..04687d89 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/JsonSchemaShapeParserTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/JsonSchemaShapeParserTests.cs @@ -6,6 +6,7 @@ public class JsonSchemaShapeParserTests { // ── JSON Schema (post-migration) ───────────────────────────────────────── + /// <summary>Verifies that a JSON schema parses into scalar parameters with their required flags.</summary> [Fact] public void Parameters_JsonSchema_ScalarsAndRequired() { @@ -24,6 +25,7 @@ public class JsonSchemaShapeParserTests p => { Assert.Equal("active", p.Name); Assert.Equal("Boolean", p.Type); Assert.True(p.Required); }); } + /// <summary>Verifies that a JSON schema array of strings becomes a list of strings.</summary> [Fact] public void Parameters_JsonSchema_ArrayOfStringsBecomesListString() { @@ -40,12 +42,14 @@ public class JsonSchemaShapeParserTests Assert.False(tags.Required); } + /// <summary>Verifies that a JSON schema number return type parses as a number.</summary> [Fact] public void Return_JsonSchema_Number() { Assert.Equal("Float", JsonSchemaShapeParser.ParseReturnType(@"{""type"":""number""}")); } + /// <summary>Verifies that a JSON schema array of integers return type parses as a list of integers.</summary> [Fact] public void Return_JsonSchema_ArrayOfIntegers() { @@ -55,6 +59,7 @@ public class JsonSchemaShapeParserTests // ── Legacy flat shape (pre-migration safety net) ───────────────────────── + /// <summary>Verifies that the legacy flat array parameter format still parses.</summary> [Fact] public void Parameters_Legacy_FlatArrayStillParses() { @@ -66,6 +71,7 @@ public class JsonSchemaShapeParserTests p => { Assert.Equal("y", p.Name); Assert.Equal("String", p.Type); Assert.False(p.Required); }); } + /// <summary>Verifies that the legacy list sentinel return format still parses.</summary> [Fact] public void Return_Legacy_ListSentinelStillParses() { @@ -75,6 +81,7 @@ public class JsonSchemaShapeParserTests // ── Edge cases ──────────────────────────────────────────────────────────── + /// <summary>Verifies that null parameters yield an empty list.</summary> [Fact] public void Parameters_Null_ReturnsEmpty() { @@ -83,6 +90,7 @@ public class JsonSchemaShapeParserTests Assert.Empty(JsonSchemaShapeParser.ParseParameters(" ")); } + /// <summary>Verifies that malformed parameters yield an empty list.</summary> [Fact] public void Parameters_Malformed_ReturnsEmpty() { @@ -90,6 +98,7 @@ public class JsonSchemaShapeParserTests Assert.Empty(JsonSchemaShapeParser.ParseParameters("42")); } + /// <summary>Verifies that a null return definition yields null.</summary> [Fact] public void Return_Null_ReturnsNull() { @@ -97,6 +106,7 @@ public class JsonSchemaShapeParserTests Assert.Null(JsonSchemaShapeParser.ParseReturnType("")); } + /// <summary>Verifies that a schema with no properties yields an empty parameter list.</summary> [Fact] public void Parameters_SchemaWithNoProperties_ReturnsEmpty() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxConsoleCaptureTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxConsoleCaptureTests.cs index 5cb3108c..aac551d7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxConsoleCaptureTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxConsoleCaptureTests.cs @@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis; /// <summary> /// Regression tests for the <c>SandboxConsoleCapture</c> writer that the Test Run -/// sandbox installs on <c>Console.Out</c>/<c>Console.Error</c>. CentralUI-030 +/// sandbox installs on <c>Console.Out</c>/<c>Console.Error</c>. A past defect /// surfaced an intra-script concurrency hazard: a sandboxed script can fan out /// work with <c>Task.WhenAll</c> / <c>Task.Run</c> and every child task inherits /// the capture <c>StringWriter</c> via <c>AsyncLocal</c>; <c>StringWriter</c> is @@ -14,10 +14,11 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis; public class SandboxConsoleCaptureTests { /// <summary> - /// CentralUI-030: a capture scope shared across <c>Task.WhenAll</c> child + /// A capture scope shared across <c>Task.WhenAll</c> child /// tasks must serialise writes so the resulting transcript contains exactly /// the expected number of lines without character-level interleaving. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BeginCapture_ConcurrentWritesFromTasks_DoNotCorruptBuffer() { @@ -66,7 +67,7 @@ public class SandboxConsoleCaptureTests /// <summary> /// Sanity check: the most basic capture happy-path still works after the - /// CentralUI-030 lock was introduced. + /// concurrency lock was introduced. /// </summary> [Fact] public void BeginCapture_SingleThreadedWrites_AreCaptured() diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxWaitForAttributeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxWaitForAttributeTests.cs index 8dabb959..c4112fb7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxWaitForAttributeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxWaitForAttributeTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis; /// <summary> -/// Deferred-work #14 (full fidelity): the Test-Run sandbox routes value-equality +/// Full-fidelity wait support: the Test-Run sandbox routes value-equality /// <c>WaitAsync</c>/<c>WaitForAsync</c> to the bound instance via /// <see cref="ISandboxInstanceGateway"/>; predicate overloads and unbound runs still /// throw a clearly-labelled <see cref="ScriptSandboxException"/>. @@ -15,6 +15,8 @@ public class SandboxWaitForAttributeTests private static SandboxAttributeAccessor BoundAccessor(ISandboxInstanceGateway gateway) => new(new SandboxInstanceContext(gateway), prefix: string.Empty); + /// <summary>Verifies that wait async, value form, routes through gateway, and returns matched.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitAsync_ValueForm_RoutesThroughGateway_AndReturnsMatched() { @@ -31,6 +33,8 @@ public class SandboxWaitForAttributeTests "Flag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(5), false, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that wait for async, value form, passes require good quality, and returns full result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAsync_ValueForm_PassesRequireGoodQuality_AndReturnsFullResult() { @@ -48,6 +52,8 @@ public class SandboxWaitForAttributeTests "Done", AttributeValueCodec.Encode(7), TimeSpan.FromSeconds(3), true, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that wait async, predicate form, throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitAsync_PredicateForm_Throws() { @@ -58,6 +64,8 @@ public class SandboxWaitForAttributeTests Assert.Contains("predicate", ex.Message, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that wait for async, predicate form, throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAsync_PredicateForm_Throws() { @@ -67,6 +75,8 @@ public class SandboxWaitForAttributeTests () => accessor.WaitForAsync("Done", v => v != null, TimeSpan.FromSeconds(1))); } + /// <summary>Verifies that wait async, value form, unbound instance, throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitAsync_ValueForm_UnboundInstance_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxWorkerIsolationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxWorkerIsolationTests.cs index b2089540..e5acde57 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxWorkerIsolationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/SandboxWorkerIsolationTests.cs @@ -10,6 +10,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis; [Collection("Sandbox worker processes")] public class SandboxWorkerIsolationTests { + /// <summary>Verifies that four busy workers reject a queued fifth request without starting another process.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FourBusyWorkers_RejectQueuedFifthWithoutStartingAnotherProcess() { @@ -36,6 +38,8 @@ public class SandboxWorkerIsolationTests foreach (var result in await Task.WhenAll(workers)) Assert.True(result.Success, result.Error); } + /// <summary>Verifies that an uncooperative broker still has its child killed at the deadline and keeps the lifetime until the broker completes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UncooperativeBroker_StillKillsChildAtDeadline_AndKeepsLifetimeUntilBrokerCompletes() { @@ -63,6 +67,8 @@ public class SandboxWorkerIsolationTests child?.Dispose(); } + /// <summary>Verifies that a non-cooperative worker is terminated and its capacity can be reused.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoncooperativeWorker_IsTerminated_AndCapacityCanBeReused() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisAsyncResolveTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisAsyncResolveTests.cs index 5eb26f19..153c474b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisAsyncResolveTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisAsyncResolveTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis; /// <summary> -/// Regression tests for CentralUI-013. <c>ResolveCalledShape</c> resolved shared +/// Regression tests for sync-over-async shared-script resolution. <c>ResolveCalledShape</c> resolved shared /// script shapes with <c>_sharedScripts.GetShapesAsync().GetAwaiter().GetResult()</c> /// — a sync-over-async block on a request thread that risks thread-pool /// starvation and deadlock. <c>Hover</c> and <c>SignatureHelp</c> were synchronous @@ -20,6 +20,7 @@ public class ScriptAnalysisAsyncResolveTests private readonly IServiceProvider _services = Substitute.For<IServiceProvider>(); private readonly ScriptAnalysisService _svc; + /// <summary>Initializes the test class and its script analysis service.</summary> public ScriptAnalysisAsyncResolveTests() { _catalog.GetShapesAsync().Returns(Array.Empty<ScriptShape>()); @@ -29,6 +30,8 @@ public class ScriptAnalysisAsyncResolveTests private static ScriptShape Shape(string name, params ParameterShape[] ps) => new(name, ps, null); private static ParameterShape Param(string name, string type) => new(name, type, true); + /// <summary>Verifies that hover on a shared-call name awaits the catalog and resolves the call shape.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HoverAsync_OnSharedCallName_AwaitsCatalog_AndResolvesShape() { @@ -53,6 +56,8 @@ public class ScriptAnalysisAsyncResolveTests Assert.Contains("Aggregate", resp.Markdown); } + /// <summary>Verifies that signature help inside a shared call awaits the catalog and resolves the parameters.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SignatureHelpAsync_InsideSharedCall_AwaitsCatalog_AndResolvesParameters() { @@ -75,6 +80,7 @@ public class ScriptAnalysisAsyncResolveTests Assert.Equal(2, resp.Parameters!.Count); } + /// <summary>Verifies that hover and signature help are asynchronous rather than sync-over-async.</summary> [Fact] public void HoverAndSignatureHelp_AreAsync_NotSyncOverAsync() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs index 7d3eb8e3..2b9b9b1c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs @@ -18,6 +18,7 @@ public class ScriptAnalysisServiceTests private static ParameterShape Param(string name, string type = "String", bool required = true) => new(name, type, required); + /// <summary>Initializes the service under test with a substitute shared-script catalog.</summary> public ScriptAnalysisServiceTests() { _catalog.GetShapesAsync().Returns(Array.Empty<ScriptShape>()); @@ -26,6 +27,7 @@ public class ScriptAnalysisServiceTests // ── Diagnose ────────────────────────────────────────────────────────── + /// <summary>Verifies that empty code produces no diagnostic markers.</summary> [Fact] public void EmptyCode_NoMarkers() { @@ -33,6 +35,7 @@ public class ScriptAnalysisServiceTests Assert.Empty(resp.Markers); } + /// <summary>Verifies that a clean script produces no diagnostic markers.</summary> [Fact] public void CleanScript_NoMarkers() { @@ -40,6 +43,7 @@ public class ScriptAnalysisServiceTests Assert.Empty(resp.Markers); } + /// <summary>Verifies that a missing semicolon is reported as a Roslyn diagnostic.</summary> [Fact] public void MissingSemicolon_ReportsRoslynDiagnostic() { @@ -47,6 +51,7 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Markers, m => m.Code.StartsWith("CS")); } + /// <summary>Verifies that a forbidden using directive raises diagnostic SCADA001.</summary> [Fact] public void ForbiddenUsingDirective_RaisesSCADA001() { @@ -54,19 +59,22 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains("System.IO")); } + /// <summary>Verifies that every banned namespace raises a forbidden-using diagnostic.</summary> + /// <param name="ns">The banned namespace to import.</param> [Theory] [InlineData("System.Reflection")] [InlineData("System.Net")] [InlineData("System.Threading")] public void ForbiddenUsing_AllBannedNamespaces(string ns) { - // M3.5: the editor deny-list is sourced from the shared ScriptTrustPolicy. + // The editor deny-list is sourced from the shared ScriptTrustPolicy. // System.Threading is now a forbidden root (only Tasks + // CancellationToken(Source) are excepted). var resp = _svc.Diagnose(new DiagnoseRequest($"using {ns};")); Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains(ns)); } + /// <summary>Verifies that forbidden type usage is detected through the semantic model.</summary> [Fact] public void ForbiddenTypeUsage_ResolvesViaSemanticModel() { @@ -75,16 +83,18 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("File")); } + /// <summary>Verifies that the System.Diagnostics namespace is no longer forbidden wholesale.</summary> [Fact] public void SystemDiagnosticsNamespace_NoLongerWholesaleForbidden() { - // M3.5: the unified policy forbids only System.Diagnostics.Process, not the + // The unified policy forbids only System.Diagnostics.Process, not the // whole System.Diagnostics namespace — so a bare `using System.Diagnostics;` // is no longer flagged (Stopwatch & friends are allowed). var resp = _svc.Diagnose(new DiagnoseRequest("using System.Diagnostics;")); Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA001"); } + /// <summary>Verifies that Stopwatch usage is allowed under the unified trust policy.</summary> [Fact] public void StopwatchUsage_IsAllowedUnderUnifiedPolicy() { @@ -95,6 +105,7 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code is "SCADA001" or "SCADA002"); } + /// <summary>Verifies that Process usage is forbidden under the unified trust policy.</summary> [Fact] public void ProcessUsage_IsForbiddenUnderUnifiedPolicy() { @@ -105,6 +116,8 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("Process")); } + /// <summary>Verifies that threading primitives are flagged under the unified trust policy.</summary> + /// <param name="typeName">The threading type used in the script.</param> [Theory] [InlineData("Monitor")] [InlineData("Interlocked")] @@ -120,6 +133,7 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Markers, m => m.Code is "SCADA001" or "SCADA002"); } + /// <summary>Verifies that CancellationToken usage stays allowed under the unified trust policy.</summary> [Fact] public void CancellationToken_StaysAllowed_UnderUnifiedPolicy() { @@ -130,6 +144,7 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code is "SCADA001" or "SCADA002"); } + /// <summary>Verifies that a user identifier named File does not trigger a false positive.</summary> [Fact] public void UserIdentifierNamedFile_DoesNotFalsePositive() { @@ -138,6 +153,7 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002"); } + /// <summary>Verifies that a user identifier named Thread does not trigger a false positive.</summary> [Fact] public void UserIdentifierNamedThread_DoesNotFalsePositive() { @@ -146,6 +162,7 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002"); } + /// <summary>Verifies that diagnostics are cached so a second call skips recompilation.</summary> [Fact] public void DiagnosticsAreCached_SecondCallSkipsRecompile() { @@ -155,6 +172,7 @@ public class ScriptAnalysisServiceTests Assert.Same(first, second); } + /// <summary>Verifies that different code gets different cache entries.</summary> [Fact] public void DifferentCode_GetsDifferentCacheEntries() { @@ -163,6 +181,7 @@ public class ScriptAnalysisServiceTests Assert.NotSame(a, b); } + /// <summary>Verifies that an unknown parameter key raises diagnostic SCADA003.</summary> [Fact] public void UnknownParameterKey_RaisesSCADA003() { @@ -172,6 +191,7 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Markers, m => m.Code == "SCADA003" && m.Message.Contains("'typo'")); } + /// <summary>Verifies that a declared parameter key produces no marker.</summary> [Fact] public void DeclaredParameterKey_NoMarker() { @@ -183,6 +203,8 @@ public class ScriptAnalysisServiceTests // ── Completions ─────────────────────────────────────────────────────── + /// <summary>Verifies that completion inside a Parameters string literal returns the declared parameter names.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ParametersStringLiteral_ReturnsDeclaredParameterNames() { @@ -198,6 +220,8 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Items, i => i.Label == "temperature"); } + /// <summary>Verifies that completion inside a CallScript string literal returns sibling script names with a snippet.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallScriptStringLiteral_ReturnsSiblingNamesWithSnippet() { @@ -218,6 +242,8 @@ public class ScriptAnalysisServiceTests Assert.Contains("sibling script", item.Detail); } + /// <summary>Verifies that completion inside a CallShared string literal resolves shared scripts through the catalog with their shapes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallSharedStringLiteral_ResolvesViaCatalogWithShapes() { @@ -243,6 +269,8 @@ public class ScriptAnalysisServiceTests Assert.Contains("shared script", greet.Detail); } + /// <summary>Verifies that general completion returns the in-scope symbols.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GeneralCompletion_ReturnsInScopeSymbols() { @@ -258,6 +286,8 @@ public class ScriptAnalysisServiceTests // ── Hover ───────────────────────────────────────────────────────────── + /// <summary>Verifies that hovering over a sibling script name returns its signature.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Hover_OnSiblingName_ReturnsSignature() { @@ -273,6 +303,8 @@ public class ScriptAnalysisServiceTests Assert.Contains("Calc(x: Integer, y: Float): void", resp.Markdown); } + /// <summary>Verifies that hovering over an unrelated token returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Hover_OnUnrelatedToken_ReturnsNull() { @@ -285,6 +317,8 @@ public class ScriptAnalysisServiceTests // ── Signature help ──────────────────────────────────────────────────── + /// <summary>Verifies that signature help inside a CallScript call returns the parameter strip.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SignatureHelp_InsideCallScript_ReturnsParameterStrip() { @@ -301,6 +335,8 @@ public class ScriptAnalysisServiceTests Assert.Equal(1, resp.ActiveParameter); } + /// <summary>Verifies that signature help outside a call returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SignatureHelp_OutsideCall_ReturnsNull() { @@ -313,6 +349,7 @@ public class ScriptAnalysisServiceTests // ── Format ──────────────────────────────────────────────────────────── + /// <summary>Verifies that formatting scrambled code returns pretty-printed code.</summary> [Fact] public void Format_ScrambledCode_ReturnsPrettyPrinted() { @@ -322,6 +359,7 @@ public class ScriptAnalysisServiceTests Assert.NotEqual("if(x){return 1;}else{return 2;}", resp.Code); } + /// <summary>Verifies that formatting empty code returns an empty result.</summary> [Fact] public void Format_EmptyCode_ReturnsEmpty() { @@ -334,6 +372,8 @@ public class ScriptAnalysisServiceTests private static CompositionContext Comp(string name, AttributeShape[]? attrs = null, ScriptShape[]? scripts = null) => new(name, attrs ?? Array.Empty<AttributeShape>(), scripts ?? Array.Empty<ScriptShape>()); + /// <summary>Verifies that completion inside a self attribute literal returns the self attribute names.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SelfAttribute_Literal_ReturnsSelfAttributeNames() { @@ -347,6 +387,8 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Items, i => i.Label == "Setpoint" && i.Detail.Contains("Float")); } + /// <summary>Verifies that completion inside a child attribute literal returns the child attribute names.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ChildAttribute_Literal_ReturnsChildAttributeNames() { @@ -360,6 +402,8 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Items, i => i.Label == "Humidity"); } + /// <summary>Verifies that completion inside a parent attribute literal returns the parent attribute names.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ParentAttribute_Literal_ReturnsParentAttributeNames() { @@ -372,6 +416,8 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Items, i => i.Label == "SpeedRPM"); } + /// <summary>Verifies that completion inside a children literal returns the composition names.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ChildrenLiteral_ReturnsCompositionNames() { @@ -385,6 +431,7 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Items, i => i.Label == "PressureSensor"); } + /// <summary>Verifies that an unknown self attribute raises diagnostic SCADA006.</summary> [Fact] public void UnknownSelfAttribute_RaisesSCADA006() { @@ -394,6 +441,7 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Markers, m => m.Code == "SCADA006" && m.Message.Contains("Typo")); } + /// <summary>Verifies that a known self attribute produces no marker.</summary> [Fact] public void KnownSelfAttribute_NoMarker() { @@ -403,6 +451,7 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA006"); } + /// <summary>Verifies that an unknown child attribute raises diagnostic SCADA006.</summary> [Fact] public void UnknownChildAttribute_RaisesSCADA006() { @@ -412,6 +461,7 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Markers, m => m.Code == "SCADA006" && m.Message.Contains("TempSensor")); } + /// <summary>Verifies that an unknown composition raises diagnostic SCADA007.</summary> [Fact] public void UnknownComposition_RaisesSCADA007() { @@ -421,6 +471,8 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Markers, m => m.Code == "SCADA007" && m.Message.Contains("Unknown")); } + /// <summary>Verifies that completion inside a children CallScript call returns the child scripts.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ChildrenCallScript_ReturnsChildScripts() { @@ -438,6 +490,8 @@ public class ScriptAnalysisServiceTests Assert.Contains("${1:count}", sample.InsertText); } + /// <summary>Verifies that completion inside a parent CallScript call returns the parent scripts.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ParentCallScript_ReturnsParentScripts() { @@ -452,6 +506,8 @@ public class ScriptAnalysisServiceTests // ── Hover on Parameters["name"] ─────────────────────────────────────── + /// <summary>Verifies that hovering over a Parameters key shows the declared type.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Hover_OnParametersKey_ShowsDeclaredType() { @@ -465,8 +521,9 @@ public class ScriptAnalysisServiceTests Assert.Contains("String", resp.Markdown); } - // ── CentralUI-001: trust-model gate before sandbox execution ────────── + // ── Trust-model gate before sandbox execution ────────── + /// <summary>Verifies that a fully qualified forbidden call raises diagnostic SCADA002.</summary> [Fact] public void Diagnose_FullyQualifiedForbiddenCall_RaisesSCADA002() { @@ -478,10 +535,12 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Markers, m => m.Code == "SCADA002"); } + /// <summary>Verifies that a fully qualified forbidden API is blocked before the sandbox executes the script.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunInSandbox_FullyQualifiedForbiddenApi_IsBlockedBeforeExecution() { - // Regression test for CentralUI-001. RunInSandboxAsync used to execute any + // Regression test: RunInSandboxAsync used to execute any // script that compiled, with no trust-model enforcement — so fully-qualified // forbidden API code ran in the central host process. The fix gates execution // on the forbidden-API analysis. @@ -499,6 +558,8 @@ public class ScriptAnalysisServiceTests Assert.Contains(result.Markers!, m => m.Code is "SCADA001" or "SCADA002"); } + /// <summary>Verifies that a forbidden using directive is blocked before the sandbox executes the script.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunInSandbox_ForbiddenUsingDirective_IsBlockedBeforeExecution() { @@ -513,6 +574,8 @@ public class ScriptAnalysisServiceTests Assert.Equal(SandboxErrorKind.CompileError, result.ErrorKind); } + /// <summary>Verifies that a clean script still runs in the sandbox.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunInSandbox_CleanScript_StillRuns() { @@ -525,6 +588,7 @@ public class ScriptAnalysisServiceTests Assert.Equal("42", result.ReturnValueJson); } + /// <summary>Verifies that a script using the notification outbox shape diagnoses clean.</summary> [Fact] public void NotifyOutboxShape_DiagnosesClean() { @@ -542,6 +606,8 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); } + /// <summary>Verifies that a script using the notification outbox shape still runs in the sandbox.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunInSandbox_NotifyOutboxShape_StillRuns() { @@ -561,6 +627,8 @@ public class ScriptAnalysisServiceTests Assert.Equal("\"Unknown\"", result.ReturnValueJson); } + /// <summary>Verifies that the sandbox captures console output.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunInSandbox_CapturesConsoleOutput() { @@ -575,6 +643,8 @@ public class ScriptAnalysisServiceTests Assert.Contains("hello-sandbox", result.ConsoleOutput); } + /// <summary>Verifies that the Log global writes to the sandbox console output.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunInSandbox_LogGlobal_WritesToConsoleOutput() { @@ -592,6 +662,8 @@ public class ScriptAnalysisServiceTests Assert.Contains("[info] hello", result.ConsoleOutput); } + /// <summary>Verifies that the Log global in an inbound script writes to the sandbox console output.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunInSandbox_InboundScript_LogGlobal_WritesToConsoleOutput() { @@ -609,10 +681,12 @@ public class ScriptAnalysisServiceTests Assert.Contains("[info] hello", result.ConsoleOutput); } + /// <summary>Verifies that concurrent sandbox runs do not cross-contaminate each other's console output.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunInSandbox_ConcurrentRuns_DoNotCrossContaminateConsoleOutput() { - // Regression test for CentralUI-003. RunInSandboxAsync used to redirect the + // Regression test: RunInSandboxAsync used to redirect the // process-global Console.Out/Error to a per-call StringWriter. While one run // is mid-flight, any concurrent run's `finally` restores Console.Out to the // ORIGINAL writer — so the long run loses every Console.WriteLine it makes @@ -682,6 +756,7 @@ public class ScriptAnalysisServiceTests // ── Inbound-script analysis surface ────────────────────────────────── + /// <summary>Verifies that an inbound script using WaitForAttribute diagnoses clean.</summary> [Fact] public void InboundScript_WaitForAttribute_DiagnosesClean() { @@ -696,6 +771,7 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); } + /// <summary>Verifies that an inbound script using Log diagnoses clean.</summary> [Fact] public void InboundScript_Log_DiagnosesClean() { @@ -714,6 +790,7 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); } + /// <summary>Verifies that a site script using Log diagnoses clean.</summary> [Fact] public void SiteScript_Log_DiagnosesClean() { @@ -730,10 +807,11 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); } + /// <summary>Verifies that an inbound script using the Database helper diagnoses clean.</summary> [Fact] public void InboundScript_Database_DiagnosesClean() { - // InboundAPI-026/030: the scoped Database helper is a shipped inbound-script + // The scoped Database helper is a shipped inbound-script // capability. The editor must not flag its async read/write API as an error — // the InboundScriptHost mirror must expose QuerySingleAsync/QueryAsync/ExecuteAsync // with signatures matching the runtime helper so Roslyn resolves them. @@ -747,16 +825,17 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); } - // ── Instance-script batch/wait surface (follow-up #7) ───────────────── + // ── Instance-script batch/wait surface ───────────────── + /// <summary>Verifies that an instance script using the batch-write and wait helpers diagnoses clean.</summary> [Fact] public void InstanceScript_BatchAndWaitHelpers_DiagnoseClean() { - // Follow-up #7: SandboxAttributeAccessor was missing WriteBatchAndWaitAsync / + // SandboxAttributeAccessor was missing WriteBatchAndWaitAsync / // WaitAsync / WaitForAsync, so the editor false-flagged valid instance scripts // with CS1061 even though `template validate` and the deploy gate accept them. // The sandbox surface must mirror the runtime AttributeAccessor member-for-member. - // Gitea #36: also covers the reset-aware WriteBatchAndWaitAsync overload + // Also covers the reset-aware WriteBatchAndWaitAsync overload // (writes+waits a reset key/value before the batch) with and without the // optional resetTimeout, returning a HandshakeResult. var code = @@ -774,6 +853,7 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); } + /// <summary>Verifies that a child instance script using WriteBatchAndWait diagnoses clean.</summary> [Fact] public void ChildInstanceScript_WriteBatchAndWait_DiagnoseClean() { @@ -796,6 +876,7 @@ public class ScriptAnalysisServiceTests // ── Alarms read surface (MES alarm-status API §5.2) ─────────────────── + /// <summary>Verifies that an instance script using Alarms.CurrentAsync diagnoses clean.</summary> [Fact] public void InstanceScript_AlarmsCurrentAsync_DiagnoseClean() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs index 56dd6f6d..4c76d530 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// <summary> -/// Unit tests for <see cref="AlarmSummaryService"/> (M7 T13 — Operator Alarm +/// Unit tests for <see cref="AlarmSummaryService"/> (Operator Alarm /// Summary). The snapshot client and instance repository are substituted so the /// fan-out + best-effort degradation + roll-up math are exercised without any /// cluster traffic. @@ -56,6 +56,8 @@ public class AlarmSummaryServiceTests private static DebugViewSnapshot Snapshot(string instance, params AlarmStateChanged[] alarms) => new(instance, Array.Empty<AttributeValueChanged>(), alarms, Now); + /// <summary>Verifies that site alarms are aggregated from reporting instances and the failed instance is recorded.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSiteAlarmsAsync_AggregatesReportingInstances_AndRecordsTheFailedOne() { @@ -104,6 +106,8 @@ public class AlarmSummaryServiceTests .GetSnapshotAsync(SiteIdentifier, "inst-e", Arg.Any<CancellationToken>()); } + /// <summary>Verifies that an instance-not-found snapshot goes to the not-reporting list.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSiteAlarmsAsync_InstanceNotFoundSnapshot_GoesToNotReporting() { @@ -122,6 +126,7 @@ public class AlarmSummaryServiceTests Assert.Equal(new[] { "ghost" }, result.NotReportingInstances); } + /// <summary>Verifies that the rollup computes the worst severity, the active and unacknowledged counts and the kind counts.</summary> [Fact] public void ComputeRollup_ComputesWorstSeverityActiveAndUnackedAndKindCounts() { @@ -153,6 +158,7 @@ public class AlarmSummaryServiceTests Assert.Equal(2, rollup.CountsByKind[AlarmKind.Computed]); } + /// <summary>Verifies that a rollup with no active rows has a worst severity of zero.</summary> [Fact] public void ComputeRollup_NoActiveRows_WorstSeverityZero() { @@ -168,8 +174,9 @@ public class AlarmSummaryServiceTests Assert.Equal(0, rollup.UnackedCount); } - // ── BuildFromLiveAlarms (plan #10, Task 5) ──────────────────────────────── + // ── BuildFromLiveAlarms ──────────────────────────────── + /// <summary>Verifies that building from live alarms flattens and sorts the same way as the poll path, with an empty not-reporting list.</summary> [Fact] public void BuildFromLiveAlarms_FlattensAndSorts_SameAsPollPath_WithEmptyNotReporting() { @@ -195,6 +202,7 @@ public class AlarmSummaryServiceTests Assert.Empty(result.NotReportingInstances); } + /// <summary>Verifies that building from an empty live snapshot yields no rows.</summary> [Fact] public void BuildFromLiveAlarms_EmptySnapshot_YieldsNoRows() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ApiMethodKeyScopeReconcilerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ApiMethodKeyScopeReconcilerTests.cs index 7459ea00..c3642fcb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ApiMethodKeyScopeReconcilerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ApiMethodKeyScopeReconcilerTests.cs @@ -19,6 +19,7 @@ public sealed class ApiMethodKeyScopeReconcilerTests private static IReadOnlyDictionary<string, string> Names(params (string KeyId, string Name)[] entries) => entries.ToDictionary(e => e.KeyId, e => e.Name, StringComparer.Ordinal); + /// <summary>Verifies that approve adds method to key preserving existing scopes.</summary> [Fact] public void Approve_AddsMethodToKey_PreservingExistingScopes() { @@ -35,6 +36,7 @@ public sealed class ApiMethodKeyScopeReconcilerTests Assert.Equal(new[] { "GetStatus", "PlaceOrder" }, update.NewMethods); } + /// <summary>Verifies that approve key with no existing scopes gets just this method.</summary> [Fact] public void Approve_KeyWithNoExistingScopes_GetsJustThisMethod() { @@ -49,6 +51,7 @@ public sealed class ApiMethodKeyScopeReconcilerTests Assert.Equal(new[] { "PlaceOrder" }, update.NewMethods); } + /// <summary>Verifies that revoke removes method leaving other scopes intact.</summary> [Fact] public void Revoke_RemovesMethod_LeavingOtherScopesIntact() { @@ -65,6 +68,7 @@ public sealed class ApiMethodKeyScopeReconcilerTests Assert.Equal(new[] { "GetStatus" }, update.NewMethods); } + /// <summary>Verifies that revoke last scope reported as empty conflict and not in updates.</summary> [Fact] public void Revoke_LastScope_ReportedAsEmptyConflict_AndNotInUpdates() { @@ -80,6 +84,7 @@ public sealed class ApiMethodKeyScopeReconcilerTests Assert.Equal("Key One", emptyName); } + /// <summary>Verifies that mixed approve one revoke another produces both updates.</summary> [Fact] public void Mixed_ApproveOneRevokeAnother_ProducesBothUpdates() { @@ -102,6 +107,7 @@ public sealed class ApiMethodKeyScopeReconcilerTests Assert.Equal(new[] { "Ping", "PlaceOrder" }, k2.NewMethods); } + /// <summary>Verifies that no change produces no updates.</summary> [Fact] public void NoChange_ProducesNoUpdates() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogExportServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogExportServiceTests.cs index 72c1c76e..491339c4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogExportServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogExportServiceTests.cs @@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// <summary> -/// Tests for <see cref="AuditLogExportService"/> (#23 M7-T14 / Bundle F). The +/// Tests for <see cref="AuditLogExportService"/>. The /// service streams the filtered Audit Log query to a destination stream as /// RFC 4180 CSV. These tests pin: /// <list type="bullet"> @@ -22,7 +22,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// </summary> public class AuditLogExportServiceTests { - // C3 (Task 2.5): the export service reads canonical ZB.MOM.WW.Audit.AuditEvent rows + // The export service reads canonical ZB.MOM.WW.Audit.AuditEvent rows // from IAuditLogRepository and decomposes each into the flat 21-column CSV shape; // build the canonical rows via the shared factory (domain fields ride in DetailsJson). private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null) @@ -39,6 +39,8 @@ public class AuditLogExportServiceTests errorMessage: error, ingestedAtUtc: new DateTimeOffset(new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc))); + /// <summary>Verifies that exporting five rows writes a header line plus five data lines.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows() { @@ -74,6 +76,8 @@ public class AuditLogExportServiceTests Assert.Equal(string.Empty, lines[6]); } + /// <summary>Verifies that the CSV header contains all 24 columns in specification order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_HeaderHasAll24Columns_InSpecOrder() { @@ -89,7 +93,7 @@ public class AuditLogExportServiceTests var header = csv.Split("\r\n")[0]; var columns = header.Split(','); - // arch-review 2026-08-19 P6-7: ExecutionId/ParentExecutionId/SourceNode + // ExecutionId/ParentExecutionId/SourceNode // were missing from this CSV (JSON/JSONL exports already carried them). Assert.Equal(24, columns.Length); Assert.Equal(new[] @@ -103,6 +107,8 @@ public class AuditLogExportServiceTests }, columns); } + /// <summary>Verifies that a field containing a comma is quoted and escaped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_FieldWithComma_QuotedAndEscaped() { @@ -139,10 +145,12 @@ public class AuditLogExportServiceTests Assert.Contains("\"boom\r\nthen again\"", csv); } + /// <summary>Verifies that free-text cells starting with formula characters are prefixed with a single quote.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_FreeTextLeadingFormulaCharacters_ArePrefixedWithQuote() { - // arch-review 2026-08-19 P6-7 (CWE-1236): a free-text cell starting with + // CSV formula injection guard: a free-text cell starting with // =, +, -, @, or TAB must be prefixed with a literal single quote so a // spreadsheet application renders it as inert text, not a formula/DDE // launch. Target, ErrorMessage, ResponseSummary, and Extra are @@ -176,6 +184,8 @@ public class AuditLogExportServiceTests Assert.Contains(",'@SUM(A1:A9)", dataLine); } + /// <summary>Verifies that numeric and date columns are never prefixed with a quote.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_NumericAndDateColumns_NeverPrefixed() { @@ -210,6 +220,8 @@ public class AuditLogExportServiceTests Assert.StartsWith("2026-05-20", fields[1]); // OccurredAtUtc, unprefixed } + /// <summary>Verifies that a null field is written as an empty cell.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_NullField_WrittenAsEmpty() { @@ -254,6 +266,8 @@ public class AuditLogExportServiceTests Assert.Equal(string.Empty, fields[23]); // ForwardState null } + /// <summary>Verifies that a row count above the cap is truncated and a cap footer is appended.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_RowCountAboveCap_Truncates_AppendsCapFooter() { @@ -279,6 +293,8 @@ public class AuditLogExportServiceTests Assert.Equal("# Capped at 3 rows. Use the CLI for larger exports.", lines[4]); } + /// <summary>Verifies that cancelling the token stops the export mid-stream.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_CancellationToken_StopsMidStream() { @@ -310,11 +326,13 @@ public class AuditLogExportServiceTests await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 1000, ms, cts.Token)); } + /// <summary>Verifies that the export paginates using the last row of each page as the cursor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_PaginatesUsingLastRowAsCursor() { // Two pages of 2 rows each, then empty. The service must pass the last - // row of page 1 as the cursor on the page-2 call. + // row of page 1 as the cursor on the second-page call. AuditEvent Row(string id, DateTime occurredAt) => ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCall, diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs index 811f3324..5fba153a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs @@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// <summary> -/// Service-level tests for <see cref="AuditLogQueryService"/> (#23 M7-T3). The +/// Service-level tests for <see cref="AuditLogQueryService"/>. The /// service is a thin pass-through over <see cref="IAuditLogRepository.QueryAsync"/>; /// these tests pin the filter forwarding contract and the 100-row default-page-size /// rule the grid relies on. @@ -30,13 +30,15 @@ public class AuditLogQueryServiceTests return agg; } + /// <summary>Verifies that QueryAsync forwards the filter and paging to the repository.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository() { var repo = Substitute.For<IAuditLogRepository>(); var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound }); var paging = new AuditLogPaging(PageSize: 25); - // C3 (Task 2.5): the repository returns canonical ZB.MOM.WW.Audit.AuditEvent rows; + // The repository returns canonical ZB.MOM.WW.Audit.AuditEvent rows; // the service decomposes each into a flat AuditEventView for the UI. var eventId = Guid.NewGuid(); var repoRows = new List<AuditEvent> @@ -64,6 +66,8 @@ public class AuditLogQueryServiceTests await repo.Received(1).QueryAsync(filter, paging, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that QueryAsync applies the default page size when none is specified.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_AppliesDefaultPageSize_WhenNotSpecified() { @@ -84,9 +88,11 @@ public class AuditLogQueryServiceTests } // ───────────────────────────────────────────────────────────────────────── - // M7-T13 Bundle E: GetKpiSnapshotAsync — composes repo + health-aggregator + // GetKpiSnapshotAsync — composes repo + health-aggregator // ───────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that GetKpiSnapshotAsync forwards to the repository and adds the backlog reported by the health aggregator.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetKpiSnapshotAsync_ForwardsToRepo_AddsBacklogFromHealthAggregator() { @@ -127,6 +133,8 @@ public class AuditLogQueryServiceTests Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a site without a backlog snapshot contributes zero to the KPI backlog.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetKpiSnapshotAsync_SiteWithoutBacklogSnapshot_ContributesZero() { @@ -153,7 +161,7 @@ public class AuditLogQueryServiceTests } // ───────────────────────────────────────────────────────────────────────── - // #23 M7 — DbContext concurrency race regression (Bundle H follow-up) + // DbContext concurrency race regression // // The drill-in deep link (/audit/log?correlationId=…) triggers an OnInitialized // auto-load query that races AuditFilterBar.GetAllSitesAsync() on the SAME @@ -164,6 +172,8 @@ public class AuditLogQueryServiceTests // context — these tests pin that contract. // ───────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that concurrent queries do not race on a shared database context.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditLogQueryService_ConcurrentQueries_DoNotRace() { @@ -205,6 +215,8 @@ public class AuditLogQueryServiceTests Assert.All(results, Assert.NotNull); } + /// <summary>Verifies that QueryAsync opens a fresh service scope for each call rather than sharing one across calls.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_OpensFreshScopePerCall_NotSharedAcrossCalls() { @@ -236,11 +248,13 @@ public class AuditLogQueryServiceTests } // ───────────────────────────────────────────────────────────────────────── - // Audit Log ParentExecutionId feature (Task 10): GetExecutionTreeAsync — + // Audit Log ParentExecutionId feature: GetExecutionTreeAsync — // a thin pass-through over IAuditLogRepository.GetExecutionTreeAsync, mirroring // QueryAsync's scope-per-call contract on the production path. // ───────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that GetExecutionTreeAsync forwards the execution ID to the repository.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetExecutionTreeAsync_ForwardsExecutionId_ToRepository() { @@ -265,6 +279,8 @@ public class AuditLogQueryServiceTests await repo.Received(1).GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that GetExecutionTreeAsync opens a fresh scope for each call when built with the production constructor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetExecutionTreeAsync_OpensFreshScopePerCall_OnProductionCtor() { @@ -301,9 +317,11 @@ public class AuditLogQueryServiceTests } // ───────────────────────────────────────────────────────────────────────── - // Task 15: SourceNode filter forwarding + distinct-nodes service contract. + // SourceNode filter forwarding + distinct-nodes service contract. // ───────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that QueryAsync forwards the source nodes filter to the repository.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_ForwardsSourceNodesFilter_ToRepository() { @@ -329,6 +347,8 @@ public class AuditLogQueryServiceTests Arg.Any<CancellationToken>()); } + /// <summary>Verifies that GetDistinctSourceNodesAsync forwards to the repository on the first call.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDistinctSourceNodesAsync_ForwardsToRepository_OnFirstCall() { @@ -345,6 +365,8 @@ public class AuditLogQueryServiceTests await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>()); } + /// <summary>Verifies that GetDistinctSourceNodesAsync caches the snapshot across repeated calls.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDistinctSourceNodesAsync_CachesSnapshot_AcrossRepeatedCalls() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditTreeScopeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditTreeScopeTests.cs index 4507d080..cab9f5e1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditTreeScopeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditTreeScopeTests.cs @@ -11,6 +11,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; public class AuditTreeScopeTests { + /// <summary>Verifies that the production tree read resolves a fresh caller scope on every call.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ProductionTreeRead_UsesFreshCallerScope_EveryTime() { @@ -32,7 +34,9 @@ public class AuditTreeScopeTests private sealed class ChangingAuth : AuthenticationStateProvider { + /// <summary>Gets or sets the site id claim that the next authentication state carries.</summary> public string Site { get; set; } = "42"; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState( new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.RoleClaimType, Roles.Viewer), diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuthorizedManualFailoverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuthorizedManualFailoverTests.cs index cfbceb20..8d781589 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuthorizedManualFailoverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuthorizedManualFailoverTests.cs @@ -8,6 +8,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; public class AuthorizedManualFailoverTests { + /// <summary>Verifies that an administrator whose role has been revoked cannot dispatch a manual failover to central or a site.</summary> + /// <param name="site">True to target a site failover, or false to target the central cluster.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs index dfad012d..f2b7e655 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs @@ -7,6 +7,7 @@ public class BindingGroupBuilderTests private static BindingRow Row(string canonical, string? dflt, int? conn, string? ovr) => new(canonical, dflt, conn, ovr); + /// <summary>Verifies that groups list direct attributes first, then modules in name order.</summary> [Fact] public void Groups_DirectAttributesFirst_ThenModules_InNameOrder() { @@ -21,6 +22,7 @@ public class BindingGroupBuilderTests groups.Select(g => g.Key)); } + /// <summary>Verifies that a nested module folds into its first path segment and keeps the nested tail.</summary> [Fact] public void NestedModule_FoldsIntoFirstSegment_AndKeepsNestedTail() { @@ -36,6 +38,7 @@ public class BindingGroupBuilderTests Assert.Equal("Left_002", g.InferredObject); } + /// <summary>Verifies that the inferred object is the nested object path.</summary> [Fact] public void InferredObject_NestedObjectPath() { @@ -47,6 +50,7 @@ public class BindingGroupBuilderTests Assert.Equal("Left_002.DelmiaReceiver", Assert.Single(BindingGroupBuilder.Build(rows)).InferredObject); } + /// <summary>Verifies that the inferred object is null and the state is mixed when members disagree.</summary> [Fact] public void InferredObject_NullWhenMixed_AndStateMixed() { @@ -60,6 +64,7 @@ public class BindingGroupBuilderTests Assert.True(g.IsObjectMixed); } + /// <summary>Verifies that the inferred object is null and the group is not mixed when there are no overrides.</summary> [Fact] public void InferredObject_NullWhenNoOverrides_NotMixed() { @@ -69,6 +74,7 @@ public class BindingGroupBuilderTests Assert.False(g.IsObjectMixed); } + /// <summary>Verifies that an instance group infers its object from the overrides.</summary> [Fact] public void InstanceGroup_InfersObjectFromOverrides() { @@ -78,6 +84,7 @@ public class BindingGroupBuilderTests Assert.Equal("Z28061", g.InferredObject); } + /// <summary>Verifies that the unbound count and the connection state are computed correctly.</summary> [Fact] public void Counts_UnboundAndConnectionState() { @@ -94,6 +101,7 @@ public class BindingGroupBuilderTests Assert.Null(g.CommonConnectionId); } + /// <summary>Verifies that the connection state is single when all members agree.</summary> [Fact] public void ConnectionState_SingleWhenAllAgree() { @@ -103,6 +111,7 @@ public class BindingGroupBuilderTests Assert.Equal(1, g.CommonConnectionId); } + /// <summary>Verifies that a member override that does not end with the tail counts as mixed.</summary> [Fact] public void MemberTail_OverrideThatDoesNotEndWithTail_CountsAsMixed() { @@ -112,6 +121,7 @@ public class BindingGroupBuilderTests Assert.True(Assert.Single(BindingGroupBuilder.Build(rows)).IsObjectMixed); } + /// <summary>Verifies that applying an object rewrites every member and sets the connection.</summary> [Fact] public void ApplyObject_RewritesEveryMember_AndSetsConnection() { @@ -126,6 +136,7 @@ public class BindingGroupBuilderTests Assert.Equal(("RightMESReceiver.ScanStateCmd", 1, "MESReceiver_022.ScanStateCmd"), (changes[1].CanonicalName, changes[1].ConnectionId, changes[1].Override)); } + /// <summary>Verifies that applying an object with a nested tail keeps the nesting.</summary> [Fact] public void ApplyObject_NestedTail_KeepsNesting() { @@ -134,6 +145,7 @@ public class BindingGroupBuilderTests Assert.Equal("Left_002.LeakTest.DeltaVac", c.Override); } + /// <summary>Verifies that applying an empty object clears the overrides and keeps the connection.</summary> [Fact] public void ApplyObject_EmptyObject_ClearsOverrides_KeepsConnection() { @@ -145,6 +157,7 @@ public class BindingGroupBuilderTests // ── Review fixes 2026-08-20 ── + /// <summary>Verifies that applying a null connection keeps each member's current connection.</summary> [Fact] public void ApplyObject_NullConnection_KeepsEachMembersCurrentConnection() { @@ -161,6 +174,7 @@ public class BindingGroupBuilderTests Assert.All(changes, c => Assert.StartsWith("New.", c.Override!)); } + /// <summary>Verifies that the inferred object is mixed when some members are still on the bare default.</summary> [Fact] public void InferredObject_MixedWhenSomeMembersStillOnBareDefault() { @@ -172,6 +186,7 @@ public class BindingGroupBuilderTests Assert.True(g.IsObjectMixed); } + /// <summary>Verifies that the inferred object uses the effective reference, so a default that carries the prefix is not mixed.</summary> [Fact] public void InferredObject_UsesEffectiveReference_DefaultThatCarriesPrefixIsNotMixed() { @@ -181,6 +196,7 @@ public class BindingGroupBuilderTests Assert.False(g.IsObjectMixed); } + /// <summary>Verifies that with no overrides the object is inferred from the defaults when they share a prefix.</summary> [Fact] public void InferredObject_NoOverrides_InfersFromDefaultsWhenTheyShareAPrefix() { @@ -190,6 +206,7 @@ public class BindingGroupBuilderTests Assert.False(g.IsObjectMixed); } + /// <summary>Verifies that a group is unbound when its connection id is not a known connection.</summary> [Fact] public void IsUnbound_WhenConnectionIdIsNotAKnownConnection() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ConnectionHealthQueryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ConnectionHealthQueryServiceTests.cs index a2186610..89ff12ba 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ConnectionHealthQueryServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ConnectionHealthQueryServiceTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// <summary> -/// M9-T25: unit tests for <see cref="ConnectionHealthQueryService"/>, which projects +/// Unit tests for <see cref="ConnectionHealthQueryService"/>, which projects /// the site health reports' name-keyed <see cref="SiteHealthReport.DataConnectionStatuses"/> /// onto a connection-id → <see cref="ConnectionHealth"/> map for the design /// DataConnections page. The site repository + health aggregator are substituted so @@ -62,6 +62,8 @@ public sealed class ConnectionHealthQueryServiceTests EnabledInstanceCount: 0, DisabledInstanceCount: 0); + /// <summary>Verifies that connection names in a health report are mapped to connection ids.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Maps_ConnectionNameStatuses_ToConnectionIds() { @@ -81,6 +83,8 @@ public sealed class ConnectionHealthQueryServiceTests Assert.Equal(2, map.Count); } + /// <summary>Verifies that a site without a health report yields no entries.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MissingReport_YieldsNoEntriesForThatSite() { @@ -95,6 +99,8 @@ public sealed class ConnectionHealthQueryServiceTests Assert.Empty(map); } + /// <summary>Verifies that a site state without a latest report yields no entries.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StateWithoutLatestReport_YieldsNoEntries() { @@ -110,6 +116,8 @@ public sealed class ConnectionHealthQueryServiceTests Assert.Empty(map); } + /// <summary>Verifies that a connection name that cannot be resolved is skipped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UnknownConnectionName_IsSkipped() { @@ -129,10 +137,12 @@ public sealed class ConnectionHealthQueryServiceTests Assert.Single(map); } + /// <summary>Verifies that connection names are resolved per site rather than globally.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResolvesNamesPerSite_NotGloballyAmbiguous() { - // Two sites each own a connection named "PLC-1". The name→id resolution + // Two sites each own a connection with the same name. The name→id resolution // must be scoped to the report's own site so the right id is stamped. var siteA = new Site("Plant-A", "plant-a") { Id = 1 }; var siteB = new Site("Plant-B", "plant-b") { Id = 2 }; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs index 8cf52a90..0c392a5a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs @@ -18,6 +18,7 @@ public class CreateBindingPlannerTests new BindingRow("Recv.State", "MESReceiver_023.State", null, null), ]); + /// <summary>Verifies that prefill fills blank objects only.</summary> [Fact] public void Prefill_fills_blank_objects_only() { @@ -34,6 +35,7 @@ public class CreateBindingPlannerTests Assert.Equal(CreateBindingPlanner.ObjectSource.FromTemplate, CreateBindingPlanner.SourceOf(Groups()[2], result["Recv"])); } + /// <summary>Verifies that prefill suggests root dot module for bare defaults.</summary> [Fact] public void Prefill_suggests_root_dot_module_for_bare_defaults() { @@ -42,6 +44,7 @@ public class CreateBindingPlannerTests Assert.Equal(CreateBindingPlanner.ObjectSource.Suggested, CreateBindingPlanner.SourceOf(Groups()[1], result["Left"])); } + /// <summary>Verifies that prefill with blank root sets connection only.</summary> [Fact] public void Prefill_with_blank_root_sets_connection_only() { @@ -51,6 +54,9 @@ public class CreateBindingPlannerTests Assert.Equal(new CreateBindingPlanner.GroupInput(7, "MESReceiver_023"), result["Recv"]); } + /// <summary>Verifies that object from pick strips member tail.</summary> + /// <param name="picked">The picked value for the case.</param> + /// <param name="expected">The expected value for the case.</param> [Theory] [InlineData("Z28063.Left.MoveInFlag", "Z28063.Left")] // leaf under the group → strip tail [InlineData("Z28063.Left", "Z28063.Left")] // object node → as-is @@ -59,6 +65,7 @@ public class CreateBindingPlannerTests public void ObjectFromPick_strips_member_tail(string picked, string expected) => Assert.Equal(expected, CreateBindingPlanner.ObjectFromPick(Groups()[1], picked)); + /// <summary>Verifies that build bindings skips groups without connection or object.</summary> [Fact] public void BuildBindings_skips_groups_without_connection_or_object() { @@ -73,6 +80,7 @@ public class CreateBindingPlannerTests Assert.Equal(("Running", 7, "Z28063.Running"), (single.AttributeName, single.DataConnectionId, single.DataSourceReferenceOverride)); } + /// <summary>Verifies that build bindings rewrites every member of a bound group.</summary> [Fact] public void BuildBindings_rewrites_every_member_of_a_bound_group() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs index 7013ac28..a194447e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs @@ -65,6 +65,7 @@ public sealed class FlowDebugServiceTests : IDisposable private bool _siteReachable = true; private string? _siteCallError; + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() => _system.Terminate().Wait(TimeSpan.FromSeconds(5)); private FlowDebugService CreateService(object? reply, string role = "Deployer", string? permittedSite = null) @@ -113,6 +114,10 @@ public sealed class FlowDebugServiceTests : IDisposable null, null, null, siteId, "node-a", "Sim01", "ERP.GetOrder", null, Lifecycle: null, CorrelationId: correlationId); + /// <summary>Verifies that a scoped caller can relay only for the sites it has been granted.</summary> + /// <param name="siteId">The resolved site id of the step.</param> + /// <param name="expected">Whether the relay is expected to be sent.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(42, true)] [InlineData(99, false)] @@ -128,6 +133,9 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Equal(expected ? 1 : 0, _siteCallRequests.Count); } + /// <summary>Verifies that a scoped caller cannot relay for a site that cannot be resolved.</summary> + /// <param name="retry">True to retry the outbound call, false to discard it.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(true)] [InlineData(false)] @@ -140,6 +148,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Empty(_siteCallRequests); } + /// <summary>Verifies that retrying a parked API call relays a retry site call carrying the correlation id and site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryOutbound_ApiCallParked_RelaysRetrySiteCallWithCorrelationAndSite() { @@ -158,6 +168,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Empty(_outboxRequests); } + /// <summary>Verifies that retrying a notification relays a retry notification request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryOutbound_Notify_RelaysRetryNotification() { @@ -174,6 +186,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Empty(_siteCallRequests); } + /// <summary>Verifies that discarding a database call relays a discard site call.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DiscardOutbound_DbCall_RelaysDiscardSiteCall() { @@ -191,6 +205,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Equal("tester", request.RequestedBy); } + /// <summary>Verifies that retrying a step that is not parked is refused without relaying.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryOutbound_NotParked_RefusesWithoutRelay() { @@ -204,6 +220,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Empty(_outboxRequests); } + /// <summary>Verifies that retrying a step with no correlation id is refused.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryOutbound_NoCorrelation_Refuses() { @@ -217,6 +235,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Empty(_outboxRequests); } + /// <summary>Verifies that retrying a site-kind step without a site id is refused.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryOutbound_SiteKindWithoutSiteId_Refuses() { @@ -230,6 +250,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Empty(_outboxRequests); } + /// <summary>Verifies that retrying a step that is not an outbound call is refused.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryOutbound_NonOutboundStep_Refuses() { @@ -243,6 +265,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Empty(_outboxRequests); } + /// <summary>Verifies that an unreachable site maps to a failure message on retry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryOutbound_SiteUnreachable_MapsToFailureMessage() { @@ -257,6 +281,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Single(_siteCallRequests); } + /// <summary>Verifies that an operation that is not parked at the site maps to a failure message on retry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryOutbound_NotParkedAtSite_MapsToFailureMessage() { @@ -269,6 +295,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Contains("no longer parked", result.Message); } + /// <summary>Verifies that a failed discard surfaces the site's own error message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DiscardOutbound_OperationFailed_UsesTheSitesOwnErrorMessage() { @@ -307,6 +335,8 @@ public sealed class FlowDebugServiceTests : IDisposable return JsonSerializer.Serialize(result, ServerJson); } + /// <summary>Verifies that the logs-truncated flag is carried onto both the outcome and the graph.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_LogsTruncated_RidesOntoTheOutcomeAndTheGraph() { @@ -322,6 +352,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.True(outcome.Raw!.LogsTruncated); } + /// <summary>Verifies that a run without log truncation leaves the flag false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_NoLogTruncation_LeavesTheFlagFalse() { @@ -333,6 +365,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.False(outcome.Graph!.LogsTruncated); } + /// <summary>Verifies that running a method with no management actor holder reports the service as unavailable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_HolderNull_ReportsServiceUnavailable() { @@ -346,6 +380,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Empty(_received); } + /// <summary>Verifies that a management error is mapped with its code and message unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_ManagementError_MapsCodeAndMessageVerbatim() { @@ -358,6 +394,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Equal("This node is standby.", outcome.Error); } + /// <summary>Verifies that an unauthorized management reply maps to the unauthorized code.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_Unauthorized_MapsToUnauthorizedCode() { @@ -369,6 +407,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Equal("Deployer role required.", outcome.Error); } + /// <summary>Verifies that a successful run builds the graph from the returned steps and sends the typed command for the calling principal.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_Success_BuildsGraphWithTheReturnedSteps_AndSendsTheTypedCommandForThePrincipal() { @@ -393,6 +433,9 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Contains("Deployer", envelope.User.Roles); } + /// <summary>Verifies that the run ask timeout is the method timeout plus the run grace and client slack, floored at the minimum.</summary> + /// <param name="timeoutSeconds">The method timeout in seconds.</param> + /// <param name="expectedSeconds">The expected ask timeout in seconds.</param> [Theory] [InlineData(180, 205)] [InlineData(1, 30)] @@ -403,6 +446,7 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), timeout); } + /// <summary>Verifies that the value-change ask timeout is the wait seconds plus the client slack.</summary> [Fact] public void ValueChangeAskTimeoutFor_IsWaitSecondsPlusClientSlack() { @@ -411,6 +455,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Equal(TimeSpan.FromSeconds(60), timeout); } + /// <summary>Verifies that running a write-kind script and replaying a run each send their commands.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunWriteKindScript_AndReplay_SendTheirCommands() { @@ -433,6 +479,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Equal(exec, rpCmd.ExecutionId); } + /// <summary>Verifies that a truncated run result flags the graph.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Run_TruncatedResult_FlagsTheGraph() { @@ -444,6 +492,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.True(outcome.Graph!.Truncated); } + /// <summary>Verifies that predicting a method resolves constant route targets through the instance template chain.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PredictMethod_ResolvesConstantRouteTargets_ThroughInstanceTemplateChain() { @@ -522,6 +572,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Contains(flow.Nodes, n => n.Kind == FlowStepKind.AttributeWrite && n.Title == "Sim01.Cmd"); } + /// <summary>Verifies that nested route targets are resolved across successive prediction passes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PredictMethod_NestedRouteTargets_ResolveAcrossPasses() { @@ -560,10 +612,16 @@ public sealed class FlowDebugServiceTests : IDisposable { private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new(); + /// <inheritdoc /> public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state)); + /// <inheritdoc /> public override void Send(SendOrPostCallback d, object? state) => throw new NotSupportedException(); + /// <summary>Runs the given asynchronous body on this pump, draining posted callbacks until it completes.</summary> + /// <typeparam name="T">The result type of the body.</typeparam> + /// <param name="body">The asynchronous body to run.</param> + /// <returns>The result of the body.</returns> public T Run<T>(Func<Task<T>> body) { var previous = Current; @@ -586,6 +644,8 @@ public sealed class FlowDebugServiceTests : IDisposable } } + /// <summary>Verifies that predicting an unknown method yields an empty result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PredictMethod_UnknownMethod_IsEmpty() { @@ -597,6 +657,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Empty(flow.Nodes); } + /// <summary>Verifies that instance scripts are projected from the flattened configuration with their trigger attribute and kind, ordered by name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceScripts_ProjectsFlattenedScripts_WithTriggerAttributeAndKind_OrderedByName() { @@ -615,12 +677,16 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.False(options.Single(o => o.CanonicalName == "Mod.Tick").IsWriteKind); } + /// <summary>Verifies that requesting scripts for an unknown instance yields an empty result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceScripts_UnknownInstance_IsEmpty() { Assert.Empty(await CreateService(reply: null).GetInstanceScriptsAsync(404)); } + /// <summary>Verifies that predicting a write-kind script roots at the write and appends the script.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PredictScript_WriteKind_RootsAtTheWrite_AndAppendsTheScript() { @@ -637,6 +703,8 @@ public sealed class FlowDebugServiceTests : IDisposable ], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList()); } + /// <summary>Verifies that predicting a call-kind script roots at a route call followed by the script.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PredictScript_CallKind_RootsAtARouteCall_ThenTheScript() { @@ -652,6 +720,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.True(flow.Edges[0].CrossesBoundary); } + /// <summary>Verifies that predicting an unknown script yields an empty result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PredictScript_UnknownScript_IsEmpty() { @@ -659,6 +729,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Same(PredictedFlow.Empty, await CreateService(reply: null).PredictScriptAsync(9, "Nope")); } + /// <summary>Verifies that running a script sends the command carrying the script shape.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_SendsTheCommand_WithTheScriptShape() { @@ -683,6 +755,7 @@ public sealed class FlowDebugServiceTests : IDisposable /// entry <see cref="IFlowDebugService.GetInstanceScriptsAsync"/> just refreshed (design /// 2026-09-08 §5). A regression here means every keystroke re-walks the template chain. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceScripts_ThenPredict_ThenRun_FlattenOnce() { @@ -702,6 +775,7 @@ public sealed class FlowDebugServiceTests : IDisposable /// re-flatten — otherwise a template edit or a redeploy mid-session leaves the picker, the /// parameter form and the Ask sizing stale for the rest of the session. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceScripts_CalledAgain_ReflattensAndRefreshes() { @@ -729,12 +803,18 @@ public sealed class FlowDebugServiceTests : IDisposable private sealed class StubAuth : AuthenticationStateProvider { private readonly ClaimsPrincipal _user; + /// <summary>Initializes the provider with the principal to report.</summary> + /// <param name="user">The principal reported as the authenticated user.</param> public StubAuth(ClaimsPrincipal user) => _user = user; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(_user)); } private sealed class ScriptedManagementActor : ReceiveActor { + /// <summary>Initializes the actor with the reply to send and the list that records received envelopes.</summary> + /// <param name="reply">The reply sent back for every received envelope.</param> + /// <param name="received">The list that collects each received envelope.</param> public ScriptedManagementActor(object reply, List<ManagementEnvelope> received) { Receive<ManagementEnvelope>(env => @@ -748,6 +828,8 @@ public sealed class FlowDebugServiceTests : IDisposable /// <summary>Stands in for the Site Call Audit singleton proxy: records the relay request, replies as scripted.</summary> private sealed class ScriptedSiteCallAuditActor : ReceiveActor { + /// <summary>Initializes the actor to record and answer site call audit requests on the owning test.</summary> + /// <param name="test">The owning test whose recorded requests and scripted outcome the actor uses.</param> public ScriptedSiteCallAuditActor(FlowDebugServiceTests test) { Receive<RetrySiteCallRequest>(r => @@ -768,6 +850,8 @@ public sealed class FlowDebugServiceTests : IDisposable /// <summary>Stands in for the Notification Outbox singleton proxy: records the request, replies success.</summary> private sealed class ScriptedOutboxActor : ReceiveActor { + /// <summary>Initializes the actor to record and answer notification outbox requests on the owning test.</summary> + /// <param name="test">The owning test whose recorded requests the actor appends to.</param> public ScriptedOutboxActor(FlowDebugServiceTests test) { Receive<RetryNotificationRequest>(r => diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs index 4a45e5bd..6a67e421 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs @@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.KpiHistory; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// <summary> -/// Service-level tests for <see cref="KpiHistoryQueryService"/> (M6 K11). The +/// Service-level tests for <see cref="KpiHistoryQueryService"/>. The /// service fetches a raw series via <see cref="IKpiHistoryRepository.GetRawSeriesAsync"/> /// and reduces it with <see cref="KpiSeriesBucketer"/>; these tests pin the /// argument-forwarding contract, the bucketer pass-through, and the @@ -30,6 +30,8 @@ public class KpiHistoryQueryServiceTests private static IReadOnlyList<KpiSeriesPoint> Series(params (int minute, double value)[] pts) => pts.Select(p => new KpiSeriesPoint(From.AddMinutes(p.minute), p.value)).ToList(); + /// <summary>Verifies that all arguments are forwarded to the repository.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_ForwardsAllArgs_ToRepository() { @@ -50,6 +52,8 @@ public class KpiHistoryQueryServiceTests From, To, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a null scope key is forwarded to the repository for a global scope.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_ForwardsNullScopeKey_ForGlobalScope() { @@ -70,6 +74,8 @@ public class KpiHistoryQueryServiceTests From, To, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the raw series is returned unchanged when its point count is within the maximum.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_ReturnsRawUnchanged_WhenCountWithinMax() { @@ -89,6 +95,8 @@ public class KpiHistoryQueryServiceTests Assert.Same(raw, result); } + /// <summary>Verifies that a null maximum point count falls back to the default from the options.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_NullMaxPoints_UsesDefaultFromOptions() { @@ -110,6 +118,8 @@ public class KpiHistoryQueryServiceTests Assert.True(result.Count <= 2); } + /// <summary>Verifies that an explicit maximum point count overrides the default.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_ExplicitMaxPoints_OverridesDefault() { @@ -132,6 +142,8 @@ public class KpiHistoryQueryServiceTests Assert.Same(raw, result); } + /// <summary>Verifies that an empty series yields an empty result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_EmptySeries_ReturnsEmpty() { @@ -148,6 +160,8 @@ public class KpiHistoryQueryServiceTests Assert.Empty(result); } + /// <summary>Verifies that the production constructor opens a fresh service scope for each call.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_OpensFreshScopePerCall_OnProductionCtor() { @@ -181,7 +195,7 @@ public class KpiHistoryQueryServiceTests Assert.NotSame(resolvedRepos[0], resolvedRepos[1]); } - // ---- Task 5: raw-vs-rollup range routing by RollupThresholdHours ---------------- + // ---- raw-vs-rollup range routing by RollupThresholdHours ---------------- private static IKpiHistoryRepository RepoReturningEmptyForBoth() { @@ -197,6 +211,8 @@ public class KpiHistoryQueryServiceTests return repo; } + /// <summary>Verifies that a 24-hour window uses the raw query path.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_24hWindow_UsesRawPath() { @@ -215,6 +231,8 @@ public class KpiHistoryQueryServiceTests Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a window exactly at the threshold uses the raw query path.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_WindowExactlyAtThreshold_UsesRawPath() { @@ -234,6 +252,8 @@ public class KpiHistoryQueryServiceTests Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a window just over the threshold uses the rollup query path.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_WindowJustOverThreshold_UsesRollupPath() { @@ -253,6 +273,8 @@ public class KpiHistoryQueryServiceTests Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a 90-day window uses the rollup query path.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_90dWindow_UsesRollupPath() { @@ -271,6 +293,8 @@ public class KpiHistoryQueryServiceTests Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the rollup path still buckets the series when it exceeds the maximum point count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_RollupPath_StillBucketsWhenOverMax() { @@ -303,6 +327,8 @@ public class KpiHistoryQueryServiceTests Assert.True(result.Count <= 3); } + /// <summary>Verifies that a long window uses the rollup path when built through the production constructor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_LongWindow_UsesRollupPath_OnProductionCtor() { @@ -331,8 +357,10 @@ public class KpiHistoryQueryServiceTests Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>()); } - // ---- Task 6: catalog-driven aggregation at the bucketer boundary (R3) ----------- + // ---- catalog-driven aggregation at the bucketer boundary (R3) ----------- + /// <summary>Verifies that a rate metric is summed per bucket on the rollup path.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_RateMetric_SumsPerBucket_OnRollupPath() { @@ -361,6 +389,8 @@ public class KpiHistoryQueryServiceTests Assert.All(result, p => Assert.Equal(2.0, p.Value)); } + /// <summary>Verifies that a rate metric preserves its total across the raw-to-rollup routing boundary.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_RateMetric_PreservesTotal_AcrossRoutingBoundary() { @@ -399,6 +429,8 @@ public class KpiHistoryQueryServiceTests Assert.Equal(rawResult.Sum(p => p.Value), rollupResult.Sum(p => p.Value)); } + /// <summary>Verifies that a gauge metric keeps the last value in each bucket.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSeriesAsync_GaugeMetric_KeepsLastValuePerBucket() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/PendingUpdatesCacheTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/PendingUpdatesCacheTests.cs index d98d70aa..c38ff5f0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/PendingUpdatesCacheTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/PendingUpdatesCacheTests.cs @@ -22,6 +22,7 @@ public class PendingUpdatesCacheTests : IDisposable private readonly IPendingUpdatesEvaluator _evaluator = Substitute.For<IPendingUpdatesEvaluator>(); private readonly ServiceProvider _provider; + /// <summary>Initializes a service provider that resolves the substitute pending-updates evaluator.</summary> public PendingUpdatesCacheTests() { var services = new ServiceCollection(); @@ -40,6 +41,8 @@ public class PendingUpdatesCacheTests : IDisposable private static PendingUpdatesSnapshot Snapshot() => new(T0, Array.Empty<SitePendingUpdates>()); + /// <summary>Verifies that concurrent callers share a single evaluation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConcurrentCallers_ShareOneEvaluation() { @@ -56,6 +59,8 @@ public class PendingUpdatesCacheTests : IDisposable Assert.All(results, r => Assert.Same(results[0], r)); } + /// <summary>Verifies that a call within the TTL serves the memoized value without re-evaluating.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WithinTtl_ServesTheMemoizedValue_WithoutReEvaluating() { @@ -69,6 +74,8 @@ public class PendingUpdatesCacheTests : IDisposable Assert.Same(first, second); } + /// <summary>Verifies that a forced refresh bypasses a still-fresh cached value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ForceRefresh_BypassesAFreshValue() { @@ -85,6 +92,8 @@ public class PendingUpdatesCacheTests : IDisposable Assert.Same(snapshotB, forced); } + /// <summary>Verifies that a forced refresh still joins an evaluation that is already in flight.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ForceRefresh_StillJoinsAnInFlightRound() { @@ -106,6 +115,8 @@ public class PendingUpdatesCacheTests : IDisposable await _evaluator.Received(1).EvaluateAsync(Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a failed evaluation surfaces to the caller and is not memoized.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FailedEvaluation_SurfacesToTheCaller_AndIsNotMemoized() { @@ -125,5 +136,6 @@ public class PendingUpdatesCacheTests : IDisposable await _evaluator.Received(2).EvaluateAsync(Arg.Any<CancellationToken>()); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() => _provider.Dispose(); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SharedAlarmSummaryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SharedAlarmSummaryServiceTests.cs index 66da08e5..8cdd71c1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SharedAlarmSummaryServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SharedAlarmSummaryServiceTests.cs @@ -12,7 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Communication; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// <summary> -/// Unit tests for <see cref="SharedAlarmSummaryService"/> (arch-review WP2.4). The Alarm +/// Unit tests for <see cref="SharedAlarmSummaryService"/>. The Alarm /// Summary page polls per circuit; the shared façade must collapse those into ONE per-site /// fan-out per freshness window, and must widen that window while the live alarm cache is /// serving the site (where the poll only still supplies the not-reporting list). @@ -32,6 +32,7 @@ public class SharedAlarmSummaryServiceTests : IDisposable private DateTimeOffset _now = T0; + /// <summary>Initializes a new instance of the <see cref="SharedAlarmSummaryServiceTests"/> class.</summary> public SharedAlarmSummaryServiceTests() { _siteRepo.GetSiteByIdAsync(SiteId, Arg.Any<CancellationToken>()) @@ -59,6 +60,8 @@ public class SharedAlarmSummaryServiceTests : IDisposable private SharedAlarmSummaryService CreateSut() => new(_provider.GetRequiredService<IServiceScopeFactory>(), _liveCache, () => _now); + /// <summary>Verifies that concurrent circuits share a single fan-out.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConcurrentCircuits_ShareOneFanOut() { @@ -70,6 +73,8 @@ public class SharedAlarmSummaryServiceTests : IDisposable Assert.All(results, r => Assert.Single(r.Alarms)); } + /// <summary>Verifies that a cold live cache refreshes within the page tick.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ColdLiveCache_RefreshesWithinThePageTick() { @@ -85,12 +90,14 @@ public class SharedAlarmSummaryServiceTests : IDisposable await _instanceRepo.Received(2).GetInstancesBySiteIdAsync(SiteId, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that serving from the live cache skips the fan-out entirely.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LiveCacheServing_SkipsTheFanOutEntirely() { // The aggregator's seed/reconcile already ran this exact fan-out and publishes both // halves of the answer, so the façade must not run a second one — not now, not after - // any elapsed window (arch-review phase-2 residual #4). + // any elapsed window. var sut = CreateSut(); _liveCache.Live = true; _liveCache.NotReporting = new[] { "inst-silent" }; @@ -116,6 +123,8 @@ public class SharedAlarmSummaryServiceTests : IDisposable } } + /// <summary>Verifies that when the live cache goes cold the service falls back to the fan-out.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LiveCacheGoingCold_FallsBackToTheFanOut() { @@ -133,6 +142,8 @@ public class SharedAlarmSummaryServiceTests : IDisposable Assert.Single(result.Alarms); } + /// <summary>Verifies that different sites do not share a memo slot.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DifferentSites_DoNotShareAMemoSlot() { @@ -151,6 +162,7 @@ public class SharedAlarmSummaryServiceTests : IDisposable await _instanceRepo.Received(1).GetInstancesBySiteIdAsync(otherSite, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the pure methods return the same results as the direct implementation.</summary> [Fact] public void PureMethods_MatchTheDirectImplementation() { @@ -176,6 +188,7 @@ public class SharedAlarmSummaryServiceTests : IDisposable Assert.Equal(expectedRollup.CountsByKind, sharedRollup.CountsByKind); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() => _provider.Dispose(); /// <summary> @@ -184,20 +197,28 @@ public class SharedAlarmSummaryServiceTests : IDisposable /// </summary> private sealed class FakeLiveCache : ISiteAlarmLiveCache { + /// <summary>Gets or sets whether the fake cache reports the site as live.</summary> public bool Live { get; set; } + /// <summary>Gets or sets the alarms returned as the site's current alarms.</summary> public IReadOnlyList<AlarmStateChanged> Current { get; set; } = Array.Empty<AlarmStateChanged>(); + /// <summary>Gets or sets the instances returned as not reporting.</summary> public IReadOnlyList<string> NotReporting { get; set; } = Array.Empty<string>(); + /// <inheritdoc /> public IDisposable Subscribe(int siteId, Action onChanged) => new NoOp(); + /// <inheritdoc /> public IReadOnlyList<AlarmStateChanged> GetCurrentAlarms(int siteId) => Current; + /// <inheritdoc /> public bool IsLive(int siteId) => Live; + /// <inheritdoc /> public IReadOnlyList<string> GetNotReportingInstances(int siteId) => NotReporting; private sealed class NoOp : IDisposable { + /// <summary>Does nothing; the no-op subscription holds no resources.</summary> public void Dispose() { } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SingleFlightMemoTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SingleFlightMemoTests.cs index 0dd5ad5a..ce7105fe 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SingleFlightMemoTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SingleFlightMemoTests.cs @@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// <summary> /// Unit tests for <see cref="SingleFlightMemo{T}"/> — the primitive behind the process-level -/// KPI snapshot cache and the shared Alarm Summary fan-out (arch-review WP2.4). The two +/// KPI snapshot cache and the shared Alarm Summary fan-out. The two /// properties the whole optimisation rests on are tested here: <b>single-flight</b> (N /// concurrent circuits asking the same question produce ONE query) and <b>TTL</b> (a query /// answered inside the freshness window is served from memory, and one outside it is not). @@ -13,6 +13,8 @@ public class SingleFlightMemoTests { private static readonly DateTimeOffset T0 = new(2026, 8, 14, 9, 0, 0, TimeSpan.Zero); + /// <summary>Verifies that concurrent callers share a single in-flight query.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConcurrentCallers_ShareOneFlight() { @@ -37,6 +39,8 @@ public class SingleFlightMemoTests Assert.All(results, r => Assert.Equal(42, r)); } + /// <summary>Verifies that a call within the TTL serves the memoized value without querying again.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WithinTtl_ServesTheMemoizedValue_WithoutRequerying() { @@ -54,6 +58,8 @@ public class SingleFlightMemoTests Assert.Equal(1, memo.FlightCount); } + /// <summary>Verifies that a call after the TTL expires queries exactly once more.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AfterTtl_RequeriesOnce() { @@ -70,6 +76,8 @@ public class SingleFlightMemoTests Assert.Equal(2, memo.FlightCount); } + /// <summary>Verifies that a forced refresh bypasses a value that is still fresh.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ForceRefresh_BypassesAFreshValue() { @@ -84,6 +92,8 @@ public class SingleFlightMemoTests Assert.Equal(2, memo.FlightCount); } + /// <summary>Verifies that a forced refresh still joins a query that is already in flight.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ForceRefresh_StillJoinsAnInFlightRound() { @@ -107,6 +117,8 @@ public class SingleFlightMemoTests Assert.Equal(1, invocations); } + /// <summary>Verifies that a failure is not memoized and the next caller retries.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Failure_IsNotMemoized_AndTheNextCallerRetries() { @@ -129,6 +141,8 @@ public class SingleFlightMemoTests Assert.Equal(2, memo.FlightCount); } + /// <summary>Verifies that a TTL override applies to the flight it was supplied for.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TtlOverride_AppliesToThatFlight() { @@ -146,6 +160,8 @@ public class SingleFlightMemoTests Assert.Equal(1, memo.FlightCount); } + /// <summary>Verifies that cancelling one caller does not cancel the shared flight.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallerCancellation_DoesNotCancelTheSharedFlight() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/TemplatePathFormatterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/TemplatePathFormatterTests.cs index c3ad8e62..ef936858 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/TemplatePathFormatterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/TemplatePathFormatterTests.cs @@ -7,6 +7,7 @@ public class TemplatePathFormatterTests { private static Dictionary<int, TemplateFolder> Folders(params TemplateFolder[] f) => f.ToDictionary(x => x.Id); + /// <summary>Verifies that a root template is formatted as its name only.</summary> [Fact] public void RootTemplate_IsNameOnly() { @@ -14,6 +15,7 @@ public class TemplatePathFormatterTests Assert.Equal("Motor Controller", TemplatePathFormatter.Format(t, Folders())); } + /// <summary>Verifies that a nested template is formatted with its folder chain joined by slashes.</summary> [Fact] public void NestedTemplate_JoinsFolderChainWithSlashes() { @@ -24,6 +26,7 @@ public class TemplatePathFormatterTests TemplatePathFormatter.Format(t, Folders(controllers, conveyor))); } + /// <summary>Verifies that an unknown folder id falls back to the name only without throwing.</summary> [Fact] public void UnknownFolderId_FallsBackToNameOnly_WithoutThrowing() { @@ -31,6 +34,7 @@ public class TemplatePathFormatterTests Assert.Equal("Orphan", TemplatePathFormatter.Format(t, Folders())); } + /// <summary>Verifies that a cycle in the folder chain terminates instead of looping forever.</summary> [Fact] public void FolderCycle_Terminates() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/AccessibilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/AccessibilityTests.cs index f8d5fad9..f83fdb9b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/AccessibilityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/AccessibilityTests.cs @@ -42,10 +42,11 @@ public class AccessibilityTests : BunitContext // ── Test 1: TreeView chevron toggle a11y attributes ──────────────────────── + /// <summary>Verifies that the tree view toggle exposes an accessible name and role.</summary> [Fact] public void TreeViewToggle_HasAccessibleNameAndRole() { - // BranchRoots has one branch node ("root-1") and one leaf node ("leaf-1"). + // BranchRoots has one branch node (the first root) and one leaf node (the first leaf). // The branch node's toggle span is the only .tv-toggle rendered. var cut = RenderTreeView(); @@ -57,7 +58,7 @@ public class AccessibilityTests : BunitContext // tabindex="0" puts it in the natural tab order. Assert.Equal("0", toggle.GetAttribute("tabindex")); - // aria-label must be non-empty (e.g. "Expand root-1"). + // aria-label must be non-empty (e.g. "Expand" followed by the node name). var label = toggle.GetAttribute("aria-label"); Assert.NotNull(label); Assert.NotEmpty(label!); @@ -66,6 +67,7 @@ public class AccessibilityTests : BunitContext Assert.Equal("false", toggle.GetAttribute("aria-expanded")); } + /// <summary>Verifies that the tree view toggle's aria-expanded attribute reflects the expanded state.</summary> [Fact] public void TreeViewToggle_AriaExpanded_ReflectsExpandedState() { @@ -90,6 +92,7 @@ public class AccessibilityTests : BunitContext // ── Test 3: TreeView keyboard activation (Enter / Space) ────────────────── + /// <summary>Verifies that pressing Enter on the tree view toggle toggles the expanded state.</summary> [Fact] public void TreeViewToggle_EnterKey_TogglesExpanded() { @@ -105,6 +108,7 @@ public class AccessibilityTests : BunitContext Assert.Equal("true", expandedToggle.GetAttribute("aria-expanded")); } + /// <summary>Verifies that pressing Space on the tree view toggle toggles the expanded state.</summary> [Fact] public void TreeViewToggle_SpaceKey_TogglesExpanded() { @@ -122,6 +126,7 @@ public class AccessibilityTests : BunitContext // ── Test 2: ToastNotification live-region regression lock ───────────────── + /// <summary>Verifies that the toast notification container is an aria-live polite region.</summary> [Fact] public void ToastNotification_Container_IsAriaLivePolite() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/AlarmTriggerConfigCodecTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/AlarmTriggerConfigCodecTests.cs index 763e5126..0421d0e7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/AlarmTriggerConfigCodecTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/AlarmTriggerConfigCodecTests.cs @@ -8,6 +8,7 @@ public class AlarmTriggerConfigCodecTests { // ── Parse: ValueMatch ────────────────────────────────────────────────── + /// <summary>Verifies that parsing a value-match configuration reads the canonical keys.</summary> [Fact] public void Parse_ValueMatch_ReadsCanonicalKeys() { @@ -20,6 +21,7 @@ public class AlarmTriggerConfigCodecTests Assert.False(model.NotEquals); } + /// <summary>Verifies that parsing a value-match configuration accepts the legacy attribute and value keys.</summary> [Fact] public void Parse_ValueMatch_AcceptsLegacyAttributeAndValueKeys() { @@ -32,6 +34,7 @@ public class AlarmTriggerConfigCodecTests Assert.Equal("Critical", model.MatchValue); } + /// <summary>Verifies that a not-equals prefix on the match value sets the not-equals flag and is stripped from the value.</summary> [Fact] public void Parse_ValueMatch_NotEqualsPrefix_SetsFlagAndStripsPrefix() { @@ -43,6 +46,7 @@ public class AlarmTriggerConfigCodecTests Assert.Equal("Good", model.MatchValue); } + /// <summary>Verifies that a value-match configuration without a match value leaves it null.</summary> [Fact] public void Parse_ValueMatch_MissingMatchValue_LeavesNull() { @@ -57,6 +61,7 @@ public class AlarmTriggerConfigCodecTests // ── Parse: RangeViolation ────────────────────────────────────────────── + /// <summary>Verifies that parsing a range-violation configuration reads the canonical keys.</summary> [Fact] public void Parse_RangeViolation_ReadsCanonicalKeys() { @@ -68,6 +73,7 @@ public class AlarmTriggerConfigCodecTests Assert.Equal(100, model.Max); } + /// <summary>Verifies that parsing a range-violation configuration accepts the legacy low and high keys.</summary> [Fact] public void Parse_RangeViolation_AcceptsLegacyLowHighKeys() { @@ -79,6 +85,7 @@ public class AlarmTriggerConfigCodecTests Assert.Equal(50, model.Max); } + /// <summary>Verifies that canonical range-violation keys take precedence over legacy ones when both are present.</summary> [Fact] public void Parse_RangeViolation_CanonicalKeysWinOverLegacy() { @@ -91,6 +98,7 @@ public class AlarmTriggerConfigCodecTests Assert.Equal(100, model.Max); } + /// <summary>Verifies that numeric values stored as strings in a range-violation configuration are parsed.</summary> [Fact] public void Parse_RangeViolation_StringNumericValues_AreParsed() { @@ -105,6 +113,7 @@ public class AlarmTriggerConfigCodecTests // ── Parse: RateOfChange ──────────────────────────────────────────────── + /// <summary>Verifies that parsing a rate-of-change configuration reads all of its fields.</summary> [Fact] public void Parse_RateOfChange_ReadsAllFields() { @@ -118,6 +127,9 @@ public class AlarmTriggerConfigCodecTests Assert.Equal("rising", model.Direction); } + /// <summary>Verifies that direction aliases in a rate-of-change configuration are normalized.</summary> + /// <param name="input">The direction text as stored in the configuration.</param> + /// <param name="expected">The expected normalized direction.</param> [Theory] [InlineData("rising", "rising")] [InlineData("Rising", "rising")] @@ -138,6 +150,7 @@ public class AlarmTriggerConfigCodecTests Assert.Equal(expected, model.Direction); } + /// <summary>Verifies that a rate-of-change configuration without a direction defaults to either.</summary> [Fact] public void Parse_RateOfChange_MissingDirection_DefaultsToEither() { @@ -152,6 +165,8 @@ public class AlarmTriggerConfigCodecTests // ── Parse: misc ──────────────────────────────────────────────────────── + /// <summary>Verifies that null, empty or whitespace input parses to the default model.</summary> + /// <param name="input">The null, empty or whitespace configuration text.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -166,6 +181,7 @@ public class AlarmTriggerConfigCodecTests Assert.Equal("either", model.Direction); } + /// <summary>Verifies that malformed JSON parses to the default model without throwing.</summary> [Fact] public void Parse_MalformedJson_ReturnsDefaultModel_DoesNotThrow() { @@ -177,6 +193,7 @@ public class AlarmTriggerConfigCodecTests // ── Serialize: ValueMatch ────────────────────────────────────────────── + /// <summary>Verifies that serializing a value-match model writes only the canonical keys.</summary> [Fact] public void Serialize_ValueMatch_WritesCanonicalKeysOnly() { @@ -201,6 +218,7 @@ public class AlarmTriggerConfigCodecTests Assert.False(root.TryGetProperty("direction", out _)); } + /// <summary>Verifies that serializing a not-equals value-match model prepends the not-equals marker to the match value.</summary> [Fact] public void Serialize_ValueMatch_NotEquals_PrependsBangEqualsToMatchValue() { @@ -217,6 +235,7 @@ public class AlarmTriggerConfigCodecTests Assert.Equal("!=Good", doc.RootElement.GetProperty("matchValue").GetString()); } + /// <summary>Verifies that a null attribute name is serialized as an empty string.</summary> [Fact] public void Serialize_ValueMatch_NullAttributeName_WritesEmptyString() { @@ -232,6 +251,7 @@ public class AlarmTriggerConfigCodecTests // ── Serialize: RangeViolation ────────────────────────────────────────── + /// <summary>Verifies that serializing a range-violation model writes the canonical numeric keys.</summary> [Fact] public void Serialize_RangeViolation_WritesCanonicalNumericKeys() { @@ -252,6 +272,7 @@ public class AlarmTriggerConfigCodecTests Assert.False(root.TryGetProperty("matchValue", out _)); } + /// <summary>Verifies that a null range bound is omitted from the serialized output.</summary> [Fact] public void Serialize_RangeViolation_NullBound_OmitsKey() { @@ -266,6 +287,7 @@ public class AlarmTriggerConfigCodecTests // ── Serialize: RateOfChange ──────────────────────────────────────────── + /// <summary>Verifies that serializing a rate-of-change model writes the threshold, window and direction.</summary> [Fact] public void Serialize_RateOfChange_WritesThresholdWindowAndDirection() { @@ -286,6 +308,7 @@ public class AlarmTriggerConfigCodecTests Assert.Equal("falling", root.GetProperty("direction").GetString()); } + /// <summary>Verifies that serializing a rate-of-change model always includes the direction.</summary> [Fact] public void Serialize_RateOfChange_AlwaysIncludesDirection() { @@ -301,6 +324,7 @@ public class AlarmTriggerConfigCodecTests // ── Round-trip ───────────────────────────────────────────────────────── + /// <summary>Verifies that a not-equals value-match model survives a serialize and parse round trip.</summary> [Fact] public void RoundTrip_ValueMatch_NotEquals_Preserved() { @@ -319,6 +343,7 @@ public class AlarmTriggerConfigCodecTests Assert.True(round.NotEquals); } + /// <summary>Verifies that a range-violation model survives a serialize and parse round trip.</summary> [Fact] public void RoundTrip_RangeViolation_Preserved() { @@ -336,6 +361,7 @@ public class AlarmTriggerConfigCodecTests Assert.Equal(original.Max, round.Max); } + /// <summary>Verifies that a rate-of-change model survives a serialize and parse round trip.</summary> [Fact] public void RoundTrip_RateOfChange_Preserved() { @@ -358,6 +384,7 @@ public class AlarmTriggerConfigCodecTests // ── Parse: HiLo ──────────────────────────────────────────────────────── + /// <summary>Verifies that parsing a high-low configuration reads all setpoints and priorities.</summary> [Fact] public void Parse_HiLo_ReadsAllSetpointsAndPriorities() { @@ -376,6 +403,7 @@ public class AlarmTriggerConfigCodecTests Assert.Equal(900, model.HiHiPriority); } + /// <summary>Verifies that a high-low configuration with only some setpoints parses, leaving the missing ones null.</summary> [Fact] public void Parse_HiLo_AcceptsPartialSetpoints_MissingOnesAreNull() { @@ -393,6 +421,7 @@ public class AlarmTriggerConfigCodecTests // ── Serialize: HiLo ──────────────────────────────────────────────────── + /// <summary>Verifies that serializing a high-low model omits null setpoints and priorities.</summary> [Fact] public void Serialize_HiLo_OmitsNullSetpointsAndPriorities() { @@ -418,6 +447,7 @@ public class AlarmTriggerConfigCodecTests Assert.False(root.TryGetProperty("loPriority", out _)); } + /// <summary>Verifies that serializing a high-low model does not leak fields that belong to other trigger types.</summary> [Fact] public void Serialize_HiLo_DoesNotLeakForeignTriggerTypeFields() { @@ -443,6 +473,7 @@ public class AlarmTriggerConfigCodecTests Assert.False(root.TryGetProperty("direction", out _)); } + /// <summary>Verifies that parsing a high-low configuration reads the deadbands.</summary> [Fact] public void Parse_HiLo_ReadsDeadbands() { @@ -456,6 +487,7 @@ public class AlarmTriggerConfigCodecTests Assert.Null(model.LoLoDeadband); } + /// <summary>Verifies that serializing a high-low model omits null deadbands.</summary> [Fact] public void Serialize_HiLo_OmitsNullDeadbands() { @@ -475,6 +507,7 @@ public class AlarmTriggerConfigCodecTests Assert.False(doc.RootElement.TryGetProperty("loDeadband", out _)); } + /// <summary>Verifies that a high-low model preserves all of its fields through a serialize and parse round trip.</summary> [Fact] public void RoundTrip_HiLo_PreservesAllFields() { @@ -515,6 +548,9 @@ public class AlarmTriggerConfigCodecTests // ── NormalizeDirection (direct) ──────────────────────────────────────── + /// <summary>Verifies that direction normalization handles every alias and falls back to either for unknown input.</summary> + /// <param name="input">The direction text to normalize.</param> + /// <param name="expected">The expected normalized direction.</param> [Theory] [InlineData("rising", "rising")] [InlineData("RISING", "rising")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DarkModeToggleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DarkModeToggleTests.cs index b45ce31c..4e09c502 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DarkModeToggleTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DarkModeToggleTests.cs @@ -12,6 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// </summary> public class DarkModeToggleTests : BunitContext { + /// <summary>Initializes the bUnit context with loose JavaScript interop for the dark mode toggle.</summary> public DarkModeToggleTests() { // Loose mode: unconfigured interop returns default. We configure the two @@ -19,6 +20,7 @@ public class DarkModeToggleTests : BunitContext JSInterop.Mode = JSRuntimeMode.Loose; } + /// <summary>Verifies that the toggle renders a button with the toggle dark mode aria label.</summary> [Fact] public void Renders_Button_WithToggleDarkModeAriaLabel() { @@ -30,6 +32,7 @@ public class DarkModeToggleTests : BunitContext Assert.NotNull(button); } + /// <summary>Verifies that a persisted light preference renders the button with aria-pressed false on first render.</summary> [Fact] public void OnFirstRender_PersistedLight_AriaPressedFalse() { @@ -44,6 +47,7 @@ public class DarkModeToggleTests : BunitContext Assert.NotNull(cut.Find("i.bi-moon-stars")); } + /// <summary>Verifies that a persisted dark preference renders the button with aria-pressed true on first render.</summary> [Fact] public void OnFirstRender_PersistedDark_AriaPressedTrue() { @@ -61,6 +65,7 @@ public class DarkModeToggleTests : BunitContext }); } + /// <summary>Verifies that clicking the button invokes the toggle and reflects the new state.</summary> [Fact] public void Click_InvokesToggle_AndReflectsNewState() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DataTablePagerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DataTablePagerTests.cs index e6b08ab7..cfa828a7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DataTablePagerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DataTablePagerTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// <summary> -/// Regression tests for CentralUI-016. <c>DataTable</c> looped +/// Regression tests for the <c>DataTable</c> pager, which looped /// <c>for i = 1..totalPages</c> and emitted one numbered <c><li></c> /// button per page; a few thousand records at page size 25 rendered hundreds /// of buttons into the diff on every state change. The fix windows the pager @@ -27,6 +27,7 @@ public class DataTablePagerTests : BunitContext => cut.FindAll("ul.pagination li.page-item button") .Count(b => int.TryParse(b.TextContent.Trim(), out _)); + /// <summary>Verifies that a pager with thousands of pages renders a window of buttons rather than every page.</summary> [Fact] public void Pager_WithThousandsOfPages_RendersWindowedNotEveryPage() { @@ -40,6 +41,7 @@ public class DataTablePagerTests : BunitContext $"Expected a windowed pager (<= 12 numbered buttons) but rendered {numbered}."); } + /// <summary>Verifies that a pager over a small dataset still renders a button for every page.</summary> [Fact] public void Pager_SmallDataset_StillRendersEveryPage() { @@ -51,6 +53,7 @@ public class DataTablePagerTests : BunitContext Assert.Equal(5, numbered); } + /// <summary>Verifies that the windowed pager always includes the first and last pages around the current page.</summary> [Fact] public void Pager_WindowedAroundCurrentPage_AlwaysIncludesFirstAndLast() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DateTimeRangeFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DateTimeRangeFilterTests.cs index 670cae99..e77ebaf0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DateTimeRangeFilterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DateTimeRangeFilterTests.cs @@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// </summary> public class DateTimeRangeFilterTests : BunitContext { + /// <summary>Verifies that the filter renders two inputs with prefixed ids and test hooks.</summary> [Fact] public void Renders_TwoInputs_WithPrefixedIdsAndTestHooks() { @@ -26,6 +27,7 @@ public class DateTimeRangeFilterTests : BunitContext Assert.Equal("sc-to", toInput.GetAttribute("id")); } + /// <summary>Verifies that the filter renders its labels.</summary> [Fact] public void RendersLabels() { @@ -40,6 +42,7 @@ public class DateTimeRangeFilterTests : BunitContext Assert.Contains("End", cut.Markup); } + /// <summary>Verifies that setting the from input invokes the from-changed callback with the parsed value.</summary> [Fact] public void SettingFromInput_InvokesFromChanged_WithParsedValue() { @@ -59,6 +62,7 @@ public class DateTimeRangeFilterTests : BunitContext Assert.Equal(30, captured.Value.Minute); } + /// <summary>Verifies that clearing the from input invokes the from-changed callback with null.</summary> [Fact] public void ClearingFromInput_InvokesFromChanged_WithNull() { @@ -74,6 +78,7 @@ public class DateTimeRangeFilterTests : BunitContext Assert.Null(captured); } + /// <summary>Verifies that an initial value is rendered in the input.</summary> [Fact] public void InitialValue_RendersInInput() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogHostShowAsyncTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogHostShowAsyncTests.cs index 9efd3584..3f2d1b77 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogHostShowAsyncTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogHostShowAsyncTests.cs @@ -22,6 +22,7 @@ public class DialogHostShowAsyncTests : BunitContext { private readonly DialogService _service = new(); + /// <summary>Initializes the test class and registers the dialog services.</summary> public DialogHostShowAsyncTests() { // Register the concrete service as the interface so DialogHost (which @@ -32,6 +33,8 @@ public class DialogHostShowAsyncTests : BunitContext JSInterop.Mode = JSRuntimeMode.Loose; } + /// <summary>Verifies that ShowAsync renders a custom body and resolves the typed result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ShowAsync_RendersCustomBody_AndResolvesTypedResult() { @@ -68,6 +71,8 @@ public class DialogHostShowAsyncTests : BunitContext Assert.Equal("done", await pending); } + /// <summary>Verifies that cancelling resolves ShowAsync with null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ShowAsync_CancelResolvesNull() { @@ -94,6 +99,8 @@ public class DialogHostShowAsyncTests : BunitContext Assert.Null(await pending); } + /// <summary>Verifies that pressing Escape resolves ShowAsync with null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ShowAsync_EscapeResolvesNull() { @@ -119,6 +126,8 @@ public class DialogHostShowAsyncTests : BunitContext Assert.Null(await pending); } + /// <summary>Verifies that ShowAsync applies the size class to the modal dialog.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ShowAsync_AppliesSizeClassToModalDialog() { @@ -137,6 +146,8 @@ public class DialogHostShowAsyncTests : BunitContext Assert.Contains("modal-lg", dialog.ClassList); } + /// <summary>Verifies that the backdrop carries the token hook class.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Backdrop_HasTokenHookClass() { @@ -157,6 +168,8 @@ public class DialogHostShowAsyncTests : BunitContext Assert.Contains("sb-modal-backdrop", backdrop.ClassList); } + /// <summary>Verifies that Confirm still resolves true, with no regression.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Confirm_StillResolvesTrue_NoRegression() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogServiceThreadingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogServiceThreadingTests.cs index 084b222b..01b8eb36 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogServiceThreadingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogServiceThreadingTests.cs @@ -3,8 +3,8 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// <summary> -/// Characterization tests for CentralUI-015 (re-triaged Won't Fix — see -/// findings.md). The finding claimed <c>ContinueWith(..., TaskScheduler.Default)</c> +/// Characterization tests for dialog service threading (a re-triaged +/// Won't Fix finding). The finding claimed <c>ContinueWith(..., TaskScheduler.Default)</c> /// made callers resume off the render thread; that premise is incorrect — an /// <c>await</c> always resumes on the awaiter's own captured /// <see cref="SynchronizationContext"/> regardless of where the awaited task @@ -25,6 +25,7 @@ public class DialogServiceThreadingTests private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback, object?)> _queue = new(); public int PostedCount; + /// <summary>Initializes a new instance of the <c>TrackingSyncContext</c> class.</summary> public TrackingSyncContext() { _thread = new Thread(() => @@ -38,15 +39,19 @@ public class DialogServiceThreadingTests _thread.Start(); } + /// <inheritdoc /> public override void Post(SendOrPostCallback d, object? state) { Interlocked.Increment(ref PostedCount); _queue.Add((d, state)); } + /// <summary>Marks the synchronization context as complete.</summary> public void Complete() => _queue.CompleteAdding(); } + /// <summary>Verifies that the confirm dialog's awaiter resumes on its captured synchronization context.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConfirmAsync_AwaiterResumesOnItsCapturedSyncContext() { @@ -82,6 +87,8 @@ public class DialogServiceThreadingTests Assert.NotEqual(Environment.CurrentManagedThreadId, resumeThreadId); } + /// <summary>Verifies that the confirm dialog resolves with the expected value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConfirmAsync_ResolvesWithExpectedValue() { @@ -93,6 +100,8 @@ public class DialogServiceThreadingTests Assert.True(await task); } + /// <summary>Verifies that the prompt dialog resolves with the entered value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PromptAsync_ResolvesWithExpectedValue() { @@ -104,6 +113,8 @@ public class DialogServiceThreadingTests Assert.Equal("typed value", await task); } + /// <summary>Verifies that a cancelled prompt dialog resolves to null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PromptAsync_CancelledResolvesToNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogSeverityStylingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogSeverityStylingTests.cs index f48bb9fb..a7f0b6b4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogSeverityStylingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogSeverityStylingTests.cs @@ -28,6 +28,7 @@ public class DialogSeverityStylingTests : BunitContext { private readonly DialogService _dialog = new(); + /// <summary>Initializes the test context with the dialog service and its dependencies.</summary> public DialogSeverityStylingTests() { Services.AddSingleton<IDialogService>(_dialog); @@ -52,10 +53,11 @@ public class DialogSeverityStylingTests : BunitContext return footer.Single(b => b.TextContent.Trim() != "Cancel"); } + /// <summary>Verifies that a caution dialog renders amber, neither red nor primary.</summary> [Fact] public void Caution_rendersAmber_notRedAndNotPrimary() { - // The row-75 case. Amber is the point: not red (which reads "delete"), and not + // The caution case. Amber is the point: not red (which reads "delete"), and not // the primary blue (which reads "safe default" — wrong for a live device write). var confirm = OpenConfirm(d => d.ConfirmAsync("Approve thing", "Sure?", DialogSeverity.Caution, "Approve")); @@ -64,6 +66,7 @@ public class DialogSeverityStylingTests : BunitContext Assert.DoesNotContain("btn-primary", confirm.ClassList); } + /// <summary>Verifies that a destructive dialog still renders red.</summary> [Fact] public void Destructive_stillRendersRed() { @@ -74,6 +77,7 @@ public class DialogSeverityStylingTests : BunitContext Assert.Contains("btn-danger", confirm.ClassList); } + /// <summary>Verifies that a normal dialog still renders with the primary style.</summary> [Fact] public void Normal_stillRendersPrimary() { @@ -82,6 +86,9 @@ public class DialogSeverityStylingTests : BunitContext Assert.Contains("btn-primary", confirm.ClassList); } + /// <summary>Verifies that the boolean danger form maps onto the same two severities.</summary> + /// <param name="danger">Whether the dialog is requested as dangerous.</param> + /// <param name="expectedClass">The button CSS class the dialog is expected to render.</param> [Theory] [InlineData(true, "btn-danger")] [InlineData(false, "btn-primary")] @@ -99,6 +106,7 @@ public class DialogSeverityStylingTests : BunitContext Assert.Contains(expectedClass, confirm.ClassList); } + /// <summary>Verifies that a caution dialog without a label reads Confirm rather than Delete.</summary> [Fact] public void UnlabelledCaution_readsConfirm_notDelete() { @@ -110,10 +118,11 @@ public class DialogSeverityStylingTests : BunitContext Assert.Equal("Confirm", confirm.TextContent.Trim()); } + /// <summary>Verifies that a destructive dialog without a label still falls back to Delete.</summary> [Fact] public void UnlabelledDestructive_stillFallsBackToDelete() { - // The row-28 fallback is retained deliberately (the Playwright suites click the + // The "Delete" fallback is retained deliberately (the Playwright suites click the // literal "Delete"). Pinned here alongside its new sibling so the pair is read // together. var confirm = OpenConfirm(d => d.ConfirmAsync("Delete thing", "Sure?", DialogSeverity.Destructive)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DiffDialogTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DiffDialogTests.cs index e702fd18..7bad6e41 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DiffDialogTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DiffDialogTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// <summary> -/// Regression tests for CentralUI-011. <c>DiffDialog.OpenAsync</c> returns the +/// Regression tests for disposing <c>DiffDialog</c> while it is open. <c>DiffDialog.OpenAsync</c> returns the /// <c>TaskCompletionSource</c>'s task, completed only by <c>Close()</c>. If the /// user navigated away while the dialog was open, <c>DisposeAsync</c> ran but /// never completed the TCS — the awaiting caller was suspended forever and any @@ -19,13 +19,15 @@ public class DiffDialogTests : BunitContext /// so a path that <c>await</c>s them (e.g. <c>DisposeAsync</c> → /// <c>TryUnlockBodyAsync</c>) resumes instead of hanging on a never-completed /// planned invocation, and no strict-mode unplanned-invocation exception - /// surfaces through the narrowed CentralUI-023 catch blocks. + /// surfaces through the narrowed catch blocks. /// </summary> private void SetupBodyLockInterop() { JSInterop.Mode = JSRuntimeMode.Loose; } + /// <summary>Verifies that disposing the dialog while it is open completes the pending task.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DisposeAsync_WhileOpen_CompletesPendingTask() { @@ -52,6 +54,8 @@ public class DiffDialogTests : BunitContext Assert.False(result, "Dismiss-on-dispose should resolve to false (not confirmed)."); } + /// <summary>Verifies that closing the dialog completes the pending task with true.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Close_CompletesPendingTaskWithTrue() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DurationInputTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DurationInputTests.cs index fc1d7e60..ea1477b9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DurationInputTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DurationInputTests.cs @@ -10,6 +10,7 @@ public class DurationInputTests { // ── Split: TimeSpan -> (value, unit) ─────────────────────────────────── + /// <summary>Verifies that splitting a null duration returns a blank value with the seconds unit.</summary> [Fact] public void Split_Null_ReturnsBlankWithSecondsUnit() { @@ -19,6 +20,7 @@ public class DurationInputTests Assert.Equal("sec", unit); } + /// <summary>Verifies that splitting a zero duration returns a blank value.</summary> [Fact] public void Split_Zero_ReturnsBlank() { @@ -27,6 +29,7 @@ public class DurationInputTests Assert.Null(value); } + /// <summary>Verifies that a whole number of minutes splits into the minute unit.</summary> [Fact] public void Split_WholeMinutes_UsesMinuteUnit() { @@ -36,6 +39,7 @@ public class DurationInputTests Assert.Equal("min", unit); } + /// <summary>Verifies that a whole number of seconds splits into the second unit.</summary> [Fact] public void Split_WholeSeconds_UsesSecondUnit() { @@ -45,6 +49,7 @@ public class DurationInputTests Assert.Equal("sec", unit); } + /// <summary>Verifies that a sub-second duration splits into the millisecond unit.</summary> [Fact] public void Split_SubSecond_UsesMillisecondUnit() { @@ -56,32 +61,40 @@ public class DurationInputTests // ── Compose: (value, unit) -> TimeSpan? ──────────────────────────────── + /// <summary>Verifies that composing a blank value returns null.</summary> [Fact] public void Compose_Blank_ReturnsNull() => Assert.Null(DurationInput.Compose(null, "sec")); + /// <summary>Verifies that composing a zero value returns null.</summary> [Fact] public void Compose_Zero_ReturnsNull() => Assert.Null(DurationInput.Compose("0", "sec")); + /// <summary>Verifies that composing a negative value returns null.</summary> [Fact] public void Compose_Negative_ReturnsNull() => Assert.Null(DurationInput.Compose("-5", "sec")); + /// <summary>Verifies that composing a seconds value builds the matching duration.</summary> [Fact] public void Compose_SecondsValue_BuildsDuration() => Assert.Equal(TimeSpan.FromSeconds(30), DurationInput.Compose("30", "sec")); + /// <summary>Verifies that composing a minutes value builds the matching duration.</summary> [Fact] public void Compose_MinutesValue_BuildsDuration() => Assert.Equal(TimeSpan.FromMinutes(5), DurationInput.Compose("5", "min")); + /// <summary>Verifies that composing a milliseconds value builds the matching duration.</summary> [Fact] public void Compose_MillisecondsValue_BuildsDuration() => Assert.Equal(TimeSpan.FromMilliseconds(250), DurationInput.Compose("250", "ms")); // ── Round-trip ───────────────────────────────────────────────────────── + /// <summary>Verifies that splitting and then composing a duration preserves it.</summary> + /// <param name="milliseconds">The duration in milliseconds.</param> [Theory] [InlineData(250)] [InlineData(30000)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs index 539fa18a..53d432f2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs @@ -48,6 +48,7 @@ public class FlowGraphViewTests : BunitContext return FlowGraphBuilder.Build(events); } + /// <summary>Verifies that laying out the Delmia graph assigns the expected lanes and columns.</summary> [Fact] public void Layout_DelmiaGraph_AssignsExpectedLanesAndColumns() { @@ -79,6 +80,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal(FlowGraphView.ColumnOrigin + 7 * FlowGraphView.ColumnPitch + FlowGraphView.RightPad, layout.Width); } + /// <summary>Verifies that independent sibling nodes in one lane never share a column.</summary> [Fact] public void Layout_IndependentSiblingsInOneLane_NeverShareAColumn() { @@ -102,6 +104,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal(siteCols.Distinct().Count(), siteCols.Count); } + /// <summary>Verifies that the selected node renders with the selected CSS class.</summary> [Fact] public void Render_SelectedNode_CarriesSelectedClass() { @@ -121,6 +124,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal("0", only.GetAttribute("tabindex")); } + /// <summary>Verifies that clicking a node raises the selection callback with its step.</summary> [Fact] public void Render_ClickingANode_RaisesOnSelectWithTheStep() { @@ -137,6 +141,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal(FlowStepKind.ScriptRun, picked!.Kind); } + /// <summary>Verifies that an empty graph renders the lanes and a no-steps note.</summary> [Fact] public void Render_EmptyGraph_ShowsLanesAndNoStepsNote() { @@ -150,6 +155,7 @@ public class FlowGraphViewTests : BunitContext Assert.Contains("External", cut.Markup); } + /// <summary>Verifies that a prediction-only graph draws dashed predicted nodes and edges.</summary> [Fact] public void Render_PredictionOnly_DrawsDashedPredictedNodesAndEdges() { @@ -166,6 +172,7 @@ public class FlowGraphViewTests : BunitContext Assert.DoesNotContain(nodes, n => n.ClassList.Contains("ghost")); } + /// <summary>Verifies that a graph with a prediction greys out predicted nodes the run never produced.</summary> [Fact] public void Render_GraphWithPrediction_GreysPredictedNodesTheRunNeverProduced() { @@ -193,6 +200,7 @@ public class FlowGraphViewTests : BunitContext Assert.True(int.Parse(ghost.GetAttribute("data-column")!) > realMax); } + /// <summary>Verifies that when two route calls are predicted and only the second really ran, the overlay matches it by title and leaves the first as the ghost.</summary> [Fact] public void Overlay_TwoPredictedRouteCalls_RealSecondCallMatchesByTitle_FirstIsTheGhost() { @@ -217,6 +225,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal("A.X", ghost.Predicted?.Title); } + /// <summary>Verifies that rendered coordinates stay culture-invariant under the German culture.</summary> [Fact] public void Render_UnderGermanCulture_CoordinatesStayInvariant() { @@ -245,6 +254,9 @@ public class FlowGraphViewTests : BunitContext // ── Node text readability (2026-09-06): shared-prefix stripping, width-based clipping, tooltips ── + /// <summary>Verifies that the common dotted prefix factors out only a shared dotted head that leaves every tail non-empty.</summary> + /// <param name="titles">The titles.</param> + /// <param name="expected">The expected.</param> [Theory] [InlineData(new[] { "DelmiaReceiver.RecipeDownloadFlag", "DelmiaReceiver.RecipeProcessedFlag" }, "DelmiaReceiver.")] [InlineData(new[] { "A.B.C.X", "A.B.C.Y", "A.B.D.Z" }, "A.B.")] @@ -258,6 +270,9 @@ public class FlowGraphViewTests : BunitContext Assert.Equal(expected, FlowGraphView.CommonDottedPrefix(titles)); } + /// <summary>Verifies that wrap hints mark camel-case and separator boundaries.</summary> + /// <param name="input">The input.</param> + /// <param name="expected">The expected.</param> [Theory] [InlineData("DelmiaRecipeDownload", "Delmia\u200BRecipe\u200BDownload")] [InlineData("DelmiaSim01.ProcessRecipeDownload", "Delmia\u200BSim01.\u200BProcess\u200BRecipe\u200BDownload")] @@ -270,6 +285,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal(expected, FlowGraphView.WrapHints(input)); } + /// <summary>Verifies that stripping the prefix removes the shared head from the title and from each subtitle item.</summary> [Fact] public void StripPrefix_RemovesTheHeadFromTheTitleAndFromEachSubtitleItem() { @@ -280,6 +296,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal("kept", FlowGraphView.StripPrefix("kept", null)); } + /// <summary>Verifies that the tags lane layout factors the shared dotted head into the gutter.</summary> [Fact] public void Layout_TagsLane_FactorsTheSharedDottedHeadIntoTheGutter() { @@ -296,6 +313,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal(wait.Title, wait.DisplayTitle); } + /// <summary>Verifies that predicted-only nodes never get a lane prefix.</summary> [Fact] public void Layout_PredictedOnlyNodes_NeverGetALanePrefix() { @@ -307,6 +325,7 @@ public class FlowGraphViewTests : BunitContext Assert.All(layout.LanePrefixes, p => Assert.Equal(string.Empty, p)); } + /// <summary>Verifies that a node body renders as HTML in a foreign object with a stripped title and a full-text tooltip.</summary> [Fact] public void Render_NodeBodyIsHtmlInAForeignObject_WithStrippedTitleAndFullTextTooltip() { @@ -378,6 +397,7 @@ public class FlowGraphViewTests : BunitContext return FlowGraphBuilder.Build(events); } + /// <summary>Verifies that outbound steps land in the external lane with a target sublabel.</summary> [Fact] public void Layout_OutboundSteps_LandInTheExternalLane_WithATargetSublabel() { @@ -394,6 +414,7 @@ public class FlowGraphViewTests : BunitContext Assert.Single(layout.Edges, e => e.Spawn); } + /// <summary>Verifies that the external lane sublabel lists three targets and then switches to counts.</summary> [Fact] public void Layout_ExternalSublabel_ListsThreeTargetsThenCounts() { @@ -402,6 +423,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal("Delmia.AckRecipe · MesDb · Planners +1", layout.LaneSublabels[(int)FlowLane.External]); } + /// <summary>Verifies that an API call node renders an eyebrow and an OK pill with the sync mode.</summary> [Fact] public void Render_ApiCall_HasEyebrowAndOkPillWithSyncMode() { @@ -424,6 +446,7 @@ public class FlowGraphViewTests : BunitContext Assert.NotNull(run.QuerySelector("[data-test='flow-node-subtitle']")); } + /// <summary>Verifies that a pending cached call renders the pending class and pill.</summary> [Fact] public void Render_PendingCachedCall_HasPendingClassAndPill() { @@ -439,6 +462,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal(string.Empty, db.QuerySelector(".fg-duration")!.TextContent.Trim()); } + /// <summary>Verifies that a parked notification renders the parked class and the submit-to-terminal duration.</summary> [Fact] public void Render_ParkedNotification_HasParkedClass_AndSubmitToTerminalDuration() { @@ -455,6 +479,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal("+9 s", notify.QuerySelector(".fg-duration")!.TextContent.Trim()); } + /// <summary>Verifies that async edges render dashed, and a spawn edge keeps its arrowhead and gets a bolt badge.</summary> [Fact] public void Render_AsyncEdgesAreDashedClass_SpawnEdgeKeepsAnArrowheadAndGetsABoltBadge() { @@ -487,6 +512,7 @@ public class FlowGraphViewTests : BunitContext Assert.InRange(y, Math.Min(a.Y, b.Y) + FlowGraphView.NodeHeight / 2, Math.Max(a.Y, b.Y) + FlowGraphView.NodeHeight / 2); } + /// <summary>Verifies that the spawn badge point sits on the vertical run of each connector shape.</summary> [Fact] public void SpawnBadgePoint_SitsOnTheVerticalRunOfEachConnectorShape() { @@ -517,6 +543,10 @@ public class FlowGraphViewTests : BunitContext Assert.InRange(ay, siteRun.Y + h, external.Y - FlowGraphView.LanePad / 2); } + /// <summary>Verifies that the outbound mode text drops the status the pill already carries and names the call mode.</summary> + /// <param name="kind">The kind.</param> + /// <param name="subtitle">The subtitle.</param> + /// <param name="expected">The expected.</param> [Theory] [InlineData(FlowStepKind.ApiCall, "Delivered · sync", "sync")] [InlineData(FlowStepKind.ApiCall, "Failed · sync", "sync")] @@ -532,6 +562,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal(expected, FlowGraphView.OutboundMode(kind, subtitle)); } + /// <summary>Verifies that predicted outbound nodes render as ghosts without pills.</summary> [Fact] public void Render_PredictedOutboundNodes_AreGhostsWithoutPills() { @@ -551,6 +582,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal("API CALL", external[0].QuerySelector(".fg-eyebrow")!.TextContent.Trim()); } + /// <summary>Verifies that a predicted async edge renders with the async class.</summary> [Fact] public void PredictedAsyncEdge_RendersWithAsyncClass() { @@ -584,6 +616,7 @@ public class FlowGraphViewTests : BunitContext return new FlowGraph([step], [], false, false); } + /// <summary>Verifies that a step with no log lines renders no log badge.</summary> [Fact] public void Render_StepWithNoLogLines_HasNoLogBadge() { @@ -594,6 +627,7 @@ public class FlowGraphViewTests : BunitContext Assert.NotNull(cut.Find("g[data-test='flow-node'] .fg-foot [data-test='flow-node-subtitle']")); } + /// <summary>Verifies that a step with mixed log levels shows counts with the highest severity first and counts debug lines as info.</summary> [Fact] public void Render_StepWithMixedLevels_ShowsCountsHighestSeverityFirst_DebugCountedAsInfo() { @@ -610,6 +644,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal("1 error, 2 warnings, 4 info", badge.GetAttribute("title")); } + /// <summary>Verifies that a step with only info lines shows a single faint count.</summary> [Fact] public void Render_StepWithOnlyInfoLines_ShowsASingleFaintCount() { @@ -622,6 +657,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal("2 info", badge.GetAttribute("title")); } + /// <summary>Verifies that a failed step with error detail uses the exception type and message as its subtitle.</summary> [Fact] public void Render_FailedStepWithErrorDetail_SubtitleIsTheExceptionTypeAndMessage() { @@ -634,6 +670,7 @@ public class FlowGraphViewTests : BunitContext cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim()); } + /// <summary>Verifies that a failed step without error detail falls back to the error message as its subtitle.</summary> [Fact] public void Render_FailedStepWithoutDetail_FallsBackToTheErrorMessage() { @@ -642,6 +679,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal("timed out talking to MesDb", cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim()); } + /// <summary>Verifies that an OK step carrying an error detail keeps its normal subtitle.</summary> [Fact] public void Render_OkStepWithAnErrorDetail_KeepsItsNormalSubtitle() { @@ -650,6 +688,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal("trigger: manual", cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim()); } + /// <summary>Verifies that log counts fold levels case-insensitively and treat any unrecognized level as info.</summary> [Fact] public void LogCounts_FoldsLevelsCaseInsensitively_AndTreatsEverythingElseAsInfo() { @@ -660,6 +699,11 @@ public class FlowGraphViewTests : BunitContext Assert.Equal((2, 1, 3), FlowGraphView.LogCounts(step)); } + /// <summary>Verifies that the exception subtitle parses the first line of the detail only for a failed step.</summary> + /// <param name="outcome">The outcome.</param> + /// <param name="detail">The detail.</param> + /// <param name="message">The message.</param> + /// <param name="expected">The expected.</param> [Theory] [InlineData(FlowStepOutcome.Failed, "System.InvalidOperationException: Recipe 42 rejected\n at X", "boom", "InvalidOperationException: Recipe 42 rejected")] [InlineData(FlowStepOutcome.TimedOut, "System.TimeoutException: took too long", "boom", "TimeoutException: took too long")] @@ -685,6 +729,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal(expected, FlowGraphView.ExceptionSubtitle(step)); } + /// <summary>Verifies that the log title names only the non-zero levels, using singular and plural forms correctly.</summary> [Fact] public void LogTitle_NamesOnlyTheNonZeroLevels_WithSingularAndPlural() { @@ -695,6 +740,7 @@ public class FlowGraphViewTests : BunitContext Assert.Equal(string.Empty, FlowGraphView.LogTitle((0, 0, 0))); } + /// <summary>Verifies that an outbound node with log lines shows both the pill and the log badge in its footer.</summary> [Fact] public void Render_OutboundNodeWithLogLines_ShowsBothThePillAndTheBadgeInTheFooter() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs index d67a77df..15e2a9a6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs @@ -14,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// </summary> public class InstancePickerTests : BunitContext { + /// <summary>Initializes the test context with loose JS interop.</summary> public InstancePickerTests() { JSInterop.Mode = JSRuntimeMode.Loose; @@ -69,6 +70,7 @@ public class InstancePickerTests : BunitContext private static AngleSharp.Dom.IElement TreeItem(IRenderedComponent<InstancePicker> cut, int index) => cut.FindAll("[role='treeitem']")[index]; + /// <summary>Verifies that a closed picker renders only the input field.</summary> [Fact] public void Closed_ShowsNothingButTheField() { @@ -81,6 +83,7 @@ public class InstancePickerTests : BunitContext Assert.Equal("false", input.GetAttribute("aria-expanded")); } + /// <summary>Verifies that toggling opens the panel with all areas collapsed.</summary> [Fact] public void Toggle_OpensPanel_WithAreasCollapsed() { @@ -102,6 +105,7 @@ public class InstancePickerTests : BunitContext Assert.False(areaRow.HasAttribute("data-instance-id")); } + /// <summary>Verifies that clicking an area label expands that area.</summary> [Fact] public void ClickingAnAreaLabel_ExpandsIt() { @@ -116,6 +120,7 @@ public class InstancePickerTests : BunitContext Assert.Single(cut.FindAll("[data-instance-id='7']")); } + /// <summary>Verifies that clicking an instance selects it, closes the panel and raises the selection callback.</summary> [Fact] public void ClickingAnInstance_Selects_Closes_AndRaisesTheCallback() { @@ -132,6 +137,7 @@ public class InstancePickerTests : BunitContext Assert.Equal("P1", InputValue(cut)); } + /// <summary>Verifies that a disabled picker never opens.</summary> [Fact] public void Disabled_NeverOpens() { @@ -145,6 +151,7 @@ public class InstancePickerTests : BunitContext Assert.Empty(cut.FindAll("[data-test='t-panel']")); } + /// <summary>Verifies that clicking the backdrop closes the panel without changing the selection.</summary> [Fact] public void Backdrop_ClickClosesThePanel_WithoutChangingSelection() { @@ -159,6 +166,7 @@ public class InstancePickerTests : BunitContext Assert.Equal(string.Empty, InputValue(cut)); } + /// <summary>Verifies that a selection set by the parent updates the field text.</summary> [Fact] public void ParentDrivenSelection_UpdatesTheFieldText() { @@ -171,6 +179,7 @@ public class InstancePickerTests : BunitContext Assert.Equal(string.Empty, InputValue(cut)); } + /// <summary>Verifies that supplying a new instances list after a site change clears the stale selection text.</summary> [Fact] public void SiteChange_NewInstancesList_ClearsAStaleSelectionText() { @@ -190,6 +199,7 @@ public class InstancePickerTests : BunitContext Assert.Empty(cut.FindAll("[data-test='t-panel']")); } + /// <summary>Verifies that the item label function is used for both the rows and the field text.</summary> [Fact] public void ItemLabel_IsUsedForRowsAndField() { @@ -207,6 +217,7 @@ public class InstancePickerTests : BunitContext Assert.Equal("P1 (x)", InputValue(cut)); } + /// <summary>Verifies that typing filters the tree and expands it to the matches.</summary> [Fact] public void Typing_Filters_AndExpandsToTheMatches() { @@ -224,6 +235,7 @@ public class InstancePickerTests : BunitContext Assert.DoesNotContain("Zulu", rows); } + /// <summary>Verifies that clearing the filter restores the full tree in a collapsed state.</summary> [Fact] public void Clearing_RestoresTheFullTree_Collapsed() { @@ -239,6 +251,7 @@ public class InstancePickerTests : BunitContext Assert.Empty(cut.FindAll("[data-instance-id='7']")); } + /// <summary>Verifies that typing opens a closed panel.</summary> [Fact] public void Typing_OpensTheClosedPanel() { @@ -251,6 +264,7 @@ public class InstancePickerTests : BunitContext Assert.Equal(new[] { "Zulu" }, Rows(cut)); } + /// <summary>Verifies that pressing Enter with no matching instance does nothing.</summary> [Fact] public void Enter_WithNoMatch_DoesNothing() { @@ -265,6 +279,7 @@ public class InstancePickerTests : BunitContext Assert.Single(cut.FindAll("[data-test='t-panel']")); } + /// <summary>Verifies that pressing Enter picks the first visible match.</summary> [Fact] public void Enter_PicksTheFirstVisibleMatch() { @@ -281,6 +296,7 @@ public class InstancePickerTests : BunitContext Assert.Equal("Zulu", InputValue(cut)); } + /// <summary>Verifies that pressing Enter with no filter picks the first instance in document order.</summary> [Fact] public void Enter_WithNoFilter_PicksTheFirstInstanceInDocumentOrder() { @@ -296,6 +312,7 @@ public class InstancePickerTests : BunitContext Assert.Equal("Zulu", InputValue(cut)); } + /// <summary>Verifies that pressing Escape closes the panel and restores the selected text.</summary> [Fact] public void Escape_ClosesAndRestoresTheSelectedText() { @@ -319,6 +336,7 @@ public class InstancePickerTests : BunitContext Assert.Equal("P1", InputValue(cut)); } + /// <summary>Verifies that pressing ArrowDown moves focus into the tree.</summary> [Fact] public void ArrowDown_MovesFocusIntoTheTree() { @@ -345,6 +363,7 @@ public class InstancePickerTests : BunitContext i => i.Identifier.Contains("focus", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that pressing ArrowDown on a closed picker opens the panel and focuses the first row.</summary> [Fact] public void ArrowDown_OnAClosedPicker_OpensThePanel_AndFocusesTheFirstRow() { @@ -359,6 +378,7 @@ public class InstancePickerTests : BunitContext Assert.Equal("0", cut.FindAll("[role='treeitem']")[0].GetAttribute("tabindex")); } + /// <summary>Verifies that pressing Escape on a closed picker has no effect.</summary> [Fact] public void Escape_WhenClosed_IsANoOp() { @@ -371,6 +391,7 @@ public class InstancePickerTests : BunitContext Assert.Equal("Zulu", InputValue(cut)); } + /// <summary>Verifies that the filter text is not carried into the selection.</summary> [Fact] public void FilterIsNotCarriedIntoSelection() { @@ -390,6 +411,7 @@ public class InstancePickerTests : BunitContext Assert.Empty(cut.FindAll("[data-instance-id='7']")); } + /// <summary>Verifies that toggling while the panel is open closes it.</summary> [Fact] public void Toggle_WhileOpen_ClosesThePanel() { @@ -404,6 +426,7 @@ public class InstancePickerTests : BunitContext Assert.Equal("false", cut.Find("[data-test='t-input']").GetAttribute("aria-expanded")); } + /// <summary>Verifies that the field sits above the backdrop so it stays clickable while the panel is open.</summary> [Fact] public void TheFieldSitsAboveTheBackdrop_SoItStaysClickableWhileOpen() { @@ -419,6 +442,7 @@ public class InstancePickerTests : BunitContext Assert.Single(cut.FindAll("[data-test='t-backdrop']")); } + /// <summary>Verifies that two pickers on the same page get distinct panel ids.</summary> [Fact] public void TwoPickers_GetDistinctPanelIds() { @@ -439,6 +463,7 @@ public class InstancePickerTests : BunitContext Assert.Equal(secondPanel, second.Find("[data-test='t-input']").GetAttribute("aria-controls")); } + /// <summary>Verifies that re-rendering with the same instances reference leaves the typed text and open panel unchanged.</summary> [Fact] public void SameInstancesReference_LeavesTheTypedTextAndOpenPanelAlone() { @@ -457,6 +482,7 @@ public class InstancePickerTests : BunitContext // -- Keyboard activation on the tree itself -------------------------------- + /// <summary>Verifies that keyboard activation on a tree row selects the instance.</summary> [Fact] public void KeyboardActivation_OnATreeRow_SelectsTheInstance() { @@ -475,6 +501,7 @@ public class InstancePickerTests : BunitContext Assert.Equal("Zulu", InputValue(cut)); } + /// <summary>Verifies that keyboard activation on an area row toggles it.</summary> [Fact] public void KeyboardActivation_OnAnAreaRow_TogglesIt() { @@ -497,6 +524,7 @@ public class InstancePickerTests : BunitContext Assert.DoesNotContain("Cell A", Rows(cut)); } + /// <summary>Verifies that keyboard activation followed by a mouse click raises exactly one selection.</summary> [Fact] public void KeyboardActivation_AndMouseClick_RaiseExactlyOneSelection() { @@ -516,6 +544,7 @@ public class InstancePickerTests : BunitContext // -- Accessible name / labelling ------------------------------------------- + /// <summary>Verifies that the input carries an accessible name by default and from the parameter.</summary> [Fact] public void Input_CarriesAnAccessibleName_ByDefaultAndFromTheParameter() { @@ -526,6 +555,7 @@ public class InstancePickerTests : BunitContext Assert.Equal("Target instance", cut.Find("[data-test='t-input']").GetAttribute("aria-label")); } + /// <summary>Verifies that the input id defaults to a generated id and honours the parameter.</summary> [Fact] public void InputId_DefaultsToAGeneratedId_AndHonoursTheParameter() { @@ -542,6 +572,7 @@ public class InstancePickerTests : BunitContext Assert.Equal("flow-instance", first.Find("[data-test='t-input']").Id); } + /// <summary>Verifies that aria-controls is rendered only while the panel is open.</summary> [Fact] public void AriaControls_IsRenderedOnlyWhileThePanelIsOpen() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs index 97462223..97d27086 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs @@ -15,6 +15,7 @@ public class InstanceTreeBuilderTests private static Instance MakeInstance(int id, string uniqueName, int? areaId = null) => new(uniqueName) { Id = id, SiteId = 1, AreaId = areaId }; + /// <summary>Verifies that areas are nested under their parents and root instances come after root areas.</summary> [Fact] public void Build_NestsAreasByParent_AndPutsRootInstancesAfterRootAreas() { @@ -54,6 +55,7 @@ public class InstanceTreeBuilderTests Assert.Same(zulu, roots[2].Instance); } + /// <summary>Verifies that areas and instances are sorted case-insensitively.</summary> [Fact] public void Build_SortsAreasAndInstancesCaseInsensitively() { @@ -67,6 +69,7 @@ public class InstanceTreeBuilderTests Assert.Equal(new[] { "Alpha", "beta", "Apple", "zebra" }, roots.Select(n => n.Label)); } + /// <summary>Verifies that an area whose parent is missing becomes a root.</summary> [Fact] public void Build_OrphanedArea_BecomesARoot() { @@ -82,6 +85,7 @@ public class InstanceTreeBuilderTests Assert.Equal("i:10", leaf.Key); } + /// <summary>Verifies that an instance with an unknown area surfaces at the root.</summary> [Fact] public void Build_InstanceWithUnknownArea_SurfacesAtRoot() { @@ -96,6 +100,7 @@ public class InstanceTreeBuilderTests Assert.Same(stranded, roots[1].Instance); } + /// <summary>Verifies that an instance with an unknown area can still be found by the filter.</summary> [Fact] public void Build_InstanceWithUnknownArea_StaysFilterable() { @@ -108,6 +113,7 @@ public class InstanceTreeBuilderTests Assert.Empty(InstanceTreeBuilder.Build([line1], [stranded], filter: "nomatch")); } + /// <summary>Verifies that deeply nested areas wire their children at every level.</summary> [Fact] public void Build_DeepNesting_WiresChildrenAtEveryLevel() { @@ -129,6 +135,7 @@ public class InstanceTreeBuilderTests Assert.False(leaf.HasChildren); } + /// <summary>Verifies that cyclic parent area ids neither loop forever nor throw.</summary> [Fact] public void Build_CyclicParentAreaIds_DoNotLoopOrThrow() { @@ -144,6 +151,7 @@ public class InstanceTreeBuilderTests Assert.Empty(roots); } + /// <summary>Verifies that the filter matches instance names only, so an area name match alone is not enough.</summary> [Fact] public void Build_Filter_MatchesInstanceNamesOnly_AreaNameMatchIsNotEnough() { @@ -160,6 +168,7 @@ public class InstanceTreeBuilderTests Assert.Equal("i:11", Assert.Single(root.Children).Key); } + /// <summary>Verifies that the filter keeps matching instances with their ancestors and drops empty branches.</summary> [Fact] public void Build_Filter_KeepsMatchingInstancesWithAncestors_DropsEmptyBranches() { @@ -180,6 +189,7 @@ public class InstanceTreeBuilderTests Assert.Equal("i:10", leaf.Key); } + /// <summary>Verifies that a whitespace-only filter is treated as no filter.</summary> [Fact] public void Build_Filter_WhitespaceMeansNoFilter() { @@ -193,6 +203,7 @@ public class InstanceTreeBuilderTests Assert.Single(roots[0].Children); } + /// <summary>Verifies that null or empty inputs produce an empty tree.</summary> [Fact] public void Build_NullOrEmptyInputs_ReturnEmpty() { @@ -207,6 +218,7 @@ public class InstanceTreeBuilderTests Assert.Equal("a:1", Assert.Single(areasOnly).Key); } + /// <summary>Verifies that an area with no instances is still shown when no filter is applied.</summary> [Fact] public void Build_AreaWithNoInstancesAndNoFilter_IsStillShown() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/JsInteropLoggingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/JsInteropLoggingTests.cs index 23dab610..fdc4b968 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/JsInteropLoggingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/JsInteropLoggingTests.cs @@ -18,37 +18,59 @@ using ParkedMessagesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Moni namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// <summary> -/// Regression tests for CentralUI-023. <c>DiffDialog.TryLockBodyAsync</c> / +/// Regression tests for JS-interop failure logging. <c>DiffDialog.TryLockBodyAsync</c> / /// <c>TryUnlockBodyAsync</c> and <c>ParkedMessages.CopyAsync</c> wrapped JS /// interop in bare <c>catch { }</c> blocks: a genuine <see cref="JSException"/> /// was indistinguishable from an expected <see cref="JSDisconnectedException"/> /// and neither was logged. The fix narrows the catch and logs real interop -/// failures via <c>ILogger</c>, consistent with the CentralUI-018 fixes. +/// failures via <c>ILogger</c>, consistent with the earlier JS-interop logging fixes. /// </summary> public class JsInteropLoggingTests : BunitContext { /// <summary>Captures log entries so the test can assert on them.</summary> private sealed class CapturingLoggerProvider : ILoggerProvider { + /// <summary>Gets the log entries captured by the factory's loggers.</summary> public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new(); + /// <summary>Creates a logger that records into the shared entry list.</summary> + /// <param name="categoryName">The logger category name, which is ignored.</param> + /// <returns>A logger that captures its entries.</returns> public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries); + /// <summary>Does nothing; there is nothing to release.</summary> public void Dispose() { } private sealed class CapturingLogger : ILogger { private readonly List<(LogLevel, string, Exception?)> _entries; + /// <summary>Initializes a new instance of the <c>CapturingLogger</c> class.</summary> + /// <param name="entries">The list that receives captured log entries.</param> public CapturingLogger(List<(LogLevel, string, Exception?)> entries) => _entries = entries; + /// <summary>Returns no scope because the fake does not track scopes.</summary> + /// <typeparam name="TState">The type of the state object.</typeparam> + /// <param name="state">The scope or log state.</param> + /// <returns>A no-op scope.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + /// <summary>Reports whether logging is enabled for the given level.</summary> + /// <param name="logLevel">The log level.</param> + /// <returns>True if the level is enabled; otherwise false.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The type of the state object.</typeparam> + /// <param name="logLevel">The log level.</param> + /// <param name="eventId">The event id.</param> + /// <param name="state">The scope or log state.</param> + /// <param name="exception">The exception associated with the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message text.</param> public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) => _entries.Add((logLevel, formatter(state, exception), exception)); } } + /// <summary>Verifies that a genuine JavaScript exception while locking the diff dialog body is logged.</summary> [Fact] public void DiffDialog_BodyLock_GenuineJsException_IsLogged() { @@ -74,6 +96,7 @@ public class JsInteropLoggingTests : BunitContext }); } + /// <summary>Verifies that a circuit disconnect while locking the diff dialog body is not logged.</summary> [Fact] public void DiffDialog_BodyLock_Disconnect_IsNotLogged() { @@ -93,6 +116,8 @@ public class JsInteropLoggingTests : BunitContext Assert.DoesNotContain(provider.Entries, e => e.Level >= LogLevel.Warning); } + /// <summary>Verifies that a genuine JavaScript exception while copying parked messages is logged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ParkedMessages_Copy_GenuineJsException_IsLogged() { @@ -135,7 +160,10 @@ public class JsInteropLoggingTests : BunitContext private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; + /// <summary>Initializes a new instance of the <c>StubAuthStateProvider</c> class.</summary> + /// <param name="state">The authentication state the provider returns.</param> public StubAuthStateProvider(AuthenticationState state) => _state = state; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/KeysetPagerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/KeysetPagerTests.cs index 926523a2..2cbe29e7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/KeysetPagerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/KeysetPagerTests.cs @@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// </summary> public class KeysetPagerTests : BunitContext { + /// <summary>Verifies that the Prev button is disabled when the pager cannot go back.</summary> [Fact] public void PrevDisabled_WhenCannotGoBack() { @@ -22,6 +23,7 @@ public class KeysetPagerTests : BunitContext Assert.NotNull(prev.GetAttribute("disabled")); } + /// <summary>Verifies that the Next button is disabled when there is no next page.</summary> [Fact] public void NextDisabled_WhenNoNextPage() { @@ -34,6 +36,7 @@ public class KeysetPagerTests : BunitContext Assert.NotNull(next.GetAttribute("disabled")); } + /// <summary>Verifies that clicking Prev invokes the OnPrevious callback.</summary> [Fact] public void Prev_Click_InvokesOnPrevious() { @@ -49,6 +52,7 @@ public class KeysetPagerTests : BunitContext Assert.True(fired); } + /// <summary>Verifies that clicking Next invokes the OnNext callback.</summary> [Fact] public void Next_Click_InvokesOnNext() { @@ -64,6 +68,7 @@ public class KeysetPagerTests : BunitContext Assert.True(fired); } + /// <summary>Verifies that the summary renders the page number and row count.</summary> [Fact] public void Summary_RendersPageAndRows() { @@ -77,6 +82,7 @@ public class KeysetPagerTests : BunitContext Assert.Contains("42 rows", summary.TextContent); } + /// <summary>Verifies that both buttons are disabled when the pager itself is disabled.</summary> [Fact] public void BothDisabled_WhenDisabled() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MonacoEditorLoggingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MonacoEditorLoggingTests.cs index ca99a8cf..1ab03199 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MonacoEditorLoggingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MonacoEditorLoggingTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// <summary> -/// Regression tests for CentralUI-018. <c>MonacoEditor</c> wrapped every JS +/// Regression tests for <c>MonacoEditor</c>, which wrapped every JS /// interop call in a bare <c>try { ... } catch { }</c> with no logging — a /// genuine Monaco init failure became invisible. The fix narrows the catch to /// the expected prerender / disconnect cases and logs any real @@ -18,25 +18,47 @@ public class MonacoEditorLoggingTests : BunitContext /// <summary>Captures log entries so the test can assert on them.</summary> private sealed class CapturingLoggerProvider : ILoggerProvider { + /// <summary>Gets the log entries captured so far.</summary> public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new(); + /// <summary>Creates a logger that appends to the shared entry list.</summary> + /// <param name="categoryName">The logger category name (ignored).</param> + /// <returns>A capturing logger.</returns> public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries); + /// <summary>Does nothing; the provider holds no resources.</summary> public void Dispose() { } private sealed class CapturingLogger : ILogger { private readonly List<(LogLevel, string, Exception?)> _entries; + /// <summary>Initializes the logger over the shared entry list.</summary> + /// <param name="entries">The list that receives each captured log entry.</param> public CapturingLogger(List<(LogLevel, string, Exception?)> entries) => _entries = entries; + /// <summary>Returns no scope; scopes are not captured.</summary> + /// <typeparam name="TState">The scope state type.</typeparam> + /// <param name="state">The scope state (ignored).</param> + /// <returns>Always null.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level being queried.</param> + /// <returns>Always true.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the formatted log entry for later assertions.</summary> + /// <typeparam name="TState">The log state type.</typeparam> + /// <param name="logLevel">The entry's log level.</param> + /// <param name="eventId">The entry's event id.</param> + /// <param name="state">The entry's state.</param> + /// <param name="exception">The exception attached to the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message.</param> public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) => _entries.Add((logLevel, formatter(state, exception), exception)); } } + /// <summary>Verifies that a genuine JavaScript exception during editor creation is logged rather than swallowed.</summary> [Fact] public void CreateEditor_GenuineJsException_IsLogged_NotSwallowed() { @@ -58,6 +80,7 @@ public class MonacoEditorLoggingTests : BunitContext Assert.Contains(errors, e => e.Exception is JSException); } + /// <summary>Verifies that editor creation during prerender does not log anything.</summary> [Fact] public void CreateEditor_Prerender_DoesNotLog() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MoveDataConnectionDialogTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MoveDataConnectionDialogTests.cs index 0631d923..fdd20c98 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MoveDataConnectionDialogTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MoveDataConnectionDialogTests.cs @@ -9,7 +9,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// <summary> /// bUnit tests for the <see cref="MoveDataConnectionDialog"/> body component -/// (M9-T24b, migrated to the DialogService.ShowAsync host in T33b). The dialog is +/// (migrated to the DialogService.ShowAsync host). The dialog is /// now opened through <see cref="IDialogService.ShowAsync{TResult}"/>: a single /// <c>DialogHost</c> owns the backdrop/header/focus-trap, and the body renders only /// the target-site picker (excluding the connection's current site) plus its own @@ -33,6 +33,7 @@ public class MoveDataConnectionDialogTests : BunitContext private readonly IDataConnectionMoveService _service = Substitute.For<IDataConnectionMoveService>(); private readonly DialogService _dialog = new(); + /// <summary>Initializes a new instance of the <c>MoveDataConnectionDialogTests</c> class.</summary> public MoveDataConnectionDialogTests() { Services.AddSingleton(_service); @@ -74,6 +75,7 @@ public class MoveDataConnectionDialogTests : BunitContext return (host, pending); } + /// <summary>Verifies that the dialog shown through the host renders the target site picker with the supplied options.</summary> [Fact] public void ShownViaHost_RendersTargetSitePicker_WithSuppliedOptions() { @@ -89,6 +91,7 @@ public class MoveDataConnectionDialogTests : BunitContext Assert.Contains("PLC-1", cut.Markup); } + /// <summary>Verifies that confirming dispatches the move command with the connection and target site ids.</summary> [Fact] public void Confirm_DispatchesMoveCommand_WithConnectionAndTargetSiteIds() { @@ -107,6 +110,7 @@ public class MoveDataConnectionDialogTests : BunitContext _service.Received(1).MoveAsync(100, 3, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a guard error is shown inline and the dialog stays open.</summary> [Fact] public void GuardError_IsShownInline_AndDialogStaysOpen() { @@ -129,6 +133,8 @@ public class MoveDataConnectionDialogTests : BunitContext Assert.NotNull(cut.Find("[data-test='move-connection-error']")); } + /// <summary>Verifies that a successful move closes the dialog and resolves true.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Success_ClosesDialog_AndResolvesTrue() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/OffsetPagerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/OffsetPagerTests.cs index 237681b2..55db4b62 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/OffsetPagerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/OffsetPagerTests.cs @@ -13,6 +13,7 @@ public class OffsetPagerTests : BunitContext { // ── PrevDisabled_OnFirstPage ────────────────────────────────────────────── + /// <summary>Verifies that the previous button is disabled on the first page.</summary> [Fact] public void PrevDisabled_OnFirstPage() { @@ -27,6 +28,7 @@ public class OffsetPagerTests : BunitContext // ── NextDisabled_WhenNoNextPage ─────────────────────────────────────────── + /// <summary>Verifies that the next button is disabled when there is no next page.</summary> [Fact] public void NextDisabled_WhenNoNextPage() { @@ -41,6 +43,7 @@ public class OffsetPagerTests : BunitContext // ── Next_Click_EmitsPagePlusOne ─────────────────────────────────────────── + /// <summary>Verifies that clicking next emits the current page plus one.</summary> [Fact] public void Next_Click_EmitsPagePlusOne() { @@ -58,6 +61,7 @@ public class OffsetPagerTests : BunitContext // ── Prev_Click_EmitsPageMinusOne ───────────────────────────────────────── + /// <summary>Verifies that clicking previous emits the current page minus one.</summary> [Fact] public void Prev_Click_EmitsPageMinusOne() { @@ -75,6 +79,7 @@ public class OffsetPagerTests : BunitContext // ── Summary_RendersPageOfTotal ──────────────────────────────────────────── + /// <summary>Verifies that the summary renders the current page out of the total.</summary> [Fact] public void Summary_RendersPageOfTotal() { @@ -92,6 +97,7 @@ public class OffsetPagerTests : BunitContext // ── HideWhenSinglePage_HidesBar_OnSinglePage ────────────────────────────── + /// <summary>Verifies that hide-when-single-page hides the bar when there is only one page.</summary> [Fact] public void HideWhenSinglePage_HidesBar_OnSinglePage() { @@ -109,6 +115,7 @@ public class OffsetPagerTests : BunitContext // ── HideWhenSinglePage_HidesBar_OnEmptyResult ───────────────────────────── + /// <summary>Verifies that hide-when-single-page hides the bar when the result is empty.</summary> [Fact] public void HideWhenSinglePage_HidesBar_OnEmptyResult() { @@ -124,6 +131,7 @@ public class OffsetPagerTests : BunitContext // ── HideWhenSinglePage_ShowsBar_WhenMultiplePages ───────────────────────── + /// <summary>Verifies that hide-when-single-page shows the bar when there are multiple pages.</summary> [Fact] public void HideWhenSinglePage_ShowsBar_WhenMultiplePages() { @@ -141,6 +149,7 @@ public class OffsetPagerTests : BunitContext // TotalCount is null (the host cannot count), so single-page-ness is not // provable — the bar must stay so the user can still reach a further page. + /// <summary>Verifies that hide-when-single-page shows the bar when the total count is unknown.</summary> [Fact] public void HideWhenSinglePage_ShowsBar_WhenTotalCountUnknown() { @@ -157,6 +166,7 @@ public class OffsetPagerTests : BunitContext // Default (opt-out) behaviour: consumers that use the summary as their only // "N total" readout keep the bar on a single page. + /// <summary>Verifies that with hide-when-single-page off the bar stays visible on a single page.</summary> [Fact] public void HideWhenSinglePage_Off_KeepsBarVisible_OnSinglePage() { @@ -171,6 +181,7 @@ public class OffsetPagerTests : BunitContext // ── BothDisabled_WhenDisabled ───────────────────────────────────────────── + /// <summary>Verifies that both navigation buttons are disabled when the pager is disabled.</summary> [Fact] public void BothDisabled_WhenDisabled() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/PagerWindowTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/PagerWindowTests.cs index 303da8f0..9356ca75 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/PagerWindowTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/PagerWindowTests.cs @@ -4,11 +4,12 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// <summary> /// Unit tests for the <see cref="PagerWindow"/> helper introduced for -/// CentralUI-016 — windowed pagination that keeps the rendered button count +/// windowed pagination that keeps the rendered button count /// bounded regardless of total page count. /// </summary> public class PagerWindowTests { + /// <summary>Verifies that a small page count returns every page with no ellipsis.</summary> [Fact] public void Build_SmallPageCount_ReturnsEveryPage_NoEllipsis() { @@ -17,6 +18,7 @@ public class PagerWindowTests Assert.Equal(new[] { 1, 2, 3, 4, 5 }, pages); } + /// <summary>Verifies that a large page count yields a bounded window that includes the first and last pages.</summary> [Fact] public void Build_LargePageCount_IsBounded_AndIncludesFirstAndLast() { @@ -29,6 +31,7 @@ public class PagerWindowTests Assert.True(pages.Count <= 12, $"Expected a bounded window but got {pages.Count} entries."); } + /// <summary>Verifies that a large page count inserts an ellipsis for each gap in the window.</summary> [Fact] public void Build_LargePageCount_InsertsEllipsisForGaps() { @@ -38,6 +41,7 @@ public class PagerWindowTests Assert.Contains(0, pages); } + /// <summary>Verifies that a current page near the start produces no leading ellipsis.</summary> [Fact] public void Build_CurrentNearStart_NoLeadingEllipsis() { @@ -48,6 +52,7 @@ public class PagerWindowTests Assert.NotEqual(0, pages[1]); } + /// <summary>Verifies that an out-of-range current page is clamped into the valid range.</summary> [Fact] public void Build_ClampsOutOfRangeCurrentPage() { @@ -57,6 +62,8 @@ public class PagerWindowTests Assert.True(pages.Count <= 12); } + /// <summary>Verifies that a non-positive total page count returns an empty window.</summary> + /// <param name="totalPages">The non-positive total page count under test.</param> [Theory] [InlineData(0)] [InlineData(-3)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/SchemaBuilderModelTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/SchemaBuilderModelTests.cs index db64955f..6f8f090e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/SchemaBuilderModelTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/SchemaBuilderModelTests.cs @@ -6,6 +6,7 @@ public class SchemaBuilderModelTests { // ── Parse ───────────────────────────────────────────────────────────────── + /// <summary>Verifies that parsing an empty schema returns the fallback model.</summary> [Fact] public void Parse_Empty_ReturnsFallback() { @@ -15,6 +16,7 @@ public class SchemaBuilderModelTests Assert.Same(fallback, SchemaBuilderModel.Parse(" ", fallback)); } + /// <summary>Verifies that parsing a malformed schema returns the fallback model.</summary> [Fact] public void Parse_Malformed_ReturnsFallback() { @@ -23,6 +25,7 @@ public class SchemaBuilderModelTests Assert.Same(fallback, SchemaBuilderModel.Parse("42", fallback)); } + /// <summary>Verifies that parsing an object schema extracts its properties and required names.</summary> [Fact] public void Parse_ObjectSchema_ExtractsPropertiesAndRequired() { @@ -42,6 +45,7 @@ public class SchemaBuilderModelTests p => { Assert.Equal("active", p.Name); Assert.Equal("boolean", p.Schema.Type); Assert.True(p.Required); }); } + /// <summary>Verifies that parsing an array of a primitive preserves the item type.</summary> [Fact] public void Parse_ArrayOfPrimitive_PreservesItemType() { @@ -54,6 +58,7 @@ public class SchemaBuilderModelTests Assert.Equal("integer", node.Items!.Type); } + /// <summary>Verifies that a legacy flat array is translated to an object schema.</summary> [Fact] public void Parse_LegacyFlatArray_TranslatedToObjectSchema() { @@ -66,6 +71,7 @@ public class SchemaBuilderModelTests p => { Assert.Equal("y", p.Name); Assert.Equal("string", p.Schema.Type); Assert.False(p.Required); }); } + /// <summary>Verifies that parsing nested objects recurses into the children.</summary> [Fact] public void Parse_NestedObjects_Recurses() { @@ -89,6 +95,7 @@ public class SchemaBuilderModelTests // ── Serialize ───────────────────────────────────────────────────────────── + /// <summary>Verifies that serializing an empty object omits the required list.</summary> [Fact] public void Serialize_EmptyObject_OmitsRequired() { @@ -97,6 +104,7 @@ public class SchemaBuilderModelTests Assert.Equal("""{"type":"object","properties":{}}""", json); } + /// <summary>Verifies that serializing an object with mixed required properties emits only the required names.</summary> [Fact] public void Serialize_ObjectWithMixedRequired_EmitsOnlyRequiredNames() { @@ -110,6 +118,7 @@ public class SchemaBuilderModelTests json); } + /// <summary>Verifies that serializing an array includes its items.</summary> [Fact] public void Serialize_Array_IncludesItems() { @@ -117,6 +126,7 @@ public class SchemaBuilderModelTests Assert.Equal("""{"type":"array","items":{"type":"string"}}""", SchemaBuilderModel.Serialize(node)); } + /// <summary>Verifies that properties with a blank name are skipped when serializing.</summary> [Fact] public void Serialize_PropertiesWithBlankName_Skipped() { @@ -130,6 +140,7 @@ public class SchemaBuilderModelTests // ── Round-trip ──────────────────────────────────────────────────────────── + /// <summary>Verifies that parsing and then serializing a schema round-trips to a stable result.</summary> [Fact] public void RoundTrip_Parse_Then_Serialize_Stable() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs index 0784caec..5657a31c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs @@ -10,6 +10,7 @@ public class ScriptTriggerConfigCodecTests { // ── Parse: mode field ────────────────────────────────────────────────── + /// <summary>Verifies that parsing a conditional trigger without a mode defaults to on-true.</summary> [Fact] public void Parse_Conditional_WithoutMode_DefaultsToOnTrue() { @@ -20,6 +21,7 @@ public class ScriptTriggerConfigCodecTests Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode); } + /// <summary>Verifies that parsing a conditional trigger reads the while-true mode.</summary> [Fact] public void Parse_Conditional_WhileTrue_IsRead() { @@ -31,6 +33,7 @@ public class ScriptTriggerConfigCodecTests Assert.Equal(ScriptTriggerMode.WhileTrue, model.Mode); } + /// <summary>Verifies that parsing an expression trigger without a mode defaults to on-true.</summary> [Fact] public void Parse_Expression_WithoutMode_DefaultsToOnTrue() { @@ -41,6 +44,7 @@ public class ScriptTriggerConfigCodecTests Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode); } + /// <summary>Verifies that parsing an expression trigger reads the while-true mode.</summary> [Fact] public void Parse_Expression_WhileTrue_IsRead() { @@ -52,6 +56,7 @@ public class ScriptTriggerConfigCodecTests Assert.Equal(ScriptTriggerMode.WhileTrue, model.Mode); } + /// <summary>Verifies that parsing an unrecognized mode defaults to on-true.</summary> [Fact] public void Parse_UnrecognizedMode_DefaultsToOnTrue() { @@ -65,6 +70,7 @@ public class ScriptTriggerConfigCodecTests // ── Serialize: mode field ────────────────────────────────────────────── + /// <summary>Verifies that serializing a while-true conditional trigger writes the mode.</summary> [Fact] public void Serialize_Conditional_WhileTrue_WritesMode() { @@ -81,6 +87,7 @@ public class ScriptTriggerConfigCodecTests Assert.Contains("\"mode\":\"WhileTrue\"", json); } + /// <summary>Verifies that serializing a while-true expression trigger writes the mode.</summary> [Fact] public void Serialize_Expression_WhileTrue_WritesMode() { @@ -97,6 +104,8 @@ public class ScriptTriggerConfigCodecTests // ── Round-trip ───────────────────────────────────────────────────────── + /// <summary>Verifies that a conditional trigger's mode survives a serialize and parse round trip.</summary> + /// <param name="whileTrue">Whether the trigger uses the while-true mode.</param> [Theory] [InlineData(false)] [InlineData(true)] @@ -119,6 +128,8 @@ public class ScriptTriggerConfigCodecTests // ── SupportsMinTimeBetweenRuns ───────────────────────────────────────── + /// <summary>Verifies that automatic trigger types that throttle support a minimum time between runs.</summary> + /// <param name="triggerType">The trigger type under test.</param> [Theory] [InlineData("ValueChange")] [InlineData("Conditional")] @@ -128,6 +139,8 @@ public class ScriptTriggerConfigCodecTests Assert.True(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType)); } + /// <summary>Verifies that interval, call, none and unknown trigger types do not support a minimum time between runs.</summary> + /// <param name="triggerType">The trigger type under test, or null.</param> [Theory] [InlineData("Interval")] // has its own period control [InlineData("Call")] // invoked explicitly — no throttle applies @@ -138,6 +151,8 @@ public class ScriptTriggerConfigCodecTests Assert.False(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType)); } + /// <summary>Verifies that an expression trigger's mode survives a serialize and parse round trip.</summary> + /// <param name="whileTrue">Whether the trigger uses the while-true mode.</param> [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ScriptTriggerEditorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ScriptTriggerEditorTests.cs index c1f2dacb..d04e0042 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ScriptTriggerEditorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ScriptTriggerEditorTests.cs @@ -17,6 +17,7 @@ public class ScriptTriggerEditorTests : BunitContext private const string ConditionalWhileTrueConfig = @"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":50,""mode"":""WhileTrue""}"; + /// <summary>Verifies that selecting while-true emits a trigger config with the while-true mode.</summary> [Fact] public void SelectingWhileTrue_EmitsConfigWithWhileTrueMode() { @@ -33,6 +34,7 @@ public class ScriptTriggerEditorTests : BunitContext Assert.Contains("\"mode\":\"WhileTrue\"", captured!.Config); } + /// <summary>Verifies that the mode selector defaults to on-true when the config has no mode.</summary> [Fact] public void ModeSelector_DefaultsToOnTrue_WhenConfigHasNoMode() { @@ -50,6 +52,7 @@ public class ScriptTriggerEditorTests : BunitContext Assert.Contains("\"mode\":\"OnTrue\"", captured!.Config); } + /// <summary>Verifies that a loaded while-true mode is retained across an unrelated edit.</summary> [Fact] public void LoadedWhileTrueMode_IsRetainedAcrossAnUnrelatedEdit() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TemplateFolderTreeRevealKeysTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TemplateFolderTreeRevealKeysTests.cs index 1494b510..64c437cd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TemplateFolderTreeRevealKeysTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TemplateFolderTreeRevealKeysTests.cs @@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// </summary> public class TemplateFolderTreeRevealKeysTests : BunitContext { + /// <summary>Initializes a new instance of the <c>TemplateFolderTreeRevealKeysTests</c> class.</summary> public TemplateFolderTreeRevealKeysTests() { JSInterop.Mode = JSRuntimeMode.Loose; @@ -25,6 +26,8 @@ public class TemplateFolderTreeRevealKeysTests : BunitContext return (new() { a, b, other }, new() { t7, t8 }); } + /// <summary>Verifies that a change to extra node metadata rebuilds the forest and survives filtering.</summary> + /// <returns>The result.</returns> [Fact] public void ExtraNodeMetadataChange_RebuildsAndSurvivesFiltering() { @@ -45,6 +48,8 @@ public class TemplateFolderTreeRevealKeysTests : BunitContext Assert.Equal("inherited:2", node.Key); } + /// <summary>Verifies that repeated parameters preserve the forest while an in-place rename rebuilds it.</summary> + /// <returns>The result.</returns> [Fact] public void RepeatedParameters_PreserveForest_ButInPlaceRenameRebuilds() { @@ -60,6 +65,8 @@ public class TemplateFolderTreeRevealKeysTests : BunitContext Assert.Contains("Renamed", cut.Markup); } + /// <summary>Verifies that all folders start collapsed when no reveal keys are given.</summary> + /// <returns>The result.</returns> [Fact] public void WithoutRevealKeys_AllFoldersStartCollapsed() { @@ -69,6 +76,8 @@ public class TemplateFolderTreeRevealKeysTests : BunitContext Assert.DoesNotContain("Elsewhere", cut.Markup); } + /// <summary>Verifies that reveal keys expand only the ancestors of the listed template.</summary> + /// <returns>The result.</returns> [Fact] public void RevealKeys_ExpandsOnlyTheAncestorsOfTheListedTemplate() { @@ -80,6 +89,8 @@ public class TemplateFolderTreeRevealKeysTests : BunitContext Assert.DoesNotContain("Elsewhere", cut.Markup); // Other untouched } + /// <summary>Verifies that an active filter wins over reveal keys.</summary> + /// <returns>The result.</returns> [Fact] public void ActiveFilter_WinsOverRevealKeys() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TemplatePickerDialogTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TemplatePickerDialogTests.cs index 317d8a91..16be5bb8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TemplatePickerDialogTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TemplatePickerDialogTests.cs @@ -15,6 +15,7 @@ public class TemplatePickerDialogTests : BunitContext { private readonly DialogService _service = new(); + /// <summary>Registers the dialog service and loose JS interop the picker dialog needs.</summary> public TemplatePickerDialogTests() { Services.AddSingleton<IDialogService>(_service); @@ -65,6 +66,8 @@ public class TemplatePickerDialogTests : BunitContext private static AngleSharp.Dom.IElement Label(IRenderedComponent<DialogHost> cut, string text) => cut.FindAll(".tv-label").First(l => l.TextContent.Trim() == text); + /// <summary>Verifies that the Select button is disabled until a template is clicked.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Select_IsDisabled_UntilATemplateIsClicked() { @@ -73,6 +76,8 @@ public class TemplatePickerDialogTests : BunitContext Assert.Contains("Select a template", cut.Find("[data-test='picker-status']").TextContent); } + /// <summary>Verifies that clicking a template and then Select resolves the dialog with that template's id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ClickingTemplate_ThenSelect_ResolvesItsId() { @@ -88,6 +93,8 @@ public class TemplatePickerDialogTests : BunitContext Assert.Equal(7, (await pending)!.TemplateId); } + /// <summary>Verifies that double-clicking a template resolves the dialog with that template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DoubleClickingTemplate_Resolves() { @@ -98,6 +105,8 @@ public class TemplatePickerDialogTests : BunitContext Assert.Equal(7, (await pending)!.TemplateId); } + /// <summary>Verifies that clicking a folder selects nothing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ClickingFolder_SelectsNothing() { @@ -106,6 +115,8 @@ public class TemplatePickerDialogTests : BunitContext Assert.True(cut.Find("button[data-test='picker-select']").HasAttribute("disabled")); } + /// <summary>Verifies that Cancel resolves the dialog with null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Cancel_ResolvesNull() { @@ -114,6 +125,8 @@ public class TemplatePickerDialogTests : BunitContext Assert.Null(await pending); } + /// <summary>Verifies that the initial template id is revealed in the tree and highlighted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InitialTemplateId_IsRevealedAndHighlighted() { @@ -125,6 +138,8 @@ public class TemplatePickerDialogTests : BunitContext Assert.Contains("text-primary", Label(cut, "Deep").ClassName); } + /// <summary>Verifies that typing in the search box filters the tree.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Search_FiltersTree() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TimestampDisplayTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TimestampDisplayTests.cs index 7cd01c92..673329d5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TimestampDisplayTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TimestampDisplayTests.cs @@ -4,16 +4,17 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// <summary> -/// Regression tests for HealthMonitoring-015. A heartbeat-only registered site has +/// Regression tests for rendering a null timestamp. A heartbeat-only registered site has /// a <c>null</c> <c>LastReportReceivedAt</c> ("no full report yet"). The health /// dashboard passes that value straight into <see cref="TimestampDisplay"/>, so the /// component's <c>Value</c> must accept <c>DateTimeOffset?</c> and render a /// <c>null</c> as a human-readable placeholder ("never") instead of the -/// <c>DateTimeOffset.MinValue</c> year-0001 sentinel. Non-null callers must keep +/// <c>DateTimeOffset.MinValue</c> year 0001 sentinel. Non-null callers must keep /// rendering the formatted timestamp exactly as before. /// </summary> public class TimestampDisplayTests : BunitContext { + /// <summary>Verifies that a non-null value renders as a formatted timestamp.</summary> [Fact] public void Render_NonNullValue_ShowsFormattedTimestamp() { @@ -28,6 +29,7 @@ public class TimestampDisplayTests : BunitContext Assert.Contains("2026-05-17 14:30:45 UTC", span.GetAttribute("title")!); } + /// <summary>Verifies that a null value renders the "never" placeholder.</summary> [Fact] public void Render_NullValue_ShowsNeverPlaceholder() { @@ -38,13 +40,14 @@ public class TimestampDisplayTests : BunitContext Assert.Contains("never", cut.Markup, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that a null value does not render the year 0001 minimum-value sentinel.</summary> [Fact] public void Render_NullValue_DoesNotRenderYear0001Sentinel() { var cut = Render<TimestampDisplay>(parameters => parameters .Add(p => p.Value, (DateTimeOffset?)null)); - // The year-0001 DateTimeOffset.MinValue sentinel must never reach the UI. + // The year 0001 DateTimeOffset.MinValue sentinel must never reach the UI. Assert.DoesNotContain("0001", cut.Markup); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ToastNotificationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ToastNotificationTests.cs index a8514bac..1f9e6ba2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ToastNotificationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ToastNotificationTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// <summary> -/// Regression tests for CentralUI-010. <c>ToastNotification.AddToast</c> +/// Regression tests for toast disposal safety. <c>ToastNotification.AddToast</c> /// scheduled <c>Task.Delay(dismissMs).ContinueWith(...)</c> with the result /// discarded; the continuation called <c>InvokeAsync(StateHasChanged)</c>. When /// the host page is disposed before the delay elapses, the continuation ran @@ -14,6 +14,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// </summary> public class ToastNotificationTests : BunitContext { + /// <summary>Verifies that show toast, when after disposal, is no op and schedules nothing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ShowToast_AfterDisposal_IsNoOp_AndSchedulesNothing() { @@ -29,6 +31,8 @@ public class ToastNotificationTests : BunitContext Assert.Equal(0, cut.Instance.ToastCount); } + /// <summary>Verifies that auto dismiss, when after disposal, does not throw unobserved exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AutoDismiss_AfterDisposal_DoesNotThrowUnobservedException() { @@ -63,6 +67,8 @@ public class ToastNotificationTests : BunitContext Assert.Empty(unobserved); } + /// <summary>Verifies that auto dismiss, when before disposal, still removes toast.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AutoDismiss_BeforeDisposal_StillRemovesToast() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs index 88d22131..38c5dbef 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs @@ -22,9 +22,9 @@ public class TreeViewKeyboardNavigationTests : BunitContext private record TestNode(string Key, string Label, List<TestNode> Children); // Alpha (branch) - // ├── Alpha-1 (leaf) - // └── Alpha-2 (branch) - // └── Alpha-2-X (leaf) + // ├── Alpha.1 (leaf) + // └── Alpha.2 (branch) + // └── Alpha.2.X (leaf) // Beta (leaf) private static List<TestNode> SimpleRoots() => new() { @@ -122,6 +122,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext // ── Roving tabindex ──────────────────────────────────────────────────────── + /// <summary>Verifies that on initial render exactly one node is tabbable and it is the first node.</summary> [Fact] public void InitialRender_ExactlyOneNodeIsTabbable_AndItIsTheFirst() { @@ -132,6 +133,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal(0, TabbableIndex(cut)); } + /// <summary>Verifies that on initial render the selected node owns the tab stop.</summary> [Fact] public void InitialRender_SelectedNode_OwnsTheTabStop() { @@ -141,16 +143,17 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal(1, TabbableIndex(cut)); } + /// <summary>Verifies that the tab stop falls back to the first node when the focused node is collapsed out of view.</summary> [Fact] public void TabStop_FallsBackToFirstNode_WhenFocusedNodeIsCollapsedAway() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); - // Visible: Alpha, Alpha-1, Alpha-2, Beta. Move onto Alpha-1. + // Visible: Alpha, Alpha.1, Alpha.2, Beta. Move onto Alpha.1. PressKey(cut, 0, "ArrowDown"); Assert.Equal(1, TabbableIndex(cut)); - // Collapsing Alpha removes Alpha-1 from the visible set; the tab stop must + // Collapsing Alpha removes Alpha.1 from the visible set; the tab stop must // not vanish with it. cut.InvokeAsync(() => cut.Instance.CollapseAll()); Assert.Equal(0, TabbableIndex(cut)); @@ -158,12 +161,13 @@ public class TreeViewKeyboardNavigationTests : BunitContext // ── ArrowDown / ArrowUp ──────────────────────────────────────────────────── + /// <summary>Verifies that ArrowDown moves focus to the next visible node.</summary> [Fact] public void ArrowDown_MovesToNextVisibleNode() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); - // Visible order: Alpha(0), Alpha-1(1), Alpha-2(2), Beta(3). + // Visible order: Alpha(0), Alpha.1(1), Alpha.2(2), Beta(3). Assert.Equal(4, TreeItems(cut).Count); PressKey(cut, 0, "ArrowDown"); @@ -173,18 +177,20 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal(2, TabbableIndex(cut)); } + /// <summary>Verifies that ArrowDown skips the children of a collapsed branch.</summary> [Fact] public void ArrowDown_SkipsChildrenOfCollapsedBranch() { var cut = RenderTreeView(); - // Alpha is collapsed, so the next visible node is Beta — not Alpha-1. + // Alpha is collapsed, so the next visible node is Beta — not Alpha.1. PressKey(cut, 0, "ArrowDown"); Assert.Equal(2, TreeItems(cut).Count); Assert.Equal(1, TabbableIndex(cut)); } + /// <summary>Verifies that ArrowDown on the last visible node does nothing.</summary> [Fact] public void ArrowDown_AtLastVisibleNode_IsNoOp() { @@ -197,6 +203,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal(1, TabbableIndex(cut)); } + /// <summary>Verifies that ArrowUp moves focus to the previous visible node.</summary> [Fact] public void ArrowUp_MovesToPreviousVisibleNode() { @@ -209,6 +216,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal(1, TabbableIndex(cut)); } + /// <summary>Verifies that ArrowUp on the first visible node does nothing.</summary> [Fact] public void ArrowUp_AtFirstVisibleNode_IsNoOp() { @@ -221,6 +229,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext // ── ArrowRight ───────────────────────────────────────────────────────────── + /// <summary>Verifies that ArrowRight on a collapsed branch expands it without moving focus.</summary> [Fact] public void ArrowRight_OnCollapsedBranch_Expands_WithoutMovingFocus() { @@ -230,21 +239,23 @@ public class TreeViewKeyboardNavigationTests : BunitContext var items = TreeItems(cut); Assert.Equal("true", items[0].GetAttribute("aria-expanded")); - Assert.Equal(4, items.Count); // Alpha, Alpha-1, Alpha-2, Beta + Assert.Equal(4, items.Count); // Alpha, Alpha.1, Alpha.2, Beta Assert.Equal(0, TabbableIndex(cut)); } + /// <summary>Verifies that ArrowRight on an expanded branch moves focus to its first child.</summary> [Fact] public void ArrowRight_OnExpandedBranch_MovesToFirstChild() { var cut = RenderTreeView(); PressKey(cut, 0, "ArrowRight"); // expand - PressKey(cut, 0, "ArrowRight"); // move onto Alpha-1 + PressKey(cut, 0, "ArrowRight"); // move onto Alpha.1 Assert.Equal(1, TabbableIndex(cut)); } + /// <summary>Verifies that ArrowRight on a leaf node does nothing.</summary> [Fact] public void ArrowRight_OnLeaf_IsNoOp() { @@ -262,6 +273,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext // ── ArrowLeft ────────────────────────────────────────────────────────────── + /// <summary>Verifies that ArrowLeft on an expanded branch collapses it without moving focus.</summary> [Fact] public void ArrowLeft_OnExpandedBranch_Collapses_WithoutMovingFocus() { @@ -275,12 +287,13 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal(0, TabbableIndex(cut)); } + /// <summary>Verifies that ArrowLeft on a child node moves focus to its parent.</summary> [Fact] public void ArrowLeft_OnChildNode_MovesToParent() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); - // Alpha-1 (index 1) is a collapsed leaf → ArrowLeft goes up to Alpha. + // Alpha.1 (index 1) is a collapsed leaf → ArrowLeft goes up to Alpha. PressKey(cut, 1, "ArrowLeft"); Assert.Equal(0, TabbableIndex(cut)); @@ -288,17 +301,19 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal("true", TreeItems(cut)[0].GetAttribute("aria-expanded")); } + /// <summary>Verifies that ArrowLeft on a collapsed branch that is a child moves focus to its parent.</summary> [Fact] public void ArrowLeft_OnCollapsedBranchChild_MovesToParent() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); - // Alpha-2 (index 2) is a branch but collapsed → move to its parent Alpha. + // Alpha.2 (index 2) is a branch but collapsed → move to its parent Alpha. PressKey(cut, 2, "ArrowLeft"); Assert.Equal(0, TabbableIndex(cut)); } + /// <summary>Verifies that ArrowLeft on a root-level leaf does nothing.</summary> [Fact] public void ArrowLeft_OnRootLeaf_IsNoOp() { @@ -313,6 +328,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext // ── Home / End ───────────────────────────────────────────────────────────── + /// <summary>Verifies that Home moves focus to the first visible node.</summary> [Fact] public void Home_MovesToFirstVisibleNode() { @@ -323,12 +339,13 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal(0, TabbableIndex(cut)); } + /// <summary>Verifies that End moves focus to the last visible node.</summary> [Fact] public void End_MovesToLastVisibleNode() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2"); - // Visible: Alpha(0), Alpha-1(1), Alpha-2(2), Alpha-2-X(3), Beta(4). + // Visible: Alpha(0), Alpha.1(1), Alpha.2(2), Alpha.2.X(3), Beta(4). Assert.Equal(5, TreeItems(cut).Count); PressKey(cut, 0, "End"); @@ -338,6 +355,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext // ── Enter / Space activation ─────────────────────────────────────────────── + /// <summary>Verifies that Enter selects the focused node in single-selection mode.</summary> [Fact] public void Enter_SelectsNode_InSingleMode() { @@ -347,11 +365,12 @@ public class TreeViewKeyboardNavigationTests : BunitContext selectable: true, onSelectedKeyChanged: k => selected = k); - PressKey(cut, 1, "Enter"); // Alpha-1 + PressKey(cut, 1, "Enter"); // Alpha.1 Assert.Equal("a1", selected); } + /// <summary>Verifies that Space selects the focused node in single-selection mode.</summary> [Fact] public void Space_SelectsNode_InSingleMode() { @@ -365,6 +384,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal("b", selected); } + /// <summary>Verifies that Enter raises no selection callback when the tree is not selectable.</summary> [Fact] public void Enter_WhenNotSelectable_RaisesNoCallback() { @@ -376,6 +396,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Null(selected); } + /// <summary>Verifies that Space toggles the focused node in checkbox mode.</summary> [Fact] public void Space_InCheckboxMode_TogglesTheNode() { @@ -386,14 +407,15 @@ public class TreeViewKeyboardNavigationTests : BunitContext selectedKeys: new HashSet<object>(), onSelectedKeysChanged: keys => captured = keys); - // Visible: Alpha(0), Alpha-1(1), Alpha-2(2), Alpha-2-X(3), Beta(4). - // Space on Alpha-1 selects that one leaf. + // Visible: Alpha(0), Alpha.1(1), Alpha.2(2), Alpha.2.X(3), Beta(4). + // Space on Alpha.1 selects that one leaf. PressKey(cut, 1, " "); Assert.NotNull(captured); Assert.Equal(new HashSet<object> { "a1" }, captured); } + /// <summary>Verifies that Enter on a folder in checkbox mode cascades the selection to its descendant leaves.</summary> [Fact] public void Enter_InCheckboxMode_OnFolder_CascadesToDescendantLeaves() { @@ -412,6 +434,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Contains((object)"a2x", captured); } + /// <summary>Verifies that a keydown on a nested node does not also activate its ancestor.</summary> [Fact] public void Keydown_OnNestedNode_DoesNotAlsoActivateItsAncestor() { @@ -423,13 +446,14 @@ public class TreeViewKeyboardNavigationTests : BunitContext selectable: true, onSelectedKeyChanged: k => selected = k); - PressKey(cut, 1, "Enter"); // Alpha-1, nested inside Alpha + PressKey(cut, 1, "Enter"); // Alpha.1, nested inside Alpha Assert.Equal("a1", selected); } // ── Existing chevron behaviour (regression) ──────────────────────────────── + /// <summary>Verifies that pressing Enter on the chevron toggle still expands the node and does not select it.</summary> [Fact] public void ChevronToggle_EnterKey_StillExpands_AndDoesNotSelect() { @@ -443,6 +467,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Null(selected); } + /// <summary>Verifies that pressing Space on the chevron toggle still expands the node.</summary> [Fact] public void ChevronToggle_SpaceKey_StillExpands() { @@ -455,13 +480,14 @@ public class TreeViewKeyboardNavigationTests : BunitContext // ── ARIA structure ───────────────────────────────────────────────────────── + /// <summary>Verifies that tree items carry a one-based aria-level.</summary> [Fact] public void TreeItems_CarryOneBasedAriaLevel() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2"); var items = TreeItems(cut); - // Alpha, Alpha-1, Alpha-2, Alpha-2-X, Beta + // Alpha, Alpha.1, Alpha.2, Alpha.2.X, Beta Assert.Equal("1", items[0].GetAttribute("aria-level")); Assert.Equal("2", items[1].GetAttribute("aria-level")); Assert.Equal("2", items[2].GetAttribute("aria-level")); @@ -469,6 +495,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal("1", items[4].GetAttribute("aria-level")); } + /// <summary>Verifies that tree items carry aria-posinset and aria-setsize values.</summary> [Fact] public void TreeItems_CarryAriaPosInSetAndSetSize() { @@ -491,6 +518,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal("2", items[3].GetAttribute("aria-setsize")); } + /// <summary>Verifies that the tree structure uses the tree, group and treeitem roles.</summary> [Fact] public void TreeStructure_UsesRoleTree_RoleGroup_AndRoleTreeitem() { @@ -501,6 +529,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal(4, TreeItems(cut).Count); } + /// <summary>Verifies that aria-selected is present on selectable nodes and tracks the selection.</summary> [Fact] public void AriaSelected_IsPresentOnSelectableNodes_AndTracksSelection() { @@ -511,6 +540,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext Assert.Equal("false", items[1].GetAttribute("aria-selected")); } + /// <summary>Verifies that the root tree suppresses the default browser scrolling for navigation keys.</summary> [Fact] public void RootTree_SuppressesBrowserScrollDefaults_ForNavigationKeys() { @@ -532,6 +562,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext } } + /// <summary>Verifies that aria-selected is absent when the tree is not selectable.</summary> [Fact] public void AriaSelected_IsAbsent_WhenTreeIsNotSelectable() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewStorageResilienceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewStorageResilienceTests.cs index 812d15f2..1d71acdd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewStorageResilienceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewStorageResilienceTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// <summary> -/// Regression tests for CentralUI-018. <c>TreeView</c>'s storage-restore path +/// Regression tests for <c>TreeView</c>'s storage-restore path /// called <c>JsonSerializer.Deserialize</c> on the raw <c>treeviewStorage</c> /// payload outside any try block — a corrupt payload threw an uncaught /// <c>JsonException</c> during <c>OnAfterRenderAsync</c>, breaking the @@ -31,6 +31,7 @@ public class TreeViewStorageResilienceTests : BunitContext b.AddMarkupContent(0, $"<span>{node.Label}</span>"))) .Add(p => p.StorageKey, "corrupt-tree")); + /// <summary>Verifies that restoring a corrupt JSON payload from storage does not throw and the tree still renders.</summary> [Fact] public void StorageRestore_CorruptJsonPayload_DoesNotThrow_AndStillRenders() { @@ -47,6 +48,7 @@ public class TreeViewStorageResilienceTests : BunitContext Assert.Contains("Beta", cut.Markup); } + /// <summary>Verifies that restoring JSON of the wrong shape from storage does not throw.</summary> [Fact] public void StorageRestore_WrongShapeJson_DoesNotThrow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TriggerAnalysisKindTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TriggerAnalysisKindTests.cs index eab61f80..03c5a565 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TriggerAnalysisKindTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TriggerAnalysisKindTests.cs @@ -8,10 +8,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// <summary> -/// M9-T28b: the <c>AlarmTriggerEditor</c> and <c>ScriptTriggerEditor</c> +/// The <c>AlarmTriggerEditor</c> and <c>ScriptTriggerEditor</c> /// components must surface an Advisory|Strict selector for Expression triggers /// and round-trip the <c>"analysisKind"</c> key into the trigger config JSON -/// that the T28a ValidationService backend reads. +/// that the ValidationService backend reads. /// </summary> // ── AlarmTriggerConfigCodec — analysisKind parse / serialize ──────────────── @@ -19,6 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; public class AlarmTriggerAnalysisKindCodecTests { // Codec: parse — absent key defaults to Advisory (false) + /// <summary>Verifies that parsing an Expression trigger with no analysis kind defaults to Advisory.</summary> [Fact] public void Parse_Expression_NoAnalysisKind_DefaultsToAdvisory() { @@ -28,6 +29,7 @@ public class AlarmTriggerAnalysisKindCodecTests } // Codec: parse — "Advisory" explicit → false + /// <summary>Verifies that parsing an Expression trigger with the Advisory key returns false.</summary> [Fact] public void Parse_Expression_AdvisoryKey_ReturnsFalse() { @@ -37,6 +39,7 @@ public class AlarmTriggerAnalysisKindCodecTests } // Codec: parse — "Strict" → true + /// <summary>Verifies that parsing an Expression trigger with the Strict key returns true.</summary> [Fact] public void Parse_Expression_StrictKey_ReturnsTrue() { @@ -46,6 +49,7 @@ public class AlarmTriggerAnalysisKindCodecTests } // Codec: parse — case-insensitive + /// <summary>Verifies that the Strict key is parsed case-insensitively.</summary> [Fact] public void Parse_Expression_StrictKeyCaseInsensitive() { @@ -55,6 +59,7 @@ public class AlarmTriggerAnalysisKindCodecTests } // Codec: serialize — Advisory (false) → "analysisKind" key omitted + /// <summary>Verifies that serializing an Advisory Expression trigger omits the analysisKind key.</summary> [Fact] public void Serialize_Expression_Advisory_OmitsAnalysisKindKey() { @@ -66,6 +71,7 @@ public class AlarmTriggerAnalysisKindCodecTests } // Codec: serialize — Strict (true) → "analysisKind":"Strict" + /// <summary>Verifies that serializing a Strict Expression trigger writes analysisKind as Strict.</summary> [Fact] public void Serialize_Expression_Strict_WritesAnalysisKindStrict() { @@ -77,6 +83,7 @@ public class AlarmTriggerAnalysisKindCodecTests } // Codec: serialize — Strict not emitted for non-Expression trigger types + /// <summary>Verifies that serializing a non-Expression trigger does not emit analysisKind.</summary> [Fact] public void Serialize_NonExpression_DoesNotEmitAnalysisKind() { @@ -94,6 +101,7 @@ public class AlarmTriggerAnalysisKindCodecTests } // Codec: round-trip Strict + /// <summary>Verifies that a Strict Expression trigger is preserved through a round trip.</summary> [Fact] public void RoundTrip_Expression_Strict_Preserved() { @@ -106,6 +114,7 @@ public class AlarmTriggerAnalysisKindCodecTests } // Codec: round-trip Advisory (omit + re-parse → still false) + /// <summary>Verifies that an Advisory Expression trigger is preserved through a round trip.</summary> [Fact] public void RoundTrip_Expression_Advisory_Preserved() { @@ -122,6 +131,7 @@ public class AlarmTriggerAnalysisKindCodecTests public class ScriptTriggerAnalysisKindCodecTests { // Codec: parse — absent key defaults to Advisory (false) + /// <summary>Verifies that parsing an Expression trigger with no analysis kind defaults to Advisory.</summary> [Fact] public void Parse_Expression_NoAnalysisKind_DefaultsToAdvisory() { @@ -131,6 +141,7 @@ public class ScriptTriggerAnalysisKindCodecTests } // Codec: parse — "Strict" → true + /// <summary>Verifies that parsing an Expression trigger with the Strict key returns true.</summary> [Fact] public void Parse_Expression_StrictKey_ReturnsTrue() { @@ -140,6 +151,7 @@ public class ScriptTriggerAnalysisKindCodecTests } // Codec: serialize — Advisory → key omitted + /// <summary>Verifies that serializing an Advisory Expression trigger omits the analysisKind key.</summary> [Fact] public void Serialize_Expression_Advisory_OmitsAnalysisKindKey() { @@ -151,6 +163,7 @@ public class ScriptTriggerAnalysisKindCodecTests } // Codec: serialize — Strict → "analysisKind":"Strict" + /// <summary>Verifies that serializing a Strict Expression trigger writes analysisKind as Strict.</summary> [Fact] public void Serialize_Expression_Strict_WritesAnalysisKindStrict() { @@ -162,6 +175,7 @@ public class ScriptTriggerAnalysisKindCodecTests } // Codec: serialize — Strict not emitted for non-Expression triggers + /// <summary>Verifies that serializing a non-Expression trigger does not emit analysisKind.</summary> [Fact] public void Serialize_NonExpression_DoesNotEmitAnalysisKind() { @@ -179,6 +193,7 @@ public class ScriptTriggerAnalysisKindCodecTests } // Codec: round-trip Strict + /// <summary>Verifies that a Strict Expression trigger is preserved through a round trip.</summary> [Fact] public void RoundTrip_Expression_Strict_Preserved() { @@ -200,6 +215,7 @@ public class ScriptTriggerAnalysisKindCodecTests public class AlarmTriggerEditorAnalysisKindTests : BunitContext { + /// <summary>Initializes the test context for the alarm trigger editor.</summary> public AlarmTriggerEditorAnalysisKindTests() { // MonacoEditor calls MonacoBlazor.createEditor via JS interop. Use Loose @@ -208,6 +224,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext } // Selector is visible for Expression trigger type + /// <summary>Verifies that the alarm trigger editor shows the analysis kind selector for an Expression trigger.</summary> [Fact] public void AlarmTriggerEditor_Expression_ShowsAnalysisKindSelector() { @@ -221,6 +238,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext } // Selector is NOT visible for non-Expression trigger types + /// <summary>Verifies that the alarm trigger editor does not show the analysis kind selector for a non-Expression trigger.</summary> [Fact] public void AlarmTriggerEditor_NonExpression_DoesNotShowAnalysisKindSelector() { @@ -232,6 +250,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext } // Selector defaults to Advisory + /// <summary>Verifies that the alarm trigger editor selector defaults to Advisory for an Expression trigger.</summary> [Fact] public void AlarmTriggerEditor_Expression_NoAnalysisKindInConfig_SelectorDefaultsAdvisory() { @@ -245,6 +264,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext } // Selector defaults to Advisory when config has no analysisKind (ScriptTriggerEditor) + /// <summary>Verifies that the trigger editor selector defaults to Advisory when the config has no analysis kind.</summary> [Fact] public void ScriptTriggerEditor_Expression_NoAnalysisKindInConfig_SelectorDefaultsAdvisory() { @@ -258,6 +278,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext } // Choosing Strict writes analysisKind:"Strict" into emitted config + /// <summary>Verifies that choosing Strict in the alarm trigger editor emits analysisKind as Strict.</summary> [Fact] public void AlarmTriggerEditor_Expression_ChoosingStrict_EmitsAnalysisKindStrict() { @@ -276,6 +297,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext } // Loaded Strict config reflects in selector, emits Strict on next change + /// <summary>Verifies that a loaded Strict config is retained when the alarm trigger is edited.</summary> [Fact] public void AlarmTriggerEditor_Expression_LoadedStrictConfig_RetainedOnEdit() { @@ -299,6 +321,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext public class ScriptTriggerEditorAnalysisKindTests : BunitContext { + /// <summary>Initializes the test context for the script trigger editor.</summary> public ScriptTriggerEditorAnalysisKindTests() { // MonacoEditor calls MonacoBlazor.createEditor via JS interop. Use Loose @@ -307,6 +330,7 @@ public class ScriptTriggerEditorAnalysisKindTests : BunitContext } // Selector is visible for Expression trigger type + /// <summary>Verifies that the script trigger editor shows the analysis kind selector for an Expression trigger.</summary> [Fact] public void ScriptTriggerEditor_Expression_ShowsAnalysisKindSelector() { @@ -319,6 +343,7 @@ public class ScriptTriggerEditorAnalysisKindTests : BunitContext } // Selector is NOT visible for non-Expression trigger types + /// <summary>Verifies that the script trigger editor does not show the analysis kind selector for a Conditional trigger.</summary> [Fact] public void ScriptTriggerEditor_Conditional_DoesNotShowAnalysisKindSelector() { @@ -330,6 +355,7 @@ public class ScriptTriggerEditorAnalysisKindTests : BunitContext } // Choosing Strict writes analysisKind:"Strict" into emitted config + /// <summary>Verifies that choosing Strict in the script trigger editor emits analysisKind as Strict.</summary> [Fact] public void ScriptTriggerEditor_Expression_ChoosingStrict_EmitsAnalysisKindStrict() { @@ -348,6 +374,7 @@ public class ScriptTriggerEditorAnalysisKindTests : BunitContext } // Choosing Advisory emits config without analysisKind key + /// <summary>Verifies that choosing Advisory in the script trigger editor omits the analysisKind key.</summary> [Fact] public void ScriptTriggerEditor_Expression_ChoosingAdvisory_OmitsAnalysisKindKey() { @@ -366,6 +393,7 @@ public class ScriptTriggerEditorAnalysisKindTests : BunitContext } // Loaded Strict is retained on unrelated edit (fire mode change) + /// <summary>Verifies that a loaded Strict setting is retained when the trigger mode changes.</summary> [Fact] public void ScriptTriggerEditor_Expression_LoadedStrict_RetainedOnModeChange() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs index b7dbe3f0..75ba74bc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs @@ -26,6 +26,7 @@ public class TemplatesPageTests : BunitContext private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>(); private readonly IAuditService _audit = Substitute.For<IAuditService>(); + /// <summary>Creates the test class and registers the stubbed services the page needs.</summary> public TemplatesPageTests() { // The page's TemplateService / TemplateFolderService are constructed via DI @@ -64,6 +65,7 @@ public class TemplatesPageTests : BunitContext Services.AddTestConfigurationMutations(); } + /// <summary>Verifies that the page renders an empty state when there are no templates or folders.</summary> [Fact] public void Renders_EmptyState_WhenNoTemplatesOrFolders() { @@ -77,6 +79,7 @@ public class TemplatesPageTests : BunitContext Assert.Contains("No templates yet", cut.Markup); } + /// <summary>Verifies that a folder and its template render at the correct nesting.</summary> [Fact] public void Renders_FolderAndTemplate_AtCorrectNesting() { @@ -102,6 +105,7 @@ public class TemplatesPageTests : BunitContext Assert.Contains("TestMachine", cut.Markup); } + /// <summary>Verifies that composition children render under their owning template.</summary> [Fact] public void Renders_CompositionChildren_UnderOwningTemplate() { @@ -131,10 +135,11 @@ public class TemplatesPageTests : BunitContext Assert.Contains("bi-arrow-return-right", cut.Markup); } + /// <summary>Verifies that an inherited composition renders under the derived composed member.</summary> [Fact] public void Renders_InheritedComposition_UnderDerivedComposedMember() { - // followup #9: LeakTest is composed onto the BASE ReactorSide. LeftReactorSide is + // LeakTest is composed onto the BASE ReactorSide. LeftReactorSide is // DERIVED from ReactorSide and composed into CvdReactor, so the tree must surface // the INHERITED LeakTest slot under LeftReactorSide — the composition row lives on // ReactorSide (no IsInherited placeholder on the child), so the builder must use @@ -176,6 +181,7 @@ public class TemplatesPageTests : BunitContext Assert.Contains("inherited", leftLiAfter.TextContent); } + /// <summary>Verifies that the search box is present and bound to the template tree filter.</summary> [Fact] public void SearchBox_IsPresentAndBound_ToTemplateFolderTreeFilter() { @@ -226,7 +232,7 @@ public class TemplatesPageTests : BunitContext } // ======================================================================== - // M9-T23b: folder sibling reorder menu items + root context menu + // Folder sibling reorder menu items + root context menu // ======================================================================== // Seeds two root sibling folders (Alpha @ SortOrder 0, Beta @ SortOrder 1) and @@ -258,6 +264,7 @@ public class TemplatesPageTests : BunitContext return cut.Find(".dropdown-menu.show"); } + /// <summary>Verifies that the folder context menu exposes move up and move down items.</summary> [Fact] public void FolderContextMenu_ExposesMoveUpAndMoveDown() { @@ -272,6 +279,7 @@ public class TemplatesPageTests : BunitContext Assert.Contains("Move down", labels); } + /// <summary>Verifies that choosing move down dispatches a reorder down and reloads the tree.</summary> [Fact] public void FolderContextMenu_MoveDown_DispatchesReorderDown_AndReloads() { @@ -302,6 +310,7 @@ public class TemplatesPageTests : BunitContext _repo.Received(2).GetAllTemplatesAsync(Arg.Any<CancellationToken>()); } + /// <summary>Verifies that choosing move up dispatches a reorder up and reloads the tree.</summary> [Fact] public void FolderContextMenu_MoveUp_DispatchesReorderUp_AndReloads() { @@ -327,6 +336,7 @@ public class TemplatesPageTests : BunitContext _repo.Received(2).GetAllTemplatesAsync(Arg.Any<CancellationToken>()); } + /// <summary>Verifies that move up is disabled for the first sibling folder.</summary> [Fact] public void FolderContextMenu_MoveUp_IsDisabled_OnFirstSibling() { @@ -342,6 +352,7 @@ public class TemplatesPageTests : BunitContext Assert.True(moveUpBtn.HasAttribute("disabled"), "Move up should be disabled for the first sibling"); } + /// <summary>Verifies that move down is disabled for the last sibling folder.</summary> [Fact] public void FolderContextMenu_MoveDown_IsDisabled_OnLastSibling() { @@ -357,6 +368,7 @@ public class TemplatesPageTests : BunitContext Assert.True(moveDownBtn.HasAttribute("disabled"), "Move down should be disabled for the last sibling"); } + /// <summary>Verifies that the root context menu offers new folder and new template.</summary> [Fact] public void RootContextMenu_OffersNewFolderAndNewTemplate() { @@ -376,6 +388,7 @@ public class TemplatesPageTests : BunitContext Assert.Contains("New Template", labels); } + /// <summary>Verifies that choosing new template from the root menu navigates to the root create page.</summary> [Fact] public void RootContextMenu_NewTemplate_NavigatesToRootCreate() { @@ -420,6 +433,7 @@ public class TemplatesPageTests : BunitContext return cut.Find(".dropdown-menu.show"); } + /// <summary>Verifies that the template context menu offers a new instance item to a deployer.</summary> [Fact] public void TemplateContextMenu_OffersNewInstance_ForDeployer() { @@ -438,6 +452,7 @@ public class TemplatesPageTests : BunitContext Assert.EndsWith("/deployment/instances/create?templateId=42", nav.Uri); } + /// <summary>Verifies that the template context menu hides the new instance item from a designer-only user.</summary> [Fact] public void TemplateContextMenu_HidesNewInstance_ForDesignerOnly() { @@ -454,7 +469,10 @@ public class TemplatesPageTests : BunitContext internal sealed class TestAuthStateProvider : AuthenticationStateProvider { private readonly ClaimsPrincipal _user; + /// <summary>Creates a provider that always returns the given user.</summary> + /// <param name="user">The claims principal to authenticate as.</param> public TestAuthStateProvider(ClaimsPrincipal user) => _user = user; + /// <inheritdoc /> public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(_user)); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyLiveUpdatesTimerDisposeRaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyLiveUpdatesTimerDisposeRaceTests.cs index 0f90b80e..a98ba5fe 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyLiveUpdatesTimerDisposeRaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyLiveUpdatesTimerDisposeRaceTests.cs @@ -22,14 +22,14 @@ using TopologyPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests; /// <summary> -/// Regression tests for register row 103 (the P6-6 lesser instance in +/// Regression tests for register row 103 (the lesser instance in /// <c>Topology</c>). <c>OnInitializedAsync</c> awaits <c>LoadDataAsync</c> and only then /// calls <c>StartLiveUpdatesTimer</c>. If the circuit was torn down inside that await /// (user navigates away during the initial page load), the pre-fix <c>Dispose</c> would /// have already run and found <c>_liveUpdatesTimer</c> still null — a no-op — so the /// timer <c>StartLiveUpdatesTimer</c> creates once the await resumes is never disposed: /// it ticks against a torn-down component every 15s until process restart. The fix -/// (mirroring <c>DebugView</c>'s P6-6 fix and <c>TransportImport</c>'s row-103 sibling +/// (mirroring <c>DebugView</c>'s fix and <c>TransportImport</c>'s sibling /// fix) creates/replaces the timer and observes disposal atomically under /// <c>_timerLock</c>, so a (re)start that resumes after <c>Dispose</c> already ran /// refuses to create a new timer instead of leaking one. @@ -50,13 +50,14 @@ public class TopologyLiveUpdatesTimerDisposeRaceTests : BunitContext private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>(); private readonly IAuditService _audit = Substitute.For<IAuditService>(); + /// <summary>Initializes the test context and registers the services the Topology page requires.</summary> public TopologyLiveUpdatesTimerDisposeRaceTests() { Services.AddSingleton(_repo); Services.AddSingleton(_siteRepo); Services.AddSingleton(_deployRepo); Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.DeploymentManager.IFlatteningPipeline>()); - // DeploymentService takes IStaleInstanceProbe since the Low-wave P7-6 pre-filter (T13). + // DeploymentService takes IStaleInstanceProbe since the up-to-date pre-filter. Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport.IStaleInstanceProbe>()); Services.AddSingleton(_audit); @@ -124,6 +125,7 @@ public class TopologyLiveUpdatesTimerDisposeRaceTests : BunitContext private static T? GetField<T>(object obj, string name) => (T?)obj.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(obj); + /// <summary>Verifies that rendering the page starts a live updates timer, as a sanity check for the race tests.</summary> [Fact] public void Render_StartsALiveTimer_Sanity() { @@ -134,13 +136,14 @@ public class TopologyLiveUpdatesTimerDisposeRaceTests : BunitContext cut.Instance.Dispose(); } + /// <summary>Verifies that resuming the live updates timer after dispose leaves no live timer.</summary> [Fact] public void StartLiveUpdatesTimer_ResumingAfterDispose_LeavesNoLiveTimer() { // Simulates: OnInitializedAsync is suspended awaiting LoadDataAsync; the user // navigates away, so Dispose() runs (and, since no timer exists yet, has nothing // to stop); the await then resumes and calls StartLiveUpdatesTimer() — the exact - // shape register row 103 flags for Topology (the P6-6 lesser instance). + // shape register row 103 flags for Topology (the lesser instance). var cut = Render<TopologyPage>(); // The normal init path already started a timer; dispose it first so the field is @@ -158,6 +161,7 @@ public class TopologyLiveUpdatesTimerDisposeRaceTests : BunitContext Assert.Null(GetField<Timer?>(cut.Instance, "_liveUpdatesTimer")); } + /// <summary>Verifies that disposing the page stops the live timer exactly once.</summary> [Fact] public void Dispose_StopsTheLiveTimer_ExactlyOnce() { @@ -174,6 +178,7 @@ public class TopologyLiveUpdatesTimerDisposeRaceTests : BunitContext Assert.Null(GetField<Timer?>(cut.Instance, "_liveUpdatesTimer")); } + /// <summary>Verifies that toggling live updates off and then disposing leaves no live timer.</summary> [Fact] public void OnLiveUpdatesToggled_Off_ThenDispose_LeavesNoLiveTimer() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs index a0a83e1e..7d6e8e91 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs @@ -35,6 +35,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests; /// </summary> public class TopologyPageTests : BunitContext { + /// <summary>Verifies that the storage-save fixture completes the JavaScript invocation immediately.</summary> [Fact] public void StorageSaveFixture_CompletesInvocationImmediately() { @@ -48,6 +49,7 @@ public class TopologyPageTests : BunitContext private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>(); private readonly IAuditService _audit = Substitute.For<IAuditService>(); + /// <summary>Initializes the test class with substituted repositories and the services the Topology page depends on.</summary> public TopologyPageTests() { Services.AddSingleton(_repo); @@ -67,7 +69,7 @@ public class TopologyPageTests : BunitContext { OperationLockTimeout = TimeSpan.FromSeconds(5) })); - // Notify-and-fetch (Task 6): DeploymentService now also depends on + // Notify-and-fetch: DeploymentService now also depends on // IOptions<CommunicationOptions>; register it so the page's DI graph resolves. Services.AddSingleton(Options.Create(new CommunicationOptions())); Services.AddSingleton<ILogger<DeploymentService>>(NullLogger<DeploymentService>.Instance); @@ -78,12 +80,12 @@ public class TopologyPageTests : BunitContext // from the (List-normalized) snapshot via RevisionHashService; register it // so the page's DI graph resolves. Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.RevisionHashService>(); - // CentralUI-006: DeploymentService now also depends on the + // DeploymentService now also depends on the // deployment-status notifier (a process singleton in production). Services.AddSingleton<ZB.MOM.WW.ScadaBridge.DeploymentManager.IDeploymentStatusNotifier>( new ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier( NullLogger<ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier>.Instance)); - // P7-6: the bulk site deploy's up-to-date pre-filter asks the staleness + // The bulk site deploy's up-to-date pre-filter asks the staleness // probe for each instance's current revision hash, so DeploymentService now // depends on it too. The page under test never bulk-deploys; a substitute // keeps the DI graph resolvable without dragging in the flattening stack. @@ -99,7 +101,7 @@ public class TopologyPageTests : BunitContext // DI registration still has to satisfy the [Inject]. Services.AddScoped<IDialogService, DialogService>(); - // Site scoping (CentralUI-002): Topology injects SiteScopeService to + // Site scoping: Topology injects SiteScopeService to // filter the tree by the user's permitted sites. Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>(); @@ -164,6 +166,9 @@ public class TopologyPageTests : BunitContext return cut; } + /// <summary>Verifies that branch loads and refreshes serialize repository reads without dropping the user-selected page.</summary> + /// <param name="branchFirst">Whether the branch is loaded before the refresh starts.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(true)] [InlineData(false)] @@ -209,6 +214,8 @@ public class TopologyPageTests : BunitContext Assert.Equal(51, Assert.Single((List<Instance>)field.GetValue(cut.Instance)!).Id); } + /// <summary>Verifies that branch pages are loaded lazily and each replaces the previous page.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BranchPages_AreLazyAndReplacePreviousPage() { @@ -225,6 +232,7 @@ public class TopologyPageTests : BunitContext await _repo.DidNotReceive().GetAllInstancesAsync(Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the initial load does not read instances or templates.</summary> [Fact] public void InitialLoad_DoesNotReadInstancesOrTemplates() { @@ -236,6 +244,9 @@ public class TopologyPageTests : BunitContext _repo.DidNotReceive().GetAllTemplatesAsync(Arg.Any<CancellationToken>()); } + /// <summary>Verifies that moving an existing topology item uses the current site scope.</summary> + /// <param name="revoked">Whether the user's site permission has been revoked.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] @@ -257,6 +268,9 @@ public class TopologyPageTests : BunitContext Assert.Equal(revoked ? 10 : (int?)null, instance.AreaId); } + /// <summary>Verifies that creating an item under an existing topology uses the current site scope.</summary> + /// <param name="revoked">Whether the user's site permission has been revoked.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] @@ -275,6 +289,7 @@ public class TopologyPageTests : BunitContext await _repo.Received(revoked ? 0 : 1).AddAreaAsync(Arg.Any<Area>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that an empty state is rendered when there are no sites.</summary> [Fact] public void Renders_EmptyState_WhenNoSites() { @@ -285,6 +300,7 @@ public class TopologyPageTests : BunitContext Assert.Contains("No sites configured", cut.Markup); } + /// <summary>Verifies that an empty site is rendered as a top-level node.</summary> [Fact] public void Renders_EmptySite_AsTopLevelNode() { @@ -303,6 +319,7 @@ public class TopologyPageTests : BunitContext .FirstOrDefault(row => row.QuerySelector(".tv-label")?.TextContent == label) ?.QuerySelector(".tv-toggle"); + /// <summary>Verifies that sites, areas and instances render in nested order.</summary> [Fact] public void Renders_SiteAreaInstance_Nesting() { @@ -335,6 +352,7 @@ public class TopologyPageTests : BunitContext Assert.Contains("NotDeployed", cut.Markup); } + /// <summary>Verifies that search dims non-matching nodes while preserving the tree shape.</summary> [Fact] public void Search_DimsNonMatches_PreservesShape() { @@ -363,10 +381,11 @@ public class TopologyPageTests : BunitContext Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Boilers")); } + /// <summary>Verifies that a deployment user scoped to specific sites sees only the permitted sites.</summary> [Fact] public void SiteScoping_ScopedDeploymentUser_OnlySeesPermittedSites() { - // Regression test for CentralUI-002. The SiteId claims issued at login were + // Regression test . The SiteId claims issued at login were // never read, so a Deployment user scoped to one site could view (and act // on) every site's topology. Topology now filters the tree by the user's // permitted sites via SiteScopeService. @@ -393,6 +412,7 @@ public class TopologyPageTests : BunitContext Assert.DoesNotContain("Plant-B", cut.Markup); } + /// <summary>Verifies that a system-wide deployment user sees all sites.</summary> [Fact] public void SiteScoping_SystemWideDeploymentUser_SeesAllSites() { @@ -409,6 +429,7 @@ public class TopologyPageTests : BunitContext Assert.Contains("Plant-B", cut.Markup); } + /// <summary>Verifies that double-clicking an area label enters rename mode.</summary> [Fact] public void DoubleClick_OnAreaLabel_EntersRenameMode() { @@ -428,6 +449,7 @@ public class TopologyPageTests : BunitContext Assert.NotNull(cut.Find("input.form-control-sm.d-inline-block")); } + /// <summary>Verifies that instance rows do not support double-click rename.</summary> [Fact] public void InstanceRows_DoNotHaveDoubleClickRename() { @@ -448,10 +470,11 @@ public class TopologyPageTests : BunitContext Assert.Throws<Bunit.MissingEventHandlerException>(() => instanceLabel.DoubleClick()); } + /// <summary>Verifies that a connection endpoint change renders a connection section in the diff.</summary> [Fact] public void Diff_ConnectionEndpointChange_RendersConnectionSection() { - // TemplateEngine-018 / DeploymentManager-018: a standalone connection + // A standalone connection // endpoint edit (no per-attribute binding change) must surface in the // deployment-diff modal. Before ConnectionChanges was wired through // ComputeDiff + the UI, this redeploy showed only the stale-hash badge @@ -560,6 +583,8 @@ public class TopologyPageTests : BunitContext new FlatteningPipelineResult(currentConfig, "hash-new", ValidationResult.Success())))); } + /// <summary>Verifies that a live update does not recompute staleness.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LiveUpdate_DoesNotRecomputeStaleness() { @@ -585,6 +610,7 @@ public class TopologyPageTests : BunitContext .GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the deployed state renders after the split.</summary> [Fact] public void DeployedState_Renders_AfterSplit() { @@ -601,6 +627,7 @@ public class TopologyPageTests : BunitContext Assert.Matches("Stale|Current", cut.Markup); } + /// <summary>Verifies that a manual re-check recomputes staleness.</summary> [Fact] public void ManualRecheck_RecomputesStaleness() { @@ -619,6 +646,7 @@ public class TopologyPageTests : BunitContext .GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the legacy instances route is declared on the Topology page.</summary> [Fact] public void LegacyInstancesRoute_IsDeclaredOnTopologyPage() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewMultiSelectTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewMultiSelectTests.cs index cfccb55d..9e986ddd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewMultiSelectTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewMultiSelectTests.cs @@ -62,6 +62,7 @@ public class TreeViewMultiSelectTests : BunitContext }); } + /// <summary>Verifies that checkbox mode renders a checkbox input for each node.</summary> [Fact] public void Checkbox_mode_renders_input_checkbox_per_node() { @@ -72,6 +73,7 @@ public class TreeViewMultiSelectTests : BunitContext Assert.Equal(5, checkboxes.Count); } + /// <summary>Verifies that clicking a folder checkbox selects all of its descendant leaves.</summary> [Fact] public void Clicking_folder_checkbox_selects_all_descendant_leaves() { @@ -80,7 +82,7 @@ public class TreeViewMultiSelectTests : BunitContext selectedKeys: new HashSet<object>(), onSelectedKeysChanged: keys => captured = keys); - // First checkbox is Folder1 (root #1). It has leaves l1a + l1b. + // First checkbox is Folder1 (the first root). It has leaves l1a + l1b. var folderCheckbox = cut.FindAll("input.tv-checkbox")[0]; folderCheckbox.Change(true); @@ -90,6 +92,7 @@ public class TreeViewMultiSelectTests : BunitContext Assert.Contains((object)"l1b", captured); } + /// <summary>Verifies that clicking a leaf makes its parent indeterminate when a sibling is unchecked.</summary> [Fact] public void Clicking_leaf_makes_parent_indeterminate_when_sibling_unchecked() { @@ -110,6 +113,7 @@ public class TreeViewMultiSelectTests : BunitContext Assert.NotEqual("true", folderCheckbox.GetAttribute("checked")); } + /// <summary>Verifies that single selection mode behaves as before.</summary> [Fact] public void Single_mode_unchanged() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewTests.cs index d87f4c34..9c847b52 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewTests.cs @@ -77,6 +77,7 @@ public class TreeViewTests : BunitContext }); } + /// <summary>Verifies that root-level items render with the correct labels.</summary> [Fact] public void RendersRootLevelItems_WithCorrectLabels() { @@ -89,6 +90,7 @@ public class TreeViewTests : BunitContext Assert.Equal("Beta", labels[1].TextContent); } + /// <summary>Verifies that empty content renders when there are no items.</summary> [Fact] public void RendersEmptyContent_WhenItemsEmpty() { @@ -104,6 +106,7 @@ public class TreeViewTests : BunitContext Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find("ul[role='tree']")); } + /// <summary>Verifies that leaf nodes have no expand toggle.</summary> [Fact] public void LeafNodes_HaveNoToggle() { @@ -118,6 +121,7 @@ public class TreeViewTests : BunitContext Assert.NotNull(betaLi.QuerySelector(".tv-spacer")); } + /// <summary>Verifies that branch nodes show a collapsed toggle.</summary> [Fact] public void BranchNodes_ShowCollapsedToggle() { @@ -131,6 +135,7 @@ public class TreeViewTests : BunitContext Assert.NotNull(toggle!.QuerySelector("i.bi.bi-chevron-right")); } + /// <summary>Verifies that the children of a collapsed branch are not in the DOM.</summary> [Fact] public void CollapsedBranch_ChildrenNotInDom() { @@ -141,6 +146,7 @@ public class TreeViewTests : BunitContext Assert.Empty(groups); } + /// <summary>Verifies that clicking the toggle expands the node and shows its children.</summary> [Fact] public void ClickToggle_ExpandsNode_ShowsChildren() { @@ -160,6 +166,7 @@ public class TreeViewTests : BunitContext Assert.Contains(labels, l => l.TextContent == "Alpha-2"); } + /// <summary>Verifies that clicking an expanded toggle collapses the node and hides its children.</summary> [Fact] public void ClickExpandedToggle_Collapses_HidesChildren() { @@ -182,6 +189,7 @@ public class TreeViewTests : BunitContext Assert.Empty(cut.FindAll("ul[role='group']")); } + /// <summary>Verifies that expanding a parent and then its child shows the grandchildren.</summary> [Fact] public void DeepNesting_ExpandParentThenChild_ShowsGrandchildren() { @@ -190,29 +198,31 @@ public class TreeViewTests : BunitContext // Expand Alpha cut.Find(".tv-toggle").Click(); - // Now find Alpha-2's toggle (Alpha-2 is a branch) + // Now find Alpha 2's toggle (Alpha 2 is a branch) var toggles = cut.FindAll(".tv-toggle"); - // toggles[0] = Alpha (now expanded, shows minus), toggles[1] = Alpha-2 + // toggles[0] = Alpha (now expanded, shows minus), toggles[1] = Alpha 2 Assert.True(toggles.Count >= 2); toggles[1].Click(); - // Alpha-2-X should be visible + // Alpha 2 X should be visible var labels = cut.FindAll(".node-label"); Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); } + /// <summary>Verifies that the initially-expanded predicate expands the matching nodes.</summary> [Fact] public void InitiallyExpanded_ExpandsMatchingNodes() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2"); - // Alpha and Alpha-2 should be expanded, so Alpha-2-X should be visible + // Alpha and Alpha 2 should be expanded, so Alpha 2 X should be visible var labels = cut.FindAll(".node-label"); Assert.Contains(labels, l => l.TextContent == "Alpha-1"); Assert.Contains(labels, l => l.TextContent == "Alpha-2"); Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); } + /// <summary>Verifies that the root list has the tree role.</summary> [Fact] public void RootUl_HasRoleTree() { @@ -222,6 +232,7 @@ public class TreeViewTests : BunitContext Assert.NotNull(rootUl); } + /// <summary>Verifies that each node item has the treeitem role.</summary> [Fact] public void NodeLi_HasRoleTreeitem() { @@ -231,6 +242,7 @@ public class TreeViewTests : BunitContext Assert.Equal(2, items.Count); // Two root nodes } + /// <summary>Verifies that an expanded branch has aria-expanded set to true.</summary> [Fact] public void ExpandedBranch_HasAriaExpandedTrue() { @@ -240,6 +252,7 @@ public class TreeViewTests : BunitContext Assert.Equal("true", alphaLi.GetAttribute("aria-expanded")); } + /// <summary>Verifies that a child list has the group role.</summary> [Fact] public void ChildGroup_HasRoleGroup() { @@ -249,6 +262,7 @@ public class TreeViewTests : BunitContext Assert.Single(groups); } + /// <summary>Verifies that children are indented by the configured pixels per depth level.</summary> [Fact] public void Children_IndentedByIndentPxPerDepth() { @@ -263,15 +277,16 @@ public class TreeViewTests : BunitContext var alphaRow = rows[0]; Assert.Contains("padding-left: 0px", alphaRow.GetAttribute("style")); - // Find Alpha-1 row (depth 1) + // Find Alpha 1 row (depth 1) var alpha1Row = rows[1]; Assert.Contains("padding-left: 30px", alpha1Row.GetAttribute("style")); - // Find Alpha-2-X row (depth 2) - it's after Alpha-2 at index 3 + // Find Alpha 2 X row (depth 2) - it's after Alpha 2 at index 3 var alpha2xRow = rows[3]; Assert.Contains("padding-left: 60px", alpha2xRow.GetAttribute("style")); } + /// <summary>Verifies that clicking a node with selection disabled does not fire the callback.</summary> [Fact] public void Selection_Disabled_ClickDoesNotFireCallback() { @@ -283,6 +298,7 @@ public class TreeViewTests : BunitContext Assert.Null(selected); } + /// <summary>Verifies that clicking node content with selection enabled fires the callback.</summary> [Fact] public void Selection_Enabled_ClickContentFiresCallback() { @@ -294,6 +310,7 @@ public class TreeViewTests : BunitContext Assert.Equal("a", selected); } + /// <summary>Verifies that clicking the toggle does not change the selection.</summary> [Fact] public void Selection_ClickToggle_DoesNotChangeSelection() { @@ -305,6 +322,7 @@ public class TreeViewTests : BunitContext Assert.Null(selected); } + /// <summary>Verifies that the selected node has the selected CSS class.</summary> [Fact] public void Selection_SelectedNode_HasCssClass() { @@ -314,6 +332,7 @@ public class TreeViewTests : BunitContext Assert.Contains("bg-primary", alphaRow.GetAttribute("class")); } + /// <summary>Verifies that a custom selected CSS class is applied.</summary> [Fact] public void Selection_CustomCssClass_Applied() { @@ -323,6 +342,7 @@ public class TreeViewTests : BunitContext Assert.Contains("my-highlight", alphaRow.GetAttribute("class")); } + /// <summary>Verifies that aria-selected is set on the selected node.</summary> [Fact] public void Selection_AriaSelected_SetOnSelectedNode() { @@ -332,6 +352,7 @@ public class TreeViewTests : BunitContext Assert.Equal("true", alphaLi.GetAttribute("aria-selected")); } + /// <summary>Verifies that a null storage key makes no JS interop calls.</summary> [Fact] public void SessionStorage_NullKey_NoJsInteropCalls() { @@ -344,6 +365,7 @@ public class TreeViewTests : BunitContext Assert.Empty(JSInterop.Invocations); } + /// <summary>Verifies that expanding a node writes the expanded state to session storage.</summary> [Fact] public void SessionStorage_Set_ExpandWritesToStorage() { @@ -362,6 +384,7 @@ public class TreeViewTests : BunitContext Assert.Single(saveInvocations); } + /// <summary>Verifies that the expanded state is restored from session storage on mount.</summary> [Fact] public void SessionStorage_RestoresExpandedOnMount() { @@ -376,6 +399,7 @@ public class TreeViewTests : BunitContext Assert.Contains(labels, l => l.TextContent == "Alpha-2"); } + /// <summary>Verifies that stored session state takes precedence over the initially-expanded predicate.</summary> [Fact] public void SessionStorage_TakesPrecedenceOverInitiallyExpanded() { @@ -392,6 +416,7 @@ public class TreeViewTests : BunitContext Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1"); } + /// <summary>Verifies that ExpandAll expands all branches.</summary> [Fact] public void ExpandAll_ExpandsAllBranches() { @@ -408,6 +433,7 @@ public class TreeViewTests : BunitContext Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); } + /// <summary>Verifies that CollapseAll collapses all branches.</summary> [Fact] public void CollapseAll_CollapsesAllBranches() { @@ -425,6 +451,7 @@ public class TreeViewTests : BunitContext Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1"); } + /// <summary>Verifies that revealing a node expands its ancestors.</summary> [Fact] public void RevealNode_ExpandsAncestors() { @@ -435,13 +462,14 @@ public class TreeViewTests : BunitContext cut.InvokeAsync(() => cut.Instance.RevealNode("a2x")); - // Alpha-2-X should now be visible (Alpha and Alpha-2 expanded) + // Alpha 2 X should now be visible (Alpha and Alpha 2 expanded) var labels = cut.FindAll(".node-label"); Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); Assert.Contains(labels, l => l.TextContent == "Alpha-1"); // sibling also visible since Alpha is expanded Assert.Contains(labels, l => l.TextContent == "Alpha-2"); } + /// <summary>Verifies that revealing a node with select also selects it.</summary> [Fact] public void RevealNode_WithSelect_SelectsNode() { @@ -453,6 +481,7 @@ public class TreeViewTests : BunitContext Assert.Equal("a2x", selected); } + /// <summary>Verifies that revealing an unknown key has no effect.</summary> [Fact] public void RevealNode_UnknownKey_NoOp() { @@ -467,6 +496,7 @@ public class TreeViewTests : BunitContext // ── External filtering tests (R8) ────────────────────────────────── + /// <summary>Verifies that a reduced item list hides the removed roots.</summary> [Fact] public void Filtering_ReducedItems_HidesRemovedRoots() { @@ -497,6 +527,7 @@ public class TreeViewTests : BunitContext Assert.Equal("Alpha", labels[0].TextContent); } + /// <summary>Verifies that the expansion state is preserved when the items are filtered.</summary> [Fact] public void Filtering_ExpansionStatePreserved() { @@ -522,7 +553,7 @@ public class TreeViewTests : BunitContext })); }); - // Alpha-1 still visible (expansion state preserved) + // Alpha 1 still visible (expansion state preserved) Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1"); // Re-render with full list again @@ -539,10 +570,11 @@ public class TreeViewTests : BunitContext })); }); - // Alpha-1 still visible after restoration + // Alpha 1 still visible after restoration Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1"); } + /// <summary>Verifies that the selection is cleared when the selected node disappears.</summary> [Fact] public void Filtering_SelectionCleared_WhenNodeDisappears() { @@ -578,6 +610,7 @@ public class TreeViewTests : BunitContext // ── Context menu tests ────────────────────────────────────────────── + /// <summary>Verifies that no menu renders when the context menu is null.</summary> [Fact] public void ContextMenu_Null_NoMenuRendered() { @@ -591,6 +624,7 @@ public class TreeViewTests : BunitContext Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find(".dropdown-menu")); } + /// <summary>Verifies that a right-click shows the context menu.</summary> [Fact] public void ContextMenu_RightClickShowsMenu() { @@ -611,6 +645,7 @@ public class TreeViewTests : BunitContext Assert.Equal("Alpha", btn!.TextContent); } + /// <summary>Verifies that pressing Escape dismisses the context menu.</summary> [Fact] public void ContextMenu_EscapeKeyDismissesMenu() { @@ -631,6 +666,7 @@ public class TreeViewTests : BunitContext Assert.Empty(cut.FindAll(".dropdown-menu")); } + /// <summary>Verifies that a key other than Escape does not dismiss the context menu.</summary> [Fact] public void ContextMenu_NonEscapeKey_DoesNotDismiss() { @@ -648,6 +684,7 @@ public class TreeViewTests : BunitContext Assert.NotEmpty(cut.FindAll(".dropdown-menu")); } + /// <summary>Verifies that right-clicking a different node replaces the open menu.</summary> [Fact] public void ContextMenu_RightClickDifferentNode_ReplacesMenu() { @@ -662,11 +699,11 @@ public class TreeViewTests : BunitContext var rows = cut.FindAll(".tv-row"); rows[0].TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 }); - // Now right-click Alpha-1 + // Now right-click Alpha 1 rows = cut.FindAll(".tv-row"); rows[1].TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 150, ClientY = 250 }); - // Should be only one dropdown-menu, showing Alpha-1 + // Should be only one dropdown-menu, showing Alpha 1 var menus = cut.FindAll(".dropdown-menu"); Assert.Single(menus); var btn = menus[0].QuerySelector(".ctx-btn"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/UnitTest1.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/UnitTest1.cs index 17467ebf..7e16f6b3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/UnitTest1.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/UnitTest1.cs @@ -7,6 +7,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests; /// </summary> public class UnitTest1 { + /// <summary>Verifies that the central UI assembly is loadable.</summary> + /// <returns>The result.</returns> [Fact] public void CentralUI_Assembly_IsLoadable() { @@ -15,6 +17,8 @@ public class UnitTest1 Assert.Equal("ZB.MOM.WW.ScadaBridge.CentralUI", assembly.GetName().Name); } + /// <summary>Verifies that all expected pages exist in the assembly.</summary> + /// <returns>The result.</returns> [Fact] public void Pages_AllExist_InAssembly() { @@ -46,6 +50,8 @@ public class UnitTest1 Assert.True(types.Length > 15, $"Expected many types in CentralUI assembly, got {types.Length}"); } + /// <summary>Verifies that the expected shared components exist in the assembly.</summary> + /// <returns>The result.</returns> [Fact] public void SharedComponents_Exist_InAssembly() { @@ -56,6 +62,8 @@ public class UnitTest1 Assert.True(assembly.GetTypes().Length > 0); } + /// <summary>Verifies that the AddCentralUI service collection extension is callable.</summary> + /// <returns>The result.</returns> [Fact] public void ServiceCollectionExtensions_AddCentralUI_IsCallable() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterBootstrapGuardTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterBootstrapGuardTests.cs index e71dcbca..4efecb07 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterBootstrapGuardTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterBootstrapGuardTests.cs @@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests; /// <summary> /// Unit tests for <see cref="ClusterBootstrapGuard"/> — the pure decision core of the -/// simultaneous-cold-start split-brain guard (Gitea #33). The runtime probe + JoinSeedNodes wiring +/// simultaneous-cold-start split-brain guard. The runtime probe + JoinSeedNodes wiring /// lives in <c>ClusterBootstrapCoordinator</c> and is covered by the real-ActorSystem /// <c>ClusterBootstrapCoordinatorTests</c> in the integration suite. /// </summary> @@ -11,6 +11,7 @@ public class ClusterBootstrapGuardTests private const string A = "akka.tcp://scadabridge@scadabridge-site-a-a:8082"; private const string B = "akka.tcp://scadabridge@scadabridge-site-a-b:8082"; + /// <summary>Verifies that the node with the lower address is the founder and needs no probe.</summary> [Fact] public void Lower_address_node_is_the_founder_and_needs_no_probe() { @@ -22,6 +23,7 @@ public class ClusterBootstrapGuardTests Assert.Equal(new[] { A, B }, role.SelfFirstOrder); } + /// <summary>Verifies that the node with the higher address is not the founder and probes its partner.</summary> [Fact] public void Higher_address_node_is_not_the_founder_and_targets_the_partner_for_probing() { @@ -34,6 +36,7 @@ public class ClusterBootstrapGuardTests Assert.Equal(8082, role.PartnerPort); } + /// <summary>Verifies that the founder tie-break gives the same result regardless of seed order.</summary> [Fact] public void Tie_break_is_symmetric_regardless_of_seed_order() { @@ -47,6 +50,7 @@ public class ClusterBootstrapGuardTests Assert.True(onLower.IsFounder ^ onHigher.IsFounder); } + /// <summary>Verifies that the tie-break ignores case, so a casing mismatch cannot make both nodes founders.</summary> [Fact] public void Tie_break_is_case_insensitive_so_a_casing_mismatch_cannot_make_both_founders() { @@ -62,6 +66,7 @@ public class ClusterBootstrapGuardTests Assert.True(onLower.IsFounder); // "site-a-a" < "site-a-b" ordinal-ignore-case } + /// <summary>Verifies that the higher node joins the founder when the partner is reachable.</summary> [Fact] public void Higher_node_joins_the_founder_when_partner_is_reachable() { @@ -71,6 +76,7 @@ public class ClusterBootstrapGuardTests Assert.Equal(new[] { A, B }, ClusterBootstrapGuard.HigherNodeOrder(role, partnerReachable: true)); // partner (node-a) first } + /// <summary>Verifies that the higher node forms a cluster alone when the partner is unreachable.</summary> [Fact] public void Higher_node_forms_alone_when_partner_is_unreachable() { @@ -80,6 +86,7 @@ public class ClusterBootstrapGuardTests Assert.Equal(new[] { B, A }, ClusterBootstrapGuard.HigherNodeOrder(role, partnerReachable: false)); // self (node-b) first } + /// <summary>Verifies that the guard does not apply to a single seed node.</summary> [Fact] public void Guard_does_not_apply_to_a_single_seed_node() { @@ -89,6 +96,7 @@ public class ClusterBootstrapGuardTests Assert.False(role.Applies); } + /// <summary>Verifies that the guard does not apply when the local node is absent from the two seeds.</summary> [Fact] public void Guard_does_not_apply_when_self_is_absent_from_the_two_seeds() { @@ -97,6 +105,7 @@ public class ClusterBootstrapGuardTests Assert.False(role.Applies); } + /// <summary>Verifies that the guard does not apply to three or more seeds.</summary> [Fact] public void Guard_does_not_apply_to_three_or_more_seeds() { @@ -106,6 +115,7 @@ public class ClusterBootstrapGuardTests Assert.False(role.Applies); } + /// <summary>Verifies that the guard does not apply when a seed address is malformed.</summary> [Fact] public void Guard_does_not_apply_when_a_seed_is_malformed() { @@ -114,6 +124,10 @@ public class ClusterBootstrapGuardTests Assert.False(role.Applies); } + /// <summary>Verifies that parsing a seed address extracts the host and port.</summary> + /// <param name="seed">The seed address to parse.</param> + /// <param name="expectedHost">The expected host.</param> + /// <param name="expectedPort">The expected port.</param> [Theory] [InlineData("akka.tcp://scadabridge@scadabridge-site-a-a:8082", "scadabridge-site-a-a", 8082)] [InlineData("akka.tcp://scadabridge@10.0.0.5:8082/", "10.0.0.5", 8082)] @@ -125,6 +139,8 @@ public class ClusterBootstrapGuardTests Assert.Equal(expectedPort, port); } + /// <summary>Verifies that parsing rejects malformed seed addresses.</summary> + /// <param name="seed">The malformed seed address.</param> [Theory] [InlineData("")] [InlineData("garbage")] @@ -134,6 +150,7 @@ public class ClusterBootstrapGuardTests Assert.False(ClusterBootstrapGuard.TryParse(seed, out _, out _)); } + /// <summary>Verifies that the port decides the tie-break when the hosts are equal.</summary> [Fact] public void Port_participates_in_the_tie_break_when_hosts_are_equal() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterOptionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterOptionsTests.cs index 069a9d0f..598c8e7c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterOptionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterOptionsTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests; /// </summary> public class ClusterOptionsTests { + /// <summary>Verifies that a new options instance carries the expected default values.</summary> [Fact] public void DefaultValues_AreCorrect() { @@ -21,6 +22,7 @@ public class ClusterOptionsTests Assert.True(options.DownIfAlone); } + /// <summary>Verifies that the down-if-alone setting can be changed.</summary> [Fact] public void DownIfAlone_CanBeSet() { @@ -29,6 +31,7 @@ public class ClusterOptionsTests Assert.False(options.DownIfAlone); } + /// <summary>Verifies that the seed node list defaults to an empty list.</summary> [Fact] public void SeedNodes_DefaultsToEmptyList() { @@ -38,17 +41,18 @@ public class ClusterOptionsTests Assert.Empty(options.SeedNodes); } - // ClusterInfra-011: SectionName constant deleted — the previous test + // The SectionName constant was deleted — the previous test // `SectionName_IsTheExpectedAppSettingsSection` is removed alongside it. // The Host's SiteServiceRegistration / StartupValidator continue to // reference the `"ScadaBridge:Cluster"` literal directly; reinstating the // constant should happen when those Host binding sites can be updated in // the same change. + /// <summary>Verifies that the options properties accept custom values.</summary> [Fact] public void Properties_CanBeSetToCustomValues() { - // ClusterInfra-013: this test exercises the POCO property setters only — + // This test exercises the POCO property setters only — // `SplitBrainResolverStrategy = "keep-majority"` and `MinNrOfMembers = 2` // are values the design doc explicitly forbids in production // (`keep-majority` causes total shutdown on a two-node partition; diff --git a/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterOptionsValidatorTests.cs index 7430a854..88fecb41 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterOptionsValidatorTests.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests; /// <summary> -/// CI-004: Tests that <see cref="ClusterOptionsValidator"/> rejects the +/// Tests that <see cref="ClusterOptionsValidator"/> rejects the /// catastrophic misconfigurations the design doc warns against — a /// <c>MinNrOfMembers</c> other than 1, an unsupported split-brain strategy, /// empty seed nodes, and timings where the heartbeat is not below the @@ -22,6 +22,7 @@ public class ClusterOptionsValidatorTests DownIfAlone = true }; + /// <summary>Verifies that the default options are valid.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -30,6 +31,7 @@ public class ClusterOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a minimum member count other than one fails validation.</summary> [Fact] public void MinNrOfMembers_NotOne_FailsValidation() { @@ -42,6 +44,8 @@ public class ClusterOptionsValidatorTests Assert.Contains("MinNrOfMembers", result.FailureMessage); } + /// <summary>Verifies that an unsupported split-brain strategy fails validation.</summary> + /// <param name="strategy">The unsupported strategy name.</param> [Theory] [InlineData("keep-majority")] [InlineData("static-quorum")] @@ -57,6 +61,7 @@ public class ClusterOptionsValidatorTests Assert.Contains("SplitBrainResolverStrategy", result.FailureMessage); } + /// <summary>Verifies that an empty seed node list fails validation.</summary> [Fact] public void EmptySeedNodes_FailsValidation() { @@ -69,6 +74,7 @@ public class ClusterOptionsValidatorTests Assert.Contains("SeedNodes", result.FailureMessage); } + /// <summary>Verifies that a single seed node without acknowledgement fails validation.</summary> [Fact] public void SingleSeed_WithoutAcknowledgement_Fails() { @@ -81,6 +87,7 @@ public class ClusterOptionsValidatorTests Assert.Contains("AllowSingleNodeCluster", result.FailureMessage); } + /// <summary>Verifies that a single seed node passes when single-node clusters are explicitly allowed.</summary> [Fact] public void SingleSeed_WithAllowSingleNodeCluster_Passes() { @@ -96,6 +103,7 @@ public class ClusterOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that an empty seed list fails even when single-node clusters are allowed.</summary> [Fact] public void EmptySeeds_FailsEvenWithFlag() { @@ -110,10 +118,11 @@ public class ClusterOptionsValidatorTests Assert.Contains("SeedNodes", result.FailureMessage); } + /// <summary>Verifies that a single seed node fails validation.</summary> [Fact] public void SingleSeedNode_FailsValidation() { - // CI-012: design doc says "both nodes are seed nodes" — a single-seed + // The design doc says "both nodes are seed nodes" — a single-seed // configuration defeats the no-startup-ordering-dependency guarantee and // must be rejected by the contract owner's validator, not just by the // Host's startup validator. @@ -127,6 +136,7 @@ public class ClusterOptionsValidatorTests Assert.Contains("2", result.FailureMessage); } + /// <summary>Verifies that a heartbeat interval that is not below the failure threshold fails validation.</summary> [Fact] public void HeartbeatNotBelowFailureThreshold_FailsValidation() { @@ -140,6 +150,7 @@ public class ClusterOptionsValidatorTests Assert.Contains("HeartbeatInterval", result.FailureMessage); } + /// <summary>Verifies that a non-positive stable-after duration fails validation.</summary> [Fact] public void NonPositiveStableAfter_FailsValidation() { @@ -152,6 +163,7 @@ public class ClusterOptionsValidatorTests Assert.Contains("StableAfter", result.FailureMessage); } + /// <summary>Verifies that disabling down-if-alone under the keep-oldest strategy fails validation.</summary> [Fact] public void DownIfAloneFalse_UnderKeepOldest_FailsValidation() { @@ -165,6 +177,7 @@ public class ClusterOptionsValidatorTests Assert.Contains("DownIfAlone", result.FailureMessage); } + /// <summary>Verifies that the auto-down strategy passes validation.</summary> [Fact] public void AutoDownStrategy_Passes() { @@ -179,6 +192,7 @@ public class ClusterOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that disabling down-if-alone under the auto-down strategy passes validation.</summary> [Fact] public void DownIfAloneFalse_UnderAutoDown_Passes() { @@ -193,6 +207,7 @@ public class ClusterOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that validation accumulates every failure rather than stopping at the first.</summary> [Fact] public void Validate_AccumulatesAllFailures() { @@ -216,8 +231,9 @@ public class ClusterOptionsValidatorTests Assert.Contains("HeartbeatInterval", result.FailureMessage); } - // ---- BootstrapGuard timing validation (Gitea #33) ---- + // ---- BootstrapGuard timing validation ---- + /// <summary>Verifies that a disabled bootstrap guard with zero timings passes validation.</summary> [Fact] public void BootstrapGuard_disabled_with_zero_timings_passes_validation() { @@ -237,6 +253,7 @@ public class ClusterOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that an enabled bootstrap guard with default timings passes validation.</summary> [Fact] public void BootstrapGuard_enabled_with_default_timings_passes_validation() { @@ -248,6 +265,7 @@ public class ClusterOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that an enabled bootstrap guard with non-positive probe seconds fails validation.</summary> [Fact] public void BootstrapGuard_enabled_with_nonpositive_probe_seconds_fails_validation() { @@ -262,6 +280,7 @@ public class ClusterOptionsValidatorTests Assert.Contains("PartnerProbeSeconds", result.FailureMessage); } + /// <summary>Verifies that an enabled bootstrap guard with a non-positive interval or timeout fails validation.</summary> [Fact] public void BootstrapGuard_enabled_with_nonpositive_interval_or_timeout_fails_validation() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ServiceCollectionExtensionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ServiceCollectionExtensionsTests.cs index f0beca89..acd964bc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ServiceCollectionExtensionsTests.cs @@ -4,14 +4,15 @@ using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests; /// <summary> -/// CI-002: Tests that <see cref="ServiceCollectionExtensions.AddClusterInfrastructure"/> +/// Tests that <see cref="ServiceCollectionExtensions.AddClusterInfrastructure"/> /// does real work rather than silently returning success — it must register /// the <see cref="ClusterOptionsValidator"/> so misconfiguration fails fast. /// (The companion actor-registration test was removed alongside the deleted -/// `AddClusterInfrastructureActors` extension method — see ClusterInfra-014.) +/// `AddClusterInfrastructureActors` extension method.) /// </summary> public class ServiceCollectionExtensionsTests { + /// <summary>Verifies the case: add Cluster Infrastructure, registers Options Validator.</summary> [Fact] public void AddClusterInfrastructure_RegistersOptionsValidator() { @@ -29,6 +30,7 @@ public class ServiceCollectionExtensionsTests Assert.IsType<ClusterOptionsValidator>(validator); } + /// <summary>Verifies the case: add Cluster Infrastructure, validator Rejects Bad Options At Resolution.</summary> [Fact] public void AddClusterInfrastructure_ValidatorRejectsBadOptionsAtResolution() { @@ -48,7 +50,7 @@ public class ServiceCollectionExtensionsTests Assert.Contains("MinNrOfMembers", ex.Message); } - // ClusterInfra-014: `AddClusterInfrastructureActors_ThrowsRatherThanSilentlySucceeding` + // `AddClusterInfrastructureActors_ThrowsRatherThanSilentlySucceeding` // was removed alongside the now-deleted `AddClusterInfrastructureActors` // extension method. The Akka.NET actor wiring legitimately lives in // `ZB.MOM.WW.ScadaBridge.Host` (AkkaHostedService) per the diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ArchitecturalConstraintTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ArchitecturalConstraintTests.cs index 47cea343..c09548e3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ArchitecturalConstraintTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ArchitecturalConstraintTests.cs @@ -27,6 +27,7 @@ public class ArchitecturalConstraintTests return Path.GetDirectoryName(csproj)!; } + /// <summary>Verifies that the Commons project file declares none of the forbidden package references.</summary> [Fact] public void Csproj_ShouldNotHaveForbiddenPackageReferences() { @@ -51,6 +52,7 @@ public class ArchitecturalConstraintTests } } + /// <summary>Verifies that the Commons assembly references none of the forbidden assemblies.</summary> [Fact] public void Assembly_ShouldNotReferenceForbiddenAssemblies() { @@ -68,6 +70,7 @@ public class ArchitecturalConstraintTests } } + /// <summary>Verifies that Commons contains no service or actor implementations.</summary> [Fact] public void Commons_ShouldNotContainServiceOrActorImplementations() { @@ -99,6 +102,7 @@ public class ArchitecturalConstraintTests } } + /// <summary>Verifies that no source file calls <c>ToLocalTime</c>, since all timestamps are UTC.</summary> [Fact] public void SourceFiles_ShouldNotContainToLocalTime() { @@ -112,6 +116,7 @@ public class ArchitecturalConstraintTests } } + /// <summary>Verifies that no entity class carries Entity Framework attributes.</summary> [Fact] public void AllEntities_ShouldHaveNoEfAttributes() { @@ -145,6 +150,7 @@ public class ArchitecturalConstraintTests } } + /// <summary>Verifies that the retired <c>DebugStreamEvent</c> type does not exist.</summary> [Fact] public void DebugStreamEvent_ShouldNotExist() { @@ -154,6 +160,7 @@ public class ArchitecturalConstraintTests Assert.Null(type); } + /// <summary>Verifies that every enum has a singular name.</summary> [Fact] public void AllEnums_ShouldBeSingularNamed() { @@ -168,6 +175,7 @@ public class ArchitecturalConstraintTests } } + /// <summary>Verifies that every message type is a record.</summary> [Fact] public void AllMessageTypes_ShouldBeRecords() { @@ -187,6 +195,7 @@ public class ArchitecturalConstraintTests } } + /// <summary>Verifies that the key entities use <see cref="DateTimeOffset"/> for their timestamp properties.</summary> [Fact] public void KeyEntities_ShouldHaveDateTimeOffsetTimestamps() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Audit/ScriptTraceEnumTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Audit/ScriptTraceEnumTests.cs index dcd159a7..5a6f8556 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Audit/ScriptTraceEnumTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Audit/ScriptTraceEnumTests.cs @@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Audit; /// </summary> public class ScriptTraceEnumTests { + /// <summary>Verifies that each script trace kind fits the central audit columns.</summary> + /// <param name="kind">The script trace audit kind under test.</param> [Theory] [InlineData(AuditKind.RouteCall)] [InlineData(AuditKind.ScriptRun)] @@ -22,6 +24,7 @@ public class ScriptTraceEnumTests Assert.Equal($"ScriptTrace.{kind}", AuditFieldBuilders.BuildAction(AuditChannel.ScriptTrace, kind)); } + /// <summary>Verifies that the script trace values are appended after the cluster values so existing ordinals stay stable.</summary> [Fact] public void ScriptTrace_IsAppendedAfterCluster_SoExistingOrdinalsAreStable() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/ExceptionHeaderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/ExceptionHeaderTests.cs index 45a129a2..3137a9c6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/ExceptionHeaderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/ExceptionHeaderTests.cs @@ -3,12 +3,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging; /// <summary> -/// Final review M1: the ONE parser behind the graph node's exception subtitle and the step +/// The single parser behind the graph node's exception subtitle and the step /// detail panel's exception block. Before it, the panel showed the OUTERMOST exception of a /// wrapped chain while the node showed the innermost — the two now agree by construction. /// </summary> public class ExceptionHeaderTests { + /// <summary>Verifies that a plain detail splits the type from the message and shortens the type only when asked.</summary> [Fact] public void Parse_PlainDetail_SplitsTypeFromMessage_AndShortensOnlyWhenAsked() { @@ -18,6 +19,7 @@ public class ExceptionHeaderTests Assert.Equal(("System.InvalidOperationException", "Recipe 42 rejected"), ExceptionHeader.Parse(detail, shortenType: false)); } + /// <summary>Verifies that a wrapped exception chain resolves to the innermost exception.</summary> [Fact] public void Parse_WrappedChain_TakesTheINNERMOSTException() { @@ -28,6 +30,7 @@ public class ExceptionHeaderTests Assert.Equal(("C.Innermost", "the real one"), ExceptionHeader.Parse(detail, shortenType: false)); } + /// <summary>Verifies that a generic type keeps its arity and arguments verbatim and only the head is shortened.</summary> [Fact] public void Parse_GenericType_KeepsTheArityAndArgumentsVerbatim_ShorteningOnlyTheHead() { @@ -37,6 +40,7 @@ public class ExceptionHeaderTests ExceptionHeader.Parse("RecipeException`1[System.String]: msg", shortenType: true)); } + /// <summary>Verifies that the chain marker is matched after trimming, so a trailing marker is not treated as a chain.</summary> [Fact] public void Parse_ChainMarkerIsMatchedAfterTrimming_SoATrailingMarkerIsNotAChain() { @@ -46,12 +50,14 @@ public class ExceptionHeaderTests Assert.Equal(("Outer", "outer --->"), ExceptionHeader.Parse("A.Outer: outer ---> ", shortenType: true)); } + /// <summary>Verifies that a line without a colon is treated as all type and no message.</summary> [Fact] public void Parse_LineWithoutAColon_IsAllTypeAndNoMessage() { Assert.Equal(("SomethingWentWrong", string.Empty), ExceptionHeader.Parse("SomethingWentWrong", shortenType: true)); } + /// <summary>Verifies that a CRLF detail has the carriage return trimmed off the first line.</summary> [Fact] public void Parse_CrLfDetail_TrimsTheCarriageReturnOffTheFirstLine() { @@ -59,6 +65,8 @@ public class ExceptionHeaderTests ExceptionHeader.Parse("System.TimeoutException: took too long\r\n at X\r\n", shortenType: true)); } + /// <summary>Verifies that a missing or blank detail returns null.</summary> + /// <param name="detail">The exception detail text to parse.</param> [Theory] [InlineData(null)] [InlineData("")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowEnumContractTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowEnumContractTests.cs index ba89bf06..5f6dc9a8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowEnumContractTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowEnumContractTests.cs @@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging; public class FlowEnumContractTests { + /// <summary>Verifies that the enum names and numeric values remain stable.</summary> [Fact] public void EnumNamesAndValues_RemainStable() { @@ -15,6 +16,7 @@ public class FlowEnumContractTests Assert.Equal(Enumerable.Range(0, 7), Enum.GetValues<FlowStepOutcome>().Select(x => (int)x)); } + /// <summary>Verifies that the kind rank follows the display order independent of the enum's numeric value.</summary> [Fact] public void KindRank_UsesDisplayOrderIndependentOfEnumValue() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderLogLineTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderLogLineTests.cs index 0a132fe0..10eaacd5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderLogLineTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderLogLineTests.cs @@ -51,6 +51,7 @@ public class FlowGraphBuilderLogLineTests private static FlowStep StepOf(FlowGraph graph, FlowStepKind kind) => Assert.Single(graph.Steps, s => s.Kind == kind); + /// <summary>Verifies that a log line carrying a step event id attaches to that step.</summary> [Fact] public void LogLine_WithStepEventId_AttachesToThatStep() { @@ -65,6 +66,7 @@ public class FlowGraphBuilderLogLineTests Assert.Null(StepOf(graph, FlowStepKind.ScriptRun).Logs); } + /// <summary>Verifies that a log line without a step id attaches to the execution anchor.</summary> [Fact] public void LogLine_WithoutStepId_AttachesToTheExecutionAnchor() { @@ -76,6 +78,7 @@ public class FlowGraphBuilderLogLineTests Assert.Null(StepOf(graph, FlowStepKind.AttributeWrite).Logs); } + /// <summary>Verifies that a central log line attaches to the inbound row rather than the response.</summary> [Fact] public void CentralLine_AttachesToInboundRow_NotResponse() { @@ -87,6 +90,7 @@ public class FlowGraphBuilderLogLineTests Assert.Null(StepOf(graph, FlowStepKind.Response).Logs); } + /// <summary>Verifies that a script-entry log line attaches to the route-call root.</summary> [Fact] public void ScriptEntryLine_AttachesToRouteCallRoot() { @@ -97,6 +101,7 @@ public class FlowGraphBuilderLogLineTests Assert.Single(StepOf(graph, FlowStepKind.RouteCall).Logs!); } + /// <summary>Verifies that a log line with an unknown step id falls back to the anchor.</summary> [Fact] public void LogLine_UnknownStepId_FallsBackToAnchor() { @@ -108,6 +113,7 @@ public class FlowGraphBuilderLogLineTests Assert.Equal(0, graph.UnattachedLogLines); } + /// <summary>Verifies that a log line with no anchor falls back to the first step.</summary> [Fact] public void LogLine_NoAnchor_FallsBackToFirstStep() { @@ -119,6 +125,7 @@ public class FlowGraphBuilderLogLineTests Assert.Equal(0, graph.UnattachedLogLines); } + /// <summary>Verifies that a log line whose execution has no step is counted as unattached.</summary> [Fact] public void LogLine_NoStepInExecution_IsCountedUnattached() { @@ -130,6 +137,7 @@ public class FlowGraphBuilderLogLineTests Assert.All(graph.Steps, s => Assert.True(s.Logs is null or { Count: 0 })); } + /// <summary>Verifies that building from log lines only returns an empty graph with the unattached count.</summary> [Fact] public void Build_LinesOnly_ReturnsEmptyGraphWithUnattachedCount() { @@ -141,6 +149,7 @@ public class FlowGraphBuilderLogLineTests Assert.True(graph.LogsTruncated); } + /// <summary>Verifies that lines within the same millisecond are ordered by sequence with null sequences last.</summary> [Fact] public void Lines_OrderBySeqWithinSameMillisecond_NullSeqLast() { @@ -160,6 +169,7 @@ public class FlowGraphBuilderLogLineTests Assert.Equal(new[] { "one", "two", "three", "null" }, logs.Select(l => l.Message).ToArray()); } + /// <summary>Verifies that log lines never become graph steps.</summary> [Fact] public void LogLines_NeverBecomeSteps() { @@ -177,6 +187,7 @@ public class FlowGraphBuilderLogLineTests Assert.DoesNotContain(graph.Edges, e => lineIds.Contains(e.FromEventId) || lineIds.Contains(e.ToEventId)); } + /// <summary>Verifies that the builder passes the logs-truncated flag through to the graph.</summary> [Fact] public void Build_PassesLogsTruncatedThrough() { @@ -188,6 +199,7 @@ public class FlowGraphBuilderLogLineTests Assert.False(graph.Truncated); } + /// <summary>Verifies that a log line on a folded outbound step attaches by the first row id.</summary> [Fact] public void LogLine_OnFoldedOutboundStep_AttachesByFirstRowId() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs index 55855112..64e89aa0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs @@ -52,6 +52,7 @@ public class FlowGraphBuilderTests return [inbound, route, wait, run, write2, write1, cascade]; } + /// <summary>Verifies that an inferred device cascade edge is drawn dashed.</summary> [Fact] public void InferredDeviceCascade_IsDashed() { @@ -65,6 +66,9 @@ public class FlowGraphBuilderTests Assert.True(edge.External); } + /// <summary>Verifies that a folded node uses the latest error detail from its rows.</summary> + /// <param name="initialDetail">The error detail on the initial row.</param> + /// <param name="finalDetail">The error detail on the final row.</param> [Theory] [InlineData(null, "terminal stack")] [InlineData("old stack", null)] @@ -81,6 +85,7 @@ public class FlowGraphBuilderTests Assert.Equal(finalDetail, step.Lifecycle[1].ErrorDetail); } + /// <summary>Verifies that the Delmia call shape projects the expected lanes and edges plus a synthesized response.</summary> [Fact] public void DelmiaShape_ProjectsLanes_Edges_AndSynthesizedResponse() { @@ -142,6 +147,7 @@ public class FlowGraphBuilderTests Assert.All(graph.Steps, s => Assert.Equal(FlowStepOutcome.Ok, s.Outcome)); } + /// <summary>Verifies that building the graph is deterministic and the synthesized response id is stable.</summary> [Fact] public void Build_IsDeterministic_ResponseIdIsStable() { @@ -152,6 +158,7 @@ public class FlowGraphBuilderTests Assert.Equal(a.Edges, b.Edges); } + /// <summary>Verifies that the truncated flag passes through to the built graph.</summary> [Fact] public void Truncated_PassesThrough() { @@ -160,6 +167,7 @@ public class FlowGraphBuilderTests Assert.Empty(FlowGraphBuilder.Build(Array.Empty<AuditEvent>()).Steps); } + /// <summary>Verifies that an unrecognized step kind projects to an unknown node without throwing.</summary> [Fact] public void UnknownKind_ProjectsToUnknown_NeverThrows() { @@ -190,6 +198,7 @@ public class FlowGraphBuilderTests Assert.Empty(graph.Edges); } + /// <summary>Verifies that a failed script run with a timeout message is classified as timed out.</summary> [Fact] public void FailedScriptRun_WithTimeoutMessage_IsTimedOut() { @@ -205,6 +214,7 @@ public class FlowGraphBuilderTests Assert.Single(graph.Edges); } + /// <summary>Verifies that the script trace extra payload round-trips as camelCase JSON and ignores unknown members.</summary> [Fact] public void ScriptTraceExtra_RoundTrips_CamelCase_AndIgnoresUnknownMembers() { @@ -219,7 +229,7 @@ public class FlowGraphBuilderTests Assert.Null(ScriptTraceExtra.TryParse("nope")); } - // ---- Outbound calls (2026-09-06 plan, Task 2) -------------------------------------------- + // ---- Outbound calls (2026-09-06 plan) -------------------------------------------- private static AuditEvent SiteRunRow(int ms, Guid executionId = default) => ScadaBridgeAuditEventFactory.Create( AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, @@ -240,6 +250,7 @@ public class FlowGraphBuilderTests executionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", errorMessage: error, durationMs: durationMs, requestSummary: request, responseSummary: response); + /// <summary>Verifies that the four rows of a cached API call fold into a single asynchronous node.</summary> [Fact] public void CachedApiCall_FourRows_FoldIntoOneAsyncNode() { @@ -286,6 +297,7 @@ public class FlowGraphBuilderTests Assert.Equal(3, graph.Edges.Count); } + /// <summary>Verifies that a synchronous API call sits in the chain without an asynchronous edge.</summary> [Fact] public void SyncApiCall_SitsInChain_NoAsyncEdge() { @@ -309,6 +321,7 @@ public class FlowGraphBuilderTests Assert.DoesNotContain(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[3].EventId); } + /// <summary>Verifies that two synchronous calls with no correlation id are not folded together.</summary> [Fact] public void TwoSyncCalls_NullCorrelation_AreNotFoldedTogether() { @@ -326,6 +339,7 @@ public class FlowGraphBuilderTests Assert.Equal(2, graph.Edges.Count); } + /// <summary>Verifies that a notification send and its parked central delivery fold into one parked notify node.</summary> [Fact] public void NotifySend_PlusCentralDeliverParked_FoldsIntoOneParkedNotifyNode() { @@ -354,6 +368,7 @@ public class FlowGraphBuilderTests Assert.Equal(notify.EventId, async.ToEventId); } + /// <summary>Verifies that a cached call with only a submit row is pending and has no duration.</summary> [Fact] public void CachedSubmitOnly_IsPending_WithNoDuration() { @@ -374,6 +389,7 @@ public class FlowGraphBuilderTests Assert.Equal(graph.Steps[0].EventId, async.FromEventId); } + /// <summary>Verifies that the response links from the last step of the inbound execution rather than the last cascaded step.</summary> [Fact] public void ResponseLinksFromTheInboundExecutionsLastStep_NotTheLastCascadedStep() { @@ -385,6 +401,7 @@ public class FlowGraphBuilderTests Assert.Equal(FlowStepKind.RouteCall, graph.Steps[1].Kind); } + /// <summary>Verifies that the response falls back to the inbound row when no other row shares its execution.</summary> [Fact] public void ResponseFallsBackToTheInboundRow_WhenNothingElseSharesItsExecution() { @@ -422,6 +439,7 @@ public class FlowGraphBuilderTests return [inbound, route, run, write]; } + /// <summary>Verifies that with a site clock running ahead, site steps still follow their route call and precede the response.</summary> [Fact] public void SiteClockAhead_SiteStepsStillFollowTheirRouteCall_AndPrecedeResponse() { @@ -440,6 +458,7 @@ public class FlowGraphBuilderTests Assert.Equal(T0.AddMilliseconds(900), graph.Steps[4].OccurredAtUtc.UtcDateTime); } + /// <summary>Verifies that with a site clock running behind, site steps do not sort ahead of the inbound request.</summary> [Fact] public void SiteClockBehind_SiteStepsDoNotSortAheadOfTheInboundRequest() { @@ -456,6 +475,7 @@ public class FlowGraphBuilderTests Assert.Equal(events.Select(e => e.EventId), graph.Steps.Take(4).Select(s => s.EventId)); } + /// <summary>Verifies that with a site clock running behind, a trigger cascade spawns from the last parent step rather than the first.</summary> [Fact] public void SiteClockBehind_TriggerCascade_SpawnsFromTheLastParentStep_NotTheFirst() { @@ -477,6 +497,7 @@ public class FlowGraphBuilderTests Assert.Equal(run.EventId, spawn.ToEventId); } + /// <summary>Verifies that on a shared clock a trigger cascade spawns from the write that fired it rather than the parent's last step.</summary> [Fact] public void SameClock_TriggerCascade_SpawnsFromTheWriteThatFiredIt_NotTheParentsLastStep() { @@ -502,6 +523,7 @@ public class FlowGraphBuilderTests Assert.Equal(cascade.EventId, spawn.ToEventId); } + /// <summary>Verifies that with simultaneous writes a trigger cascade spawns from the write of the trigger attribute rather than the last write.</summary> [Fact] public void SimultaneousWrites_TriggerCascade_SpawnsFromTheWriteOfTheTriggerAttribute_NotTheLastWrite() { @@ -524,6 +546,7 @@ public class FlowGraphBuilderTests Assert.Equal(cascade.EventId, spawn.ToEventId); } + /// <summary>Verifies that a trigger cascade whose attribute the parent never wrote falls back to time-based placement.</summary> [Fact] public void TriggerCascade_WhoseAttributeTheParentNeverWrote_FallsBackToTheTimeRule() { @@ -543,6 +566,7 @@ public class FlowGraphBuilderTests Assert.Equal(writeA.EventId, spawn.FromEventId); } + /// <summary>Verifies that a folded node takes the emission index of its earliest emitted row.</summary> [Fact] public void FoldedNode_TakesTheEmissionIndexOfItsEarliestEmittedRow() { @@ -562,8 +586,9 @@ public class FlowGraphBuilderTests Assert.Equal(new[] { FlowStepKind.ApiCall, FlowStepKind.ScriptRun }, graph.Steps.Select(s => s.Kind)); } - // ---- Review findings on Task 2 (2026-09-06) -------------------------------------------- + // ---- Review findings on outbound calls (2026-09-06) -------------------------------------------- + /// <summary>Verifies that leading asynchronous steps hang off the spawner and are never orphaned.</summary> [Fact] public void LeadingAsyncSteps_HangOffTheSpawner_NeverOrphaned() { @@ -591,6 +616,7 @@ public class FlowGraphBuilderTests Assert.Equal(3, graph.Edges.Count); } + /// <summary>Verifies that when an execution has only notify rows, all of them link from the spawner.</summary> [Fact] public void OnlyNotifyRows_InAnExecution_AllLinkFromTheSpawner() { @@ -606,6 +632,7 @@ public class FlowGraphBuilderTests Assert.All(graph.Edges, e => { Assert.True(e.Async); Assert.True(e.Spawn); Assert.Equal(graph.Steps[0].EventId, e.FromEventId); }); } + /// <summary>Verifies that folding is independent of row order and ties are broken by lifecycle rank.</summary> [Fact] public void Fold_IsOrderIndependent_AndTiesBreakByLifecycleRank() { @@ -628,6 +655,7 @@ public class FlowGraphBuilderTests Assert.Equal(sa.Lifecycle!.Select(r => r.Kind), sb.Lifecycle!.Select(r => r.Kind)); } + /// <summary>Verifies that a cached step whose submit row is missing is still treated as asynchronous.</summary> [Fact] public void CachedStep_WhoseSubmitRowIsMissing_IsStillAsync() { @@ -642,6 +670,7 @@ public class FlowGraphBuilderTests Assert.Single(graph.Edges, e => e.Async && e.ToEventId == api.EventId); } + /// <summary>Verifies that edges on the external lane never count as cluster boundary crossings.</summary> [Fact] public void ExternalLaneEdges_NeverCountAsClusterBoundaryCrossings() { @@ -660,8 +689,9 @@ public class FlowGraphBuilderTests Assert.Equal(graph.Steps[1].EventId, into.FromEventId); } - // ---- Script entry point (2026-09-08 plan, Task 6) ----------------------------------------- + // ---- Script entry point (2026-09-08 plan) ----------------------------------------- + /// <summary>Verifies that a route-call root without an inbound row builds a central-to-site graph with no response.</summary> [Fact] public void RouteCallRoot_WithoutInboundRow_BuildsCentralToSite_AndNoResponse() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorLogLineTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorLogLineTests.cs index ff501123..8d909e6b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorLogLineTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorLogLineTests.cs @@ -5,12 +5,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging; /// <summary> -/// Flow Debugger step-logs plan (2026-09-09) Task 1: the Commons log-line vocabulary — +/// Flow Debugger step-logs plan (2026-09-09): the Commons log-line vocabulary — /// exception detail on a step (D3), and the <c>ScriptTrace.ScriptLog</c> row projecting /// onto a <see cref="FlowLogLine"/> rather than a node (D1). /// </summary> public class FlowStepProjectorLogLineTests { + /// <summary>Verifies that projecting an audit event carries its error detail onto the step.</summary> [Fact] public void Project_CarriesErrorDetail() { @@ -23,6 +24,7 @@ public class FlowStepProjectorLogLineTests Assert.StartsWith("System.InvalidOperationException", step.ErrorDetail); } + /// <summary>Verifies that the projected error detail is null when the audit event has none.</summary> [Fact] public void Project_ErrorDetail_IsNullWhenAbsent() { @@ -31,6 +33,7 @@ public class FlowStepProjectorLogLineTests Assert.Null(FlowStepProjector.Project(evt).ErrorDetail); } + /// <summary>Verifies that a script log row projects to the unknown kind and never to a step kind of its own.</summary> [Fact] public void ScriptLog_ProjectsToUnknownKind_NeverItsOwnStepKind() { @@ -40,6 +43,7 @@ public class FlowStepProjectorLogLineTests Assert.False(Enum.GetNames<FlowStepKind>().Contains("ScriptLog")); } + /// <summary>Verifies that a row is a log line only for the script trace channel with the script log kind.</summary> [Fact] public void IsLogLine_TrueOnlyForScriptTraceScriptLog() { @@ -50,6 +54,7 @@ public class FlowStepProjectorLogLineTests Assert.False(FlowStepProjector.IsLogLine(run)); } + /// <summary>Verifies that rows on other channels are not log lines.</summary> [Fact] public void IsLogLine_FalseForOtherChannels() { @@ -58,6 +63,7 @@ public class FlowStepProjectorLogLineTests Assert.False(FlowStepProjector.IsLogLine(outbound)); } + /// <summary>Verifies that converting a row to a log line reads its level, source and exception.</summary> [Fact] public void ToLogLine_ReadsLevelSourceAndException() { @@ -81,6 +87,7 @@ public class FlowStepProjectorLogLineTests Assert.Equal(3, line.Seq); } + /// <summary>Verifies that converting a row to a log line defaults the level and source when the extra payload is missing.</summary> [Fact] public void ToLogLine_DefaultsLevelAndSourceWhenExtraMissing() { @@ -97,6 +104,7 @@ public class FlowStepProjectorLogLineTests Assert.Null(line.Seq); } + /// <summary>Verifies that the log members of the script trace extra round-trip through JSON.</summary> [Fact] public void ScriptTraceExtra_LogMembers_RoundTripThroughJson() { @@ -111,6 +119,7 @@ public class FlowStepProjectorLogLineTests Assert.Equal(12, parsed.Seq); } + /// <summary>Verifies that a flow step's logs and error detail default to null.</summary> [Fact] public void FlowStep_LogsAndErrorDetail_DefaultToNull() { @@ -124,6 +133,7 @@ public class FlowStepProjectorLogLineTests Assert.Single((step with { Logs = [line] }).Logs!); } + /// <summary>Verifies that the log truncation members of a flow graph default to off.</summary> [Fact] public void FlowGraph_LogTruncationMembers_DefaultToOff() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorTests.cs index aebc334b..90167e29 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorTests.cs @@ -6,13 +6,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging; /// <summary> -/// Flow Debugger outbound-calls plan (2026-09-06) Task 0: the Commons vocabulary — External lane, +/// Flow Debugger outbound-calls plan (2026-09-06): the Commons vocabulary — External lane, /// the three outbound kinds, Pending/Parked outcomes, the lifecycle row, and additive edge/step shapes. /// </summary> public class FlowStepProjectorTests { private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero); + /// <summary>Verifies that the vocabulary has an External lane, three outbound kinds, and Pending and Parked outcomes.</summary> [Fact] public void Vocabulary_HasExternalLane_ThreeOutboundKinds_AndPendingParkedOutcomes() { @@ -26,6 +27,7 @@ public class FlowStepProjectorTests Assert.True(Enum.IsDefined(FlowStepOutcome.Parked)); } + /// <summary>Verifies that a flow step can be constructed without a lifecycle and defaults it to null.</summary> [Fact] public void FlowStep_WithoutLifecycle_StillConstructs_AndDefaultsToNull() { @@ -39,6 +41,7 @@ public class FlowStepProjectorTests Assert.Equal("Submitted", withLifecycle.Lifecycle![0].Status); } + /// <summary>Verifies that the Async and Spawn flags of a flow edge default to false.</summary> [Fact] public void FlowEdge_AsyncAndSpawn_DefaultFalse() { @@ -51,8 +54,9 @@ public class FlowStepProjectorTests Assert.True(new FlowEdge(a, b, false, false, Async: false, Spawn: true).Spawn); } - // ---- Task 1: outbound rows project onto the External lane --------------------------------- + // ---- Outbound rows project onto the External lane --------------------------------- + /// <summary>Verifies that a delivered synchronous API call projects to an Ok API-call step on the External lane with a sync subtitle.</summary> [Fact] public void SyncApiCall_Delivered_IsApiCall_External_Ok_SyncSubtitle() { @@ -73,6 +77,7 @@ public class FlowStepProjectorTests Assert.Equal(212, step.DurationMs); } + /// <summary>Verifies that a cached submit on the database outbound channel projects to a pending database-call step with a cached subtitle.</summary> [Fact] public void CachedSubmit_InDbOutbound_IsDbCall_Pending_CachedSubtitle() { @@ -87,6 +92,7 @@ public class FlowStepProjectorTests Assert.Equal("Submitted · cached", step.Subtitle); } + /// <summary>Verifies that a database write with a rows-affected extra has a subtitle combining the operation and the row count.</summary> [Fact] public void DbWrite_WithRowsAffectedExtra_SubtitleIsOpAndRows() { @@ -105,6 +111,7 @@ public class FlowStepProjectorTests Assert.Equal("read · 1 row", FlowStepProjector.Project(read).Subtitle); } + /// <summary>Verifies that a submitted notification send projects to a pending notify step.</summary> [Fact] public void NotifySend_Submitted_IsNotify_Pending() { @@ -120,6 +127,9 @@ public class FlowStepProjectorTests Assert.Equal("Submitted", step.Subtitle); } + /// <summary>Verifies that each outbound audit status maps to the expected flow step outcome.</summary> + /// <param name="status">The outbound audit status.</param> + /// <param name="expected">The expected flow step outcome.</param> [Theory] [InlineData(AuditStatus.Parked, FlowStepOutcome.Parked)] [InlineData(AuditStatus.Discarded, FlowStepOutcome.Parked)] @@ -139,6 +149,7 @@ public class FlowStepProjectorTests Assert.Equal($"{status} · cached", step.Subtitle); } + /// <summary>Verifies that script-trace outcomes are unchanged, so a failed status stays failed and other statuses are Ok.</summary> [Fact] public void ScriptTraceOutcome_Unchanged_FailedStaysFailed_OtherStatusesOk() { @@ -150,6 +161,9 @@ public class FlowStepProjectorTests Assert.Equal(FlowStepOutcome.Failed, FlowStepProjector.Project(failed).Outcome); } + /// <summary>Verifies that building a database subtitle never throws on odd extra values and yields the expected text.</summary> + /// <param name="extra">The extra payload of the audit row.</param> + /// <param name="expected">The expected subtitle.</param> [Theory] [InlineData("{\"op\":\"write\",\"rowsAffected\":2147483648}", "write")] [InlineData("{\"op\":\"write\",\"rowsAffected\":1.5}", "write")] @@ -163,6 +177,7 @@ public class FlowStepProjectorTests Assert.Equal(expected, FlowStepProjector.Project(evt).Subtitle); } + /// <summary>Verifies that projection carries the audit row's correlation id onto the step.</summary> [Fact] public void Project_CarriesTheAuditRowsCorrelationId() { @@ -173,6 +188,7 @@ public class FlowStepProjectorTests Assert.Equal(corr, FlowStepProjector.Project(evt).CorrelationId); } + /// <summary>Verifies that a null correlation id on the audit row leaves the step's correlation id null.</summary> [Fact] public void Project_NullCorrelation_LeavesCorrelationIdNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowTraceCollectorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowTraceCollectorTests.cs index b78404b5..2e17e252 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowTraceCollectorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowTraceCollectorTests.cs @@ -24,6 +24,7 @@ public class FlowTraceCollectorTests AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, target: "S", requestSummary: details); + /// <summary>Verifies that add, when beyond cap, keeps first n and flags truncated.</summary> [Fact] public void Add_BeyondCap_KeepsFirstN_AndFlagsTruncated() { @@ -39,6 +40,7 @@ public class FlowTraceCollectorTests Assert.Equal("499", snap[499].Target); } + /// <summary>Verifies that snapshot, when is a copy, in emission order.</summary> [Fact] public void Snapshot_IsACopy_InEmissionOrder() { @@ -52,6 +54,8 @@ public class FlowTraceCollectorTests Assert.Equal(new[] { "1", "2" }, c.Snapshot().Select(e => e.Target)); } + /// <summary>Verifies that concurrent adds, when never throw, and never exceed cap.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConcurrentAdds_NeverThrow_AndNeverExceedCap() { @@ -66,6 +70,7 @@ public class FlowTraceCollectorTests Assert.True(c.Truncated); } + /// <summary>Verifies that default cap, when is 500, and non-positive cap is rejected.</summary> [Fact] public void DefaultCap_Is500_AndNonPositiveCapIsRejected() { @@ -73,6 +78,7 @@ public class FlowTraceCollectorTests Assert.Throws<ArgumentOutOfRangeException>(() => new FlowTraceCollector(0)); } + /// <summary>Verifies that complete, when closes the collector, later adds are ignored and never truncate.</summary> [Fact] public void Complete_ClosesTheCollector_LaterAddsAreIgnored_AndNeverTruncate() { @@ -198,6 +204,9 @@ public class FlowTraceCollectorTests /// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary> private sealed class UpperRedactor : IAuditRedactor { + /// <summary>Applies the upper-casing redaction.</summary> + /// <param name="rawEvent">The raw event.</param> + /// <returns>The event with upper-cased details.</returns> public AuditEvent Apply(AuditEvent rawEvent) => rawEvent with { DetailsJson = rawEvent.DetailsJson?.ToUpperInvariant() }; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/DeploymentFetchTokenTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/DeploymentFetchTokenTests.cs index 8f9b3adf..2c588ea1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/DeploymentFetchTokenTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/DeploymentFetchTokenTests.cs @@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests; public class DeploymentFetchTokenTests { + /// <summary>Verifies that generate, returns url safe token of adequate length.</summary> [Fact] public void Generate_ReturnsUrlSafeTokenOfAdequateLength() { @@ -14,6 +15,7 @@ public class DeploymentFetchTokenTests Assert.DoesNotContain('/', token); } + /// <summary>Verifies that generate, two calls return different values.</summary> [Fact] public void Generate_TwoCallsReturnDifferentValues() { @@ -23,6 +25,7 @@ public class DeploymentFetchTokenTests Assert.NotEqual(a, b); } + /// <summary>Verifies that constant time equals, same token, returns true.</summary> [Fact] public void ConstantTimeEquals_SameToken_ReturnsTrue() { @@ -31,6 +34,7 @@ public class DeploymentFetchTokenTests Assert.True(DeploymentFetchToken.ConstantTimeEquals(token, token)); } + /// <summary>Verifies that constant time equals, token with suffix, returns false.</summary> [Fact] public void ConstantTimeEquals_TokenWithSuffix_ReturnsFalse() { @@ -39,6 +43,7 @@ public class DeploymentFetchTokenTests Assert.False(DeploymentFetchToken.ConstantTimeEquals(token, token + "x")); } + /// <summary>Verifies that constant time equals, both empty, returns true.</summary> [Fact] public void ConstantTimeEquals_BothEmpty_ReturnsTrue() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/Audit/SiteCallTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/Audit/SiteCallTests.cs index 5dfd8c3d..48b235e2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/Audit/SiteCallTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/Audit/SiteCallTests.cs @@ -23,6 +23,7 @@ public class SiteCallTests IngestedAtUtc = new DateTime(2026, 5, 23, 12, 0, 1, DateTimeKind.Utc), }; + /// <summary>Verifies that a site call carries its source node.</summary> [Fact] public void SiteCall_carries_SourceNode() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/EntityConventionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/EntityConventionTests.cs index 6efa8fce..ca355c5f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/EntityConventionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/EntityConventionTests.cs @@ -12,6 +12,7 @@ public class EntityConventionTests .Where(t => t.IsClass && !t.IsAbstract && t.Namespace != null && t.Namespace.Contains(".Entities.")); + /// <summary>Verifies that no entity class carries Entity Framework attributes.</summary> [Fact] public void AllEntities_ShouldHaveNoEfAttributes() { @@ -45,6 +46,7 @@ public class EntityConventionTests } } + /// <summary>Verifies that every timestamp property on an entity is a DateTimeOffset.</summary> [Fact] public void AllTimestampProperties_ShouldBeDateTimeOffset() { @@ -68,6 +70,7 @@ public class EntityConventionTests } } + /// <summary>Verifies that collection navigation properties on entities are declared as ICollection.</summary> [Fact] public void NavigationProperties_ShouldBeICollection() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/ExternalSystemDefinitionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/ExternalSystemDefinitionTests.cs index 70c79ec8..308a67d6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/ExternalSystemDefinitionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/ExternalSystemDefinitionTests.cs @@ -9,6 +9,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities; /// </summary> public class ExternalSystemDefinitionTests { + /// <summary>Verifies that the timeout seconds default to zero, meaning the gateway default is used.</summary> [Fact] public void ExternalSystemDefinition_TimeoutSeconds_DefaultsToZeroMeaningUseGatewayDefault() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NativeAlarmSourceEntityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NativeAlarmSourceEntityTests.cs index 50374bc6..aed2eb5d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NativeAlarmSourceEntityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NativeAlarmSourceEntityTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities; public class NativeAlarmSourceEntityTests { + /// <summary>Verifies that a template native alarm source is constructed with its required name.</summary> [Fact] public void TemplateNativeAlarmSource_RequiresName() { @@ -13,6 +14,7 @@ public class NativeAlarmSourceEntityTests Assert.False(s.IsLocked); } + /// <summary>Verifies that a flattened configuration has an empty native alarm source list by default.</summary> [Fact] public void FlattenedConfiguration_HasNativeAlarmSourcesDefaultEmpty() { @@ -20,6 +22,7 @@ public class NativeAlarmSourceEntityTests Assert.Empty(f.NativeAlarmSources); } + /// <summary>Verifies that a resolved native alarm source has a default source template value.</summary> [Fact] public void ResolvedNativeAlarmSource_DefaultsSourceTemplate() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NotificationEntityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NotificationEntityTests.cs index 61ce235c..a1435867 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NotificationEntityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NotificationEntityTests.cs @@ -9,6 +9,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities; /// </summary> public class NotificationEntityTests { + /// <summary>Verifies that the constructor sets the expected default values.</summary> [Fact] public void Constructor_SetsDefaults() { @@ -21,10 +22,11 @@ public class NotificationEntityTests Assert.Equal("SiteA", n.SourceSiteId); } + /// <summary>Verifies that OriginExecutionId defaults to null and can be set.</summary> [Fact] public void OriginExecutionId_DefaultsToNull_AndIsSettable() { - // Audit Log #23: OriginExecutionId carries the originating script + // OriginExecutionId carries the originating script // execution's id from the site so the dispatcher can echo it onto // NotifyDeliver rows. Null for notifications submitted before the // column existed; settable from the NotificationSubmit message. @@ -36,6 +38,7 @@ public class NotificationEntityTests Assert.Equal(executionId, n.OriginExecutionId); } + /// <summary>Verifies that OriginParentExecutionId defaults to null and can be set.</summary> [Fact] public void OriginParentExecutionId_DefaultsToNull_AndIsSettable() { @@ -51,6 +54,7 @@ public class NotificationEntityTests Assert.Equal(parentExecutionId, n.OriginParentExecutionId); } + /// <summary>Verifies that SourceNode defaults to null and can be set.</summary> [Fact] public void SourceNode_DefaultsToNull_AndIsSettable() { @@ -65,6 +69,7 @@ public class NotificationEntityTests Assert.Equal("node-a", n.SourceNode); } + /// <summary>Verifies that the constructor throws for null arguments.</summary> [Fact] public void Constructor_NullArguments_Throw() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NotificationRecipientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NotificationRecipientTests.cs index 8ccfad29..1f2560f3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NotificationRecipientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NotificationRecipientTests.cs @@ -8,6 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities; /// </summary> public class NotificationRecipientTests { + /// <summary>Verifies that creating an email recipient sets the email address and leaves the phone number null.</summary> [Fact] public void ForEmail_SetsEmail_LeavesPhoneNull() { @@ -18,6 +19,7 @@ public class NotificationRecipientTests Assert.Null(r.PhoneNumber); } + /// <summary>Verifies that creating an SMS recipient sets the phone number and leaves the email address null.</summary> [Fact] public void ForSms_SetsPhone_LeavesEmailNull() { @@ -28,6 +30,8 @@ public class NotificationRecipientTests Assert.Null(r.EmailAddress); } + /// <summary>Verifies that creating an email recipient with a blank name throws.</summary> + /// <param name="name">The blank recipient name.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -35,6 +39,8 @@ public class NotificationRecipientTests public void ForEmail_BlankName_Throws(string? name) => Assert.Throws<ArgumentException>(() => NotificationRecipient.ForEmail(name!, "jane@example.com")); + /// <summary>Verifies that creating an SMS recipient with a blank name throws.</summary> + /// <param name="name">The blank recipient name.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -42,14 +48,18 @@ public class NotificationRecipientTests public void ForSms_BlankName_Throws(string? name) => Assert.Throws<ArgumentException>(() => NotificationRecipient.ForSms(name!, "+15551234567")); + /// <summary>Verifies that creating an email recipient with a null email address throws.</summary> [Fact] public void ForEmail_NullEmail_Throws() => Assert.Throws<ArgumentNullException>(() => NotificationRecipient.ForEmail("Jane", null!)); + /// <summary>Verifies that creating an SMS recipient with a null phone number throws.</summary> [Fact] public void ForSms_NullPhone_Throws() => Assert.Throws<ArgumentNullException>(() => NotificationRecipient.ForSms("Jane", null!)); + /// <summary>Verifies that the public constructor throws for a blank name.</summary> + /// <param name="name">The blank recipient name.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -57,6 +67,7 @@ public class NotificationRecipientTests public void PublicCtor_BlankName_Throws(string? name) => Assert.Throws<ArgumentException>(() => new NotificationRecipient(name!, "jane@example.com")); + /// <summary>Verifies that the public constructor throws for a null email address.</summary> [Fact] public void PublicCtor_NullEmail_Throws() => Assert.Throws<ArgumentNullException>(() => new NotificationRecipient("Jane", null!)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/TemplateFolderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/TemplateFolderTests.cs index 8a7f58c2..d15ff37d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/TemplateFolderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/TemplateFolderTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities; public class TemplateFolderTests { + /// <summary>Verifies that the constructor sets the folder name.</summary> [Fact] public void Constructor_SetsName() { @@ -15,12 +16,14 @@ public class TemplateFolderTests Assert.Equal(0, folder.SortOrder); } + /// <summary>Verifies that the constructor throws when the name is null.</summary> [Fact] public void Constructor_NullName_Throws() { Assert.Throws<ArgumentNullException>(() => new TemplateFolder(null!)); } + /// <summary>Verifies that a template's folder id defaults to null.</summary> [Fact] public void Template_FolderId_DefaultsToNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Protocol/AlarmCapableProtocolsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Protocol/AlarmCapableProtocolsTests.cs index 4d15029e..ee50702a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Protocol/AlarmCapableProtocolsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Protocol/AlarmCapableProtocolsTests.cs @@ -5,12 +5,14 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Interfaces.Protocol; /// <summary> /// Single-source-of-truth coverage for <see cref="AlarmCapableProtocols.IsAlarmCapable"/>. /// The OpcUa/MxGateway literal set previously lived inline in the Central UI -/// (TemplateEdit / InstanceConfigure); #53 collapsed those copies into this helper, +/// (TemplateEdit / InstanceConfigure); those copies were collapsed into this helper, /// so the literal set is pinned here rather than against page markup. Keep this in /// sync with the alarm-subscribable adapters registered in DataConnectionFactory. /// </summary> public class AlarmCapableProtocolsTests { + /// <summary>Verifies that alarm-subscribable protocols are reported as alarm capable.</summary> + /// <param name="protocol">The protocol name under test.</param> [Theory] [InlineData("OpcUa")] [InlineData("opcua")] // case-insensitive — matches DataConnectionFactory's OrdinalIgnoreCase key lookup @@ -22,6 +24,8 @@ public class AlarmCapableProtocolsTests Assert.True(AlarmCapableProtocols.IsAlarmCapable(protocol)); } + /// <summary>Verifies that non-alarm-capable, null or blank protocols are not reported as alarm capable.</summary> + /// <param name="protocol">The protocol name under test.</param> [Theory] [InlineData(null)] [InlineData("")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs index 68b55218..bf0b7866 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs @@ -6,11 +6,13 @@ using ICentralAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IC namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Interfaces.Services; /// <summary> -/// Reflection-level contract guards for the Audit Log (#23) writer interfaces. +/// Reflection-level contract guards for the Audit Log writer interfaces. /// Locks in method signature so DI bindings + adapter implementations stay aligned. /// </summary> public class AuditWriterContractTests { + /// <summary>Verifies that the writer interface exposes <c>WriteAsync</c> with the expected signature.</summary> + /// <param name="writerType">The writer interface type under test.</param> [Theory] [InlineData(typeof(IAuditWriter))] [InlineData(typeof(ICentralAuditWriter))] @@ -27,6 +29,7 @@ public class AuditWriterContractTests Assert.True(parameters[1].HasDefaultValue); } + /// <summary>Verifies that the site and central audit writer interfaces are distinct types.</summary> [Fact] public void IAuditWriter_AndICentralAuditWriter_AreDistinctTypes() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Services/ExternalCallResultTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Services/ExternalCallResultTests.cs index 734db340..46a06c04 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Services/ExternalCallResultTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Services/ExternalCallResultTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Interfaces.Services; /// <summary> -/// Tests for <see cref="ExternalCallResult"/>, in particular the Commons-021 +/// Tests for <see cref="ExternalCallResult"/>, in particular the /// thread-safe lazy parse of <c>Response</c>. The pre-fix implementation used /// two mutable fields (<c>_response</c>/<c>_responseParsed</c>) with no /// synchronization, so concurrent readers could each construct a fresh @@ -14,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Interfaces.Services; /// </summary> public class ExternalCallResultTests { + /// <summary>Verifies that a null or empty response JSON yields a null response.</summary> [Fact] public void Response_NullOrEmptyJson_ReturnsNull() { @@ -24,6 +25,7 @@ public class ExternalCallResultTests Assert.Null(withEmpty.Response); } + /// <summary>Verifies that the response JSON is parsed into a dynamic element.</summary> [Fact] public void Response_ParsesJsonIntoDynamicElement() { @@ -37,7 +39,7 @@ public class ExternalCallResultTests } /// <summary> - /// Commons-021: concurrent readers must observe the same parsed instance + /// Concurrent readers must observe the same parsed instance /// (a `Lazy<T>` invariant). Under the pre-fix code two threads could /// both produce a fresh `DynamicJsonElement` and one would win the race — /// `ReferenceEquals` would then occasionally fail. With the fix every diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs index d6df9cbf..4d67d0fc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs @@ -86,15 +86,24 @@ public class KpiMetricAggregationTests (KpiSources.SiteHealth, "localDbReplicationConnected"), }; + /// <summary>Supplies every emitted source and metric pair as theory data.</summary> + /// <returns>The theory data rows of source and metric names.</returns> public static IEnumerable<object[]> AllEmittedCases() => AllEmittedMetrics.Select(m => new object[] { m.Source, m.Metric }); + /// <summary>Supplies the rate metric source and metric pairs as theory data.</summary> + /// <returns>The theory data rows of source and metric names.</returns> public static IEnumerable<object[]> RateCases() => RateMetrics.Select(m => new object[] { m.Source, m.Metric }); + /// <summary>Supplies the gauge-sampled source and metric pairs as theory data.</summary> + /// <returns>The theory data rows of source and metric names.</returns> public static IEnumerable<object[]> GaugeCases() => GaugeSampling.Select(m => new object[] { m.Source, m.Metric }); + /// <summary>Verifies that every emitted metric resolves to a defined aggregation.</summary> + /// <param name="source">The KPI source name.</param> + /// <param name="metric">The metric name.</param> [Theory] [MemberData(nameof(AllEmittedCases))] public void Resolve_EveryEmittedMetric_ReturnsDefinedAggregation(string source, string metric) @@ -106,6 +115,9 @@ public class KpiMetricAggregationTests $"({source}, {metric}) resolved to an undefined aggregation: {result}"); } + /// <summary>Verifies that rate metrics are classified as rate.</summary> + /// <param name="source">The KPI source name.</param> + /// <param name="metric">The metric name.</param> [Theory] [MemberData(nameof(RateCases))] public void Resolve_RateMetrics_ClassifiedAsRate(string source, string metric) @@ -113,6 +125,9 @@ public class KpiMetricAggregationTests Assert.Equal(KpiRollupAggregation.Rate, KpiMetricAggregationCatalog.Resolve(source, metric)); } + /// <summary>Verifies that gauge-sampled metrics are classified as gauge.</summary> + /// <param name="source">The KPI source name.</param> + /// <param name="metric">The metric name.</param> [Theory] [MemberData(nameof(GaugeCases))] public void Resolve_GaugeSampling_ClassifiedAsGauge(string source, string metric) @@ -120,6 +135,7 @@ public class KpiMetricAggregationTests Assert.Equal(KpiRollupAggregation.Gauge, KpiMetricAggregationCatalog.Resolve(source, metric)); } + /// <summary>Verifies that last-hour audit metrics resolve as gauges rather than rates.</summary> [Fact] public void Resolve_LastHourAuditMetrics_AreGaugeNotRate() { @@ -133,6 +149,9 @@ public class KpiMetricAggregationTests KpiMetricAggregationCatalog.Resolve(KpiSources.AuditLog, "errorEventsLastHour")); } + /// <summary>Verifies that an unknown metric defaults to a gauge.</summary> + /// <param name="source">The KPI source name.</param> + /// <param name="metric">The metric name.</param> [Theory] [InlineData(KpiSources.NotificationOutbox, "someBrandNewMetricAddedLater")] [InlineData("AnEntirelyNewSource", "anyMetric")] @@ -144,6 +163,7 @@ public class KpiMetricAggregationTests Assert.Equal(KpiRollupAggregation.Gauge, KpiMetricAggregationCatalog.Resolve(source, metric)); } + /// <summary>Verifies that the same metric name under a different source is keyed on the source and metric pair.</summary> [Fact] public void Resolve_SameMetricNameDifferentSource_KeysOnSourcePair() { @@ -157,8 +177,9 @@ public class KpiMetricAggregationTests KpiMetricAggregationCatalog.Resolve(KpiSources.SiteCallAudit, "deliveredLastInterval")); } - // ---- Task 8: catalog metric literals promoted to shared KpiMetrics constants (R4) ---- + // ---- Catalog metric literals promoted to shared KpiMetrics constants (R4) ---- + /// <summary>Verifies that the promoted metric constants lock the historical persisted values.</summary> [Fact] public void PromotedMetricConstants_LockHistoricalPersistedValues() { @@ -168,6 +189,9 @@ public class KpiMetricAggregationTests Assert.Equal("eventLogWriteFailures", KpiMetrics.SiteHealth.EventLogWriteFailures); } + /// <summary>Verifies that the promoted rate pairs still classify as rate.</summary> + /// <param name="source">The KPI source name.</param> + /// <param name="metric">The metric name.</param> [Theory] [InlineData(KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.DeliveredLastInterval)] [InlineData(KpiSources.SiteHealth, KpiMetrics.SiteHealth.AlarmEvalErrors)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSampleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSampleTests.cs index 1d02c8a1..ddafb33d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSampleTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSampleTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Kpi; public class KpiSampleTests { + /// <summary>Verifies that a KpiSample constructed with its required members round-trips the values.</summary> [Fact] public void KpiSample_ConstructedWithRequiredMembers_RoundTripsValues() { @@ -31,6 +32,7 @@ public class KpiSampleTests Assert.Equal(DateTimeKind.Utc, sample.CapturedAtUtc.Kind); } + /// <summary>Verifies that a scoped sample carries its scope key.</summary> [Fact] public void KpiSample_ScopedSample_CarriesScopeKey() { @@ -49,6 +51,7 @@ public class KpiSampleTests Assert.Equal(12.5, sample.Value); } + /// <summary>Verifies that each KPI scope and source constant equals its own name.</summary> [Fact] public void KpiScopesAndSources_ConstantValues_EqualTheirNames() { @@ -62,6 +65,7 @@ public class KpiSampleTests Assert.Equal("Node", KpiScopes.Node); } + /// <summary>Verifies that KpiSeriesPoint is a value record struct with component-wise equality.</summary> [Fact] public void KpiSeriesPoint_IsValueRecordStruct_WithComponentEquality() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs index 4b08b22b..d51ded9a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs @@ -16,6 +16,7 @@ public class KpiSeriesBucketerTests // Guard / invalid-argument contracts // ----------------------------------------------------------------------- + /// <summary>Verifies that a maximum point count below two throws an ArgumentOutOfRangeException.</summary> [Fact] public void Bucket_MaxPointsLessThan2_ThrowsArgumentOutOfRangeException() { @@ -25,6 +26,7 @@ public class KpiSeriesBucketerTests KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 1)); } + /// <summary>Verifies that a maximum point count of zero throws an ArgumentOutOfRangeException.</summary> [Fact] public void Bucket_MaxPointsZero_ThrowsArgumentOutOfRangeException() { @@ -34,6 +36,7 @@ public class KpiSeriesBucketerTests KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 0)); } + /// <summary>Verifies that a window whose end equals its start throws an ArgumentOutOfRangeException.</summary> [Fact] public void Bucket_ToUtcEqualFromUtc_ThrowsArgumentOutOfRangeException() { @@ -43,6 +46,7 @@ public class KpiSeriesBucketerTests KpiSeriesBucketer.Bucket(raw, T(30), T(30), maxPoints: 5)); } + /// <summary>Verifies that a window that ends before it starts throws an ArgumentOutOfRangeException.</summary> [Fact] public void Bucket_ToUtcBeforeFromUtc_ThrowsArgumentOutOfRangeException() { @@ -56,6 +60,7 @@ public class KpiSeriesBucketerTests // Empty / null raw series // ----------------------------------------------------------------------- + /// <summary>Verifies that a null raw series returns an empty list.</summary> [Fact] public void Bucket_NullRaw_ReturnsEmptyList() { @@ -64,6 +69,7 @@ public class KpiSeriesBucketerTests Assert.Empty(result); } + /// <summary>Verifies that an empty raw series returns an empty list.</summary> [Fact] public void Bucket_EmptyRaw_ReturnsEmptyList() { @@ -77,6 +83,7 @@ public class KpiSeriesBucketerTests // raw.Count <= maxPoints → returned unchanged (same reference) // ----------------------------------------------------------------------- + /// <summary>Verifies that a raw series with exactly the maximum number of points is returned as the same reference.</summary> [Fact] public void Bucket_RawCountEqualsMaxPoints_ReturnsSameReference() { @@ -92,6 +99,7 @@ public class KpiSeriesBucketerTests Assert.Same(raw, result); } + /// <summary>Verifies that a raw series with fewer points than the maximum is returned as the same reference.</summary> [Fact] public void Bucket_RawCountLessThanMaxPoints_ReturnsSameReference() { @@ -106,6 +114,7 @@ public class KpiSeriesBucketerTests Assert.Same(raw, result); } + /// <summary>Verifies that a single-point series is returned as the same reference.</summary> [Fact] public void Bucket_SinglePoint_ReturnsSameReference() { @@ -120,6 +129,7 @@ public class KpiSeriesBucketerTests // Downsampling: correct bucket count and last-value selection // ----------------------------------------------------------------------- + /// <summary>Verifies that a series with more points than the maximum is reduced to the maximum number of buckets.</summary> [Fact] public void Bucket_MorePointsThanMaxPoints_ReducesToMaxPointsBuckets() { @@ -140,6 +150,7 @@ public class KpiSeriesBucketerTests Assert.Equal(30.0, result[2].Value); } + /// <summary>Verifies that when several points fall in the same bucket the last value wins.</summary> [Fact] public void Bucket_MultiplePointsInSameBucket_LastValueWins() { @@ -160,6 +171,7 @@ public class KpiSeriesBucketerTests Assert.Equal(5.0, result[1].Value); // only point in bucket 1 } + /// <summary>Verifies that each output point carries the bucket start rather than the raw point timestamp.</summary> [Fact] public void Bucket_BucketStartUtc_IsSetToBucketStartNotRawPointTimestamp() { @@ -187,10 +199,11 @@ public class KpiSeriesBucketerTests // Unsorted input: last-in-iteration wins within a bucket (NOT largest timestamp) // ----------------------------------------------------------------------- + /// <summary>Verifies that for unsorted input the last point in iteration order is selected, not the point with the largest timestamp.</summary> [Fact] public void Bucket_UnsortedInput_SelectsLastInIterationNotLargestTimestamp() { - // KpiHistory-006 regression: pins the documented contract that for unsorted input the + // Regression: pins the documented contract that for unsorted input the // bucketer selects the LAST point in iteration order within a bucket — it does NOT pick // the largest-timestamp point. Both points below fall in bucket 0 ([0, 30)); the later // one in iteration order, T(5)=value 1.0, arrives second, so it overwrites T(20)=value @@ -216,10 +229,11 @@ public class KpiSeriesBucketerTests // Short series (raw.Count <= maxPoints): returned unchanged → raw capture timestamps // ----------------------------------------------------------------------- + /// <summary>Verifies that a short series keeps its raw capture timestamps instead of bucket boundaries.</summary> [Fact] public void Bucket_ShortSeries_ReturnsRawCaptureTimestampsNotBucketBoundaries() { - // KpiHistory-005/003 regression: the short-series early-return path returns the input + // Regression: the short-series early-return path returns the input // unchanged, so each output point's BucketStartUtc is the RAW capture timestamp — NOT a // bucket-boundary timestamp (which is what the downsample path emits, asserted by // Bucket_BucketStartUtc_IsSetToBucketStartNotRawPointTimestamp). This pins the @@ -243,6 +257,7 @@ public class KpiSeriesBucketerTests // Right-edge: point exactly at toUtc lands in the last bucket // ----------------------------------------------------------------------- + /// <summary>Verifies that a point exactly at the window end lands in the last bucket.</summary> [Fact] public void Bucket_PointAtToUtc_LandsInLastBucket() { @@ -264,6 +279,7 @@ public class KpiSeriesBucketerTests Assert.Equal(7.0, result[1].Value); } + /// <summary>Verifies that in a three-bucket window a point exactly at the window end lands in the last bucket.</summary> [Fact] public void Bucket_ThreeBucketWindow_PointAtToUtc_LandsInLastBucket() { @@ -291,6 +307,7 @@ public class KpiSeriesBucketerTests // Empty buckets omitted — no gap-filling // ----------------------------------------------------------------------- + /// <summary>Verifies that empty buckets caused by a gap in the raw data are omitted.</summary> [Fact] public void Bucket_GapInRaw_EmptyBucketsOmitted() { @@ -329,6 +346,7 @@ public class KpiSeriesBucketerTests // Points outside [fromUtc, toUtc] are ignored // ----------------------------------------------------------------------- + /// <summary>Verifies that points outside the window are ignored.</summary> [Fact] public void Bucket_PointsOutsideWindow_AreIgnored() { @@ -353,6 +371,7 @@ public class KpiSeriesBucketerTests // Two-point minimum — maxPoints == 2 works correctly // ----------------------------------------------------------------------- + /// <summary>Verifies that a maximum of exactly two points produces at most two buckets.</summary> [Fact] public void Bucket_MaxPointsExactly2_ProducesAtMostTwoBuckets() { @@ -374,6 +393,7 @@ public class KpiSeriesBucketerTests // Per-metric reduction: Rate series sum per bucket (arch-review 04 round 2, R3) // ----------------------------------------------------------------------- + /// <summary>Verifies that a rate series sums the values in each bucket instead of taking the last value.</summary> [Fact] public void Bucket_RateSeries_SumsPerBucket_InsteadOfLastValue() { @@ -397,6 +417,7 @@ public class KpiSeriesBucketerTests Assert.Equal(30.0, result[1].Value); } + /// <summary>Verifies that a short rate series still sums clustered points.</summary> [Fact] public void Bucket_RateSeries_ShortSeries_StillSumsClusteredPoints() { @@ -418,6 +439,7 @@ public class KpiSeriesBucketerTests Assert.Equal(10.0, result[1].Value); // T(30) alone } + /// <summary>Verifies that bucketing a rate series preserves the series total.</summary> [Fact] public void Bucket_RateSeries_PreservesSeriesTotal() { @@ -436,6 +458,7 @@ public class KpiSeriesBucketerTests Assert.Equal(inWindowTotal, result.Sum(p => p.Value)); } + /// <summary>Verifies that the default aggregation is gauge and its behavior is unchanged.</summary> [Fact] public void Bucket_DefaultAggregation_IsGauge_AndBehaviorUnchanged() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/AlarmStateChangedEnrichmentTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/AlarmStateChangedEnrichmentTests.cs index 56987d01..9b96e61e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/AlarmStateChangedEnrichmentTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/AlarmStateChangedEnrichmentTests.cs @@ -6,6 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; public class AlarmStateChangedEnrichmentTests { + /// <summary>Verifies that a default alarm state change is of the computed kind with auto-acknowledge.</summary> [Fact] public void Defaults_AreComputedKind_WithAutoAck() { @@ -17,6 +18,7 @@ public class AlarmStateChangedEnrichmentTests Assert.Equal("", m.SourceReference); } + /// <summary>Verifies that the computed factory maps priority and state onto the message.</summary> [Fact] public void Factory_ForComputed_MapsPriorityAndState() { @@ -28,6 +30,7 @@ public class AlarmStateChangedEnrichmentTests // ── MES alarm-status API §6.4: additive AckTime ── + /// <summary>Verifies that the acknowledge time defaults to null when using the positional constructor.</summary> [Fact] public void AckTime_DefaultsToNull_OnThePositionalConstructor() { @@ -40,6 +43,7 @@ public class AlarmStateChangedEnrichmentTests Assert.Null(m.AckTime); // …yet still no ack instant } + /// <summary>Verifies that the acknowledge time round-trips through its init property.</summary> [Fact] public void AckTime_RoundTripsThroughTheInitProperty() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs index 6fa96525..c1637ca3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs @@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; /// </summary> public class BrowseCommandsRegistryTests { + /// <summary>Verifies that the command registry discovers the browse-node command.</summary> [Fact] public void Registry_discovers_BrowseNodeCommand() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseVerifyCertSiteIdentifierTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseVerifyCertSiteIdentifierTests.cs index 9ffc84cd..f864ead2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseVerifyCertSiteIdentifierTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseVerifyCertSiteIdentifierTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; /// <summary> -/// Task 21 (PLAN-07): the additive optional <c>SiteIdentifier</c> trailing param on the +/// The additive optional <c>SiteIdentifier</c> trailing param on the /// Browse/Verify/Cert-trust management commands. Verifies backward compatibility (legacy /// JSON without the field deserializes to null) and round-trip survival when set. /// </summary> @@ -17,6 +17,7 @@ public class BrowseVerifyCertSiteIdentifierTests // ── Backward compatibility: legacy JSON (no SiteIdentifier) → null ── + /// <summary>Verifies that browse node command legacy json site identifier null.</summary> [Fact] public void BrowseNodeCommand_LegacyJson_SiteIdentifierNull() { @@ -26,6 +27,7 @@ public class BrowseVerifyCertSiteIdentifierTests Assert.Equal("conn", cmd.ConnectionName); } + /// <summary>Verifies that search address space command legacy json site identifier null.</summary> [Fact] public void SearchAddressSpaceCommand_LegacyJson_SiteIdentifierNull() { @@ -35,6 +37,7 @@ public class BrowseVerifyCertSiteIdentifierTests Assert.Equal("pump", cmd.Query); } + /// <summary>Verifies that verify endpoint command legacy json site identifier null.</summary> [Fact] public void VerifyEndpointCommand_LegacyJson_SiteIdentifierNull() { @@ -44,6 +47,7 @@ public class BrowseVerifyCertSiteIdentifierTests Assert.Equal("OpcUa", cmd.Protocol); } + /// <summary>Verifies that trust server cert command legacy json site identifier null.</summary> [Fact] public void TrustServerCertCommand_LegacyJson_SiteIdentifierNull() { @@ -53,6 +57,7 @@ public class BrowseVerifyCertSiteIdentifierTests Assert.Equal("ABCD", cmd.Thumbprint); } + /// <summary>Verifies that remove server cert command legacy json site identifier null.</summary> [Fact] public void RemoveServerCertCommand_LegacyJson_SiteIdentifierNull() { @@ -62,6 +67,7 @@ public class BrowseVerifyCertSiteIdentifierTests Assert.Equal("ABCD", cmd.Thumbprint); } + /// <summary>Verifies that list server certs command legacy json site identifier null.</summary> [Fact] public void ListServerCertsCommand_LegacyJson_SiteIdentifierNull() { @@ -72,6 +78,7 @@ public class BrowseVerifyCertSiteIdentifierTests // ── Round-trip: SiteIdentifier set survives serialize → deserialize ── + /// <summary>Verifies that browse node command round trip site identifier survives.</summary> [Fact] public void BrowseNodeCommand_RoundTrip_SiteIdentifierSurvives() { @@ -82,6 +89,7 @@ public class BrowseVerifyCertSiteIdentifierTests Assert.Equal(original, round); } + /// <summary>Verifies that search address space command round trip site identifier survives.</summary> [Fact] public void SearchAddressSpaceCommand_RoundTrip_SiteIdentifierSurvives() { @@ -92,6 +100,7 @@ public class BrowseVerifyCertSiteIdentifierTests Assert.Equal(original, round); } + /// <summary>Verifies that verify endpoint command round trip site identifier survives.</summary> [Fact] public void VerifyEndpointCommand_RoundTrip_SiteIdentifierSurvives() { @@ -102,6 +111,7 @@ public class BrowseVerifyCertSiteIdentifierTests Assert.Equal(original, round); } + /// <summary>Verifies that trust server cert command round trip site identifier survives.</summary> [Fact] public void TrustServerCertCommand_RoundTrip_SiteIdentifierSurvives() { @@ -112,6 +122,7 @@ public class BrowseVerifyCertSiteIdentifierTests Assert.Equal(original, round); } + /// <summary>Verifies that remove server cert command round trip site identifier survives.</summary> [Fact] public void RemoveServerCertCommand_RoundTrip_SiteIdentifierSurvives() { @@ -122,6 +133,7 @@ public class BrowseVerifyCertSiteIdentifierTests Assert.Equal(original, round); } + /// <summary>Verifies that list server certs command round trip site identifier survives.</summary> [Fact] public void ListServerCertsCommand_RoundTrip_SiteIdentifierSurvives() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ClusterClientContractLockTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ClusterClientContractLockTests.cs index c8ab1f6e..4790566f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ClusterClientContractLockTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ClusterClientContractLockTests.cs @@ -39,6 +39,7 @@ public class ClusterClientContractLockTests // (a) Old-shape payloads: newer trailing optional fields absent → defaults. // --------------------------------------------------------------------- + /// <summary>Verifies that an old-shape <c>CreateDataConnectionCommand</c> without the newer trailing fields still deserializes.</summary> [Fact] public void CreateDataConnectionCommand_OldShape_WithoutNewerTrailingFields_Deserializes() { @@ -62,6 +63,7 @@ public class ClusterClientContractLockTests Assert.Equal(0, cmd.FailoverRetryCount); // value-type missing field → default(int), not 3 } + /// <summary>Verifies that an old-shape <c>DeployInstanceCommand</c> without the fetch fields still deserializes.</summary> [Fact] public void DeployInstanceCommand_OldShape_WithoutFetchFields_Deserializes() { @@ -78,6 +80,7 @@ public class ClusterClientContractLockTests Assert.Null(cmd.FetchToken); // default applied } + /// <summary>Verifies that an old-shape <c>NotificationSubmit</c> without the execution correlation fields still deserializes.</summary> [Fact] public void NotificationSubmit_OldShape_WithoutExecutionCorrelationFields_Deserializes() { @@ -99,6 +102,7 @@ public class ClusterClientContractLockTests // (b) Unknown fields from a newer peer are ignored (never throw). // --------------------------------------------------------------------- + /// <summary>Verifies that an unknown field from a newer peer is ignored when deserializing <c>AttributeValueChanged</c>.</summary> [Fact] public void AttributeValueChanged_UnknownFieldFromNewerPeer_IsIgnored() { @@ -112,6 +116,7 @@ public class ClusterClientContractLockTests Assert.Equal("Good", evt.Quality); } + /// <summary>Verifies that an unknown field from a newer peer is ignored when deserializing <c>GetAttributeRequest</c>.</summary> [Fact] public void GetAttributeRequest_UnknownFieldFromNewerPeer_IsIgnored() { @@ -125,6 +130,7 @@ public class ClusterClientContractLockTests Assert.Equal("a", req.AttributeName); } + /// <summary>Verifies that an unknown field from a newer peer is ignored when deserializing <c>SetStaticAttributeCommand</c>.</summary> [Fact] public void SetStaticAttributeCommand_UnknownFieldFromNewerPeer_IsIgnored() { @@ -142,6 +148,7 @@ public class ClusterClientContractLockTests // (c) Round-trip preserves values. // --------------------------------------------------------------------- + /// <summary>Verifies that a <c>ManagementEnvelope</c> round trip preserves the correlation id and user.</summary> [Fact] public void ManagementEnvelope_RoundTrip_PreservesCorrelationIdAndUser() { @@ -159,6 +166,7 @@ public class ClusterClientContractLockTests Assert.Equal(new[] { "site-1", "site-2" }, round.User.PermittedSiteIds); } + /// <summary>Verifies that an <c>AttributeValueChanged</c> round trip preserves its values.</summary> [Fact] public void AttributeValueChanged_RoundTrip_PreservesValues() { @@ -175,6 +183,7 @@ public class ClusterClientContractLockTests Assert.Equal(evt.Timestamp, round.Timestamp); } + /// <summary>Verifies that a <c>CreateDataConnectionCommand</c> round trip preserves its values.</summary> [Fact] public void CreateDataConnectionCommand_RoundTrip_PreservesValues() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/CompatibilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/CompatibilityTests.cs index dcbae05c..d8f40592 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/CompatibilityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/CompatibilityTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; /// <summary> -/// WP-9 (Phase 8): Message contract compatibility tests. +/// Message contract compatibility tests. /// Verifies forward compatibility (unknown fields), backward compatibility (missing optional fields), /// and version skew scenarios for all critical message types. /// </summary> @@ -24,6 +24,7 @@ public class CompatibilityTests // ── Forward Compatibility: unknown fields are ignored ── + /// <summary>Verifies that a deploy-instance command ignores an unknown field when deserialized.</summary> [Fact] public void ForwardCompat_DeployInstanceCommand_UnknownFieldIgnored() { @@ -46,6 +47,7 @@ public class CompatibilityTests Assert.Equal("abc123", msg.RevisionHash); } + /// <summary>Verifies that a site health report ignores an unknown field when deserialized.</summary> [Fact] public void ForwardCompat_SiteHealthReport_UnknownFieldIgnored() { @@ -70,6 +72,7 @@ public class CompatibilityTests Assert.Equal(5, msg.SequenceNumber); } + /// <summary>Verifies that a script call request ignores an unknown field when deserialized.</summary> [Fact] public void ForwardCompat_ScriptCallRequest_UnknownFieldIgnored() { @@ -90,6 +93,7 @@ public class CompatibilityTests Assert.Equal("OnTrigger", msg.ScriptName); } + /// <summary>Verifies that an attribute-value-changed message ignores an unknown field when deserialized.</summary> [Fact] public void ForwardCompat_AttributeValueChanged_UnknownFieldIgnored() { @@ -112,6 +116,7 @@ public class CompatibilityTests // ── Backward Compatibility: missing optional fields ── + /// <summary>Verifies that a deployment status response deserializes when the error message is missing.</summary> [Fact] public void BackwardCompat_DeploymentStatusResponse_MissingErrorMessage() { @@ -130,6 +135,7 @@ public class CompatibilityTests Assert.Null(msg.ErrorMessage); } + /// <summary>Verifies that a script call result deserializes when the return value is missing.</summary> [Fact] public void BackwardCompat_ScriptCallResult_MissingReturnValue() { @@ -147,6 +153,7 @@ public class CompatibilityTests Assert.Null(msg.ReturnValue); } + /// <summary>Verifies that a deploy-artifacts command deserializes when the optional lists are missing.</summary> [Fact] public void BackwardCompat_DeployArtifactsCommand_MissingOptionalLists() { @@ -164,6 +171,7 @@ public class CompatibilityTests Assert.Null(msg.ExternalSystems); } + /// <summary>Verifies that an instance lifecycle response deserializes when the error message is missing.</summary> [Fact] public void BackwardCompat_InstanceLifecycleResponse_MissingErrorMessage() { @@ -184,6 +192,7 @@ public class CompatibilityTests // ── Version Skew: old message format still deserializable ── + /// <summary>Verifies that an old-shape deploy command deserializes with default values for newer fields.</summary> [Fact] public void VersionSkew_OldDeployCommand_DeserializesWithDefaults() { @@ -205,6 +214,7 @@ public class CompatibilityTests Assert.Equal("old-hash", msg.RevisionHash); } + /// <summary>Verifies that an old-shape health report deserializes correctly.</summary> [Fact] public void VersionSkew_OldHealthReport_DeserializesCorrectly() { @@ -230,11 +240,12 @@ public class CompatibilityTests // ── Round-trip serialization for all key message types ── - // Communication-016: RoundTrip_ConnectionStateChanged_Succeeds removed + // RoundTrip_ConnectionStateChanged_Succeeds removed // alongside the dead ConnectionStateChanged message record. No production // code emits or receives this message — disconnect detection is owned by // the gRPC keepalive and the Ask-timeout path. + /// <summary>Verifies that an alarm-state-changed message survives a serialization round trip.</summary> [Fact] public void RoundTrip_AlarmStateChanged_Succeeds() { @@ -247,6 +258,7 @@ public class CompatibilityTests Assert.Equal("HighTemp", deserialized.AlarmName); } + /// <summary>Verifies that an alarm-state-changed message preserves its acknowledgement time across a round trip.</summary> [Fact] public void RoundTrip_AlarmStateChanged_PreservesAckTime() { @@ -264,6 +276,7 @@ public class CompatibilityTests Assert.Equal(ackedAt, deserialized!.AckTime); } + /// <summary>Verifies that a missing acknowledgement time deserializes as null.</summary> [Fact] public void BackwardCompat_AlarmStateChanged_MissingAckTime_DefaultsToNull() { @@ -285,6 +298,7 @@ public class CompatibilityTests Assert.Null(deserialized!.AckTime); } + /// <summary>Verifies that a missing MES-reportable flag deserializes as false.</summary> [Fact] public void BackwardCompat_AlarmStateChanged_MissingIsMesReportable_DefaultsToFalse() { @@ -308,6 +322,7 @@ public class CompatibilityTests Assert.False(deserialized!.IsMesReportable); } + /// <summary>Verifies that a heartbeat message survives a serialization round trip.</summary> [Fact] public void RoundTrip_HeartbeatMessage_Succeeds() { @@ -320,6 +335,7 @@ public class CompatibilityTests Assert.Equal("node-a", deserialized.NodeHostname); } + /// <summary>Verifies that a disable-instance command survives a serialization round trip.</summary> [Fact] public void RoundTrip_DisableInstanceCommand_Succeeds() { @@ -331,6 +347,7 @@ public class CompatibilityTests Assert.Equal("cmd-1", deserialized!.CommandId); } + /// <summary>Verifies that an enable-instance command survives a serialization round trip.</summary> [Fact] public void RoundTrip_EnableInstanceCommand_Succeeds() { @@ -342,6 +359,7 @@ public class CompatibilityTests Assert.Equal("cmd-2", deserialized!.CommandId); } + /// <summary>Verifies that a delete-instance command survives a serialization round trip.</summary> [Fact] public void RoundTrip_DeleteInstanceCommand_Succeeds() { @@ -355,6 +373,7 @@ public class CompatibilityTests // ── Additive-only evolution: new fields added as nullable ── + /// <summary>Verifies that newly added nullable fields do not break deserialization of older payloads.</summary> [Fact] public void AdditiveEvolution_NewNullableFields_DoNotBreakDeserialization() { @@ -369,6 +388,7 @@ public class CompatibilityTests Assert.Null(msg!.ErrorMessage); // Optional field defaults to null } + /// <summary>Verifies that an unknown enum value is handled gracefully during deserialization.</summary> [Fact] public void EnumDeserialization_UnknownValue_HandledGracefully() { @@ -381,8 +401,9 @@ public class CompatibilityTests Assert.Equal((DeploymentStatus)99, msg!.Status); } - // ── DeploymentManager-006: query-the-site-before-redeploy contracts ── + // ── query-the-site-before-redeploy contracts ── + /// <summary>Verifies that a deployment-state query request survives a serialization round trip.</summary> [Fact] public void RoundTrip_DeploymentStateQueryRequest_Succeeds() { @@ -395,6 +416,7 @@ public class CompatibilityTests Assert.Equal("inst-1", deserialized.InstanceUniqueName); } + /// <summary>Verifies that a deployed deployment-state query response survives a serialization round trip.</summary> [Fact] public void RoundTrip_DeploymentStateQueryResponse_Deployed_Succeeds() { @@ -409,6 +431,7 @@ public class CompatibilityTests Assert.Equal("sha256:abc", deserialized.AppliedRevisionHash); } + /// <summary>Verifies that a not-deployed deployment-state query response keeps its null applied state across a round trip.</summary> [Fact] public void RoundTrip_DeploymentStateQueryResponse_NotDeployed_NullApplied() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs index 856aaf5a..9c9406cb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ConnectionBindingSerializationTests.cs @@ -4,13 +4,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; /// <summary> -/// Regression tests for Commons-008 — <see cref="SetConnectionBindingsCommand"/> +/// Regression tests: <see cref="SetConnectionBindingsCommand"/> /// previously declared its bindings as <c>IReadOnlyList<(string, int)></c>. /// A <c>ValueTuple</c> serializes as <c>Item1</c>/<c>Item2</c> and cannot evolve /// additively (REQ-COM-5a). It is now a named <see cref="ConnectionBinding"/> record. /// </summary> public class ConnectionBindingSerializationTests { + /// <summary>Verifies that connection binding serializes with named properties.</summary> [Fact] public void ConnectionBinding_SerializesWithNamedProperties() { @@ -26,6 +27,7 @@ public class ConnectionBindingSerializationTests Assert.False(doc.RootElement.TryGetProperty("Item2", out _)); } + /// <summary>Verifies that set connection bindings command round trips through json.</summary> [Fact] public void SetConnectionBindingsCommand_RoundTripsThroughJson() { @@ -52,6 +54,7 @@ public class ConnectionBindingSerializationTests Assert.Equal(original.Bindings, deserialized.Bindings); } + /// <summary>Verifies that roundtrip preserves override when set.</summary> [Fact] public void Roundtrip_preserves_override_when_set() { @@ -65,6 +68,7 @@ public class ConnectionBindingSerializationTests Assert.Equal("ns=2;s=Pump1.Speed", roundtripped!.DataSourceReferenceOverride); } + /// <summary>Verifies that roundtrip defaults override to null when absent.</summary> [Fact] public void Roundtrip_defaults_override_to_null_when_absent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs index 3ea747c5..80a0be38 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs @@ -6,12 +6,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages.Integration; /// <summary> -/// Audit Log (#23) telemetry handoff: envelope + pull request/response DTOs. +/// Audit Log telemetry handoff: envelope + pull request/response DTOs. /// At-least-once from sites; idempotent at central on <see cref="AuditEvent.EventId"/>. /// </summary> public class AuditTelemetryMessagesTests { - // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent built via the shared + // Canonical ZB.MOM.WW.Audit.AuditEvent built via the shared // factory (domain fields ride in DetailsJson). These tests assert only the // envelope/response DTO behaviour, so the audit row's contents are incidental. private static AuditEvent MakeEvent(Guid? id = null) => @@ -21,6 +21,7 @@ public class AuditTelemetryMessagesTests status: AuditStatus.Delivered, eventId: id ?? Guid.NewGuid()); + /// <summary>Verifies that a telemetry envelope constructed with three events can be enumerated.</summary> [Fact] public void AuditTelemetryEnvelope_ConstructsWithThreeEvents_AndIsEnumerable() { @@ -42,6 +43,7 @@ public class AuditTelemetryMessagesTests Assert.Equal(3, collected.Count); } + /// <summary>Verifies that the telemetry envelope is immutable and compares its event list by reference identity.</summary> [Fact] public void AuditTelemetryEnvelope_IsImmutable_RecordEqualityOnReferenceIdentityOfList() { @@ -62,6 +64,7 @@ public class AuditTelemetryMessagesTests Assert.Equal("site-01", a.SourceSiteId); } + /// <summary>Verifies that a pull request can be constructed and is immutable.</summary> [Fact] public void PullAuditEventsRequest_ConstructsAndIsImmutable() { @@ -77,6 +80,7 @@ public class AuditTelemetryMessagesTests Assert.Equal(500, bigger.BatchSize); } + /// <summary>Verifies that a pull response with more-available set can be constructed and enumerated.</summary> [Fact] public void PullAuditEventsResponse_ConstructsWithMoreAvailableTrue_AndIsEnumerable() { @@ -94,6 +98,7 @@ public class AuditTelemetryMessagesTests Assert.Equal(2, collected.Count); } + /// <summary>Verifies that a with-expression on a pull response changes only the targeted field.</summary> [Fact] public void PullAuditEventsResponse_WithExpression_ChangesSingleField() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs index 7b9e1979..2d6ececd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages.Integration; /// <summary> -/// Audit Log #23 (M3 Bundle A — Task A4) — tests for the combined +/// Tests for the combined /// audit + operational telemetry packet emitted per cached-call lifecycle event /// (<c>Submit</c> → per-attempt <c>ApiCallCached</c> / <c>DbWriteCached</c> → /// terminal <c>Resolve</c>). The site emits one packet per event; central writes @@ -29,7 +29,7 @@ public class CachedCallTelemetryTests string? errorMessage = null, int? httpStatus = null) { - // C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the + // Build the canonical ZB.MOM.WW.Audit.AuditEvent via the // shared factory; the ScadaBridge domain fields ride in DetailsJson and // are read back as typed properties via AsRow() in the assertions. // ForwardState is no longer a packet field — it is a site-storage-only @@ -61,7 +61,7 @@ public class CachedCallTelemetryTests Channel: nameof(AuditChannel.ApiOutbound), Target: "ERP.GetOrder", SourceSite: SiteId, - // SourceNode: actual stamping arrives with Task 14; for now the + // SourceNode: actual stamping is not done here; the // packet builder leaves the column null so existing assertions on // the packet's other fields stay intact. SourceNode: null, @@ -74,6 +74,7 @@ public class CachedCallTelemetryTests TerminalAtUtc: terminalAtUtc); } + /// <summary>Verifies that a submit packet carries a CachedSubmit audit row and an operational retry count of zero.</summary> [Fact] public void SubmitPacket_AuditCarriesCachedSubmit_AndOperationalRetryCountZero() { @@ -91,6 +92,7 @@ public class CachedCallTelemetryTests Assert.Equal(trackedId, packet.Operational.TrackedOperationId); } + /// <summary>Verifies that an attempted packet carries an ApiCallCached audit row whose retry count matches the operational record.</summary> [Fact] public void AttemptedPacket_AuditCarriesApiCallCached_RetryCountAlignsBetweenAuditAndOperational() { @@ -121,6 +123,7 @@ public class CachedCallTelemetryTests Assert.Null(packet.Operational.TerminalAtUtc); } + /// <summary>Verifies that an attempted database-write packet carries the DbWriteCached kind.</summary> [Fact] public void AttemptedPacket_DbWriteCached_CarriesDbWriteCachedKind() { @@ -144,6 +147,8 @@ public class CachedCallTelemetryTests Assert.Equal(1, packet.Operational.RetryCount); } + /// <summary>Verifies that a resolve packet carries a CachedResolve audit row and sets the operational terminal timestamp.</summary> + /// <param name="terminalStatus">The terminal audit status being resolved.</param> [Theory] [InlineData(AuditStatus.Delivered)] [InlineData(AuditStatus.Failed)] @@ -169,6 +174,7 @@ public class CachedCallTelemetryTests Assert.Equal(terminalAt, packet.Operational.TerminalAtUtc); } + /// <summary>Verifies that a cached-call telemetry packet is equal to a copy built from the same values.</summary> [Fact] public void CachedCallTelemetry_RoundTripEquality() { @@ -190,6 +196,7 @@ public class CachedCallTelemetryTests Assert.Equal(1, c.Operational.RetryCount); } + /// <summary>Verifies that a site-call operational record supports value equality and non-destructive copying with a with-expression.</summary> [Fact] public void SiteCallOperational_RoundTripEquality_AndWithExpression() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ManagementCommandRegistryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ManagementCommandRegistryTests.cs index dccccedc..6e6edb1e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ManagementCommandRegistryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ManagementCommandRegistryTests.cs @@ -4,8 +4,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; /// <summary> -/// Tests for <see cref="ManagementCommandRegistry"/>, including the Commons-004 -/// regression: <c>GetCommandName</c> and <c>Resolve</c> must be symmetric — every +/// Tests for <see cref="ManagementCommandRegistry"/>, including the +/// regression where <c>GetCommandName</c> and <c>Resolve</c> must be symmetric — every /// type for which <c>GetCommandName</c> yields a name must round-trip back to the /// same type via <c>Resolve</c>. /// </summary> @@ -19,6 +19,7 @@ public class ManagementCommandRegistryTests && t != typeof(ReadTagValuesCommand) && t != typeof(ResolveRolesCommand)); + /// <summary>Verifies that every registered command type round-trips through <c>GetCommandName</c> and <c>Resolve</c>.</summary> [Fact] public void GetCommandName_Resolve_RoundTrips_ForEveryRegisteredCommand() { @@ -30,6 +31,7 @@ public class ManagementCommandRegistryTests } } + /// <summary>Verifies that resolving a known command name returns its type.</summary> [Fact] public void Resolve_KnownCommand_ReturnsType() { @@ -37,12 +39,14 @@ public class ManagementCommandRegistryTests Assert.Equal(typeof(CreateSiteCommand), type); } + /// <summary>Verifies that resolving an unknown command name returns null.</summary> [Fact] public void Resolve_UnknownCommand_ReturnsNull() { Assert.Null(ManagementCommandRegistry.Resolve("NoSuchCommand")); } + /// <summary>Verifies that resolving a command name is case-insensitive.</summary> [Fact] public void Resolve_IsCaseInsensitive() { @@ -50,7 +54,7 @@ public class ManagementCommandRegistryTests } /// <summary> - /// Commons-004: <c>GetCommandName</c> previously stripped a <c>Command</c> suffix + /// <c>GetCommandName</c> previously stripped a <c>Command</c> suffix /// from <em>any</em> type, producing names the registry cannot resolve. It must /// only return a name for a command type the registry actually contains. /// </summary> @@ -62,6 +66,7 @@ public class ManagementCommandRegistryTests () => ManagementCommandRegistry.GetCommandName(typeof(UnregisteredFakeCommand))); } + /// <summary>Verifies that <c>GetCommandName</c> throws for a type that is not a registered command.</summary> [Fact] public void GetCommandName_NonCommandType_Throws() { @@ -69,12 +74,14 @@ public class ManagementCommandRegistryTests () => ManagementCommandRegistry.GetCommandName(typeof(string))); } + /// <summary>Verifies that <c>GetCommandName</c> returns the command name with the <c>Command</c> suffix stripped.</summary> [Fact] public void GetCommandName_RegisteredCommand_ReturnsStrippedName() { Assert.Equal("CreateSite", ManagementCommandRegistry.GetCommandName(typeof(CreateSiteCommand))); } + /// <summary>Verifies that all native alarm source commands are registered.</summary> [Fact] public void Resolve_NativeAlarmSourceCommands_AllRegistered() { @@ -95,7 +102,7 @@ public class ManagementCommandRegistryTests } /// <summary> - /// M9-T32c: the schema-library CRUD commands must auto-register by reflection + /// The schema-library CRUD commands must auto-register by reflection /// (no manual registry entry) so the HTTP management boundary can route them. /// </summary> [Fact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/MessageConventionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/MessageConventionTests.cs index a3dafac9..721ee491 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/MessageConventionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/MessageConventionTests.cs @@ -17,6 +17,7 @@ public class MessageConventionTests && !t.Name.StartsWith("<") // exclude compiler-generated types && (t.IsClass || (t.IsValueType && !t.IsPrimitive))); + /// <summary>Verifies that every message type is a record.</summary> [Fact] public void AllMessageTypes_ShouldBeRecords() { @@ -29,6 +30,7 @@ public class MessageConventionTests } } + /// <summary>Verifies that every message timestamp property is a <c>DateTimeOffset</c>.</summary> [Fact] public void AllMessageTimestampProperties_ShouldBeDateTimeOffset() { @@ -46,6 +48,7 @@ public class MessageConventionTests } } + /// <summary>Verifies that a deploy-instance command survives a JSON round trip.</summary> [Fact] public void JsonRoundTrip_DeployInstanceCommand_ShouldSucceed() { @@ -60,6 +63,7 @@ public class MessageConventionTests Assert.Equal(msg.InstanceUniqueName, deserialized.InstanceUniqueName); } + /// <summary>Verifies that JSON containing an unknown field still deserializes.</summary> [Fact] public void JsonForwardCompatibility_UnknownField_ShouldDeserialize() { @@ -72,6 +76,7 @@ public class MessageConventionTests Assert.Equal("dep-1", deserialized!.DeploymentId); } + /// <summary>Verifies that JSON missing an optional field still deserializes.</summary> [Fact] public void JsonBackwardCompatibility_MissingOptionalField_ShouldDeserialize() { @@ -85,6 +90,7 @@ public class MessageConventionTests Assert.Null(deserialized.ErrorMessage); } + /// <summary>Verifies that a site health report survives a JSON round trip.</summary> [Fact] public void JsonRoundTrip_SiteHealthReport_ShouldSucceed() { @@ -110,6 +116,7 @@ public class MessageConventionTests Assert.Equal(1, deserialized.SequenceNumber); } + /// <summary>Verifies that a deploy-artifacts command survives a JSON round trip.</summary> [Fact] public void JsonRoundTrip_DeployArtifactsCommand_ShouldSucceed() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NativeAlarmMessagesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NativeAlarmMessagesTests.cs index cca82270..18f9c8c4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NativeAlarmMessagesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NativeAlarmMessagesTests.cs @@ -6,6 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; public class NativeAlarmMessagesTests { + /// <summary>Verifies that subscribe alarms request: carries source and filter.</summary> [Fact] public void SubscribeAlarmsRequest_CarriesSourceAndFilter() { @@ -14,6 +15,7 @@ public class NativeAlarmMessagesTests Assert.Null(r.ConditionFilter); } + /// <summary>Verifies that native alarm transition update: wraps transition.</summary> [Fact] public void NativeAlarmTransitionUpdate_WrapsTransition() { @@ -25,6 +27,7 @@ public class NativeAlarmMessagesTests Assert.Equal("Tank01", u.Transition.SourceObjectReference); } + /// <summary>Verifies that native alarm transition, when ack time, is an additive trailing parameter.</summary> [Fact] public void NativeAlarmTransition_AckTime_IsAnAdditiveTrailingParameter() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NotificationMessagesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NotificationMessagesTests.cs index 7bbb6c75..a2e6047a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NotificationMessagesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NotificationMessagesTests.cs @@ -9,8 +9,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; /// </summary> public class NotificationMessagesTests { - // ── Task 7: site/central notification message contracts ── + // ── Site/central notification message contracts ── + /// <summary>Verifies that positional construction of a notification submit sets all fields.</summary> [Fact] public void NotificationSubmit_PositionalConstruction_SetsAllFields() { @@ -29,6 +30,7 @@ public class NotificationMessagesTests Assert.Equal(enqueuedAt, msg.SiteEnqueuedAt); } + /// <summary>Verifies that a notification submit allows null optional source fields.</summary> [Fact] public void NotificationSubmit_AllowsNullOptionalSourceFields() { @@ -40,10 +42,11 @@ public class NotificationMessagesTests Assert.Null(msg.SourceScript); } + /// <summary>Verifies that the origin execution id of a notification submit defaults to null.</summary> [Fact] public void NotificationSubmit_OriginExecutionId_DefaultsToNull() { - // Audit Log #23: OriginExecutionId is an additive trailing member — a + // OriginExecutionId is an additive trailing member — a // submit built without it (old call sites / old serialized payloads) // leaves the id null. var msg = new NotificationSubmit( @@ -53,6 +56,7 @@ public class NotificationMessagesTests Assert.Null(msg.OriginExecutionId); } + /// <summary>Verifies that the origin execution id round-trips when supplied.</summary> [Fact] public void NotificationSubmit_OriginExecutionId_RoundTripsWhenSupplied() { @@ -64,6 +68,7 @@ public class NotificationMessagesTests Assert.Equal(executionId, msg.OriginExecutionId); } + /// <summary>Verifies that the origin execution id survives a JSON round trip.</summary> [Fact] public void NotificationSubmit_OriginExecutionId_SurvivesJsonRoundTrip() { @@ -81,6 +86,7 @@ public class NotificationMessagesTests Assert.Equal(executionId, roundTripped!.OriginExecutionId); } + /// <summary>Verifies that the origin parent execution id defaults to null.</summary> [Fact] public void NotificationSubmit_OriginParentExecutionId_DefaultsToNull() { @@ -94,6 +100,7 @@ public class NotificationMessagesTests Assert.Null(msg.OriginParentExecutionId); } + /// <summary>Verifies that the origin parent execution id round-trips when supplied.</summary> [Fact] public void NotificationSubmit_OriginParentExecutionId_RoundTripsWhenSupplied() { @@ -107,6 +114,7 @@ public class NotificationMessagesTests Assert.Equal(parentExecutionId, msg.OriginParentExecutionId); } + /// <summary>Verifies that the origin parent execution id survives a JSON round trip.</summary> [Fact] public void NotificationSubmit_OriginParentExecutionId_SurvivesJsonRoundTrip() { @@ -126,6 +134,7 @@ public class NotificationMessagesTests Assert.Equal(parentExecutionId, roundTripped!.OriginParentExecutionId); } + /// <summary>Verifies that a notification submit carries the source node.</summary> [Fact] public void NotificationSubmit_carries_SourceNode() { @@ -152,6 +161,7 @@ public class NotificationMessagesTests Assert.Equal("node-a", roundTripped!.SourceNode); } + /// <summary>Verifies that two notification submits are equal when all fields match.</summary> [Fact] public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch() { @@ -163,6 +173,7 @@ public class NotificationMessagesTests Assert.Equal(a.GetHashCode(), b.GetHashCode()); } + /// <summary>Verifies that a with-expression on a notification submit ack changes a single field.</summary> [Fact] public void NotificationSubmitAck_WithExpression_ChangesSingleField() { @@ -176,6 +187,7 @@ public class NotificationMessagesTests Assert.NotEqual(ack, rejected); } + /// <summary>Verifies that positional construction of a notification status query sets all fields.</summary> [Fact] public void NotificationStatusQuery_PositionalConstruction_SetsAllFields() { @@ -185,6 +197,7 @@ public class NotificationMessagesTests Assert.Equal("notif-9", msg.NotificationId); } + /// <summary>Verifies that positional construction of a notification status response sets all fields.</summary> [Fact] public void NotificationStatusResponse_PositionalConstruction_SetsAllFields() { @@ -200,8 +213,9 @@ public class NotificationMessagesTests Assert.Equal(deliveredAt, msg.DeliveredAt); } - // ── Task 8: outbox UI query/action contracts ── + // ── Outbox UI query/action contracts ── + /// <summary>Verifies that positional construction of a notification outbox query request sets all fields.</summary> [Fact] public void NotificationOutboxQueryRequest_PositionalConstruction_SetsAllFields() { @@ -224,6 +238,7 @@ public class NotificationMessagesTests Assert.Equal(50, msg.PageSize); } + /// <summary>Verifies that two notification summaries are equal when all fields match.</summary> [Fact] public void NotificationSummary_ValueEquality_EqualWhenAllFieldsMatch() { @@ -239,6 +254,7 @@ public class NotificationMessagesTests Assert.Equal(a.GetHashCode(), b.GetHashCode()); } + /// <summary>Verifies that positional construction of a notification outbox query response sets all fields.</summary> [Fact] public void NotificationOutboxQueryResponse_PositionalConstruction_SetsAllFields() { @@ -255,6 +271,7 @@ public class NotificationMessagesTests Assert.Equal(1, msg.TotalCount); } + /// <summary>Verifies that the retry notification request and response round-trip their fields.</summary> [Fact] public void RetryNotificationRequestAndResponse_RoundTripFields() { @@ -267,6 +284,7 @@ public class NotificationMessagesTests Assert.Null(response.ErrorMessage); } + /// <summary>Verifies that the discard notification request and response round-trip their fields.</summary> [Fact] public void DiscardNotificationRequestAndResponse_RoundTripFields() { @@ -278,6 +296,7 @@ public class NotificationMessagesTests Assert.Equal("not found", response.ErrorMessage); } + /// <summary>Verifies that a with-expression on a notification KPI response changes a single field.</summary> [Fact] public void NotificationKpiResponse_WithExpression_ChangesSingleField() { @@ -294,6 +313,7 @@ public class NotificationMessagesTests Assert.NotEqual(kpi, updated); } + /// <summary>Verifies that positional construction of a notification KPI request sets the correlation id.</summary> [Fact] public void NotificationKpiRequest_PositionalConstruction_SetsCorrelationId() { @@ -301,6 +321,7 @@ public class NotificationMessagesTests Assert.Equal("corr-1", msg.CorrelationId); } + /// <summary>Verifies that the per-site notification KPI request carries the correlation id.</summary> [Fact] public void PerSiteNotificationKpiRequest_CarriesCorrelationId() { @@ -308,6 +329,7 @@ public class NotificationMessagesTests Assert.Equal("corr-1", request.CorrelationId); } + /// <summary>Verifies that the per-site notification KPI response carries the per-site snapshots.</summary> [Fact] public void PerSiteNotificationKpiResponse_CarriesPerSiteSnapshots() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs index 600da98f..30440f36 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs @@ -4,6 +4,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; public class ReadTagValuesCommandRegistryTests { + /// <summary>Verifies that site-only and retired commands are not registered as HTTP commands.</summary> + /// <param name="type">The command message type.</param> + /// <param name="wireName">The command wire name.</param> [Theory] [InlineData(typeof(ReadTagValuesCommand), "ReadTagValues")] [InlineData(typeof(ResolveRolesCommand), "ResolveRoles")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/SiteCallQueriesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/SiteCallQueriesTests.cs index b278c449..b6b4f887 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/SiteCallQueriesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/SiteCallQueriesTests.cs @@ -4,13 +4,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; /// <summary> -/// Site Call Audit (#22): construction, value-equality and optionality tests +/// Site Call Audit: construction, value-equality and optionality tests /// for the Site Calls UI query / KPI / detail message contracts. Mirrors the /// Notification Outbox <c>NotificationMessagesTests</c> coverage of the read /// side, scoped to the contracts the Site Calls page consumes. /// </summary> public class SiteCallQueriesTests { + /// <summary>Verifies that positional construction of a site call query request sets all fields.</summary> [Fact] public void SiteCallQueryRequest_PositionalConstruction_SetsAllFields() { @@ -33,6 +34,7 @@ public class SiteCallQueriesTests Assert.Equal(50, request.PageSize); } + /// <summary>Verifies that a site call query request allows its optional filters to be null.</summary> [Fact] public void SiteCallQueryRequest_AllowsNullOptionalFilters() { @@ -48,6 +50,7 @@ public class SiteCallQueriesTests Assert.Null(request.AfterId); } + /// <summary>Verifies that two site call query responses are equal when all their fields match.</summary> [Fact] public void SiteCallQueryResponse_ValueEquality_EqualWhenAllFieldsMatch() { @@ -58,6 +61,7 @@ public class SiteCallQueriesTests Assert.Equal(a.GetHashCode(), b.GetHashCode()); } + /// <summary>Verifies that a site call summary carries the entity columns and the stuck flag.</summary> [Fact] public void SiteCallSummary_CarriesEntityColumnsAndStuckFlag() { @@ -77,6 +81,7 @@ public class SiteCallQueriesTests Assert.True(summary.IsStuck); } + /// <summary>Verifies that a detail response for a missing row has a null detail.</summary> [Fact] public void SiteCallDetailResponse_MissingRow_HasNullDetail() { @@ -87,6 +92,7 @@ public class SiteCallQueriesTests Assert.Equal("site call not found", response.ErrorMessage); } + /// <summary>Verifies that a failed KPI response zeroes its KPI fields.</summary> [Fact] public void SiteCallKpiResponse_FailureShape_ZeroesKpiFields() { @@ -101,6 +107,7 @@ public class SiteCallQueriesTests Assert.Null(response.OldestPendingAge); } + /// <summary>Verifies that the per-site KPI response carries a snapshot for each site.</summary> [Fact] public void PerSiteSiteCallKpiResponse_CarriesPerSiteSnapshots() { @@ -119,6 +126,7 @@ public class SiteCallQueriesTests Assert.Equal(TimeSpan.FromMinutes(15), site.OldestPendingAge); } + /// <summary>Verifies that the oldest pending age of a KPI snapshot can be null for an empty table.</summary> [Fact] public void SiteCallKpiSnapshot_OldestPendingAge_IsNullableForEmptyTable() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/TraceFlagDefaultsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/TraceFlagDefaultsTests.cs index d7596963..f7f709aa 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/TraceFlagDefaultsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/TraceFlagDefaultsTests.cs @@ -12,6 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; /// </summary> public class TraceFlagDefaultsTests { + /// <summary>Verifies that existing positional constructions default to untraced.</summary> [Fact] public void ExistingPositionalConstructions_DefaultToUntraced() { @@ -31,6 +32,7 @@ public class TraceFlagDefaultsTests Assert.Null(result.TraceEvents); } + /// <summary>Verifies that trace events are audit events.</summary> [Fact] public void TraceEvents_AreAuditEvents() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/NativeAlarmSourceOverrideCsvParserTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/NativeAlarmSourceOverrideCsvParserTests.cs index e7d1e0f5..dce12d94 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/NativeAlarmSourceOverrideCsvParserTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/NativeAlarmSourceOverrideCsvParserTests.cs @@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests; public class NativeAlarmSourceOverrideCsvParserTests { + /// <summary>Verifies that a simple file parses into rows with no errors.</summary> [Fact] public void Parse_SimpleFile_ReturnsRowsNoErrors() { @@ -30,6 +31,7 @@ public class NativeAlarmSourceOverrideCsvParserTests Assert.Equal(3, result.Rows[1].LineNumber); } + /// <summary>Verifies that blank override fields become null so the inherited value applies.</summary> [Fact] public void Parse_BlankOverrideFields_BecomeNullInherited() { @@ -47,6 +49,7 @@ public class NativeAlarmSourceOverrideCsvParserTests Assert.Null(row.Filter); } + /// <summary>Verifies that header names are matched case-insensitively.</summary> [Fact] public void Parse_HeaderCaseInsensitive_Accepted() { @@ -60,6 +63,7 @@ public class NativeAlarmSourceOverrideCsvParserTests Assert.Single(result.Rows); } + /// <summary>Verifies that a quoted field preserves an embedded comma.</summary> [Fact] public void Parse_QuotedFieldWithComma_PreservesEmbeddedComma() { @@ -74,6 +78,7 @@ public class NativeAlarmSourceOverrideCsvParserTests Assert.Equal("ns=2;s=a,b,c", row.SourceReference); } + /// <summary>Verifies that a missing header returns a header error.</summary> [Fact] public void Parse_MissingHeader_ReturnsHeaderError() { @@ -86,6 +91,7 @@ public class NativeAlarmSourceOverrideCsvParserTests Assert.Contains("SourceName,Connection,SourceReference,Filter", error); } + /// <summary>Verifies that a row with the wrong column count reports a per-line error and is skipped.</summary> [Fact] public void Parse_WrongColumnCount_ReportsPerLineErrorAndSkipsRow() { @@ -104,6 +110,7 @@ public class NativeAlarmSourceOverrideCsvParserTests Assert.Equal("Temp", row.SourceName); } + /// <summary>Verifies that a blank source name reports an error.</summary> [Fact] public void Parse_BlankSourceName_ReportsError() { @@ -118,6 +125,7 @@ public class NativeAlarmSourceOverrideCsvParserTests Assert.Contains("SourceName must not be blank", error); } + /// <summary>Verifies that an unterminated quote reports an error.</summary> [Fact] public void Parse_UnterminatedQuote_ReportsError() { @@ -131,6 +139,7 @@ public class NativeAlarmSourceOverrideCsvParserTests Assert.Contains(result.Errors, e => e.Contains("Unterminated quoted field")); } + /// <summary>Verifies that blank lines are skipped without an error.</summary> [Fact] public void Parse_BlankLinesSkippedWithoutError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Observability/DataConnectionGaugeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Observability/DataConnectionGaugeTests.cs index 98f1de8c..1f047fef 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Observability/DataConnectionGaugeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Observability/DataConnectionGaugeTests.cs @@ -23,6 +23,7 @@ public sealed class DataConnectionGaugeTests private const string ConnectedGauge = "scadabridge.site.data_connections.connected"; private const string TotalGauge = "scadabridge.site.data_connections.total"; + /// <summary>Verifies that the connected state raises both the connected and total gauges.</summary> [Fact] public void ConnectedState_RaisesConnectedAndTotal() { @@ -43,6 +44,8 @@ public sealed class DataConnectionGaugeTests } } + /// <summary>Verifies that a non-connected state raises only the total gauge.</summary> + /// <param name="state">The state.</param> [Theory] [InlineData(ConnectionHealth.Disconnected)] [InlineData(ConnectionHealth.Connecting)] @@ -70,6 +73,7 @@ public sealed class DataConnectionGaugeTests } } + /// <summary>Verifies that a transition from connected to disconnected drops the connected gauge but keeps the total.</summary> [Fact] public void TransitionFromConnectedToDisconnected_DropsConnectedButKeepsTotal() { @@ -94,6 +98,7 @@ public sealed class DataConnectionGaugeTests } } + /// <summary>Verifies that removing a data connection drops it from both gauges.</summary> [Fact] public void RemoveDataConnection_DropsItFromBothGauges() { @@ -111,6 +116,7 @@ public sealed class DataConnectionGaugeTests Assert.Equal(totalBefore, ReadGauge(TotalGauge)); } + /// <summary>Verifies that removing a data connection is idempotent.</summary> [Fact] public void RemoveDataConnection_IsIdempotent() { @@ -123,6 +129,7 @@ public sealed class DataConnectionGaugeTests Assert.Null(ex); } + /// <summary>Verifies that a null or empty connection name is ignored.</summary> [Fact] public void NullOrEmptyConnectionName_IsIgnored() { @@ -136,6 +143,7 @@ public sealed class DataConnectionGaugeTests Assert.Equal(totalBefore, ReadGauge(TotalGauge)); } + /// <summary>Verifies that the stream subscriber gauge is published under its renamed name.</summary> [Fact] public void StreamSubscriberGauge_IsPublishedUnderTheRenamedName() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/OverrideCsvParserTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/OverrideCsvParserTests.cs index bacddcc4..34f6a5a7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/OverrideCsvParserTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/OverrideCsvParserTests.cs @@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests; public class OverrideCsvParserTests { + /// <summary>Verifies that a simple three-column file parses into two rows with no errors.</summary> [Fact] public void Parse_SimpleThreeColumnFile_ReturnsTwoRowsNoErrors() { @@ -25,6 +26,7 @@ public class OverrideCsvParserTests Assert.Equal(3, result.Rows[1].LineNumber); } + /// <summary>Verifies that rows from a two-column file without an element type have a null element type.</summary> [Fact] public void Parse_TwoColumnFileWithoutElementType_RowsHaveNullElementType() { @@ -39,6 +41,7 @@ public class OverrideCsvParserTests Assert.Equal("Pump A", result.Rows[1].Value); } + /// <summary>Verifies that a quoted value containing a comma keeps the embedded comma.</summary> [Fact] public void Parse_QuotedValueWithComma_PreservesEmbeddedComma() { @@ -53,6 +56,7 @@ public class OverrideCsvParserTests Assert.Null(row.ElementType); } + /// <summary>Verifies that a doubled quote escape is unescaped to a single quote.</summary> [Fact] public void Parse_DoubledQuoteEscape_UnescapesToSingleQuote() { @@ -65,6 +69,7 @@ public class OverrideCsvParserTests Assert.Equal("he said \"hi\"", row.Value); } + /// <summary>Verifies that an empty value field yields a null value.</summary> [Fact] public void Parse_EmptyValueField_YieldsNullValue() { @@ -79,6 +84,7 @@ public class OverrideCsvParserTests Assert.Null(row.ElementType); } + /// <summary>Verifies that a row with too few columns produces a line-numbered error and is excluded from the result.</summary> [Fact] public void Parse_RowWithTooFewColumns_ProducesLineNumberedErrorAndExcludesRow() { @@ -95,6 +101,7 @@ public class OverrideCsvParserTests Assert.Contains("2", error); } + /// <summary>Verifies that a blank attribute name produces a line-numbered error.</summary> [Fact] public void Parse_BlankAttributeName_ProducesLineNumberedError() { @@ -107,6 +114,7 @@ public class OverrideCsvParserTests Assert.Contains("2", error); } + /// <summary>Verifies that blank lines are skipped without raising an error.</summary> [Fact] public void Parse_BlankLines_AreSkippedWithoutError() { @@ -121,6 +129,7 @@ public class OverrideCsvParserTests Assert.Equal(5, result.Rows[1].LineNumber); } + /// <summary>Verifies that a missing header returns zero rows and a header error.</summary> [Fact] public void Parse_MissingHeader_ReturnsZeroRowsAndHeaderError() { @@ -133,6 +142,7 @@ public class OverrideCsvParserTests Assert.Contains("header", error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that the header match is case-insensitive and trims surrounding whitespace.</summary> [Fact] public void Parse_HeaderIsCaseInsensitiveAndTrimsWhitespace() { @@ -147,6 +157,7 @@ public class OverrideCsvParserTests Assert.Equal("Int32", row.ElementType); } + /// <summary>Verifies that empty input returns a header error.</summary> [Fact] public void Parse_EmptyInput_ReturnsHeaderError() { @@ -156,6 +167,7 @@ public class OverrideCsvParserTests Assert.Single(result.Errors); } + /// <summary>Verifies that unquoted whitespace is trimmed while quoted whitespace is preserved.</summary> [Fact] public void Parse_UnquotedWhitespace_IsTrimmedButQuotedWhitespacePreserved() { @@ -170,6 +182,7 @@ public class OverrideCsvParserTests Assert.Equal(" spaced ", result.Rows[1].Value); } + /// <summary>Verifies that an unquoted quote character in the middle of a field is preserved literally.</summary> [Fact] public void Parse_MidFieldUnquotedQuote_IsPreservedAsLiteral() { @@ -186,6 +199,7 @@ public class OverrideCsvParserTests Assert.Equal("Type", row.ElementType); } + /// <summary>Verifies that an unterminated quoted field produces a line-numbered error and is excluded from the result.</summary> [Fact] public void Parse_UnterminatedQuotedField_ProducesLineNumberedErrorAndExcludesRow() { @@ -200,6 +214,7 @@ public class OverrideCsvParserTests Assert.Contains("Unterminated", error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that a well-formed quoted field containing a comma still parses correctly.</summary> [Fact] public void Parse_WellFormedQuotedFieldWithComma_StillParses() { @@ -216,6 +231,7 @@ public class OverrideCsvParserTests Assert.Equal("Type", row.ElementType); } + /// <summary>Verifies that an empty quoted field yields a null value.</summary> [Fact] public void Parse_EmptyQuotedField_YieldsNullValue() { @@ -231,6 +247,7 @@ public class OverrideCsvParserTests Assert.Equal("Type", row.ElementType); } + /// <summary>Verifies that a quoted field followed by trailing whitespace after the closing quote is accepted.</summary> [Fact] public void Parse_QuotedFieldWithTrailingWhitespaceAfterClose_IsAccepted() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Scripts/ScriptTriggerAttributeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Scripts/ScriptTriggerAttributeTests.cs index 2b2d14f2..c538f75d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Scripts/ScriptTriggerAttributeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Scripts/ScriptTriggerAttributeTests.cs @@ -5,6 +5,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Scripts; /// <summary>Flow Debugger script entry (design 2026-09-08 §2): the monitored attribute of a ValueChange/Conditional trigger.</summary> public class ScriptTriggerAttributeTests { + /// <summary>Verifies the case: write Kinds, return The Monitored Attribute.</summary> + /// <param name="kind">The kind test input.</param> + /// <param name="config">The config test input.</param> + /// <param name="expected">The expected test input.</param> [Theory] [InlineData("ValueChange", """{"attributeName":"Level"}""", "Level")] [InlineData("valuechange", """{"attribute":"Level"}""", "Level")] @@ -17,6 +21,9 @@ public class ScriptTriggerAttributeTests Assert.True(ScriptTriggerAttribute.IsWriteKind(kind)); } + /// <summary>Verifies the case: other Kinds, return False, even When The Config Names An Attribute.</summary> + /// <param name="kind">The kind test input.</param> + /// <param name="config">The config test input.</param> [Theory] [InlineData("Call", """{"attributeName":"Level"}""")] [InlineData("Interval", """{"intervalMs":1000}""")] @@ -30,6 +37,9 @@ public class ScriptTriggerAttributeTests Assert.False(ScriptTriggerAttribute.IsWriteKind(kind)); } + /// <summary>Verifies the case: write Kind, without AReadable Attribute, returns False, but Is Still AWrite Kind.</summary> + /// <param name="kind">The kind test input.</param> + /// <param name="config">The config test input.</param> [Theory] [InlineData("ValueChange", null)] [InlineData("ValueChange", "")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Alarms/AlarmConditionStateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Alarms/AlarmConditionStateTests.cs index 376a52e5..a64300b0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Alarms/AlarmConditionStateTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Alarms/AlarmConditionStateTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Alarms; public class AlarmConditionStateTests { + /// <summary>Verifies that a default alarm condition state is normal and unshelved.</summary> [Fact] public void AlarmConditionState_DefaultsAreNormalUnshelved() { @@ -17,6 +18,7 @@ public class AlarmConditionStateTests Assert.Equal(0, s.Severity); } + /// <summary>Verifies that a native alarm transition carries its source and condition.</summary> [Fact] public void NativeAlarmTransition_CarriesSourceAndCondition() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs index 3f81ba37..4c8e736b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs @@ -12,15 +12,18 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// </summary> public class AttributeValueCodecTests { + /// <summary>Verifies that a string list encodes as a JSON array.</summary> [Fact] public void Encode_StringList_ProducesJsonArray() => Assert.Equal("[\"WO-1\",\"WO-2\"]", AttributeValueCodec.Encode(new List<string> { "WO-1", "WO-2" })); + /// <summary>Verifies that a scalar string is encoded unchanged.</summary> [Fact] public void Encode_Scalar_String_ReturnedAsIs() => Assert.Equal("hello", AttributeValueCodec.Encode("hello")); + /// <summary>Verifies that a scalar double is encoded using the invariant culture.</summary> [Fact] public void Encode_Scalar_Double_IsInvariant() { @@ -36,19 +39,23 @@ public class AttributeValueCodecTests } } + /// <summary>Verifies that encoding null returns null.</summary> [Fact] public void Encode_Null_ReturnsNull() => Assert.Null(AttributeValueCodec.Encode(null)); + /// <summary>Verifies that an empty list encodes as an empty bracket pair.</summary> [Fact] public void Encode_EmptyList_IsBracketPair() => Assert.Equal("[]", AttributeValueCodec.Encode(new List<string>())); + /// <summary>Verifies that a string containing a comma is escaped inside the encoded array.</summary> [Fact] public void Encode_StringWithComma_IsEscaped() => Assert.Equal("[\"ACME, Inc.\"]", AttributeValueCodec.Encode(new List<string> { "ACME, Inc." })); + /// <summary>Verifies that a float list survives an encode and decode round trip.</summary> [Fact] public void RoundTrip_FloatList() { @@ -57,6 +64,7 @@ public class AttributeValueCodecTests Assert.Equal(new[] { 1.5f, 2.25f, -3.75f }, back); } + /// <summary>Verifies that a double list is encoded using the invariant culture.</summary> [Fact] public void Encode_DoubleList_IsInvariant() { @@ -73,21 +81,25 @@ public class AttributeValueCodecTests } } + /// <summary>Verifies that an integer list encodes as native JSON numbers.</summary> [Fact] public void Encode_Int32List_ProducesNativeNumbers() => Assert.Equal("[10,20,30]", AttributeValueCodec.Encode(new List<int> { 10, 20, 30 })); + /// <summary>Verifies that a boolean list encodes as native JSON booleans.</summary> [Fact] public void Encode_BoolList_ProducesNativeBooleans() => Assert.Equal("[true,false]", AttributeValueCodec.Encode(new List<bool> { true, false })); + /// <summary>Verifies that string elements remain quoted in the encoded array.</summary> [Fact] public void Encode_StringList_StaysQuoted() => Assert.Equal("[\"a\",\"b\"]", AttributeValueCodec.Encode(new List<string> { "a", "b" })); + /// <summary>Verifies that a date-time list encodes its elements in ISO 8601 form.</summary> [Fact] public void Encode_DateTimeList_IsIso8601() { @@ -98,6 +110,7 @@ public class AttributeValueCodecTests Assert.DoesNotContain("06/16/2026", json); } + /// <summary>Verifies that the old string form of a float list still decodes.</summary> [Fact] public void Decode_OldStringFloatForm_BackwardCompatible() { @@ -105,6 +118,7 @@ public class AttributeValueCodecTests Assert.Equal(new[] { 1.5f, 2.25f }, back); } + /// <summary>Verifies that the old string form of a date-time list still decodes.</summary> [Fact] public void Decode_OldStringDateTimeForm_BackwardCompatible() { @@ -115,6 +129,7 @@ public class AttributeValueCodecTests Assert.Equal(new DateTime(2026, 6, 16, 12, 30, 45, DateTimeKind.Unspecified), back[0]); } + /// <summary>Verifies that the native integer array form decodes correctly.</summary> [Fact] public void Decode_NewNativeIntForm_Parses() { @@ -122,6 +137,7 @@ public class AttributeValueCodecTests Assert.Equal(new[] { 10, 20 }, back); } + /// <summary>Verifies that the old string form of an integer list still decodes.</summary> [Fact] public void Decode_OldStringIntForm_BackwardCompatible() { @@ -129,6 +145,8 @@ public class AttributeValueCodecTests Assert.Equal(new[] { 10, 20 }, back); } + /// <summary>Verifies that both the native and the string boolean array forms decode.</summary> + /// <param name="json">The encoded boolean array to decode.</param> [Theory] [InlineData("[true,false]")] [InlineData("[\"True\",\"False\"]")] @@ -138,6 +156,7 @@ public class AttributeValueCodecTests Assert.Equal(new[] { true, false }, back); } + /// <summary>Verifies that an integer list survives an encode and decode round trip.</summary> [Fact] public void RoundTrip_Int32List() { @@ -146,6 +165,7 @@ public class AttributeValueCodecTests Assert.Equal(new[] { 1, 2, 3 }, back); } + /// <summary>Verifies that a double list round-trips regardless of the current culture.</summary> [Fact] public void RoundTrip_DoubleList_IsCultureInvariant() { @@ -163,6 +183,7 @@ public class AttributeValueCodecTests } } + /// <summary>Verifies that a boolean list survives an encode and decode round trip.</summary> [Fact] public void RoundTrip_BoolList() { @@ -171,6 +192,7 @@ public class AttributeValueCodecTests Assert.Equal(new[] { true, false, true }, back); } + /// <summary>Verifies that a date-time list round-trips through its ISO 8601 form.</summary> [Fact] public void RoundTrip_DateTimeList_Iso8601() { @@ -181,6 +203,7 @@ public class AttributeValueCodecTests Assert.Equal(new[] { a, b }, back); } + /// <summary>Verifies that a JSON string array decodes to a list of strings.</summary> [Fact] public void Decode_StringArray_ProducesListOfString() { @@ -188,38 +211,48 @@ public class AttributeValueCodecTests Assert.Equal(new[] { "a", "b" }, back); } + /// <summary>Verifies that decoding a scalar value returns it as a string.</summary> [Fact] public void Decode_Scalar_ReturnsString() => Assert.Equal("42", AttributeValueCodec.Decode("42", DataType.Int32, null)); + /// <summary>Verifies that decoding a null list value returns null.</summary> [Fact] public void Decode_List_NullValue_ReturnsNull() => Assert.Null(AttributeValueCodec.Decode(null, DataType.List, DataType.String)); + /// <summary>Verifies that decoding an empty list value returns null.</summary> [Fact] public void Decode_List_EmptyValue_ReturnsNull() => Assert.Null(AttributeValueCodec.Decode("", DataType.List, DataType.String)); + /// <summary>Verifies that malformed JSON throws when decoding a list.</summary> [Fact] public void Decode_MalformedJson_Throws() => Assert.Throws<FormatException>(() => AttributeValueCodec.Decode("not json", DataType.List, DataType.String)); + /// <summary>Verifies that an element that cannot be parsed throws when decoding a list.</summary> [Fact] public void Decode_UnparseableElement_Throws() => Assert.Throws<FormatException>(() => AttributeValueCodec.Decode("[\"abc\"]", DataType.List, DataType.Int32)); + /// <summary>Verifies that a null element throws when decoding a list.</summary> [Fact] public void Decode_NullElement_Throws() => Assert.Throws<FormatException>(() => AttributeValueCodec.Decode("[null]", DataType.List, DataType.String)); + /// <summary>Verifies that decoding a list without an element type throws.</summary> [Fact] public void Decode_List_WithoutElementType_Throws() => Assert.Throws<FormatException>(() => AttributeValueCodec.Decode("[\"a\"]", DataType.List, null)); + /// <summary>Verifies that only scalar data types are valid list element types.</summary> + /// <param name="t">The data type to check.</param> + /// <param name="expected">Whether the type is expected to be a valid element type.</param> [Theory] [InlineData(DataType.String, true)] [InlineData(DataType.Int32, true)] @@ -237,6 +270,9 @@ public class AttributeValueCodecTests // string-input path in Decode (JSON elements): here elements arrive as boxed // CLR values and are converted via Convert.ToXxx (or parsed when string). + /// <summary>Verifies that each scalar data type maps to its expected CLR type.</summary> + /// <param name="t">The scalar data type to map.</param> + /// <param name="expected">The expected CLR type.</param> [Theory] [InlineData(DataType.String, typeof(string))] [InlineData(DataType.Int32, typeof(int))] @@ -247,10 +283,12 @@ public class AttributeValueCodecTests public void ElementClrType_MapsEachScalar(DataType t, Type expected) => Assert.Equal(expected, AttributeValueCodec.ElementClrType(t)); + /// <summary>Verifies that a data type that is not a valid element type is rejected.</summary> [Fact] public void ElementClrType_RejectsNonElementType() => Assert.Throws<FormatException>(() => AttributeValueCodec.ElementClrType(DataType.List)); + /// <summary>Verifies that coercing an enumerable builds a list of the requested element type.</summary> [Fact] public void CoerceEnumerable_BuildsTypedList() { @@ -259,16 +297,18 @@ public class AttributeValueCodecTests Assert.Equal(new[] { 10, 20, 30 }, typed); } + /// <summary>Verifies that elements of mixed CLR types are converted to the requested element type.</summary> [Fact] public void CoerceEnumerable_ConvertsMixedClrElementTypes() { // Ints and numeric strings both coerce to List<double> via Convert/parse — - // the object-input behavior InstanceActor's MV-8 coercion depends on. + // the object-input behavior the Instance Actor's coercion depends on. var list = AttributeValueCodec.CoerceEnumerable(new object[] { 1, "2.5", 3.75 }, DataType.Double); var typed = Assert.IsType<List<double>>(list); Assert.Equal(new[] { 1.0, 2.5, 3.75 }, typed); } + /// <summary>Verifies that date-time elements round-trip through the invariant culture during coercion.</summary> [Fact] public void CoerceEnumerable_DateTimeRoundtripsViaInvariantCulture() { @@ -279,6 +319,7 @@ public class AttributeValueCodecTests Assert.Equal(when, typed[0]); } + /// <summary>Verifies that coercion throws for an unsupported element type.</summary> [Fact] public void CoerceEnumerable_ThrowsOnUnsupportedElementType() => Assert.Throws<FormatException>( diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditDetailsCodecTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditDetailsCodecTests.cs index b059bdc4..5aba9758 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditDetailsCodecTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditDetailsCodecTests.cs @@ -40,6 +40,7 @@ public class AuditDetailsCodecTests // Round-trip: all non-null fields survive // --------------------------------------------------------------------------- + /// <summary>Verifies that details with every field populated survive a serialize and deserialize round trip.</summary> [Fact] public void RoundTrip_AllFields_Populated() { @@ -67,6 +68,7 @@ public class AuditDetailsCodecTests // Round-trip: all-null record // --------------------------------------------------------------------------- + /// <summary>Verifies that details whose optional fields are all null round trip to empty details.</summary> [Fact] public void RoundTrip_AllNullOptionals_ReturnsEmptyDetails() { @@ -97,6 +99,7 @@ public class AuditDetailsCodecTests // Null / empty JSON deserialize → empty AuditDetails (no throw) // --------------------------------------------------------------------------- + /// <summary>Verifies that deserializing null returns empty details.</summary> [Fact] public void Deserialize_Null_ReturnsEmpty() { @@ -105,6 +108,7 @@ public class AuditDetailsCodecTests Assert.Null(d.Channel); } + /// <summary>Verifies that deserializing an empty string returns empty details.</summary> [Fact] public void Deserialize_EmptyString_ReturnsEmpty() { @@ -117,6 +121,7 @@ public class AuditDetailsCodecTests // Determinism: same input → byte-identical JSON across two calls // --------------------------------------------------------------------------- + /// <summary>Verifies that serializing the same input repeatedly produces byte-identical output.</summary> [Fact] public void Serialize_SameInput_ProducesByteIdenticalOutput_AcrossCalls() { @@ -135,6 +140,7 @@ public class AuditDetailsCodecTests // null fields omitted, PayloadTruncated=false present because it is non-nullable bool) // --------------------------------------------------------------------------- + /// <summary>Verifies that serializing a known input produces the expected JSON.</summary> [Fact] public void Serialize_KnownInput_MatchesExpectedJson() { @@ -178,6 +184,7 @@ public class AuditDetailsCodecTests // Redaction marker preserved verbatim (UnsafeRelaxedJsonEscaping must not mangle <>) // --------------------------------------------------------------------------- + /// <summary>Verifies that a redaction marker is preserved verbatim through serialization.</summary> [Fact] public void Serialize_RedactionMarker_IsPreservedVerbatim() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditFieldBuildersTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditFieldBuildersTests.cs index 90487b0c..506eea4f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditFieldBuildersTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditFieldBuildersTests.cs @@ -12,6 +12,10 @@ public class AuditFieldBuildersTests // BuildAction: "{channel}.{kind}" // --------------------------------------------------------------------------- + /// <summary>Verifies that building an action yields the channel and kind joined by a dot.</summary> + /// <param name="channel">The audit channel.</param> + /// <param name="kind">The audit kind.</param> + /// <param name="expected">The expected action string.</param> [Theory] [InlineData(AuditChannel.ApiOutbound, AuditKind.ApiCall, "ApiOutbound.ApiCall")] [InlineData(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, "ApiOutbound.ApiCallCached")] @@ -32,6 +36,9 @@ public class AuditFieldBuildersTests // BuildCategory: channel.ToString() // --------------------------------------------------------------------------- + /// <summary>Verifies that building a category yields the channel name.</summary> + /// <param name="channel">The audit channel.</param> + /// <param name="expected">The expected category string.</param> [Theory] [InlineData(AuditChannel.ApiOutbound, "ApiOutbound")] [InlineData(AuditChannel.DbOutbound, "DbOutbound")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditOutcomeProjectorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditOutcomeProjectorTests.cs index 66666cd7..69d4bc4c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditOutcomeProjectorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditOutcomeProjectorTests.cs @@ -14,6 +14,8 @@ public class AuditOutcomeProjectorTests // InboundAuthFailure → Denied, regardless of status (checked FIRST) // --------------------------------------------------------------------------- + /// <summary>Verifies that an inbound auth failure always projects to a denied outcome, whatever the status.</summary> + /// <param name="status">The audit status under test.</param> [Theory] [InlineData(AuditStatus.Submitted)] [InlineData(AuditStatus.Forwarded)] @@ -33,6 +35,8 @@ public class AuditOutcomeProjectorTests // Delivered → Success (for all non-auth-failure kinds) // --------------------------------------------------------------------------- + /// <summary>Verifies that a delivered status projects to success for every kind other than an auth failure.</summary> + /// <param name="kind">The audit kind under test.</param> [Theory] [InlineData(AuditKind.ApiCall)] [InlineData(AuditKind.ApiCallCached)] @@ -53,6 +57,8 @@ public class AuditOutcomeProjectorTests // Failure statuses → Failure outcome // --------------------------------------------------------------------------- + /// <summary>Verifies that failed, parked and discarded statuses project to a failure outcome.</summary> + /// <param name="status">The failure status under test.</param> [Theory] [InlineData(AuditStatus.Failed)] [InlineData(AuditStatus.Parked)] @@ -65,6 +71,8 @@ public class AuditOutcomeProjectorTests } // InboundAuthFailure still wins over failure statuses + /// <summary>Verifies that an inbound auth failure projects to denied even when the status is a failure status.</summary> + /// <param name="status">The failure status under test.</param> [Theory] [InlineData(AuditStatus.Failed)] [InlineData(AuditStatus.Parked)] @@ -79,6 +87,8 @@ public class AuditOutcomeProjectorTests // In-progress / short-circuit statuses → Success // --------------------------------------------------------------------------- + /// <summary>Verifies that in-progress and skipped statuses project to a success outcome.</summary> + /// <param name="status">The in-progress status under test.</param> [Theory] [InlineData(AuditStatus.Submitted)] [InlineData(AuditStatus.Forwarded)] @@ -94,6 +104,10 @@ public class AuditOutcomeProjectorTests // Full status × non-auth-failure kind cross-product (spot-check each status once more) // --------------------------------------------------------------------------- + /// <summary>Verifies that each status and kind pair projects to the outcome in the specification table.</summary> + /// <param name="status">The audit status under test.</param> + /// <param name="kind">The audit kind under test.</param> + /// <param name="expected">The expected projected outcome.</param> [Theory] [InlineData(AuditStatus.Submitted, AuditKind.DbWrite, AuditOutcome.Success)] [InlineData(AuditStatus.Forwarded, AuditKind.CachedSubmit, AuditOutcome.Success)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/ScadaBridgeAuditEventFactoryActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/ScadaBridgeAuditEventFactoryActorTests.cs index 70b55bf2..785362af 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/ScadaBridgeAuditEventFactoryActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/ScadaBridgeAuditEventFactoryActorTests.cs @@ -15,6 +15,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Audit; /// </summary> public class ScadaBridgeAuditEventFactoryActorTests { + /// <summary>Verifies that system originated emit, preserves caller actor, verbatim.</summary> + /// <param name="systemActor">Test case input.</param> [Theory] // Mirrors the literal system actors the outbound emitters pass: // NotificationOutboxActor → "system"; AuditingDbCommand → the source script. @@ -33,6 +35,7 @@ public class ScadaBridgeAuditEventFactoryActorTests Assert.Equal(systemActor, evt.Actor); } + /// <summary>Verifies that authenticated emit, preserves caller actor, verbatim.</summary> [Fact] public void AuthenticatedEmit_PreservesCallerActor_Verbatim() { @@ -47,6 +50,7 @@ public class ScadaBridgeAuditEventFactoryActorTests Assert.Equal("alice", evt.Actor); } + /// <summary>Verifies that null actor, maps to empty string, on canonical record.</summary> [Fact] public void NullActor_MapsToEmptyString_OnCanonicalRecord() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AuditQueryParamParsersTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AuditQueryParamParsersTests.cs index 0201bff2..5d61e98d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AuditQueryParamParsersTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AuditQueryParamParsersTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// <summary> -/// Audit Log #23 (M8): tests for the shared multi-value query-param parsers used by +/// Tests for the shared multi-value query-param parsers used by /// the ManagementService + CentralUI audit endpoints and the <c>AuditLogPage</c> /// drill-in parser. Two contracts are under test: /// <list type="bullet"> @@ -18,18 +18,21 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// </summary> public class AuditQueryParamParsersTests { + /// <summary>Verifies that parse enum list null input returns null.</summary> [Fact] public void ParseEnumList_NullInput_ReturnsNull() { Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(null)); } + /// <summary>Verifies that parse enum list empty input returns null.</summary> [Fact] public void ParseEnumList_EmptyInput_ReturnsNull() { Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(Array.Empty<string?>())); } + /// <summary>Verifies that parse enum list all values valid parses everything.</summary> [Fact] public void ParseEnumList_AllValuesValid_ParsesEverything() { @@ -38,6 +41,7 @@ public class AuditQueryParamParsersTests Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, result); } + /// <summary>Verifies that parse enum list is case insensitive.</summary> [Fact] public void ParseEnumList_IsCaseInsensitive() { @@ -45,6 +49,7 @@ public class AuditQueryParamParsersTests Assert.Equal(new[] { AuditChannel.ApiOutbound }, result); } + /// <summary>Verifies that parse enum list drops unparseable element keeps the rest.</summary> [Fact] public void ParseEnumList_DropsUnparseableElement_KeepsTheRest() { @@ -53,6 +58,7 @@ public class AuditQueryParamParsersTests Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, result); } + /// <summary>Verifies that parse enum list all values unparseable returns null.</summary> [Fact] public void ParseEnumList_AllValuesUnparseable_ReturnsNull() { @@ -65,18 +71,22 @@ public class AuditQueryParamParsersTests // case-SENSITIVE AcceptOnlyFromAmong guard and still fail loudly on garbage. // ======================================================================== + /// <summary>Verifies that parse enum list strict null input returns null.</summary> [Fact] public void ParseEnumListStrict_NullInput_ReturnsNull() { Assert.Null(AuditQueryParamParsers.ParseEnumListStrict<AuditStatus>(null, "status")); } + /// <summary>Verifies that parse enum list strict empty input returns null.</summary> [Fact] public void ParseEnumListStrict_EmptyInput_ReturnsNull() { Assert.Null(AuditQueryParamParsers.ParseEnumListStrict<AuditStatus>(Array.Empty<string?>(), "status")); } + /// <summary>Verifies that parse enum list strict is case insensitive and trims.</summary> + /// <param name="raw">The raw value.</param> [Theory] [InlineData("delivered")] [InlineData("DELIVERED")] @@ -88,6 +98,7 @@ public class AuditQueryParamParsersTests Assert.Equal(new[] { AuditStatus.Delivered }, result); } + /// <summary>Verifies that parse enum list strict all values valid parses everything.</summary> [Fact] public void ParseEnumListStrict_AllValuesValid_ParsesEverything() { @@ -96,6 +107,7 @@ public class AuditQueryParamParsersTests Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, result); } + /// <summary>Verifies that parse enum list strict unrecognised value throws naming the valid set.</summary> [Fact] public void ParseEnumListStrict_UnrecognisedValue_ThrowsNamingTheValidSet() { @@ -113,6 +125,8 @@ public class AuditQueryParamParsersTests } } + /// <summary>Verifies that parse enum list strict numeric string is rejected.</summary> + /// <param name="raw">The raw value.</param> [Theory] // "7" is out of range; "3" lands on a REAL member. Enum.TryParse accepts BOTH — // a filter is a name-based contract, never an ordinal one, so both are rejected. @@ -128,6 +142,7 @@ public class AuditQueryParamParsersTests Assert.Contains(raw, ex.Message); } + /// <summary>Verifies that parse enum list strict blank elements are still dropped silently.</summary> [Fact] public void ParseEnumListStrict_BlankElements_AreStillDroppedSilently() { @@ -138,6 +153,7 @@ public class AuditQueryParamParsersTests Assert.Equal(new[] { AuditStatus.Delivered }, result); } + /// <summary>Verifies that parse enum list strict all blank returns null.</summary> [Fact] public void ParseEnumListStrict_AllBlank_ReturnsNull() { @@ -146,8 +162,7 @@ public class AuditQueryParamParsersTests } // ======================================================================== - // Comma-joined single token (arch-review 2026-08-19 P6-3, register row 99 - // closure defect): a bare Enum.TryParse splits and bitwise-ORs a comma list + // Comma-joined single token (arch-review 2026-08-19 closure defect): a bare Enum.TryParse splits and bitwise-ORs a comma list // inside ONE token for ANY enum, not just [Flags] ones. AuditStatus is dense // and 0-based, so the OR'd composite almost always collides with a REAL, but // WRONG, member. Values arriving as separate tokens (repeated query param / @@ -157,6 +172,7 @@ public class AuditQueryParamParsersTests // instead RETURNS the wrong composite value below. // ======================================================================== + /// <summary>Verifies that parse enum list strict comma joined two names is rejected not silently orded into wrong member.</summary> [Fact] public void ParseEnumListStrict_CommaJoinedTwoNames_IsRejected_NotSilentlyOrdedIntoWrongMember() { @@ -169,6 +185,7 @@ public class AuditQueryParamParsersTests Assert.Contains("status", ex.Message); } + /// <summary>Verifies that parse enum list strict comma joined name and ordinal is rejected not smuggled past numeric guard.</summary> [Fact] public void ParseEnumListStrict_CommaJoinedNameAndOrdinal_IsRejected_NotSmuggledPastNumericGuard() { @@ -181,6 +198,7 @@ public class AuditQueryParamParsersTests Assert.Contains("Failed,7", ex.Message); } + /// <summary>Verifies that parse enum list strict comma joined names drop a member is rejected.</summary> [Fact] public void ParseEnumListStrict_CommaJoinedNamesDropAMember_IsRejected() { @@ -192,6 +210,7 @@ public class AuditQueryParamParsersTests Assert.Contains("Submitted,Parked", ex.Message); } + /// <summary>Verifies that parse enum list strict repeated param tokens of valid names still parse independently.</summary> [Fact] public void ParseEnumListStrict_RepeatedParamTokensOfValidNames_StillParseIndependently() { @@ -205,12 +224,14 @@ public class AuditQueryParamParsersTests result); } + /// <summary>Verifies that parse string list null input returns null.</summary> [Fact] public void ParseStringList_NullInput_ReturnsNull() { Assert.Null(AuditQueryParamParsers.ParseStringList(null)); } + /// <summary>Verifies that parse string list trims values and drops blanks.</summary> [Fact] public void ParseStringList_TrimsValuesAndDropsBlanks() { @@ -219,6 +240,7 @@ public class AuditQueryParamParsersTests Assert.Equal(new[] { "site-1", "site-2" }, result); } + /// <summary>Verifies that parse string list all blank returns null.</summary> [Fact] public void ParseStringList_AllBlank_ReturnsNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/MxGatewayEndpointConfigSerializerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/MxGatewayEndpointConfigSerializerTests.cs index 2493111c..bc76f254 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/MxGatewayEndpointConfigSerializerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/MxGatewayEndpointConfigSerializerTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections; public class MxGatewayEndpointConfigSerializerTests { + /// <summary>Verifies that serializing and then deserializing round-trips every field.</summary> [Fact] public void Serialize_then_Deserialize_round_trips_all_fields() { @@ -33,6 +34,8 @@ public class MxGatewayEndpointConfigSerializerTests Assert.Equal(original.ReadTimeoutMs, round.ReadTimeoutMs); } + /// <summary>Verifies that a null, blank or malformed JSON document deserializes to the default configuration.</summary> + /// <param name="json">The JSON document to deserialize.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -46,6 +49,7 @@ public class MxGatewayEndpointConfigSerializerTests Assert.Equal(def.ReadTimeoutMs, result.ReadTimeoutMs); } + /// <summary>Verifies that converting to a flat dictionary and back round-trips the configuration.</summary> [Fact] public void ToFlatDict_FromFlatDict_round_trips() { @@ -74,6 +78,7 @@ public class MxGatewayEndpointConfigSerializerTests Assert.Equal(original.ReadTimeoutMs, round.ReadTimeoutMs); } + /// <summary>Verifies that an invalid numeric value in a flat dictionary falls back to the default.</summary> [Fact] public void FromFlatDict_invalid_numeric_falls_back_to_default() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaEndpointConfigSerializerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaEndpointConfigSerializerTests.cs index e400879f..16037647 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaEndpointConfigSerializerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaEndpointConfigSerializerTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections; public class OpcUaEndpointConfigSerializerTests { + /// <summary>Verifies that a typed config round-trips through serialization with all fields preserved.</summary> [Fact] public void Serialize_TypedRoundtrip_PreservesAllFields() { @@ -44,6 +45,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal(60, round.Heartbeat.MaxSilenceSeconds); } + /// <summary>Verifies that serialization uses camelCase property names.</summary> [Fact] public void Serialize_UsesCamelCase() { @@ -53,6 +55,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.DoesNotContain("\"EndpointUrl\"", json); } + /// <summary>Verifies that the security mode is serialized as a camelCase string.</summary> [Fact] public void Serialize_SecurityModeAsCamelCaseString() { @@ -63,6 +66,8 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal("signAndEncrypt", doc.RootElement.GetProperty("securityMode").GetString()); } + /// <summary>Verifies that a null or empty input deserializes to the default config.</summary> + /// <param name="input">The null, empty or whitespace JSON input.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -77,6 +82,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Null(config.Heartbeat); } + /// <summary>Verifies that a legacy flat dictionary is flagged as legacy and its fields are populated.</summary> [Fact] public void Deserialize_LegacyFlatDict_IsLegacyTrue_PopulatesFields() { @@ -105,6 +111,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal(20, config.Heartbeat.MaxSilenceSeconds); } + /// <summary>Verifies that a legacy dictionary with a PascalCase <c>EndpointUrl</c> key is deserialized correctly.</summary> [Fact] public void Deserialize_LegacyWithEndpointUrlPascalKey_Works() { @@ -114,6 +121,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal("opc.tcp://x:4840", config.EndpointUrl); } + /// <summary>Verifies that a legacy dictionary with mixed value types preserves all fields.</summary> [Fact] public void Deserialize_LegacyWithMixedTypeValues_PreservesAllFields() { @@ -135,6 +143,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal(1000, config.PublishingIntervalMs); } + /// <summary>Verifies that a parsed legacy config reports a legacy status.</summary> [Fact] public void Deserialize_LegacyParsed_StatusIsLegacy() { @@ -145,6 +154,8 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal(OpcUaConfigParseStatus.Legacy, result.Status); } + /// <summary>Verifies that malformed input is reported as malformed rather than legacy.</summary> + /// <param name="input">The malformed JSON input.</param> [Theory] [InlineData("not json at all")] [InlineData("[1,2,3]")] @@ -159,6 +170,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal("", result.Config.EndpointUrl); } + /// <summary>Verifies that an object without an endpoint URL is parsed as legacy.</summary> [Fact] public void Deserialize_ObjectWithoutEndpointUrl_ParsesAsLegacy() { @@ -168,8 +180,9 @@ public class OpcUaEndpointConfigSerializerTests Assert.True(result.IsLegacy); } - // ── Commons-014 regression: a corrupt typed row must not be mislabelled Legacy ── + // ── Regression: a corrupt typed row must not be mislabelled Legacy ── + /// <summary>Verifies that a typed-shaped row with an invalid enum value is reported as malformed rather than legacy.</summary> [Fact] public void Deserialize_TypedShapeWithInvalidEnum_ReportsMalformedNotLegacy() { @@ -184,6 +197,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.False(result.IsLegacy); } + /// <summary>Verifies that a typed-shaped row with a wrongly typed field is reported as malformed rather than legacy.</summary> [Fact] public void Deserialize_TypedShapeWithWrongTypeField_ReportsMalformedNotLegacy() { @@ -196,10 +210,11 @@ public class OpcUaEndpointConfigSerializerTests Assert.False(result.IsLegacy); } + /// <summary>Verifies that a valid typed row is still reported as typed.</summary> [Fact] public void Deserialize_ValidTypedRow_StillReportsTyped() { - // Guard: a clean typed row is still classified Typed after the Commons-014 fix. + // Guard: a clean typed row is still classified Typed after the fix. var json = """{"endpointUrl":"opc.tcp://x:4840","securityMode":"sign"}"""; var result = OpcUaEndpointConfigSerializer.Deserialize(json); @@ -209,6 +224,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal(OpcUaSecurityMode.Sign, result.Config.SecurityMode); } + /// <summary>Verifies that deserializing the result into a two-element deconstruction still works.</summary> [Fact] public void Deserialize_TwoElementDeconstruction_StillWorks() { @@ -219,6 +235,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal("opc.tcp://x:4840", config.EndpointUrl); } + /// <summary>Verifies that a null heartbeat is omitted from the flat dictionary.</summary> [Fact] public void ToFlatDict_OmitsNullHeartbeat() { @@ -233,6 +250,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.False(dict.ContainsKey("HeartbeatMaxSilence")); } + /// <summary>Verifies that the heartbeat is included in the flat dictionary when set.</summary> [Fact] public void ToFlatDict_IncludesHeartbeat_WhenSet() { @@ -247,6 +265,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal("45", dict["HeartbeatMaxSilence"]); } + /// <summary>Verifies that building a config from a flat dictionary round-trips all keys.</summary> [Fact] public void FromFlatDict_RoundTripsAllKeys() { @@ -269,6 +288,7 @@ public class OpcUaEndpointConfigSerializerTests // ── Layer A/B extensions: subscription tuning, auth, deadband ── + /// <summary>Verifies that the new subscription scalar settings round-trip through serialization.</summary> [Fact] public void Serialize_RoundtripsNewSubscriptionScalars() { @@ -291,6 +311,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal(OpcUaTimestampsToReturn.Both, round.TimestampsToReturn); } + /// <summary>Verifies that the deadband setting round-trips through serialization.</summary> [Fact] public void Serialize_RoundtripsDeadband() { @@ -312,6 +333,12 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal(2.5, round.Deadband.Value); } + /// <summary>Verifies that the user identity settings round-trip through serialization for each token type.</summary> + /// <param name="tokenType">The user token type.</param> + /// <param name="user">The user name.</param> + /// <param name="pass">The password.</param> + /// <param name="certPath">The user certificate path.</param> + /// <param name="certPass">The user certificate password.</param> [Theory] [InlineData(OpcUaUserTokenType.UsernamePassword, "user1", "pass1", "", "")] [InlineData(OpcUaUserTokenType.X509Certificate, "", "", "/etc/pki/client.pfx", "pfxpass")] @@ -343,6 +370,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal(certPass, round.UserIdentity.CertificatePassword); } + /// <summary>Verifies that a null user identity and deadband are omitted from the typed JSON.</summary> [Fact] public void Serialize_NullUserIdentityAndDeadband_OmittedFromTypedJson() { @@ -356,6 +384,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Null(round.Deadband); } + /// <summary>Verifies that the new scalar settings are included in the flat dictionary.</summary> [Fact] public void ToFlatDict_IncludesNewScalars() { @@ -375,6 +404,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal("Server", dict["TimestampsToReturn"]); } + /// <summary>Verifies that a null user identity and deadband are omitted from the flat dictionary.</summary> [Fact] public void ToFlatDict_OmitsNullUserIdentityAndDeadband() { @@ -387,6 +417,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.False(dict.ContainsKey("Deadband.Value")); } + /// <summary>Verifies that the user identity is included in the flat dictionary when set.</summary> [Fact] public void ToFlatDict_IncludesUserIdentity_WhenSet() { @@ -407,6 +438,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal("secret", dict["UserIdentity.Password"]); } + /// <summary>Verifies that the deadband is included in the flat dictionary when set.</summary> [Fact] public void ToFlatDict_IncludesDeadband_WhenSet() { @@ -421,6 +453,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal("1.5", dict["Deadband.Value"]); } + /// <summary>Verifies that building a config from a flat dictionary materializes the user identity.</summary> [Fact] public void FromFlatDict_MaterializesUserIdentity() { @@ -439,6 +472,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal("hunter2", config.UserIdentity.Password); } + /// <summary>Verifies that building a config from a flat dictionary materializes the deadband.</summary> [Fact] public void FromFlatDict_MaterializesDeadband() { @@ -455,6 +489,7 @@ public class OpcUaEndpointConfigSerializerTests Assert.Equal(0.25, config.Deadband.Value); } + /// <summary>Verifies that an anonymous token type still materializes the user identity from a flat dictionary.</summary> [Fact] public void FromFlatDict_AnonymousTokenTypeStillMaterializesUserIdentity() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaReferenceFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaReferenceFormTests.cs index 63fb12f7..abe49247 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaReferenceFormTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaReferenceFormTests.cs @@ -4,6 +4,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections; public class OpcUaReferenceFormTests { + /// <summary>Verifies that OPC UA reference strings are classified as durable or non-durable by form.</summary> + /// <param name="reference">The OPC UA reference string under test.</param> + /// <param name="expected">Whether the reference is expected to be durable.</param> [Theory] // durable → true [InlineData("nsu=https://zb.com/otopcua/raw;s=Line1.Pump.Speed", true)] @@ -16,6 +19,9 @@ public class OpcUaReferenceFormTests [InlineData(" ", true)] [InlineData(null, true)] // bare server-namespace index → false (warn) + /// <summary>Verifies that reference forms are classified as durable or not as expected.</summary> + /// <param name="reference">The OPC UA reference text, or null.</param> + /// <param name="expected">The expected durability classification.</param> [InlineData("ns=2;s=MyDevice.Temperature", false)] [InlineData("ns=1;i=1001", false)] [InlineData("ns=10;s=x", false)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DynamicJsonElementTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DynamicJsonElementTests.cs index 4026f094..c2f2f696 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DynamicJsonElementTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DynamicJsonElementTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// <summary> -/// Tests for <see cref="DynamicJsonElement"/>, including the Commons-002 regression: +/// Tests for <see cref="DynamicJsonElement"/>, including the regression where /// a wrapped element must remain valid for deferred (script-time) access even after /// the <see cref="JsonDocument"/> it was parsed from has been disposed. /// </summary> @@ -19,8 +19,9 @@ public class DynamicJsonElementTests // now throw ObjectDisposedException on the first member access. } - // ── Commons-002 regression: lifetime independence from the source document ── + // ── Regression: lifetime independence from the source document ── + /// <summary>Verifies that member access still works after the source document is disposed.</summary> [Fact] public void MemberAccess_WorksAfterSourceDocumentDisposed() { @@ -30,6 +31,7 @@ public class DynamicJsonElementTests Assert.Equal(7, (int)obj.id); } + /// <summary>Verifies that index access still works after the source document is disposed.</summary> [Fact] public void IndexAccess_WorksAfterSourceDocumentDisposed() { @@ -38,6 +40,7 @@ public class DynamicJsonElementTests Assert.Equal("b", obj.items[1]); } + /// <summary>Verifies that nested member access still works after the source document is disposed.</summary> [Fact] public void NestedAccess_WorksAfterSourceDocumentDisposed() { @@ -46,6 +49,7 @@ public class DynamicJsonElementTests Assert.Equal(42, (int)obj.outer.inner.value); } + /// <summary>Verifies that <c>ToString</c> still works after the source document is disposed.</summary> [Fact] public void ToString_WorksAfterSourceDocumentDisposed() { @@ -54,6 +58,7 @@ public class DynamicJsonElementTests Assert.Equal("site-1", obj.label.ToString()); } + /// <summary>Verifies that access survives garbage collection of the source document.</summary> [Fact] public void Access_SurvivesGarbageCollection_OfSourceDocument() { @@ -78,6 +83,7 @@ public class DynamicJsonElementTests // ── Basic conversion / access behavior ────────────────────────── + /// <summary>Verifies that a JSON number converts to an int.</summary> [Fact] public void Convert_NumberToInt() { @@ -85,6 +91,7 @@ public class DynamicJsonElementTests Assert.Equal(123, (int)obj.n); } + /// <summary>Verifies that a JSON boolean converts to a bool.</summary> [Fact] public void Convert_BoolFromJson() { @@ -92,6 +99,7 @@ public class DynamicJsonElementTests Assert.True((bool)obj.flag); } + /// <summary>Verifies that accessing a missing member throws a RuntimeBinderException.</summary> [Fact] public void MissingMember_Throws() { @@ -102,8 +110,9 @@ public class DynamicJsonElementTests () => { var _ = obj.doesNotExist; }); } - // ── Commons-006 regression: TryConvert(object) must never null out a present value ── + // ── Regression: TryConvert(object) must never null out a present value ── + /// <summary>Verifies that converting a present value to <c>object</c> returns a non-null result.</summary> [Fact] public void TryConvert_ObjectTarget_OnPresentValue_ReturnsNonNull() { @@ -118,6 +127,7 @@ public class DynamicJsonElementTests Assert.NotNull(result); } + /// <summary>Verifies that converting a JSON null to <c>object</c> succeeds with a null result.</summary> [Fact] public void TryConvert_ObjectTarget_OnJsonNull_ReturnsNull() { @@ -131,6 +141,7 @@ public class DynamicJsonElementTests Assert.Null(result); } + /// <summary>Verifies that converting an unconvertible value to a non-object target reports failure.</summary> [Fact] public void TryConvert_NonObjectTarget_OnUnconvertibleValue_ReportsFailure() { @@ -145,8 +156,9 @@ public class DynamicJsonElementTests Assert.Null(result); } - // ── Commons-013 regression: integral index values other than int must work ── + // ── Regression: integral index values other than int must work ── + /// <summary>Verifies that index access works with a long index.</summary> [Fact] public void IndexAccess_WithLongIndex_Works() { @@ -158,16 +170,20 @@ public class DynamicJsonElementTests Assert.Equal("b", obj.items[idx]); } + /// <summary>Verifies that index access works with an index derived from a wrapped JSON number.</summary> [Fact] public void IndexAccess_WithIndexDerivedFromWrappedJsonNumber_Works() { - // The exact failing case from Commons-013: count is a wrapped JSON number + // The exact failing case from that regression: count is a wrapped JSON number // (unwrapped as long), so count - 1 is a long. dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ], "count": 3 }"""); Assert.Equal("c", obj.items[obj.count - 1]); } + /// <summary>Verifies that index access works with integral indexes that widen to an int.</summary> + /// <param name="index">The test input.</param> + /// <param name="expected">The test input.</param> [Theory] [InlineData((byte)0, "a")] [InlineData((short)1, "b")] @@ -178,6 +194,7 @@ public class DynamicJsonElementTests Assert.Equal(expected, obj.items[index]); } + /// <summary>Verifies that a long index outside the array range throws.</summary> [Fact] public void IndexAccess_WithLongIndexOutOfRange_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/EnumTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/EnumTests.cs index 2257f9da..b144f49d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/EnumTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/EnumTests.cs @@ -4,6 +4,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; public class EnumTests { + /// <summary>Verifies that an enum has exactly the expected member names, in order.</summary> + /// <param name="enumType">The enum type under test.</param> + /// <param name="expectedNames">The expected member names in declaration order.</param> [Theory] [InlineData(typeof(DataType), new[] { "Boolean", "Int32", "Float", "Double", "String", "DateTime", "Binary", "List" })] [InlineData(typeof(InstanceState), new[] { "NotDeployed", "Enabled", "Disabled" })] @@ -20,6 +23,8 @@ public class EnumTests Assert.Equal(expectedNames, actualNames); } + /// <summary>Verifies that an enum type has a singular name rather than a plural one.</summary> + /// <param name="enumType">The enum type under test.</param> [Theory] [InlineData(typeof(DataType))] [InlineData(typeof(InstanceState))] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Enums/AuditEnumTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Enums/AuditEnumTests.cs index 8b00af9a..a1864a89 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Enums/AuditEnumTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Enums/AuditEnumTests.cs @@ -3,12 +3,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Enums; /// <summary> -/// Asserts the exact member sets of the Audit Log (#23) enums. +/// Asserts the exact member sets of the Audit Log enums. /// Lock-in tests; any addition/removal/rename is a deliberate design change /// that must come with a corresponding update to alog.md §4. /// </summary> public class AuditEnumTests { + /// <summary>Verifies that audit channel has exactly expected members.</summary> [Fact] public void AuditChannel_HasExactlyExpectedMembers() { @@ -22,6 +23,7 @@ public class AuditEnumTests Assert.Equal(expected, actual); } + /// <summary>Verifies that audit kind has exactly twenty two expected members.</summary> [Fact] public void AuditKind_HasExactlyTwentyTwoExpectedMembers() { @@ -45,6 +47,7 @@ public class AuditEnumTests Assert.Equal(expected, actual); } + /// <summary>Verifies that audit status has exactly eight expected members.</summary> [Fact] public void AuditStatus_HasExactlyEightExpectedMembers() { @@ -62,6 +65,7 @@ public class AuditEnumTests Assert.Equal(expected, actual); } + /// <summary>Verifies that audit forward state has exactly expected members.</summary> [Fact] public void AuditForwardState_HasExactlyExpectedMembers() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/FlatteningAndScriptScopeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/FlatteningAndScriptScopeTests.cs index 911e9965..a2d8f7c5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/FlatteningAndScriptScopeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/FlatteningAndScriptScopeTests.cs @@ -4,11 +4,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// <summary> -/// Commons-010: coverage for the small computed-property logic on +/// Coverage for the small computed-property logic on /// <see cref="ConfigurationDiff"/> and <see cref="ScriptScope"/>. /// </summary> public class FlatteningAndScriptScopeTests { + /// <summary>Verifies that a configuration diff with no changes reports HasChanges as false.</summary> [Fact] public void ConfigurationDiff_NoChanges_HasChangesIsFalse() { @@ -17,6 +18,7 @@ public class FlatteningAndScriptScopeTests Assert.False(diff.HasChanges); } + /// <summary>Verifies that a configuration diff containing an attribute change reports HasChanges as true.</summary> [Fact] public void ConfigurationDiff_WithAttributeChange_HasChangesIsTrue() { @@ -32,6 +34,7 @@ public class FlatteningAndScriptScopeTests Assert.True(diff.HasChanges); } + /// <summary>Verifies that a root script scope has no parent.</summary> [Fact] public void ScriptScope_Root_HasNoParent() { @@ -39,6 +42,7 @@ public class FlatteningAndScriptScopeTests Assert.Null(ScriptScope.Root.ParentPath); } + /// <summary>Verifies that a script scope with a parent path reports HasParent as true.</summary> [Fact] public void ScriptScope_WithParentPath_HasParentIsTrue() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/HandshakeResultTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/HandshakeResultTests.cs index c6427e13..117c1d02 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/HandshakeResultTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/HandshakeResultTests.cs @@ -3,12 +3,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// <summary> -/// Gitea #36: <see cref="HandshakeResult"/> lets a script tell WHICH leg of a reset-aware +/// <see cref="HandshakeResult"/> lets a script tell WHICH leg of a reset-aware /// handshake failed without naming the <see cref="HandshakePhase"/> enum (script bodies /// import only System/System.Collections.Generic/System.Linq/System.Threading.Tasks). /// </summary> public class HandshakeResultTests { + /// <summary>Verifies that ResetFailed is true only when the reset phase did not match.</summary> [Fact] public void ResetFailed_IsTrue_OnlyForAnUnmatchedResetPhase() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/InboundApi/InboundApiSchemaRefTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/InboundApi/InboundApiSchemaRefTests.cs index 315a1781..2521902f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/InboundApi/InboundApiSchemaRefTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/InboundApi/InboundApiSchemaRefTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.InboundApi; /// <summary> -/// Tests for the M9-T32b JSON-Schema <c>$ref</c> resolver: the caller-supplied +/// Tests for the JSON-Schema <c>$ref</c> resolver: the caller-supplied /// resolution seam (<c>lib:Name</c> pointer convention), cycle/depth guarding, /// and dangling-ref reporting. The resolver is OPTIONAL — schemas without a /// <c>$ref</c> parse exactly as before (verified by the no-ref-unchanged tests). @@ -13,6 +13,7 @@ public class InboundApiSchemaRefTests { // ── Pointer convention: {"$ref":"lib:Name"} resolves through the seam ────── + /// <summary>Verifies that a library reference resolves to the referenced shape.</summary> [Fact] public void ParseWithRefs_LibRef_ResolvesToReferencedShape() { @@ -33,6 +34,7 @@ public class InboundApiSchemaRefTests Assert.Equal("integer", field.Schema.Type); } + /// <summary>Verifies that a reference on a nested field is resolved inline.</summary> [Fact] public void ParseWithRefs_NestedFieldRef_ResolvesInline() { @@ -52,6 +54,7 @@ public class InboundApiSchemaRefTests Assert.Equal("zip", Assert.Single(shipTo.Schema.Fields).Name); } + /// <summary>Verifies that parsing a library reference with a resolver yields the referenced shape.</summary> [Fact] public void Parse_LibRef_WithResolver_ResolvesToReferencedShape() { @@ -68,6 +71,7 @@ public class InboundApiSchemaRefTests // ── Dangling ref: resolver returns null → reported, not silently dropped ── + /// <summary>Verifies that a dangling reference is reported.</summary> [Fact] public void ParseWithRefs_DanglingRef_IsReported() { @@ -78,6 +82,7 @@ public class InboundApiSchemaRefTests Assert.Contains("Missing", result.UnresolvedRefs); } + /// <summary>Verifies that a dangling nested reference is reported.</summary> [Fact] public void ParseWithRefs_DanglingNestedRef_IsReported() { @@ -88,6 +93,7 @@ public class InboundApiSchemaRefTests Assert.Contains("Gone", result.UnresolvedRefs); } + /// <summary>Verifies that a reference parsed without a resolver is reported as dangling.</summary> [Fact] public void ParseWithRefs_RefWithNoResolver_IsDangling() { @@ -100,6 +106,7 @@ public class InboundApiSchemaRefTests Assert.Contains("Whatever", result.UnresolvedRefs); } + /// <summary>Verifies that parsing a dangling reference with a resolver throws.</summary> [Fact] public void Parse_DanglingRef_WithResolver_Throws() { @@ -111,6 +118,7 @@ public class InboundApiSchemaRefTests // ── Cycle / depth guard: controlled error, no stack overflow ────────────── + /// <summary>Verifies that a direct reference cycle is reported instead of overflowing the stack.</summary> [Fact] public void ParseWithRefs_DirectCycle_IsReportedNotStackOverflow() { @@ -128,6 +136,7 @@ public class InboundApiSchemaRefTests Assert.Contains(result.UnresolvedRefs, r => r.Contains("Foo", StringComparison.Ordinal)); } + /// <summary>Verifies that an indirect reference cycle is reported instead of overflowing the stack.</summary> [Fact] public void ParseWithRefs_IndirectCycle_IsReportedNotStackOverflow() { @@ -145,6 +154,7 @@ public class InboundApiSchemaRefTests Assert.NotEmpty(result.UnresolvedRefs); } + /// <summary>Verifies that parsing a direct cycle with a resolver throws instead of overflowing the stack.</summary> [Fact] public void Parse_DirectCycle_WithResolver_ThrowsNotStackOverflow() { @@ -159,6 +169,7 @@ public class InboundApiSchemaRefTests name => schemas.GetValueOrDefault(name))); } + /// <summary>Verifies that a deep reference chain respects the depth cap without overflowing the stack.</summary> [Fact] public void ParseWithRefs_DeepRefChain_RespectsDepthCapWithoutOverflow() { @@ -179,6 +190,7 @@ public class InboundApiSchemaRefTests // ── Behavior unchanged for schemas WITHOUT a $ref ───────────────────────── + /// <summary>Verifies that parsing a schema with no references gives the same result as the plain parse.</summary> [Fact] public void ParseWithRefs_NoRef_MatchesParse() { @@ -196,6 +208,7 @@ public class InboundApiSchemaRefTests Assert.False(withRefs.Schema.Fields[1].Required); } + /// <summary>Verifies that a schema with no references parses identically with or without a resolver.</summary> [Fact] public void Parse_NoRef_UnchangedWithOrWithoutResolver() { @@ -212,6 +225,7 @@ public class InboundApiSchemaRefTests Assert.Equal("number", withResolver.Items!.Type); } + /// <summary>Verifies that an empty input returns a null schema and no references.</summary> [Fact] public void ParseWithRefs_Empty_ReturnsNullSchemaNoRefs() { @@ -222,6 +236,7 @@ public class InboundApiSchemaRefTests // ── Diamond: two sibling refs to the SAME name resolve (not a cycle) ─────── + /// <summary>Verifies that sibling references to the same target resolve without being reported as a false cycle.</summary> [Fact] public void ParseWithRefs_DiamondSiblingRefs_ResolveBothNotFalseCycle() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/PurgeTimerScheduleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/PurgeTimerScheduleTests.cs index 5591e10c..ab435261 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/PurgeTimerScheduleTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/PurgeTimerScheduleTests.cs @@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// </summary> public class PurgeTimerScheduleTests { + /// <summary>Verifies that a long interval is capped at the short first tick.</summary> [Fact] public void InitialDelay_LongInterval_IsCappedAtShortFirstTick() { @@ -17,6 +18,7 @@ public class PurgeTimerScheduleTests Assert.Equal(PurgeTimerSchedule.ShortFirstTick, PurgeTimerSchedule.InitialDelay(TimeSpan.FromHours(24))); } + /// <summary>Verifies that an interval shorter than the short first tick is left unchanged.</summary> [Fact] public void InitialDelay_IntervalShorterThanShortFirstTick_IsUnchanged() { @@ -26,12 +28,14 @@ public class PurgeTimerScheduleTests Assert.Equal(fast, PurgeTimerSchedule.InitialDelay(fast)); } + /// <summary>Verifies that an interval equal to the short first tick yields that value.</summary> [Fact] public void InitialDelay_IntervalEqualToShortFirstTick_IsThatValue() { Assert.Equal(PurgeTimerSchedule.ShortFirstTick, PurgeTimerSchedule.InitialDelay(PurgeTimerSchedule.ShortFirstTick)); } + /// <summary>Verifies that the short first tick is five minutes.</summary> [Fact] public void ShortFirstTick_IsFiveMinutes() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ResultTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ResultTests.cs index 9d355ed9..f1685658 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ResultTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ResultTests.cs @@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; public class ResultTests { + /// <summary>Verifies that creating a success produces a successful result.</summary> [Fact] public void Success_ShouldCreateSuccessfulResult() { @@ -14,6 +15,7 @@ public class ResultTests Assert.Equal(42, result.Value); } + /// <summary>Verifies that creating a failure produces a failed result.</summary> [Fact] public void Failure_ShouldCreateFailedResult() { @@ -24,6 +26,7 @@ public class ResultTests Assert.Equal("something went wrong", result.Error); } + /// <summary>Verifies that reading the value of a failed result throws.</summary> [Fact] public void Value_OnFailure_ShouldThrow() { @@ -32,6 +35,7 @@ public class ResultTests Assert.Throws<InvalidOperationException>(() => result.Value); } + /// <summary>Verifies that reading the error of a successful result throws.</summary> [Fact] public void Error_OnSuccess_ShouldThrow() { @@ -40,6 +44,7 @@ public class ResultTests Assert.Throws<InvalidOperationException>(() => result.Error); } + /// <summary>Verifies that matching a successful result invokes the success handler.</summary> [Fact] public void Match_OnSuccess_ShouldCallOnSuccess() { @@ -52,6 +57,7 @@ public class ResultTests Assert.Equal("value=42", output); } + /// <summary>Verifies that matching a failed result invokes the failure handler.</summary> [Fact] public void Match_OnFailure_ShouldCallOnFailure() { @@ -64,6 +70,7 @@ public class ResultTests Assert.Equal("error=bad", output); } + /// <summary>Verifies that a success result works with a nullable reference type.</summary> [Fact] public void Success_WithNullableReferenceType_ShouldWork() { @@ -73,14 +80,17 @@ public class ResultTests Assert.Equal("hello", result.Value); } - // ── Commons-011 regression: a failed Result must always carry a message ── + // ── Regression: a failed Result must always carry a message ── + /// <summary>Verifies that creating a failure with a null error throws.</summary> [Fact] public void Failure_WithNullError_ShouldThrow() { Assert.Throws<ArgumentNullException>(() => Result<int>.Failure(null!)); } + /// <summary>Verifies that creating a failure with a blank error throws.</summary> + /// <param name="error">A blank or whitespace-only error message.</param> [Theory] [InlineData("")] [InlineData(" ")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/RetryPolicyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/RetryPolicyTests.cs index 10a89568..f6138661 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/RetryPolicyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/RetryPolicyTests.cs @@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; public class RetryPolicyTests { + /// <summary>Verifies that retry policy should be immutable record.</summary> [Fact] public void RetryPolicy_ShouldBeImmutableRecord() { @@ -13,6 +14,7 @@ public class RetryPolicyTests Assert.Equal(TimeSpan.FromSeconds(5), policy.Delay); } + /// <summary>Verifies that retry policy with expression should create new instance.</summary> [Fact] public void RetryPolicy_WithExpression_ShouldCreateNewInstance() { @@ -24,6 +26,7 @@ public class RetryPolicyTests Assert.Equal(original.Delay, modified.Delay); } + /// <summary>Verifies that retry policy equal values should be equal.</summary> [Fact] public void RetryPolicy_EqualValues_ShouldBeEqual() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ScriptArgsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ScriptArgsTests.cs index fd57969b..79b40e46 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ScriptArgsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ScriptArgsTests.cs @@ -4,17 +4,19 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// <summary> -/// Commons-010: coverage for <see cref="ScriptArgs.Normalize"/> — the script-call +/// Coverage for <see cref="ScriptArgs.Normalize"/> — the script-call /// parameter normalizer (dictionary / anonymous-object / primitive-rejection paths). /// </summary> public class ScriptArgsTests { + /// <summary>Verifies that normalizing null returns null.</summary> [Fact] public void Normalize_Null_ReturnsNull() { Assert.Null(ScriptArgs.Normalize(null)); } + /// <summary>Verifies that a read-only dictionary is returned as is.</summary> [Fact] public void Normalize_ReadOnlyDictionary_ReturnedAsIs() { @@ -26,6 +28,7 @@ public class ScriptArgsTests Assert.Same(input, result); } + /// <summary>Verifies that a plain dictionary is returned as is.</summary> [Fact] public void Normalize_PlainDictionary_ReturnedAsIs() { @@ -39,6 +42,7 @@ public class ScriptArgsTests Assert.Equal(1, result!["a"]); } + /// <summary>Verifies that the keys of a non-generic dictionary are converted to strings.</summary> [Fact] public void Normalize_NonGenericDictionary_KeysStringified() { @@ -49,6 +53,7 @@ public class ScriptArgsTests Assert.Equal("answer", result!["42"]); } + /// <summary>Verifies that the properties of an anonymous object become dictionary entries.</summary> [Fact] public void Normalize_AnonymousObject_PropertiesBecomeEntries() { @@ -58,6 +63,8 @@ public class ScriptArgsTests Assert.Equal(3, result["count"]); } + /// <summary>Verifies that normalizing a primitive value throws.</summary> + /// <param name="primitive">The primitive value to normalize.</param> [Theory] [InlineData(42)] [InlineData(true)] @@ -67,12 +74,14 @@ public class ScriptArgsTests Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize(primitive)); } + /// <summary>Verifies that normalizing a string throws.</summary> [Fact] public void Normalize_String_Throws() { Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize("hello")); } + /// <summary>Verifies that normalizing a decimal throws.</summary> [Fact] public void Normalize_Decimal_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ScriptParametersTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ScriptParametersTests.cs index 42897673..c7b5718e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ScriptParametersTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ScriptParametersTests.cs @@ -7,6 +7,7 @@ public class ScriptParametersTests { // ── Non-nullable scalar Get<T> ────────────────────────────────── + /// <summary>Verifies that getting an int returns the value when the stored type is already an int.</summary> [Fact] public void Get_Int_ExactTypeMatch() { @@ -14,6 +15,7 @@ public class ScriptParametersTests Assert.Equal(42, p.Get<int>("x")); } + /// <summary>Verifies that getting an int converts a stored long.</summary> [Fact] public void Get_Int_FromLong() { @@ -21,6 +23,7 @@ public class ScriptParametersTests Assert.Equal(42, p.Get<int>("x")); } + /// <summary>Verifies that getting an int parses a stored string.</summary> [Fact] public void Get_Int_FromString() { @@ -28,6 +31,7 @@ public class ScriptParametersTests Assert.Equal(42, p.Get<int>("x")); } + /// <summary>Verifies that getting an int throws when the key is missing.</summary> [Fact] public void Get_Int_MissingKey_Throws() { @@ -36,6 +40,7 @@ public class ScriptParametersTests Assert.Contains("Parameter 'x' not found", ex.Message); } + /// <summary>Verifies that getting an int throws when the stored value is null.</summary> [Fact] public void Get_Int_NullValue_Throws() { @@ -44,6 +49,7 @@ public class ScriptParametersTests Assert.Contains("Parameter 'x' value is null", ex.Message); } + /// <summary>Verifies that getting an int throws when the stored value cannot be parsed.</summary> [Fact] public void Get_Int_Unparsable_Throws() { @@ -52,6 +58,7 @@ public class ScriptParametersTests Assert.Contains("could not be parsed as Int32", ex.Message); } + /// <summary>Verifies that getting a string returns a stored string directly.</summary> [Fact] public void Get_String_DirectReturn() { @@ -59,6 +66,7 @@ public class ScriptParametersTests Assert.Equal("hello", p.Get<string>("s")); } + /// <summary>Verifies that getting a string throws when the stored value is null.</summary> [Fact] public void Get_String_NullValue_Throws() { @@ -67,6 +75,7 @@ public class ScriptParametersTests Assert.Contains("value is null", ex.Message); } + /// <summary>Verifies that getting a string converts a stored int.</summary> [Fact] public void Get_String_FromInt() { @@ -74,6 +83,7 @@ public class ScriptParametersTests Assert.Equal("42", p.Get<string>("s")); } + /// <summary>Verifies that getting a bool returns true for a stored true value.</summary> [Fact] public void Get_Bool_True() { @@ -81,6 +91,7 @@ public class ScriptParametersTests Assert.True(p.Get<bool>("b")); } + /// <summary>Verifies that getting a double converts a stored long.</summary> [Fact] public void Get_Double_FromLong() { @@ -88,6 +99,7 @@ public class ScriptParametersTests Assert.Equal(42.0, p.Get<double>("d")); } + /// <summary>Verifies that getting a double converts a stored float.</summary> [Fact] public void Get_Double_FromFloat() { @@ -95,6 +107,7 @@ public class ScriptParametersTests Assert.Equal(3.14, p.Get<double>("d"), 2); } + /// <summary>Verifies that getting a long converts a stored int.</summary> [Fact] public void Get_Long_FromInt() { @@ -102,6 +115,7 @@ public class ScriptParametersTests Assert.Equal(42L, p.Get<long>("l")); } + /// <summary>Verifies that getting a float converts a stored double.</summary> [Fact] public void Get_Float_FromDouble() { @@ -109,6 +123,7 @@ public class ScriptParametersTests Assert.Equal(3.14f, p.Get<float>("f"), 2); } + /// <summary>Verifies that getting a date and time parses a stored string.</summary> [Fact] public void Get_DateTime_FromString() { @@ -117,6 +132,7 @@ public class ScriptParametersTests Assert.Equal(new DateTime(2026, 3, 22, 10, 30, 0, DateTimeKind.Utc), result); } + /// <summary>Verifies that getting a date and time returns the value when the stored type is already a date and time.</summary> [Fact] public void Get_DateTime_ExactType() { @@ -127,6 +143,7 @@ public class ScriptParametersTests // ── Nullable scalar Get<T?> ───────────────────────────────────── + /// <summary>Verifies that getting a nullable int returns null when the key is missing.</summary> [Fact] public void Get_NullableInt_MissingKey_ReturnsNull() { @@ -134,6 +151,7 @@ public class ScriptParametersTests Assert.Null(p.Get<int?>("x")); } + /// <summary>Verifies that getting a nullable int returns null when the stored value is null.</summary> [Fact] public void Get_NullableInt_NullValue_ReturnsNull() { @@ -141,6 +159,7 @@ public class ScriptParametersTests Assert.Null(p.Get<int?>("x")); } + /// <summary>Verifies that getting a nullable int returns the value when it is valid.</summary> [Fact] public void Get_NullableInt_ValidValue_ReturnsValue() { @@ -148,9 +167,10 @@ public class ScriptParametersTests Assert.Equal(42, p.Get<int?>("x")); } - // Commons-003: a parameter that is *present but unconvertible* is a caller/script + // A parameter that is *present but unconvertible* is a caller/script // bug and must throw — not be silently mapped to null (which a script would // misread as "not supplied"). Genuinely absent/null still returns null. + /// <summary>Verifies that getting a nullable int throws when the value is present but cannot be parsed.</summary> [Fact] public void Get_NullableInt_PresentButUnparsable_Throws() { @@ -159,6 +179,7 @@ public class ScriptParametersTests Assert.Contains("could not be parsed as Int32", ex.Message); } + /// <summary>Verifies that getting a nullable int throws when the value is present but overflows.</summary> [Fact] public void Get_NullableInt_PresentButOverflowing_Throws() { @@ -167,6 +188,7 @@ public class ScriptParametersTests Assert.Contains("could not be parsed as Int32", ex.Message); } + /// <summary>Verifies that getting a nullable date and time throws when the value is present but cannot be parsed.</summary> [Fact] public void Get_NullableDateTime_PresentButUnparsable_Throws() { @@ -174,6 +196,7 @@ public class ScriptParametersTests Assert.Throws<ScriptParameterException>(() => p.Get<DateTime?>("dt")); } + /// <summary>Verifies that getting a nullable double returns the value when it is valid.</summary> [Fact] public void Get_NullableDouble_ValidValue() { @@ -181,6 +204,7 @@ public class ScriptParametersTests Assert.Equal(3.14, p.Get<double?>("d")); } + /// <summary>Verifies that getting a nullable bool returns the value when it is valid.</summary> [Fact] public void Get_NullableBool_ValidValue() { @@ -190,6 +214,7 @@ public class ScriptParametersTests // ── Array Get<T[]> ────────────────────────────────────────────── + /// <summary>Verifies that getting an int array converts a stored list of longs.</summary> [Fact] public void Get_IntArray_FromListOfLongs() { @@ -198,6 +223,7 @@ public class ScriptParametersTests Assert.Equal(new[] { 1, 2, 3 }, p.Get<int[]>("arr")); } + /// <summary>Verifies that getting an int array from an empty list returns an empty array.</summary> [Fact] public void Get_IntArray_EmptyList() { @@ -205,6 +231,7 @@ public class ScriptParametersTests Assert.Empty(p.Get<int[]>("arr")); } + /// <summary>Verifies that getting an int array throws when the stored value is not a list.</summary> [Fact] public void Get_IntArray_NonList_Throws() { @@ -213,6 +240,7 @@ public class ScriptParametersTests Assert.Contains("is not a list or array", ex.Message); } + /// <summary>Verifies that getting an int array throws with the element index when an element cannot be parsed.</summary> [Fact] public void Get_IntArray_UnparsableElement_ThrowsWithIndex() { @@ -224,6 +252,7 @@ public class ScriptParametersTests Assert.Contains("Int32", ex.Message); } + /// <summary>Verifies that getting an int array throws when an element is null.</summary> [Fact] public void Get_IntArray_NullElement_Throws() { @@ -233,6 +262,7 @@ public class ScriptParametersTests Assert.Contains("element at index 1 is null", ex.Message); } + /// <summary>Verifies that getting a string array converts a stored list of strings.</summary> [Fact] public void Get_StringArray_FromListOfStrings() { @@ -241,6 +271,7 @@ public class ScriptParametersTests Assert.Equal(new[] { "a", "b", "c" }, p.Get<string[]>("arr")); } + /// <summary>Verifies that getting a double array converts a stored list of numbers.</summary> [Fact] public void Get_DoubleArray_FromListOfNumbers() { @@ -249,6 +280,7 @@ public class ScriptParametersTests Assert.Equal(new[] { 1.1, 2.2, 3.3 }, p.Get<double[]>("arr")); } + /// <summary>Verifies that getting an int array throws when the key is missing.</summary> [Fact] public void Get_IntArray_MissingKey_Throws() { @@ -259,6 +291,7 @@ public class ScriptParametersTests // ── List<T> Get<List<T>> ──────────────────────────────────────── + /// <summary>Verifies that getting an int list converts a stored list of longs.</summary> [Fact] public void Get_ListInt_FromListOfLongs() { @@ -268,6 +301,7 @@ public class ScriptParametersTests Assert.Equal(new List<int> { 10, 20, 30 }, result); } + /// <summary>Verifies that getting a string list converts a stored list of strings.</summary> [Fact] public void Get_ListString_FromListOfStrings() { @@ -276,6 +310,7 @@ public class ScriptParametersTests Assert.Equal(new List<string> { "x", "y" }, p.Get<List<string>>("items")); } + /// <summary>Verifies that getting an int list throws with the element index when an element cannot be parsed.</summary> [Fact] public void Get_ListInt_UnparsableElement_ThrowsWithIndex() { @@ -285,6 +320,7 @@ public class ScriptParametersTests Assert.Contains("element at index 1", ex.Message); } + /// <summary>Verifies that getting a double list converts a stored list of mixed numeric types.</summary> [Fact] public void Get_ListDouble_FromMixedNumbers() { @@ -299,6 +335,7 @@ public class ScriptParametersTests // ── Backward compatibility ────────────────────────────────────── + /// <summary>Verifies that the indexer returns the stored value for a key.</summary> [Fact] public void Indexer_Works() { @@ -306,6 +343,7 @@ public class ScriptParametersTests Assert.Equal("val", p["key"]); } + /// <summary>Verifies that <c>ContainsKey</c> reports whether a key is present.</summary> [Fact] public void ContainsKey_Works() { @@ -314,6 +352,7 @@ public class ScriptParametersTests Assert.False(p.ContainsKey("missing")); } + /// <summary>Verifies that <c>TryGetValue</c> returns the stored value for a present key.</summary> [Fact] public void TryGetValue_Works() { @@ -322,6 +361,7 @@ public class ScriptParametersTests Assert.Equal(42, val); } + /// <summary>Verifies that the count reflects the number of stored entries.</summary> [Fact] public void Count_Works() { @@ -329,6 +369,7 @@ public class ScriptParametersTests Assert.Equal(2, p.Count); } + /// <summary>Verifies that enumeration yields every stored entry.</summary> [Fact] public void Enumeration_Works() { @@ -338,6 +379,7 @@ public class ScriptParametersTests Assert.Equal(new[] { "a", "b" }, keys); } + /// <summary>Verifies that the empty constructor produces an empty dictionary.</summary> [Fact] public void EmptyConstructor_ProducesEmptyDictionary() { @@ -348,6 +390,7 @@ public class ScriptParametersTests // ── Edge cases ────────────────────────────────────────────────── + /// <summary>Verifies that getting a double converts a stored int.</summary> [Fact] public void Get_Double_FromInt() { @@ -357,6 +400,7 @@ public class ScriptParametersTests // ── JsonElement values (from JSON deserialization) ───────────── + /// <summary>Verifies that getting an int reads a stored JSON element.</summary> [Fact] public void Get_Int_FromJsonElement() { @@ -366,6 +410,7 @@ public class ScriptParametersTests Assert.Equal(42, p.Get<int>("x")); } + /// <summary>Verifies that getting an int array reads a stored list of JSON elements.</summary> [Fact] public void Get_IntArray_FromJsonElementList() { @@ -376,6 +421,7 @@ public class ScriptParametersTests Assert.Equal(new[] { 10, 20, 30 }, p.Get<int[]>("items")); } + /// <summary>Verifies that getting an int list reads a stored list of JSON elements.</summary> [Fact] public void Get_ListInt_FromJsonElementList() { @@ -385,6 +431,7 @@ public class ScriptParametersTests Assert.Equal(new List<int> { 1, 2, 3 }, p.Get<List<int>>("items")); } + /// <summary>Verifies that getting a string reads a stored JSON element.</summary> [Fact] public void Get_String_FromJsonElement() { @@ -394,6 +441,7 @@ public class ScriptParametersTests Assert.Equal("hello", p.Get<string>("s")); } + /// <summary>Verifies that getting a double reads a stored JSON element.</summary> [Fact] public void Get_Double_FromJsonElement() { @@ -403,6 +451,7 @@ public class ScriptParametersTests Assert.Equal(3.14, p.Get<double>("d")); } + /// <summary>Verifies that getting a bool reads a stored JSON element.</summary> [Fact] public void Get_Bool_FromJsonElement() { @@ -412,6 +461,7 @@ public class ScriptParametersTests Assert.True(p.Get<bool>("b")); } + /// <summary>Verifies that getting an int throws when the stored long overflows an int.</summary> [Fact] public void Get_Int_OverflowFromLong_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs index bbb0c287..3ddf9224 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs @@ -12,6 +12,7 @@ public class SecretReferenceScannerTests private static DataConnectionArtifact Dc(string name, string? primary, string? backup = null) => new(name, "MxGateway", primary, backup, 3); + /// <summary>Verifies that nested secret references are found case-insensitively across the primary and backup configuration.</summary> [Fact] public void FindsNestedRefs_CaseInsensitive_AcrossPrimaryAndBackup() { @@ -23,6 +24,8 @@ public class SecretReferenceScannerTests Assert.Equal(["GW1", "GW2"], refs.Single(r => r.Name == "mxgw-key").ReferencedBy); } + /// <summary>Verifies that values that are not secret references are ignored.</summary> + /// <param name="json">The connection JSON that contains no secret reference.</param> [Theory] [InlineData("""{"ApiKey":"${secret:node-local}"}""")] [InlineData("""{"Note":"see secret:x in docs"}""")] @@ -34,6 +37,7 @@ public class SecretReferenceScannerTests public void IgnoresNonReferences(string json) => Assert.Empty(SecretReferenceScanner.Scan([Dc("C", json)])); + /// <summary>Verifies that a connection referencing the same secret several times is reported once.</summary> [Fact] public void ReferencedBy_DeduplicatesWhenOneConnectionRefsTheSameSecretMultipleTimes() { @@ -47,8 +51,11 @@ public class SecretReferenceScannerTests Assert.Equal(["GW1"], refs.Single(r => r.Name == "mxgw-key").ReferencedBy); } - // ── Shared parser (final review M3) ── + // ── Shared parser ── + /// <summary>Verifies that the parser accepts trimmed, case-insensitive secret references.</summary> + /// <param name="value">The raw value to parse.</param> + /// <param name="expected">The secret name the parser is expected to return.</param> [Theory] [InlineData("secret:mxgw-key", "mxgw-key")] [InlineData("SECRET:mxgw-key", "mxgw-key")] @@ -60,6 +67,8 @@ public class SecretReferenceScannerTests Assert.Equal(expected, name); } + /// <summary>Verifies that the parser rejects values that are not secret references.</summary> + /// <param name="value">The value that is not a secret reference.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -74,6 +83,7 @@ public class SecretReferenceScannerTests Assert.Equal(string.Empty, name); } + /// <summary>Verifies that the scanner and the parser agree on the name of a padded, differently-cased reference.</summary> [Fact] public void Scan_AndParser_AgreeOnThePaddedCaseVariantName() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/SiteCallOperationalTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/SiteCallOperationalTests.cs index e66804a9..caacbbf9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/SiteCallOperationalTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/SiteCallOperationalTests.cs @@ -11,12 +11,13 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// </summary> public class SiteCallOperationalTests { + /// <summary>Verifies that a site call operational record carries its source node.</summary> [Fact] public void SiteCallOperational_carries_SourceNode() { // SourceNode identifies the cluster node that emitted the cached call // (site node-a/node-b or central-a/central-b). Nullable — callsites - // pass null until INodeIdentityProvider stamping arrives in Task 14. + // pass null until INodeIdentityProvider stamping arrives. var trackedId = TrackedOperationId.New(); var nowUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/SiteNotificationKpiSnapshotTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/SiteNotificationKpiSnapshotTests.cs index 2439b04f..00500a58 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/SiteNotificationKpiSnapshotTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/SiteNotificationKpiSnapshotTests.cs @@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; public class SiteNotificationKpiSnapshotTests { + /// <summary>Verifies that the constructor assigns all members.</summary> [Fact] public void Constructor_AssignsAllMembers() { @@ -23,6 +24,7 @@ public class SiteNotificationKpiSnapshotTests Assert.Equal(TimeSpan.FromMinutes(12), snapshot.OldestPendingAge); } + /// <summary>Verifies that the oldest pending age is nullable for sites with no backlog.</summary> [Fact] public void OldestPendingAge_IsNullableForSitesWithNoBacklog() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/StaleTagMonitorRaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/StaleTagMonitorRaceTests.cs index 06ce9d59..11ab5e2d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/StaleTagMonitorRaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/StaleTagMonitorRaceTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// <summary> -/// Regression tests for Commons-001: the check-then-act race between the timer +/// Regression tests for the check-then-act race between the timer /// callback (<c>OnTimerElapsed</c>) and <c>OnValueReceived</c> / <c>Stop</c> / <c>Start</c>. /// Placed in the same non-parallel collection as <see cref="StaleTagMonitorTests"/> to /// avoid CPU contention from other timer-based tests running concurrently. diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/StaleTagMonitorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/StaleTagMonitorTests.cs index 2f4f359b..cac8f88a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/StaleTagMonitorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/StaleTagMonitorTests.cs @@ -29,18 +29,22 @@ public class StaleTagMonitorTests return condition(); // final check } + /// <summary>Verifies that constructing the monitor with a zero time span throws.</summary> [Fact] public void Constructor_ZeroTimeSpan_Throws() { Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.Zero)); } + /// <summary>Verifies that constructing the monitor with a negative time span throws.</summary> [Fact] public void Constructor_NegativeTimeSpan_Throws() { Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.FromSeconds(-1))); } + /// <summary>Verifies that the stale event fires after the maximum silence elapses.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Stale_FiresAfterMaxSilence() { @@ -54,6 +58,8 @@ public class StaleTagMonitorTests Assert.Equal(1, staleCount); } + /// <summary>Verifies that the stale event fires only once during continued silence.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Stale_FiresOnlyOnce() { @@ -72,6 +78,8 @@ public class StaleTagMonitorTests Assert.Equal(1, staleCount); } + /// <summary>Verifies that receiving a value resets the silence timer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OnValueReceived_ResetsTimer() { @@ -93,6 +101,8 @@ public class StaleTagMonitorTests Assert.Equal(0, staleCount); } + /// <summary>Verifies that the stale event can still fire after silence following a received value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OnValueReceived_AllowsStaleAfterSilence() { @@ -110,6 +120,8 @@ public class StaleTagMonitorTests Assert.Equal(1, staleCount); } + /// <summary>Verifies that receiving a value resets the stale flag so the event can fire a second time.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OnValueReceived_ResetsStaleFlag_AllowsSecondFire() { @@ -130,6 +142,8 @@ public class StaleTagMonitorTests Assert.Equal(2, staleCount); } + /// <summary>Verifies that stopping the monitor prevents the stale event from firing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Stop_PreventsStale() { @@ -143,6 +157,8 @@ public class StaleTagMonitorTests Assert.Equal(0, staleCount); } + /// <summary>Verifies that disposing the monitor prevents the stale event from firing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Dispose_PreventsStale() { @@ -156,6 +172,7 @@ public class StaleTagMonitorTests Assert.Equal(0, staleCount); } + /// <summary>Verifies that the maximum silence property returns the configured value.</summary> [Fact] public void MaxSilence_ReturnsConfiguredValue() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/TrackedOperationIdTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/TrackedOperationIdTests.cs index bf2a25c0..c28d88dc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/TrackedOperationIdTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/TrackedOperationIdTests.cs @@ -3,12 +3,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// <summary> -/// Audit Log #23 (M3): tests for the strongly-typed cached-operation identifier +/// Tests for the strongly-typed cached-operation identifier /// produced by <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> and /// surfaced to scripts via <c>Tracking.Status(id)</c>. /// </summary> public class TrackedOperationIdTests { + /// <summary>Verifies that each new identifier is unique and non-empty.</summary> [Fact] public void New_ProducesUniqueIds() { @@ -20,6 +21,7 @@ public class TrackedOperationIdTests Assert.NotEqual(Guid.Empty, b.Value); } + /// <summary>Verifies that parsing the string form of an identifier restores the original value.</summary> [Fact] public void Parse_RoundTrip_PreservesValue() { @@ -32,6 +34,7 @@ public class TrackedOperationIdTests Assert.Equal(original.Value, parsed.Value); } + /// <summary>Verifies that invalid, null and empty input fail to parse and yield a default identifier.</summary> [Fact] public void TryParse_InvalidInput_ReturnsFalse() { @@ -45,6 +48,7 @@ public class TrackedOperationIdTests Assert.Equal(default, emptyResult); } + /// <summary>Verifies that valid input parses successfully and yields the original identifier.</summary> [Fact] public void TryParse_ValidInput_ReturnsTrueAndId() { @@ -55,6 +59,7 @@ public class TrackedOperationIdTests Assert.Equal(original, parsed); } + /// <summary>Verifies that two identifiers wrapping the same value are equal and share a hash code.</summary> [Fact] public void Equality_BasedOnValue() { @@ -68,6 +73,7 @@ public class TrackedOperationIdTests Assert.Equal(a.GetHashCode(), b.GetHashCode()); } + /// <summary>Verifies that the string form uses the standard hyphenated GUID format.</summary> [Fact] public void ToString_StandardGuidFormat() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/BundleNameMapTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/BundleNameMapTests.cs index ea12f9a9..68ffa243 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/BundleNameMapTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/BundleNameMapTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport; /// <summary> -/// M8 A1: focused shape / value-equality / round-trip tests for the Transport (#24) +/// Focused shape / value-equality / round-trip tests for the Transport /// name-mapping DTOs — <see cref="SiteMapping"/>, <see cref="ConnectionMapping"/>, /// and <see cref="BundleNameMap"/>. These records cross the Central UI ⇆ ManagementActor /// boundary (and may be persisted in an import session) via System.Text.Json, so a @@ -16,6 +16,7 @@ public sealed class BundleNameMapTests { private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + /// <summary>Verifies that the empty map has no sites or connections.</summary> [Fact] public void Empty_HasNoSitesOrConnections() { @@ -25,6 +26,7 @@ public sealed class BundleNameMapTests Assert.Empty(map.Connections); } + /// <summary>Verifies that the empty map is a stable shared instance.</summary> [Fact] public void Empty_IsStableInstance() { @@ -32,6 +34,7 @@ public sealed class BundleNameMapTests Assert.Same(BundleNameMap.Empty, BundleNameMap.Empty); } + /// <summary>Verifies that site mappings with identical fields are equal.</summary> [Fact] public void SiteMapping_ValueEquality_HoldsForIdenticalFields() { @@ -42,6 +45,7 @@ public sealed class BundleNameMapTests Assert.Equal(a.GetHashCode(), b.GetHashCode()); } + /// <summary>Verifies that site mappings with different actions are not equal.</summary> [Fact] public void SiteMapping_ValueEquality_DiffersWhenActionDiffers() { @@ -51,6 +55,7 @@ public sealed class BundleNameMapTests Assert.NotEqual(mapTo, createNew); } + /// <summary>Verifies that connection mappings with identical fields are equal.</summary> [Fact] public void ConnectionMapping_ValueEquality_HoldsForIdenticalFields() { @@ -61,6 +66,7 @@ public sealed class BundleNameMapTests Assert.Equal(a.GetHashCode(), b.GetHashCode()); } + /// <summary>Verifies that a map-to-existing mapping carries a non-null target.</summary> [Fact] public void MapToExisting_CarriesNonNullTarget() { @@ -76,6 +82,7 @@ public sealed class BundleNameMapTests Assert.Equal("OPC-Primary", conn.TargetConnectionName); } + /// <summary>Verifies that a create-new mapping leaves the target null.</summary> [Fact] public void CreateNew_LeavesTargetNull() { @@ -86,6 +93,7 @@ public sealed class BundleNameMapTests Assert.Null(conn.TargetConnectionName); } + /// <summary>Verifies that a JSON round trip preserves the sites and connections of a name map.</summary> [Fact] public void BundleNameMap_JsonRoundTrip_PreservesSitesAndConnections() { @@ -114,6 +122,7 @@ public sealed class BundleNameMapTests Assert.True(map.Connections.SequenceEqual(rt.Connections)); } + /// <summary>Verifies that a JSON round trip of an empty name map stays empty.</summary> [Fact] public void BundleNameMap_Empty_JsonRoundTrip_PreservesEmptiness() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/EncryptionMetadataTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/EncryptionMetadataTests.cs index 20a5f90b..cd0a2e53 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/EncryptionMetadataTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/EncryptionMetadataTests.cs @@ -3,12 +3,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport; /// <summary> -/// Commons-015: <see cref="EncryptionMetadata"/> must reject malformed envelopes at +/// <see cref="EncryptionMetadata"/> must reject malformed envelopes at /// the type boundary (unknown algorithm, unsupported KDF, sub-minimum or over-cap /// iteration counts, null salt/IV). Valid construction must round-trip the fields. /// </summary> public sealed class EncryptionMetadataTests { + /// <summary>Verifies that the constructor accepts the documented values.</summary> [Fact] public void Constructor_WithDocumentedValues_Succeeds() { @@ -28,6 +29,8 @@ public sealed class EncryptionMetadataTests Assert.Equal("def", meta.IvB64); } + /// <summary>Verifies that an unknown algorithm is rejected.</summary> + /// <param name="algorithm">The unsupported algorithm name.</param> [Theory] [InlineData("AES-128-CBC")] // weaker algorithm [InlineData("AES-256-CBC")] // unauthenticated mode @@ -47,6 +50,8 @@ public sealed class EncryptionMetadataTests Assert.Contains("AES-256-GCM", ex.Message); } + /// <summary>Verifies that an unknown key-derivation function is rejected.</summary> + /// <param name="kdf">The unsupported key-derivation function name.</param> [Theory] [InlineData("PBKDF2-SHA1")] // weaker hash [InlineData("argon2id")] // unsupported KDF @@ -65,6 +70,8 @@ public sealed class EncryptionMetadataTests Assert.Contains("PBKDF2-SHA256", ex.Message); } + /// <summary>Verifies that an out-of-range iteration count is rejected.</summary> + /// <param name="iterations">The out-of-range iteration count.</param> [Theory] [InlineData(0)] [InlineData(-1)] @@ -84,6 +91,8 @@ public sealed class EncryptionMetadataTests Assert.Equal("Iterations", ex.ParamName); } + /// <summary>Verifies that an iteration count at the allowed boundary is accepted.</summary> + /// <param name="iterations">The boundary iteration count.</param> [Theory] [InlineData(100_000)] // OWASP minimum (exact) [InlineData(600_000)] // design-doc production value @@ -101,6 +110,7 @@ public sealed class EncryptionMetadataTests Assert.Equal(iterations, meta.Iterations); } + /// <summary>Verifies that a null salt is rejected.</summary> [Fact] public void Constructor_NullSalt_Throws() { @@ -113,6 +123,7 @@ public sealed class EncryptionMetadataTests IvB64: "def")); } + /// <summary>Verifies that a null initialization vector is rejected.</summary> [Fact] public void Constructor_NullIv_Throws() { @@ -124,6 +135,7 @@ public sealed class EncryptionMetadataTests IvB64: null!)); } + /// <summary>Verifies that an empty salt and initialization vector are accepted for the seed pattern.</summary> [Fact] public void Constructor_EmptySaltAndIv_Succeeds_ForSeedPattern() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/TransportRecordsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/TransportRecordsTests.cs index b9a4ca4d..b01cdbcf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/TransportRecordsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/TransportRecordsTests.cs @@ -4,18 +4,16 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport; /// <summary> -/// Commons-020: focused shape / round-trip tests for the Transport (#24) record DTOs +/// Focused shape / round-trip tests for the Transport record DTOs /// — <see cref="BundleManifest"/>, <see cref="ExportSelection"/>, /// <see cref="ImportPreview"/>, <see cref="ImportResolution"/>, and /// <see cref="ImportResult"/>. These records cross the Central UI ⇆ bundle file boundary /// via System.Text.Json, so a positional/tuple slip would break bundles in the field. /// EncryptionMetadata has its own focused tests under EncryptionMetadataTests.cs -/// (Commons-015) and is reused here only to populate manifest fixtures. +/// and is reused here only to populate manifest fixtures. /// </summary> public sealed class TransportRecordsTests { - // STM: TransportRecordsTests-Commons-020 marker — used by grep verification. - private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false, @@ -25,6 +23,7 @@ public sealed class TransportRecordsTests // BundleManifest // -------------------------------------------------------------- + /// <summary>Verifies that the <c>BundleManifest</c> constructor preserves all fields.</summary> [Fact] public void BundleManifest_Constructor_RoundTripsAllFields() { @@ -62,6 +61,7 @@ public sealed class TransportRecordsTests Assert.Equal("Pump", manifest.Contents[0].Name); } + /// <summary>Verifies that a <c>BundleManifest</c> survives a JSON round trip with all fields intact.</summary> [Fact] public void BundleManifest_JsonRoundTrip_PreservesAllFields() { @@ -106,6 +106,7 @@ public sealed class TransportRecordsTests // ExportSelection // -------------------------------------------------------------- + /// <summary>Verifies that the <c>ExportSelection</c> constructor preserves every id list.</summary> [Fact] public void ExportSelection_Constructor_PreservesAllIdLists() { @@ -126,6 +127,7 @@ public sealed class TransportRecordsTests Assert.True(sel.IncludeDependencies); } + /// <summary>Verifies that an <c>ExportSelection</c> JSON round trip preserves the include-dependencies flag and ids.</summary> [Fact] public void ExportSelection_JsonRoundTrip_PreservesIncludeDependenciesAndIds() { @@ -152,6 +154,7 @@ public sealed class TransportRecordsTests // ImportPreview // -------------------------------------------------------------- + /// <summary>Verifies that an <c>ImportPreview</c> can be constructed with every conflict kind.</summary> [Fact] public void ImportPreview_Constructor_AllowsAllConflictKinds() { @@ -175,6 +178,7 @@ public sealed class TransportRecordsTests Assert.Equal("Parameters property mismatch", preview.Items[3].BlockerReason); } + /// <summary>Verifies that an <c>ImportPreview</c> JSON round trip preserves the conflict kind and optional fields.</summary> [Fact] public void ImportPreview_JsonRoundTrip_PreservesConflictKindAndOptionalFields() { @@ -201,6 +205,9 @@ public sealed class TransportRecordsTests // ImportResolution // -------------------------------------------------------------- + /// <summary>Verifies that the <c>ImportResolution</c> constructor preserves each resolution action and rename target.</summary> + /// <param name="action">The resolution action under test.</param> + /// <param name="renameTo">The rename target, or null when the action is not a rename.</param> [Theory] [InlineData(ResolutionAction.Add, null)] [InlineData(ResolutionAction.Overwrite, null)] @@ -215,6 +222,7 @@ public sealed class TransportRecordsTests Assert.Equal(renameTo, res.RenameTo); } + /// <summary>Verifies that an <c>ImportResolution</c> JSON round trip preserves the rename target.</summary> [Fact] public void ImportResolution_JsonRoundTrip_PreservesRenameTo() { @@ -232,6 +240,7 @@ public sealed class TransportRecordsTests // ImportResult // -------------------------------------------------------------- + /// <summary>Verifies that the <c>ImportResult</c> constructor preserves all counters and stale instance ids.</summary> [Fact] public void ImportResult_Constructor_PreservesAllCountersAndStaleIds() { @@ -254,6 +263,7 @@ public sealed class TransportRecordsTests Assert.Equal("audit-corr-001", result.AuditEventCorrelation); } + /// <summary>Verifies that an <c>ImportResult</c> JSON round trip preserves the counts and correlation id.</summary> [Fact] public void ImportResult_JsonRoundTrip_PreservesCountsAndCorrelation() { @@ -280,6 +290,7 @@ public sealed class TransportRecordsTests // Record equality sanity (catches positional/tuple slip) // -------------------------------------------------------------- + /// <summary>Verifies that transport records compare equal by value.</summary> [Fact] public void TransportRecords_RecordValueEquality() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ValidationResultSummaryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ValidationResultSummaryTests.cs index d75f2f5a..28419387 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ValidationResultSummaryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ValidationResultSummaryTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// <summary> -/// Tests for <see cref="ValidationResult.SummarizeErrors"/> (followup #8) — the grouped, +/// Tests for <see cref="ValidationResult.SummarizeErrors"/> — the grouped, /// capped summary that replaces the flat semicolon-joined deploy error dump. /// </summary> public class ValidationResultSummaryTests @@ -14,12 +14,14 @@ public class ValidationResultSummaryTests $"Attribute '{canonicalName}' has a data source reference but no connection binding.", canonicalName); + /// <summary>Verifies that a result with no errors summarizes to an empty string.</summary> [Fact] public void SummarizeErrors_NoErrors_ReturnsEmpty() { Assert.Equal(string.Empty, ValidationResult.Success().SummarizeErrors()); } + /// <summary>Verifies that the summary leads with the total error count.</summary> [Fact] public void SummarizeErrors_LeadsWithTotalCount() { @@ -33,6 +35,7 @@ public class ValidationResultSummaryTests Assert.StartsWith("3 errors:", summary); } + /// <summary>Verifies that a single error uses the singular noun.</summary> [Fact] public void SummarizeErrors_SingleError_UsesSingularNoun() { @@ -40,6 +43,7 @@ public class ValidationResultSummaryTests Assert.StartsWith("1 error:", result.SummarizeErrors()); } + /// <summary>Verifies that unbound attributes are rolled up by module with counts.</summary> [Fact] public void SummarizeErrors_RollsUpUnboundAttributesByModuleWithCounts() { @@ -58,6 +62,7 @@ public class ValidationResultSummaryTests Assert.DoesNotContain("Attr11' has a data source reference", summary); } + /// <summary>Verifies that the number of listed modules is capped with an "and N more" suffix.</summary> [Fact] public void SummarizeErrors_CapsModuleBreadthWithAndNMore() { @@ -70,6 +75,7 @@ public class ValidationResultSummaryTests Assert.Contains("Module0 (1)", summary); } + /// <summary>Verifies that a top-level member is grouped under the root.</summary> [Fact] public void SummarizeErrors_TopLevelMember_GroupsUnderRoot() { @@ -77,6 +83,7 @@ public class ValidationResultSummaryTests Assert.Contains("(root) (1)", summary); } + /// <summary>Verifies that entries without an entity name fall back to a capped message list.</summary> [Fact] public void SummarizeErrors_EntriesWithoutEntityName_FallBackToCappedMessageList() { @@ -91,6 +98,7 @@ public class ValidationResultSummaryTests Assert.Contains("… and 3 more", summary); } + /// <summary>Verifies that mixed error categories are listed as separate category groups.</summary> [Fact] public void SummarizeErrors_MixedCategories_ListsEachCategoryGroup() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ValueFormatterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ValueFormatterTests.cs index fa24a55c..2e23a689 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ValueFormatterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ValueFormatterTests.cs @@ -4,32 +4,36 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; /// <summary> -/// Tests for <see cref="ValueFormatter"/>. Includes the Commons-012 regression: +/// Tests for <see cref="ValueFormatter"/>. Includes the culture-invariance regression: /// formatting must be culture-invariant because the formatter feeds non-UI contexts /// (gRPC stream events, logs) where locale-dependent output would be inconsistent. /// </summary> public class ValueFormatterTests { + /// <summary>Verifies that formatting a null value returns an empty string.</summary> [Fact] public void FormatDisplayValue_Null_ReturnsEmptyString() { Assert.Equal("", ValueFormatter.FormatDisplayValue(null)); } + /// <summary>Verifies that formatting a string returns the value unchanged.</summary> [Fact] public void FormatDisplayValue_String_ReturnsValueUnchanged() { Assert.Equal("hello", ValueFormatter.FormatDisplayValue("hello")); } + /// <summary>Verifies that formatting a collection joins the elements with a comma.</summary> [Fact] public void FormatDisplayValue_Collection_JoinsWithComma() { Assert.Equal("1,2,3", ValueFormatter.FormatDisplayValue(new[] { 1, 2, 3 })); } - // ── Commons-012 regression: culture-invariant numeric/date formatting ── + // ── Culture-invariance regression: numeric/date formatting ── + /// <summary>Verifies that formatting a double uses the invariant culture regardless of the thread culture.</summary> [Fact] public void FormatDisplayValue_Double_UsesInvariantCulture_RegardlessOfThreadCulture() { @@ -46,6 +50,7 @@ public class ValueFormatterTests } } + /// <summary>Verifies that formatting a DateTime uses the invariant culture regardless of the thread culture.</summary> [Fact] public void FormatDisplayValue_DateTime_UsesInvariantCulture_RegardlessOfThreadCulture() { @@ -63,6 +68,7 @@ public class ValueFormatterTests } } + /// <summary>Verifies that formatting a collection of doubles uses the invariant culture.</summary> [Fact] public void FormatDisplayValue_CollectionOfDoubles_UsesInvariantCulture() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/MxGatewayEndpointConfigValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/MxGatewayEndpointConfigValidatorTests.cs index 0813132a..37bdc69b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/MxGatewayEndpointConfigValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/MxGatewayEndpointConfigValidatorTests.cs @@ -12,6 +12,7 @@ public class MxGatewayEndpointConfigValidatorTests ApiKey = "key", }; + /// <summary>Verifies that a valid config passes validation.</summary> [Fact] public void Validate_ValidConfig_IsValid() { @@ -20,6 +21,7 @@ public class MxGatewayEndpointConfigValidatorTests Assert.Empty(result.Errors); } + /// <summary>Verifies that a missing endpoint fails validation.</summary> [Fact] public void Validate_MissingEndpoint_Fails() { @@ -33,6 +35,8 @@ public class MxGatewayEndpointConfigValidatorTests && e.Message.Contains("required", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that an endpoint with an unsupported scheme fails validation.</summary> + /// <param name="url">The endpoint URL with the bad scheme.</param> [Theory] [InlineData("opc.tcp://x:4840")] [InlineData("ftp://x")] @@ -46,6 +50,7 @@ public class MxGatewayEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "Endpoint"); } + /// <summary>Verifies that a missing API key fails validation.</summary> [Fact] public void Validate_MissingApiKey_Fails() { @@ -56,6 +61,7 @@ public class MxGatewayEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "ApiKey"); } + /// <summary>Verifies that a non-positive read timeout fails validation.</summary> [Fact] public void Validate_NonPositiveReadTimeout_Fails() { @@ -66,6 +72,7 @@ public class MxGatewayEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "ReadTimeoutMs"); } + /// <summary>Verifies that prefixed field names appear in the validation errors.</summary> [Fact] public void Validate_PrefixedFieldNames_AppearInErrors() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/OpcUaEndpointConfigValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/OpcUaEndpointConfigValidatorTests.cs index 86cc9df3..040c76b9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/OpcUaEndpointConfigValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/OpcUaEndpointConfigValidatorTests.cs @@ -12,6 +12,7 @@ public class OpcUaEndpointConfigValidatorTests // Defaults satisfy the spec: Lifetime(30) >= 3 * KeepAlive(10). }; + /// <summary>Verifies that the default settings with a valid endpoint URL validate successfully.</summary> [Fact] public void Validate_DefaultsWithValidUrl_IsValid() { @@ -20,6 +21,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.Empty(result.Errors); } + /// <summary>Verifies that a missing endpoint URL fails validation.</summary> [Fact] public void Validate_MissingEndpointUrl_Fails() { @@ -33,6 +35,8 @@ public class OpcUaEndpointConfigValidatorTests && e.Message.Contains("required", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that a malformed or non-OPC UA endpoint URL fails validation.</summary> + /// <param name="url">The invalid endpoint URL to validate.</param> [Theory] [InlineData("http://x")] [InlineData("opc.tcp://")] @@ -46,6 +50,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "EndpointUrl"); } + /// <summary>Verifies that a lifetime count below three times the keep-alive count fails validation.</summary> [Fact] public void Validate_LifetimeLessThanThreeTimesKeepAlive_Fails() { @@ -57,6 +62,8 @@ public class OpcUaEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "LifetimeCount"); } + /// <summary>Verifies that a non-positive timing value fails validation.</summary> + /// <param name="field">The name of the timing field set to a non-positive value.</param> [Theory] [InlineData(nameof(OpcUaEndpointConfig.SessionTimeoutMs))] [InlineData(nameof(OpcUaEndpointConfig.OperationTimeoutMs))] @@ -71,6 +78,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == field); } + /// <summary>Verifies that a queue size of zero fails validation.</summary> [Fact] public void Validate_QueueSizeZero_Fails() { @@ -80,6 +88,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "QueueSize"); } + /// <summary>Verifies that enabling the heartbeat without a tag path fails validation.</summary> [Fact] public void Validate_HeartbeatEnabledNoTagPath_Fails() { @@ -89,6 +98,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.TagPath"); } + /// <summary>Verifies that a non-positive heartbeat silence threshold fails validation.</summary> [Fact] public void Validate_HeartbeatNonPositiveSilence_Fails() { @@ -98,6 +108,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.MaxSilenceSeconds"); } + /// <summary>Verifies that the field prefix is applied to every validation error.</summary> [Fact] public void Validate_FieldPrefix_AppliedToEveryError() { @@ -112,6 +123,7 @@ public class OpcUaEndpointConfigValidatorTests // ── Layer B extensions: auth, deadband, subscription display name ── + /// <summary>Verifies that an empty subscription display name fails validation.</summary> [Fact] public void Validate_EmptySubscriptionDisplayName_Fails() { @@ -121,6 +133,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "SubscriptionDisplayName"); } + /// <summary>Verifies that an anonymous user identity requires no additional fields.</summary> [Fact] public void Validate_UserIdentityAnonymous_NoExtraFieldsRequired() { @@ -130,6 +143,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.True(r.IsValid); } + /// <summary>Verifies that a username/password identity without a username fails validation.</summary> [Fact] public void Validate_UsernamePasswordWithoutUsername_Fails() { @@ -144,6 +158,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "UserIdentity.Username"); } + /// <summary>Verifies that an X.509 identity without a certificate path fails validation.</summary> [Fact] public void Validate_X509WithoutCertificatePath_Fails() { @@ -157,6 +172,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "UserIdentity.CertificatePath"); } + /// <summary>Verifies that a username/password identity with a username passes validation.</summary> [Fact] public void Validate_UsernamePasswordWithUsername_Passes() { @@ -171,6 +187,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.True(r.IsValid); } + /// <summary>Verifies that a deadband with a non-positive value fails validation.</summary> [Fact] public void Validate_DeadbandWithNonPositiveValue_Fails() { @@ -180,6 +197,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.Contains(r.Errors, e => e.EntityName == "Deadband.Value"); } + /// <summary>Verifies that a deadband with a positive value passes validation.</summary> [Fact] public void Validate_DeadbandWithPositiveValue_Passes() { @@ -189,6 +207,7 @@ public class OpcUaEndpointConfigValidatorTests Assert.True(r.IsValid); } + /// <summary>Verifies that user identity errors carry the field prefix.</summary> [Fact] public void Validate_UserIdentityErrorsPrefixed_WithFieldPrefix() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/ActiveNodeEvaluatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/ActiveNodeEvaluatorTests.cs index 07589409..a5812cda 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/ActiveNodeEvaluatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/ActiveNodeEvaluatorTests.cs @@ -11,6 +11,8 @@ public class ActiveNodeEvaluatorTests : IAsyncLifetime { private ActorSystem? _system; + /// <summary>Starts the test actor system and cluster before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { var port = FreePort(); @@ -31,8 +33,11 @@ akka {{ await Task.Delay(100); } + /// <summary>Terminates the test actor system.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task DisposeAsync() { if (_system != null) await _system.Terminate(); } + /// <summary>Verifies that the sole Up member is considered the oldest Up member.</summary> [Fact] public void SelfIsOldestUp_SoleUpMember_ReturnsTrue() { @@ -41,6 +46,7 @@ akka {{ Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(cluster, "site-x")); } + /// <summary>Verifies that a node that does not hold the requested role is not considered the oldest Up member.</summary> [Fact] public void SelfIsOldestUp_RoleNotHeld_ReturnsFalse() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Actors/SiteCommunicationActorTransportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Actors/SiteCommunicationActorTransportTests.cs index d414f325..fae92b00 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Actors/SiteCommunicationActorTransportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Actors/SiteCommunicationActorTransportTests.cs @@ -30,6 +30,7 @@ public class SiteCommunicationActorTransportTests : TestKit return (actor, transport); } + /// <summary>Verifies that a notification submit is delegated to the transport with the sender as the reply target.</summary> [Fact] public void NotificationSubmit_DelegatesToTransport_WithSenderAsReplyTo() { @@ -43,6 +44,7 @@ public class SiteCommunicationActorTransportTests : TestKit Arg.Is<NotificationSubmit>(m => m.NotificationId == "notif-1"), Arg.Is(TestActor))); } + /// <summary>Verifies that a notification status query is delegated to the transport with the sender as the reply target.</summary> [Fact] public void NotificationStatusQuery_DelegatesToTransport_WithSenderAsReplyTo() { @@ -54,6 +56,7 @@ public class SiteCommunicationActorTransportTests : TestKit Arg.Is<NotificationStatusQuery>(m => m.NotificationId == "notif-1"), Arg.Is(TestActor))); } + /// <summary>Verifies that an audit event ingest is delegated to the transport with the sender as the reply target.</summary> [Fact] public void IngestAuditEvents_DelegatesToTransport_WithSenderAsReplyTo() { @@ -64,6 +67,7 @@ public class SiteCommunicationActorTransportTests : TestKit AwaitAssert(() => transport.Received(1).IngestAuditEvents(Arg.Any<IngestAuditEventsCommand>(), Arg.Is(TestActor))); } + /// <summary>Verifies that a cached telemetry ingest is delegated to the transport with the sender as the reply target.</summary> [Fact] public void IngestCachedTelemetry_DelegatesToTransport_WithSenderAsReplyTo() { @@ -74,6 +78,7 @@ public class SiteCommunicationActorTransportTests : TestKit AwaitAssert(() => transport.Received(1).IngestCachedTelemetry(Arg.Any<IngestCachedTelemetryCommand>(), Arg.Is(TestActor))); } + /// <summary>Verifies that a site reconciliation request is delegated to the transport with the sender as the reply target.</summary> [Fact] public void ReconcileSite_DelegatesToTransport_WithSenderAsReplyTo() { @@ -86,6 +91,7 @@ public class SiteCommunicationActorTransportTests : TestKit Arg.Is<ReconcileSiteRequest>(m => m.NodeId == "node-a"), Arg.Is(TestActor))); } + /// <summary>Verifies that a site health report is delegated to the transport with the sender as the reply target.</summary> [Fact] public void ReportSiteHealth_DelegatesToTransport_WithSenderAsReplyTo() { @@ -98,6 +104,7 @@ public class SiteCommunicationActorTransportTests : TestKit Arg.Is<SiteHealthReport>(m => m.SequenceNumber == 7), Arg.Is(TestActor))); } + /// <summary>Verifies that a transport failure reply is routed back to the waiting sender.</summary> [Fact] public void TransportFailureReply_RoutesBackToTheWaitingSender() { @@ -122,6 +129,7 @@ public class SiteCommunicationActorTransportTests : TestKit Assert.IsType<InvalidOperationException>(failure.Cause); } + /// <summary>Verifies that an exception thrown by the transport during a heartbeat does not fault the actor.</summary> [Fact] public void HeartbeatTransportThrow_DoesNotFaultTheActor() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs index 3841a294..abf34b4c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs @@ -11,7 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// the canonical <see cref="AuditEvent"/> ↔ <see cref="AuditEventDto"/> (proto). /// </summary> /// <remarks> -/// C3 (Task 2.5): the canonical record carries the ScadaBridge domain fields inside +/// The canonical record carries the ScadaBridge domain fields inside /// <c>DetailsJson</c>; the proto contract is unchanged (24-field wire). Domain fields /// are read back as typed properties via <c>AsRow()</c>. <c>ForwardState</c> is /// site-storage-only (never on the wire) and <c>IngestedAtUtc</c> is central-set @@ -19,6 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// </remarks> public class AuditEventDtoMapperTests { + /// <summary>Verifies that a fully populated event survives a DTO round trip with every field preserved.</summary> [Fact] public void ToDto_FromDto_Roundtrip_FullyPopulated_PreservesAllFields() { @@ -88,6 +89,7 @@ public class AuditEventDtoMapperTests Assert.Null(rt.IngestedAtUtc); } + /// <summary>Verifies that null string fields map to empty strings in the DTO.</summary> [Fact] public void ToDto_NullableStringFields_BecomeEmptyString() { @@ -115,6 +117,7 @@ public class AuditEventDtoMapperTests Assert.Equal(string.Empty, dto.Extra); } + /// <summary>Verifies that empty strings in the DTO map back to null properties.</summary> [Fact] public void FromDto_EmptyString_BecomesNullProperty() { @@ -159,6 +162,7 @@ public class AuditEventDtoMapperTests Assert.Null(evt.Extra); } + /// <summary>Verifies that the occurrence timestamp keeps its UTC kind through mapping.</summary> [Fact] public void ToDto_OccurredAtUtc_PreservesUtcKind() { @@ -176,6 +180,7 @@ public class AuditEventDtoMapperTests Assert.Equal(occurredAt, roundTripped.OccurredAtUtc); } + /// <summary>Verifies that a null integer maps to a null wrapped Int32 value.</summary> [Fact] public void ToDto_NullableInt_BecomesNullInt32Value() { @@ -192,6 +197,7 @@ public class AuditEventDtoMapperTests Assert.Null(dto.DurationMs); } + /// <summary>Verifies that a null wrapped Int32 value maps back to a null property.</summary> [Fact] public void FromDto_NullInt32Value_BecomesNullProperty() { @@ -214,6 +220,7 @@ public class AuditEventDtoMapperTests Assert.Null(evt.DurationMs); } + /// <summary>Verifies that enum values are stored in the DTO as their string names.</summary> [Fact] public void ToDto_EnumValues_StoredAsStringNames() { @@ -229,6 +236,7 @@ public class AuditEventDtoMapperTests Assert.Equal("Parked", dto.Status); } + /// <summary>Verifies that a DTO round trip preserves the source node.</summary> [Fact] public void AuditEventDto_round_trip_preserves_SourceNode() { @@ -249,6 +257,7 @@ public class AuditEventDtoMapperTests Assert.Equal("node-a", roundTripped.SourceNode); } + /// <summary>Verifies that a DTO round trip preserves a null source node.</summary> [Fact] public void AuditEventDto_round_trip_preserves_null_SourceNode() { @@ -270,7 +279,7 @@ public class AuditEventDtoMapperTests } /// <summary> - /// C3 hardening (Task 2.5): a DTO that carries an unknown/renamed enum string + /// Hardening: a DTO that carries an unknown/renamed enum string /// for Channel, Kind, or Status must NOT throw on <see cref="AuditEventDtoMapper.FromDto"/>; /// it degrades gracefully to the same fallbacks used by <c>AuditRowProjection.Decompose</c> /// (ApiInbound / InboundRequest / Submitted). diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorClientLifecycleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorClientLifecycleTests.cs index 1cf67ab9..054db4bf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorClientLifecycleTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorClientLifecycleTests.cs @@ -20,10 +20,11 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// </summary> public class CentralCommunicationActorClientLifecycleTests : TestKit { + /// <summary>Verifies that periodic refresh prunes deleted sites from health aggregator.</summary> [Fact] public void PeriodicRefresh_PrunesDeletedSites_FromHealthAggregator() { - // PLAN-01 Task 18: the periodic site refresh feeds the currently-configured + // The periodic site refresh feeds the currently-configured // site ids to the aggregator so a deleted site is evicted within one // refresh interval. Repo returns only "site-a". var siteRepo = Substitute.For<ISiteRepository>(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorReconcileTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorReconcileTests.cs index 8a872ca0..ebb2660a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorReconcileTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorReconcileTests.cs @@ -21,6 +21,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// </summary> public class CentralCommunicationActorReconcileTests : TestKit { + /// <summary>Verifies that the response to a reconcile request is routed back to the original sender.</summary> [Fact] public void ReconcileSiteRequest_RoutesResponseToSender() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorTests.cs index 21da6f91..55607ebb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorTests.cs @@ -26,6 +26,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// </summary> public class CentralCommunicationActorTests : TestKit { + /// <summary>Initializes a new instance of the <see cref="CentralCommunicationActorTests"/> class with debug-level Akka logging.</summary> public CentralCommunicationActorTests() : base(@"akka.loglevel = DEBUG") { } private (IActorRef actor, ISiteRepository mockRepo) CreateActorWithMockRepo( @@ -44,6 +45,7 @@ public class CentralCommunicationActorTests : TestKit return (actor, mockRepo); } + /// <summary>Verifies that a heartbeat bumps the aggregator timestamp for the site.</summary> [Fact] public void Heartbeat_BumpsAggregatorTimestamp() { @@ -68,10 +70,11 @@ public class CentralCommunicationActorTests : TestKit AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site1", timestamp)); } + /// <summary>Verifies that a replicated heartbeat marks the local aggregator without being rebroadcast.</summary> [Fact] public void HeartbeatReplica_MarksLocalAggregator_WithoutRebroadcast() { - // Task 16 (arch review 02): a heartbeat replicated from the peer central + // A heartbeat replicated from the peer central // node marks the local aggregator (so post-failover both nodes know the // site's liveness) but is NOT re-published (it arrives via pub-sub already). var mockRepo = Substitute.For<ISiteRepository>(); @@ -95,10 +98,11 @@ public class CentralCommunicationActorTests : TestKit AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site-1", ts)); } + /// <summary>Verifies that a failure loading site addresses is logged rather than silently swallowed.</summary> [Fact] public void LoadSiteAddressesFailure_IsLoggedNotSilentlySwallowed() { - // Regression test for Communication-006. When the repository query throws, + // Regression test: when the repository query throws, // PipeTo delivers a Status.Failure to the actor. Without a Receive<Status.Failure> // handler the failure becomes an unhandled message (debug-level only) and the // periodic refresh fails silently — operators cannot tell "no addresses @@ -123,6 +127,7 @@ public class CentralCommunicationActorTests : TestKit private NotificationSubmit CreateSubmit(string id = "notif1") => new(id, "ops-list", "Subject", "Body", "site1", "inst1", "script.cs", DateTimeOffset.UtcNow); + /// <summary>Verifies that a notification submit is forwarded to the outbox proxy and the ack is routed back to the site.</summary> [Fact] public void NotificationSubmit_ForwardedToOutboxProxy_AckRoutesBackToSite() { @@ -142,6 +147,7 @@ public class CentralCommunicationActorTests : TestKit siteProbe.ExpectMsg<NotificationSubmitAck>(a => a.NotificationId == "notif1" && a.Accepted); } + /// <summary>Verifies that a notification status query is forwarded to the outbox proxy and the response is routed back to the site.</summary> [Fact] public void NotificationStatusQuery_ForwardedToOutboxProxy_ResponseRoutesBackToSite() { @@ -160,6 +166,7 @@ public class CentralCommunicationActorTests : TestKit siteProbe.ExpectMsg<NotificationStatusResponse>(r => r.CorrelationId == "corr1" && r.Found); } + /// <summary>Verifies that a notification submit with no outbox configured replies with a non-accepted ack.</summary> [Fact] public void NotificationSubmit_NoOutboxConfigured_RepliesNonAccepted() { @@ -175,6 +182,7 @@ public class CentralCommunicationActorTests : TestKit Assert.NotNull(ack.Error); } + /// <summary>Verifies that a notification status query with no outbox configured replies with a not-found response.</summary> [Fact] public void NotificationStatusQuery_NoOutboxConfigured_RepliesNotFound() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorTransportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorTransportTests.cs index 52ce262b..d20dade0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorTransportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorTransportTests.cs @@ -12,13 +12,14 @@ using ZB.MOM.WW.ScadaBridge.Communication.Actors; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <summary> -/// T1B.3 seam tests: <see cref="CentralCommunicationActor"/> routing a <see cref="SiteEnvelope"/> +/// Seam tests: <see cref="CentralCommunicationActor"/> routing a <see cref="SiteEnvelope"/> /// through an injected <see cref="ISiteCommandTransport"/> (transport-agnostic), proving the /// envelope reaches the transport, the reply routes back to the waiting Ask, and the DB refresh /// tick reconciles per-site transport resources. /// </summary> public class CentralCommunicationActorTransportTests : TestKit { + /// <summary>Initializes the test kit with a quieter Akka log level.</summary> public CentralCommunicationActorTransportTests() : base(@"akka.loglevel = WARNING") { } private static Site GrpcSite(string id, string? grpcA = "http://a:8083", string? grpcB = "http://b:8083") => @@ -44,6 +45,7 @@ public class CentralCommunicationActorTransportTests : TestKit return (actor, transport, repo); } + /// <summary>Verifies that a site envelope is routed to the transport with the sender as the reply-to actor.</summary> [Fact] public void SiteEnvelope_IsRoutedToTheTransport_WithTheSenderAsReplyTo() { @@ -58,6 +60,8 @@ public class CentralCommunicationActorTransportTests : TestKit probe.Ref)); } + /// <summary>Verifies that a reply from the transport is routed back to the waiting Ask.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AskReply_FromTransport_RoutesBackToTheWaitingAsk() { @@ -80,6 +84,7 @@ public class CentralCommunicationActorTransportTests : TestKit Assert.Equal(DeploymentStatus.Success, result.Status); } + /// <summary>Verifies that a database refresh reconciles the transport with the loaded gRPC endpoints.</summary> [Fact] public void DbRefresh_ReconcilesTheTransport_WithTheLoadedGrpcEndpoints() { @@ -95,6 +100,7 @@ public class CentralCommunicationActorTransportTests : TestKit TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that a site added or removed across refreshes flows through to the transport.</summary> [Fact] public void SiteAddedAndRemoved_AcrossRefreshes_FlowsThroughToTheTransport() { @@ -117,6 +123,7 @@ public class CentralCommunicationActorTransportTests : TestKit TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that a site without gRPC addresses is absent from the gRPC contacts but still known.</summary> [Fact] public void SiteWithoutGrpcAddresses_IsAbsentFromGrpcContacts_ButStillKnown() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsBindingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsBindingTests.cs index ee9e292a..716a7f2a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsBindingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsBindingTests.cs @@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// </summary> public class CommunicationOptionsBindingTests { + /// <summary>Verifies that registering communication binds the ScadaBridge communication configuration section.</summary> [Fact] public void AddCommunication_BindsTheScadaBridgeCommunicationSection() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsTests.cs index 1cb4e276..cb97537d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsTests.cs @@ -1,10 +1,11 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <summary> -/// WP-2: Tests for per-pattern timeout configuration. +/// Tests for per-pattern timeout configuration. /// </summary> public class CommunicationOptionsTests { + /// <summary>Verifies that the default timeouts are reasonable.</summary> [Fact] public void DefaultTimeouts_AreReasonable() { @@ -19,16 +20,18 @@ public class CommunicationOptionsTests Assert.Equal(TimeSpan.FromSeconds(10), options.HealthReportTimeout); } + /// <summary>Verifies that the transport heartbeat has explicit defaults.</summary> [Fact] public void TransportHeartbeat_HasExplicitDefaults() { var options = new CommunicationOptions(); - // WP-3: Transport heartbeat is explicitly configured, not framework defaults + // Transport heartbeat is explicitly configured, not framework defaults Assert.Equal(TimeSpan.FromSeconds(5), options.TransportHeartbeatInterval); Assert.Equal(TimeSpan.FromSeconds(15), options.TransportFailureThreshold); } + /// <summary>Verifies that the deployment timeout is the longest of the timeout patterns.</summary> [Fact] public void DeploymentTimeout_IsLongestPattern() { @@ -39,6 +42,7 @@ public class CommunicationOptionsTests Assert.True(options.DeploymentTimeout > options.IntegrationTimeout); } + /// <summary>Verifies that all timeouts are configurable.</summary> [Fact] public void AllTimeouts_AreConfigurable() { @@ -59,6 +63,7 @@ public class CommunicationOptionsTests Assert.Equal(TimeSpan.FromSeconds(2), options.TransportHeartbeatInterval); } + /// <summary>Verifies that the application heartbeat interval defaults to 5 seconds, independent of the transport heartbeat.</summary> [Fact] public void ApplicationHeartbeatInterval_DefaultsTo5s_IndependentOfTransport() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs index 832c8f2c..17a87248 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs @@ -14,6 +14,7 @@ public class CommunicationOptionsValidatorTests private static ValidateOptionsResult Validate(CommunicationOptions options) => new CommunicationOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the default options are valid.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -21,6 +22,7 @@ public class CommunicationOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero deployment timeout is rejected.</summary> [Fact] public void ZeroDeploymentTimeout_IsRejected() { @@ -29,8 +31,9 @@ public class CommunicationOptionsValidatorTests Assert.Contains("DeploymentTimeout", result.FailureMessage); } - // ── Audit-ingest timeout ladder (arch-review phase-2 residual #2) ──────────── + // ── Audit-ingest timeout ladder ──────────── + /// <summary>Verifies that the audit ingest timeout ladder is strictly monotonic end to end.</summary> [Fact] public void TheAuditIngestTimeoutLadder_IsStrictlyMonotonic_EndToEnd() { @@ -45,6 +48,7 @@ public class CommunicationOptionsValidatorTests Assert.Equal(TimeSpan.FromSeconds(30), ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer.AuditIngestAskTimeout); } + /// <summary>Verifies that an audit forward timeout equal to the ask timeout is rejected.</summary> [Fact] public void AuditForwardTimeout_EqualToTheAskTimeout_IsRejected() { @@ -59,6 +63,7 @@ public class CommunicationOptionsValidatorTests Assert.Contains("AuditForwardTimeout", result.FailureMessage); } + /// <summary>Verifies that an audit forward timeout shorter than the ask timeout is rejected.</summary> [Fact] public void AuditForwardTimeout_ShorterThanTheAskTimeout_IsRejected() { @@ -71,6 +76,7 @@ public class CommunicationOptionsValidatorTests Assert.Contains("AuditForwardTimeout", result.FailureMessage); } + /// <summary>Verifies that the audit forward timeout is still configurable upwards.</summary> [Fact] public void AuditForwardTimeout_IsStillConfigurableUpwards() { @@ -82,6 +88,7 @@ public class CommunicationOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a non-positive gRPC max concurrent streams value is rejected.</summary> [Fact] public void NonPositiveGrpcMaxConcurrentStreams_IsRejected() { @@ -92,6 +99,7 @@ public class CommunicationOptionsValidatorTests // ── R2: site→central stream event batching ────────────────────────────────── + /// <summary>Verifies that the default stream batch options are valid.</summary> [Fact] public void DefaultStreamBatchOptions_AreValid() { @@ -101,6 +109,7 @@ public class CommunicationOptionsValidatorTests Assert.True(Validate(options).Succeeded); } + /// <summary>Verifies that a stream batch max events value of one is valid and means batching is disabled.</summary> [Fact] public void StreamBatchMaxEventsOfOne_IsValid_AndMeansBatchingDisabled() { @@ -108,6 +117,7 @@ public class CommunicationOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a non-positive stream batch max events value is rejected.</summary> [Fact] public void NonPositiveStreamBatchMaxEvents_IsRejected() { @@ -116,6 +126,7 @@ public class CommunicationOptionsValidatorTests Assert.Contains("GrpcStreamBatchMaxEvents", result.FailureMessage); } + /// <summary>Verifies that a zero stream batch window is valid.</summary> [Fact] public void ZeroStreamBatchWindow_IsValid() { @@ -125,6 +136,7 @@ public class CommunicationOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a negative stream batch window is rejected.</summary> [Fact] public void NegativeStreamBatchWindow_IsRejected() { @@ -136,6 +148,7 @@ public class CommunicationOptionsValidatorTests Assert.Contains("GrpcStreamBatchWindow", result.FailureMessage); } + /// <summary>Verifies that a stream batch window at or above the latency budget is rejected.</summary> [Fact] public void StreamBatchWindowAtOrAboveTheLatencyBudget_IsRejected() { @@ -157,8 +170,9 @@ public class CommunicationOptionsValidatorTests }).Succeeded); } - // ── Aggregated live alarm cache options (plan #10, Task 6) ─────────────────── + // ── Aggregated live alarm cache options ─────────────────── + /// <summary>Verifies that a zero live alarm cache linger is valid.</summary> [Fact] public void ZeroLiveAlarmCacheLinger_IsValid() { @@ -167,6 +181,7 @@ public class CommunicationOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a negative live alarm cache linger is rejected.</summary> [Fact] public void NegativeLiveAlarmCacheLinger_IsRejected() { @@ -175,6 +190,7 @@ public class CommunicationOptionsValidatorTests Assert.Contains("LiveAlarmCacheLinger", result.FailureMessage); } + /// <summary>Verifies that a zero live alarm cache reconcile interval is rejected.</summary> [Fact] public void ZeroLiveAlarmCacheReconcileInterval_IsRejected() { @@ -183,6 +199,7 @@ public class CommunicationOptionsValidatorTests Assert.Contains("LiveAlarmCacheReconcileInterval", result.FailureMessage); } + /// <summary>Verifies that a zero live alarm cache seed concurrency is rejected.</summary> [Fact] public void ZeroLiveAlarmCacheSeedConcurrency_IsRejected() { @@ -191,6 +208,7 @@ public class CommunicationOptionsValidatorTests Assert.Contains("LiveAlarmCacheSeedConcurrency", result.FailureMessage); } + /// <summary>Verifies that an excessive live alarm cache seed concurrency is rejected.</summary> [Fact] public void ExcessiveLiveAlarmCacheSeedConcurrency_IsRejected() { @@ -199,6 +217,7 @@ public class CommunicationOptionsValidatorTests Assert.Contains("LiveAlarmCacheSeedConcurrency", result.FailureMessage); } + /// <summary>Verifies that a zero live alarm cache max subscribers per site is rejected.</summary> [Fact] public void ZeroLiveAlarmCacheMaxSubscribersPerSite_IsRejected() { @@ -209,6 +228,7 @@ public class CommunicationOptionsValidatorTests // ── R2 T10: live-delta publish-coalescing window (N6) ──────────────────────── + /// <summary>Verifies that a zero live alarm cache publish coalesce is valid.</summary> [Fact] public void ZeroLiveAlarmCachePublishCoalesce_IsValid() { @@ -217,6 +237,7 @@ public class CommunicationOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a negative live alarm cache publish coalesce is rejected.</summary> [Fact] public void NegativeLiveAlarmCachePublishCoalesce_IsRejected() { @@ -231,6 +252,7 @@ public class CommunicationOptionsValidatorTests // entries; an EMPTY list is valid (central nodes legitimately declare none). The role-aware // "a Site must list at least one endpoint" rule lives in StartupValidator, tested there. + /// <summary>Verifies that an empty endpoint list is valid.</summary> [Fact] public void EmptyEndpoints_IsValid() { @@ -242,6 +264,7 @@ public class CommunicationOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a blank endpoint is rejected.</summary> [Fact] public void BlankEndpoint_IsRejected() { @@ -253,6 +276,7 @@ public class CommunicationOptionsValidatorTests Assert.Contains("CentralGrpcEndpoints", result.FailureMessage); } + /// <summary>Verifies that a configured endpoint list is valid.</summary> [Fact] public void Endpoints_IsValid() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationServiceTests.cs index 4ad6c706..89c9cbe0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationServiceTests.cs @@ -11,10 +11,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <summary> -/// WP-2: Tests for CommunicationService initialization and state. +/// Tests for CommunicationService initialization and state. /// </summary> public class CommunicationServiceTests : TestKit { + /// <summary>Verifies that unsubscribing a debug view is sent as a fire-and-forget Tell rather than an Ask.</summary> [Fact] public void UnsubscribeDebugView_IsTellNotAsk() { @@ -24,8 +25,10 @@ public class CommunicationServiceTests : TestKit Assert.Equal(typeof(void), method!.ReturnType); } - // ── DeploymentManager-006: query-the-site-before-redeploy ── + // ── Query the site before redeploy ── + /// <summary>Verifies that querying deployment state before initialization throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryDeploymentStateAsync_BeforeInitialization_Throws() { @@ -38,6 +41,8 @@ public class CommunicationServiceTests : TestKit new DeploymentStateQueryRequest("corr-1", "inst1", DateTimeOffset.UtcNow))); } + /// <summary>Verifies that querying deployment state sends a site envelope and returns the site's response.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryDeploymentStateAsync_SendsEnvelopeAndReturnsResponse() { @@ -64,6 +69,8 @@ public class CommunicationServiceTests : TestKit // ── Notification Outbox: central-side outbox actor calls ── + /// <summary>Verifies that querying the notification outbox before the outbox proxy is set throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryNotificationOutboxAsync_BeforeOutboxSet_Throws() { @@ -77,6 +84,8 @@ public class CommunicationServiceTests : TestKit "corr-1", null, null, null, null, false, null, null, null, 1, 50))); } + /// <summary>Verifies that retrying a notification before the outbox proxy is set throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryNotificationAsync_BeforeOutboxSet_Throws() { @@ -88,6 +97,8 @@ public class CommunicationServiceTests : TestKit service.RetryNotificationAsync(new RetryNotificationRequest("corr-1", "n1"))); } + /// <summary>Verifies that discarding a notification before the outbox proxy is set throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DiscardNotificationAsync_BeforeOutboxSet_Throws() { @@ -99,6 +110,8 @@ public class CommunicationServiceTests : TestKit service.DiscardNotificationAsync(new DiscardNotificationRequest("corr-1", "n1"))); } + /// <summary>Verifies that getting notification KPIs before the outbox proxy is set throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetNotificationKpisAsync_BeforeOutboxSet_Throws() { @@ -110,6 +123,8 @@ public class CommunicationServiceTests : TestKit service.GetNotificationKpisAsync(new NotificationKpiRequest("corr-1"))); } + /// <summary>Verifies that querying the notification outbox asks the outbox proxy directly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryNotificationOutboxAsync_AsksOutboxProxyDirectly() { @@ -134,6 +149,8 @@ public class CommunicationServiceTests : TestKit Assert.Same(reply, await task); } + /// <summary>Verifies that retrying a notification asks the outbox proxy directly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryNotificationAsync_AsksOutboxProxyDirectly() { @@ -154,6 +171,8 @@ public class CommunicationServiceTests : TestKit Assert.Same(reply, await task); } + /// <summary>Verifies that discarding a notification asks the outbox proxy directly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DiscardNotificationAsync_AsksOutboxProxyDirectly() { @@ -176,6 +195,8 @@ public class CommunicationServiceTests : TestKit Assert.False(result.Success); } + /// <summary>Verifies that getting notification KPIs asks the outbox proxy directly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetNotificationKpisAsync_AsksOutboxProxyDirectly() { @@ -198,6 +219,8 @@ public class CommunicationServiceTests : TestKit Assert.Equal(3, result.QueueDepth); } + /// <summary>Verifies that getting per-site notification KPIs asks the outbox proxy directly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetPerSiteNotificationKpisAsync_AsksOutboxProxyDirectly() { @@ -226,6 +249,8 @@ public class CommunicationServiceTests : TestKit // ── Site Call Audit: central-side audit actor calls ── + /// <summary>Verifies that querying site calls before the site call audit proxy is set throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QuerySiteCallsAsync_BeforeSiteCallAuditSet_Throws() { @@ -238,6 +263,8 @@ public class CommunicationServiceTests : TestKit "corr-1", null, null, null, null, false, null, null, null, null, 50))); } + /// <summary>Verifies that getting site call KPIs before the site call audit proxy is set throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSiteCallKpisAsync_BeforeSiteCallAuditSet_Throws() { @@ -249,6 +276,8 @@ public class CommunicationServiceTests : TestKit service.GetSiteCallKpisAsync(new SiteCallKpiRequest("corr-1"))); } + /// <summary>Verifies that getting site call detail before the site call audit proxy is set throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSiteCallDetailAsync_BeforeSiteCallAuditSet_Throws() { @@ -260,6 +289,8 @@ public class CommunicationServiceTests : TestKit service.GetSiteCallDetailAsync(new SiteCallDetailRequest("corr-1", Guid.NewGuid()))); } + /// <summary>Verifies that getting per-site site call KPIs before the site call audit proxy is set throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetPerSiteSiteCallKpisAsync_BeforeSiteCallAuditSet_Throws() { @@ -271,6 +302,8 @@ public class CommunicationServiceTests : TestKit service.GetPerSiteSiteCallKpisAsync(new PerSiteSiteCallKpiRequest("corr-1"))); } + /// <summary>Verifies that querying site calls asks the site call audit proxy directly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QuerySiteCallsAsync_AsksSiteCallAuditProxyDirectly() { @@ -296,6 +329,8 @@ public class CommunicationServiceTests : TestKit Assert.Same(reply, await task); } + /// <summary>Verifies that getting site call detail asks the site call audit proxy directly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSiteCallDetailAsync_AsksSiteCallAuditProxyDirectly() { @@ -318,6 +353,8 @@ public class CommunicationServiceTests : TestKit Assert.False(result.Success); } + /// <summary>Verifies that getting site call KPIs asks the site call audit proxy directly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSiteCallKpisAsync_AsksSiteCallAuditProxyDirectly() { @@ -342,6 +379,8 @@ public class CommunicationServiceTests : TestKit Assert.Equal(1, result.StuckCount); } + /// <summary>Verifies that getting per-site site call KPIs asks the site call audit proxy directly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetPerSiteSiteCallKpisAsync_AsksSiteCallAuditProxyDirectly() { @@ -368,6 +407,8 @@ public class CommunicationServiceTests : TestKit Assert.Equal("plant-a", result.Sites[0].SourceSite); } + /// <summary>Verifies that retrying a site call before the site call audit proxy is set throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetrySiteCallAsync_BeforeSiteCallAuditSet_Throws() { @@ -379,6 +420,8 @@ public class CommunicationServiceTests : TestKit service.RetrySiteCallAsync(new RetrySiteCallRequest("corr-1", Guid.NewGuid(), "plant-a"))); } + /// <summary>Verifies that retrying a site call asks the site call audit proxy directly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetrySiteCallAsync_AsksSiteCallAuditProxyDirectly() { @@ -403,6 +446,8 @@ public class CommunicationServiceTests : TestKit Assert.Same(reply, await task); } + /// <summary>Verifies that discarding a site call asks the site call audit proxy directly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DiscardSiteCallAsync_AsksSiteCallAuditProxyDirectly() { @@ -433,6 +478,7 @@ public class CommunicationServiceTests : TestKit /// </summary> private class EchoStateQueryActor : ReceiveActor { + /// <summary>Initializes the actor and registers its handler for site envelopes carrying deployment state queries.</summary> public EchoStateQueryActor() { Receive<SiteEnvelope>(env => diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CoordinatorSupervisionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CoordinatorSupervisionTests.cs index b73eb8d0..2d9803d0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CoordinatorSupervisionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CoordinatorSupervisionTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Actors; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <summary> -/// Regression tests for Communication-004 — coordinator actors must declare an +/// Regression tests that coordinator actors must declare an /// explicit <c>Resume</c> supervision strategy per the CLAUDE.md decision /// ("Resume for coordinator actors"). A child fault under the default /// (Restart) strategy would wipe a child's in-memory state; the long-lived @@ -23,9 +23,14 @@ public class CoordinatorSupervisionTests : TestKit /// </summary> private sealed class CentralCommunicationActorProbe : CentralCommunicationActor { + /// <summary>Initializes the probe with the dependencies required by the central communication actor.</summary> + /// <param name="sp">The service provider passed to the base actor.</param> + /// <param name="transport">The site command transport passed to the base actor.</param> public CentralCommunicationActorProbe(IServiceProvider sp, ISiteCommandTransport transport) : base(sp, transport) { } + /// <summary>Returns the actor's configured supervisor strategy.</summary> + /// <returns>The supervisor strategy declared by the actor.</returns> public SupervisorStrategy GetSupervisorStrategy() => SupervisorStrategy(); } @@ -34,9 +39,15 @@ public class CoordinatorSupervisionTests : TestKit /// </summary> private sealed class SiteCommunicationActorProbe : SiteCommunicationActor { + /// <summary>Initializes the probe with the dependencies required by the site communication actor.</summary> + /// <param name="siteId">The site identifier passed to the base actor.</param> + /// <param name="options">The communication options passed to the base actor.</param> + /// <param name="dm">The deployment manager reference passed to the base actor.</param> public SiteCommunicationActorProbe(string siteId, CommunicationOptions options, IActorRef dm) : base(siteId, options, dm) { } + /// <summary>Returns the actor's configured supervisor strategy.</summary> + /// <returns>The supervisor strategy declared by the actor.</returns> public SupervisorStrategy GetSupervisorStrategy() => SupervisorStrategy(); } @@ -50,6 +61,7 @@ public class CoordinatorSupervisionTests : TestKit return services.BuildServiceProvider(); } + /// <summary>Verifies that the central communication actor declares a Resume supervisor strategy.</summary> [Fact] public void CentralCommunicationActor_SupervisorStrategy_IsResume() { @@ -66,6 +78,7 @@ public class CoordinatorSupervisionTests : TestKit Assert.Equal(Directive.Resume, directive); } + /// <summary>Verifies that the site communication actor declares a Resume supervisor strategy.</summary> [Fact] public void SiteCommunicationActor_SupervisorStrategy_IsResume() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/DebugStreamServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/DebugStreamServiceTests.cs index f3ca559c..a6975a16 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/DebugStreamServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/DebugStreamServiceTests.cs @@ -23,10 +23,12 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; [Collection("DebugStreamStatics")] public class DebugStreamServiceTests : TestKit { + /// <summary>Verifies that a stream that terminates before the snapshot arrives throws a meaningful exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartStreamAsync_StreamTerminatesBeforeSnapshot_ThrowsMeaningfulException() { - // Regression test for Communication-001. When the debug stream terminates before + // Regression test: when the debug stream terminates before // the initial snapshot arrives, StartStreamAsync used to let the raw // InvalidOperationException from onTerminatedWrapper escape its // OperationCanceledException-only catch — the caller saw an untranslated exception @@ -80,6 +82,8 @@ public class DebugStreamServiceTests : TestKit Assert.NotNull(ex.InnerException); } + /// <summary>Verifies that an attached session is kept alive by the service keepalive and dies once detached.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AttachedSession_IsKeptAliveByTheServiceKeepalive_AndDiesOnceDetached() { @@ -165,8 +169,12 @@ public class DebugStreamServiceTests : TestKit } } - // --- arch-review 2026-08-19 P3-3: single-endpoint sites --- + // --- single-endpoint sites --- + /// <summary>Verifies that a single-endpoint site uses its sole address for both node slots.</summary> + /// <param name="nodeA">The gRPC address of node A, or null.</param> + /// <param name="nodeB">The gRPC address of node B, or null.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("http://localhost:5100", null)] [InlineData(null, "http://localhost:5200")] @@ -222,6 +230,8 @@ public class DebugStreamServiceTests : TestKit await Assert.ThrowsAsync<InvalidOperationException>(() => startTask); } + /// <summary>Verifies that a site with no gRPC addresses still throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartStreamAsync_SiteWithNoGrpcAddresses_StillThrows() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/AuditWireBudgetTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/AuditWireBudgetTests.cs index 7ccdb49d..1509e153 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/AuditWireBudgetTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/AuditWireBudgetTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; public class AuditWireBudgetTests { + /// <summary>Verifies that a row over the soft budget travels alone in its batch without being truncated.</summary> [Fact] public void OversizeSoftBudgetRow_TravelsAloneWithoutTruncation() { @@ -18,6 +19,7 @@ public class AuditWireBudgetTests Assert.True(bytes < GrpcMessageLimits.MaxMessageBytes); } + /// <summary>Verifies that a hard-oversize row does not block the rows before it and fails explicitly when it is first in a batch.</summary> [Fact] public void HardOversizeRow_DoesNotBlockGoodPrefix_AndFailsExplicitlyWhenFirst() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs index cdabecb9..93025d32 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs @@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; /// back as null-or-empty rather than as a zero-valued stand-in. The minimal cases are /// the ones that matter: a per-field unit test happily passes while a whole optional /// branch is silently never written, which is exactly the class of bug the round-trip -/// guard in PLAN-05 T8 caught five times over. +/// guard caught five times over. /// </para> /// <para> /// When a round-trip here fails, the mapper is wrong — not the test. @@ -38,6 +38,7 @@ public class CentralControlDtoMapperTests // NotificationSubmit / Ack // ----------------------------------------------------------------------- + /// <summary>Verifies that a fully populated notification submission round-trips with every field preserved.</summary> [Fact] public void NotificationSubmit_RoundTrip_FullyPopulated_PreservesEveryField() { @@ -60,6 +61,7 @@ public class CentralControlDtoMapperTests Assert.Equal(original, roundTripped); } + /// <summary>Verifies that a notification submission with all optional fields null keeps them null.</summary> [Fact] public void NotificationSubmit_RoundTrip_AllOptionalsNull_StayNull() { @@ -86,6 +88,7 @@ public class CentralControlDtoMapperTests Assert.Null(roundTripped.SourceNode); } + /// <summary>Verifies that a non-UTC offset timestamp is normalized to the same instant.</summary> [Fact] public void NotificationSubmit_NonUtcOffset_NormalizesToTheSameInstant() { @@ -103,6 +106,7 @@ public class CentralControlDtoMapperTests Assert.Equal(TimeSpan.Zero, roundTripped.SiteEnqueuedAt.Offset); } + /// <summary>Verifies that accepted and rejected submission acknowledgements both round-trip.</summary> [Fact] public void NotificationSubmitAck_RoundTrip_Accepted_And_Rejected() { @@ -118,6 +122,7 @@ public class CentralControlDtoMapperTests // NotificationStatusQuery / Response // ----------------------------------------------------------------------- + /// <summary>Verifies that a notification status query round-trips with every field preserved.</summary> [Fact] public void NotificationStatusQuery_RoundTrip_PreservesEveryField() { @@ -126,6 +131,7 @@ public class CentralControlDtoMapperTests Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)))); } + /// <summary>Verifies that a fully populated status response round-trips with every field preserved.</summary> [Fact] public void NotificationStatusResponse_RoundTrip_FullyPopulated_PreservesEveryField() { @@ -140,6 +146,7 @@ public class CentralControlDtoMapperTests Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)))); } + /// <summary>Verifies that a not-found status response leaves its optional fields null.</summary> [Fact] public void NotificationStatusResponse_RoundTrip_NotFound_LeavesOptionalsNull() { @@ -162,6 +169,7 @@ public class CentralControlDtoMapperTests // Audit ingest envelopes (rows delegate to AuditEventDtoMapper / SiteCallDtoMapper) // ----------------------------------------------------------------------- + /// <summary>Verifies that an audit-ingest command preserves the order and content of its rows.</summary> [Fact] public void IngestAuditEventsCommand_RoundTrip_PreservesOrderAndRows() { @@ -178,6 +186,7 @@ public class CentralControlDtoMapperTests Assert.Equal("OnTrigger", roundTripped.Events[1].AsRow().SourceScript); } + /// <summary>Verifies that an empty audit-ingest batch stays empty.</summary> [Fact] public void IngestAuditEventsCommand_RoundTrip_EmptyBatch_StaysEmpty() { @@ -187,6 +196,7 @@ public class CentralControlDtoMapperTests Assert.Empty(roundTripped.Events); } + /// <summary>Verifies that a cached-telemetry command preserves both the audit and operational halves of each packet.</summary> [Fact] public void IngestCachedTelemetryCommand_RoundTrip_PreservesBothHalvesOfEachPacket() { @@ -204,6 +214,7 @@ public class CentralControlDtoMapperTests Assert.Equal(siteCall with { IngestedAtUtc = default }, entry.SiteCall with { IngestedAtUtc = default }); } + /// <summary>Verifies that nullable site-call fields in cached telemetry stay null.</summary> [Fact] public void IngestCachedTelemetryCommand_RoundTrip_NullableSiteCallFields_StayNull() { @@ -226,6 +237,7 @@ public class CentralControlDtoMapperTests Assert.Null(entry.SiteCall.TerminalAtUtc); } + /// <summary>Verifies that an empty cached-telemetry batch stays empty.</summary> [Fact] public void IngestCachedTelemetryCommand_RoundTrip_EmptyBatch_StaysEmpty() { @@ -235,6 +247,7 @@ public class CentralControlDtoMapperTests Assert.Empty(roundTripped.Entries); } + /// <summary>Verifies that an ingest acknowledgement preserves its ids and their order.</summary> [Fact] public void IngestAck_RoundTrip_PreservesIdsAndOrder() { @@ -246,6 +259,7 @@ public class CentralControlDtoMapperTests Assert.Equal(ids, roundTripped); } + /// <summary>Verifies that an ingest acknowledgement with nothing accepted stays empty.</summary> [Fact] public void IngestAck_RoundTrip_NothingAccepted_StaysEmpty() { @@ -259,6 +273,7 @@ public class CentralControlDtoMapperTests // Startup reconciliation // ----------------------------------------------------------------------- + /// <summary>Verifies that a reconcile request preserves its inventory map.</summary> [Fact] public void ReconcileSiteRequest_RoundTrip_PreservesInventoryMap() { @@ -280,6 +295,7 @@ public class CentralControlDtoMapperTests roundTripped.LocalNameToRevisionHash.OrderBy(kv => kv.Key)); } + /// <summary>Verifies that a reconcile request with an empty inventory stays empty.</summary> [Fact] public void ReconcileSiteRequest_RoundTrip_EmptyInventory_StaysEmpty() { @@ -291,6 +307,7 @@ public class CentralControlDtoMapperTests Assert.Empty(roundTripped.LocalNameToRevisionHash); } + /// <summary>Verifies that a reconcile response preserves its gap, orphans and URL.</summary> [Fact] public void ReconcileSiteResponse_RoundTrip_PreservesGapOrphansAndUrl() { @@ -310,6 +327,7 @@ public class CentralControlDtoMapperTests Assert.Equal(original.CentralFetchBaseUrl, roundTripped.CentralFetchBaseUrl); } + /// <summary>Verifies that a reconcile response with no gap stays empty.</summary> [Fact] public void ReconcileSiteResponse_RoundTrip_NoGap_StaysEmpty() { @@ -324,6 +342,7 @@ public class CentralControlDtoMapperTests // Site health // ----------------------------------------------------------------------- + /// <summary>Verifies that a fully populated site health report round-trips with every field preserved.</summary> [Fact] public void SiteHealthReport_RoundTrip_FullyPopulated_PreservesEveryField() { @@ -334,6 +353,7 @@ public class CentralControlDtoMapperTests AssertHealthReportsEqual(original, roundTripped); } + /// <summary>Verifies that a minimal site health report leaves every optional field null or empty.</summary> [Fact] public void SiteHealthReport_RoundTrip_MinimalReport_LeavesEveryOptionalNullOrEmpty() { @@ -372,6 +392,7 @@ public class CentralControlDtoMapperTests Assert.Null(roundTripped.SecretMirrorStatuses); } + /// <summary>Verifies that empty nullable collections in a health report stay empty rather than becoming null.</summary> [Fact] public void SiteHealthReport_RoundTrip_EmptyNullableCollections_StayEmptyNotNull() { @@ -401,6 +422,7 @@ public class CentralControlDtoMapperTests Assert.Empty(roundTripped.ClusterNodes); } + /// <summary>Verifies that false and zero gauge values in a health report stay false and zero.</summary> [Fact] public void SiteHealthReport_RoundTrip_FalseAndZeroGauges_StayFalseAndZero() { @@ -427,6 +449,7 @@ public class CentralControlDtoMapperTests Assert.Equal(0d, roundTripped.ScriptOldestBusyAgeSeconds); } + /// <summary>Verifies that a backlog report with an empty queue keeps a null oldest-pending value.</summary> [Fact] public void SiteHealthReport_RoundTrip_BacklogWithEmptyQueue_KeepsNullOldestPending() { @@ -440,6 +463,7 @@ public class CentralControlDtoMapperTests Assert.Equal(new SiteAuditBacklogSnapshot(0, null, 4096), roundTripped.SiteAuditBacklog); } + /// <summary>Verifies that accepted and rejected health report acknowledgements both round-trip.</summary> [Fact] public void SiteHealthReportAck_RoundTrip_Accepted_And_Rejected() { @@ -455,6 +479,8 @@ public class CentralControlDtoMapperTests // Heartbeat + ConnectionHealth enum // ----------------------------------------------------------------------- + /// <summary>Verifies that a heartbeat message round-trips with every field preserved.</summary> + /// <param name="isActive">Whether the heartbeat reports an active node.</param> [Theory] [InlineData(true)] [InlineData(false)] @@ -465,6 +491,8 @@ public class CentralControlDtoMapperTests Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)))); } + /// <summary>Verifies that every connection health value survives a round trip.</summary> + /// <param name="health">The connection health value under test.</param> [Theory] [InlineData(ConnectionHealth.Connected)] [InlineData(ConnectionHealth.Disconnected)] @@ -475,6 +503,7 @@ public class CentralControlDtoMapperTests Assert.Equal(health, CentralControlDtoMapper.FromDto(CentralControlDtoMapper.ToDto(health))); } + /// <summary>Verifies that an unspecified wire value decodes to the error-not-connected health state.</summary> [Fact] public void ConnectionHealth_Unspecified_DecodesToErrorNotConnected() { @@ -485,6 +514,7 @@ public class CentralControlDtoMapperTests CentralControlDtoMapper.FromDto(ConnectionHealthEnum.ConnectionHealthUnspecified)); } + /// <summary>Verifies that every connection health enum member has a wire mapping.</summary> [Fact] public void ConnectionHealth_EveryEnumMemberIsMapped() { @@ -559,12 +589,12 @@ public class CentralControlDtoMapperTests LocalDbReplicationConnected = true, LocalDbOplogBacklog = 250L, StartupReconcilerActive = true, - // arch-review 2026-08-19 P8-1: these three never crossed the wire at all + // These three never crossed the wire at all // (mapper omitted them entirely, always reading back as 0) until this fix. DetachedScriptThreads = 2, ScriptRunShedCount = 13, SiteStreamAlarmDropCount = 41L, - // Review finding M1: every node's secret mirror status, standby included. + // Every node's secret mirror status, standby included. SecretMirrorStatuses = [ new SecretMirrorNodeStatus("node-a", SampleInstant, []), diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceAuditIngestTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceAuditIngestTests.cs index c937ca24..67144889 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceAuditIngestTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceAuditIngestTests.cs @@ -18,7 +18,7 @@ using Microsoft.Extensions.DependencyInjection; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; /// <summary> -/// Audit Log (#23) site→central ingest routing on <see cref="CentralControlGrpcService"/>. +/// Audit Log site→central ingest routing on <see cref="CentralControlGrpcService"/>. /// <para> /// The service Asks the <c>audit-log-ingest</c> singleton proxy DIRECTLY. It used to relay /// through <see cref="CentralCommunicationActor"/>, which re-Asked the same proxy with the same @@ -40,6 +40,8 @@ public class CentralControlGrpcServiceAuditIngestTests : TestKit NullLogger<CentralControlGrpcService>.Instance, Options.Create(new CommunicationOptions())); + /// <summary>Verifies that ingesting audit events asks the ingest proxy directly and never goes through the communication actor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task IngestAuditEvents_AsksTheIngestProxy_AndNeverTheCommunicationActor() { @@ -66,6 +68,8 @@ public class CentralControlGrpcServiceAuditIngestTests : TestKit control.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } + /// <summary>Verifies that ingesting cached telemetry asks the ingest proxy directly and never goes through the communication actor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task IngestCachedTelemetry_AsksTheIngestProxy_AndNeverTheCommunicationActor() { @@ -94,6 +98,8 @@ public class CentralControlGrpcServiceAuditIngestTests : TestKit control.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } + /// <summary>Verifies that ingesting audit events before the ingest proxy is wired returns an empty acknowledgement.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task IngestAuditEvents_BeforeTheIngestProxyIsWired_ReturnsAnEmptyAck() { @@ -111,6 +117,8 @@ public class CentralControlGrpcServiceAuditIngestTests : TestKit Assert.Empty(ack.AcceptedEventIds); } + /// <summary>Verifies that ingesting cached telemetry before the ingest proxy is wired returns an empty acknowledgement.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task IngestCachedTelemetry_BeforeTheIngestProxyIsWired_ReturnsAnEmptyAck() { @@ -129,6 +137,7 @@ public class CentralControlGrpcServiceAuditIngestTests : TestKit Assert.Empty(ack.AcceptedEventIds); } + /// <summary>Verifies that setting the audit ingest actor is independent of marking the service ready.</summary> [Fact] public void SetAuditIngestActor_IsIndependentOfSetReady() { @@ -141,6 +150,7 @@ public class CentralControlGrpcServiceAuditIngestTests : TestKit Assert.False(service.IsReady); } + /// <summary>Verifies that the central communication actor no longer relays ingest commands.</summary> [Fact] public void CentralCommunicationActor_NoLongerRelaysIngestCommands() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceTests.cs index 9d2f0e2b..6bcc2957 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceTests.cs @@ -32,6 +32,8 @@ public class CentralControlGrpcServiceTests : TestKit NullLogger<CentralControlGrpcService>.Instance, Options.Create(options ?? new CommunicationOptions())); + /// <summary>Verifies that a unary call made before the service is set ready fails as unavailable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BeforeSetReady_AUnaryCall_IsUnavailable() { @@ -45,6 +47,8 @@ public class CentralControlGrpcServiceTests : TestKit Assert.Equal(StatusCode.Unavailable, ex.StatusCode); } + /// <summary>Verifies that a non-empty ingest batch sent before the service is set ready fails as unavailable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BeforeSetReady_ANonEmptyIngestBatch_IsUnavailable() { @@ -58,6 +62,8 @@ public class CentralControlGrpcServiceTests : TestKit Assert.Equal(StatusCode.Unavailable, ex.StatusCode); } + /// <summary>Verifies that a unary call made after the service is set ready reaches the actor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AfterSetReady_AUnaryCall_ReachesTheActor() { @@ -70,6 +76,8 @@ public class CentralControlGrpcServiceTests : TestKit Assert.True(ack.Accepted); } + /// <summary>Verifies that a heartbeat before the service is set ready succeeds and is dropped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Heartbeat_BeforeSetReady_SucceedsAndIsDropped() { @@ -82,6 +90,8 @@ public class CentralControlGrpcServiceTests : TestKit Assert.NotNull(reply); } + /// <summary>Verifies that a heartbeat after the service is set ready is told to the actor and never asked.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Heartbeat_AfterSetReady_TellsTheActor_AndNeverAsks() { @@ -96,6 +106,8 @@ public class CentralControlGrpcServiceTests : TestKit Assert.NotNull(reply); } + /// <summary>Verifies that a call to an actor that never replies fails with deadline exceeded rather than unavailable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WhenTheActorNeverReplies_TheCall_IsDeadlineExceeded_NotUnavailable() { @@ -114,6 +126,8 @@ public class CentralControlGrpcServiceTests : TestKit Assert.Equal(StatusCode.DeadlineExceeded, ex.StatusCode); } + /// <summary>Verifies that an empty ingest batch short-circuits even before the service is set ready.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EmptyIngestBatch_ShortCircuits_EvenBeforeSetReady() { @@ -125,6 +139,7 @@ public class CentralControlGrpcServiceTests : TestKit Assert.Empty(ack.AcceptedEventIds); } + /// <summary>Verifies that the audit ingest timeout is the single shared constant.</summary> [Fact] public void TheAuditIngestTimeout_IsTheOneSharedConstant() { @@ -166,6 +181,7 @@ public class CentralControlGrpcServiceTests : TestKit private sealed class StubActor : ReceiveActor { + /// <summary>Creates the actor, which acknowledges every notification submit as accepted.</summary> public StubActor() { Receive<NotificationSubmit>(msg => @@ -176,6 +192,7 @@ public class CentralControlGrpcServiceTests : TestKit /// <summary>Swallows every message and never replies, so an Ask against it times out.</summary> private sealed class NeverRepliesActor : ReceiveActor { + /// <summary>Creates the actor, which ignores every message.</summary> public NeverRepliesActor() => ReceiveAny(_ => { }); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CleanupVerificationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CleanupVerificationTests.cs index 3606eee7..3031e2d2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CleanupVerificationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CleanupVerificationTests.cs @@ -17,6 +17,8 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; [Collection("SiteStreamSubscriberGauge")] public class CleanupVerificationTests : TestKit { + /// <summary>Verifies that cancelling a stream cleans up its site stream manager subscription.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Stream_Cancellation_CleansUp_SiteStreamManager_Subscription() { @@ -71,6 +73,8 @@ public class CleanupVerificationTests : TestKit subscriber.Received(1).RemoveSubscriber(subscribedActor!); } + /// <summary>Verifies that cancelling several streams cleans up all of their subscriptions.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Multiple_Streams_Cancelled_AllCleanedUp() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/DebugStreamBridgeActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/DebugStreamBridgeActorTests.cs index ad589474..0fef8e4b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/DebugStreamBridgeActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/DebugStreamBridgeActorTests.cs @@ -25,6 +25,7 @@ public class DebugStreamBridgeActorTests : TestKit private const string GrpcNodeA = "http://localhost:5100"; private const string GrpcNodeB = "http://localhost:5200"; + /// <summary>Initializes the test class with a short-timeout Akka configuration.</summary> public DebugStreamBridgeActorTests() : base(@"akka.loglevel = DEBUG") { // Use a very short reconnect delay for testing @@ -67,10 +68,11 @@ public class DebugStreamBridgeActorTests : TestKit return new TestContext(actor, commProbe, mockClient, events, terminated); } + /// <summary>Verifies that an instance-not-found snapshot is forwarded to the event callback, tears down the stream and terminates the actor.</summary> [Fact] public void On_InstanceNotFound_Snapshot_Forwards_To_OnEvent_Tears_Down_Stream_And_Terminates() { - // M2.11 (revised for M2.18 stream-first): the gRPC subscription is now opened + // Stream-first: the gRPC subscription is now opened // up-front in PreStart, so when the site reports InstanceNotFound=true the // bridge actor must // (a) forward the not-found snapshot to _onEvent so DebugStreamService's TCS @@ -111,6 +113,7 @@ public class DebugStreamBridgeActorTests : TestKit ExpectTerminated(ctx.BridgeActor, TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that PreStart sends a subscribe debug view request through the communication actor.</summary> [Fact] public void PreStart_Sends_SubscribeDebugViewRequest_Via_ClusterClient() { @@ -125,6 +128,7 @@ public class DebugStreamBridgeActorTests : TestKit Assert.Equal("corr-1", req.CorrelationId); } + /// <summary>Verifies that a snapshot is forwarded to the event callback.</summary> [Fact] public void On_Snapshot_Forwards_To_OnEvent_Callback() { @@ -144,10 +148,11 @@ public class DebugStreamBridgeActorTests : TestKit lock (ctx.ReceivedEvents) { Assert.IsType<DebugViewSnapshot>(ctx.ReceivedEvents[0]); } } + /// <summary>Verifies that receiving a snapshot does not open an additional gRPC stream.</summary> [Fact] public void On_Snapshot_Does_Not_Open_Additional_GrpcStream() { - // M2.18 stream-first: the gRPC subscription is opened in PreStart, BEFORE the + // Stream-first: the gRPC subscription is opened in PreStart, BEFORE the // snapshot arrives. After the snapshot is delivered the actor switches to // pass-through — it must NOT open a second subscription. Exactly ONE subscribe // call should have been made (the PreStart one). @@ -173,6 +178,7 @@ public class DebugStreamBridgeActorTests : TestKit Assert.Equal(InstanceName, singleCall.InstanceUniqueName); } + /// <summary>Verifies that events from the gRPC callback are forwarded to the event callback.</summary> [Fact] public void Events_From_GrpcCallback_Forwarded_To_OnEvent() { @@ -198,6 +204,7 @@ public class DebugStreamBridgeActorTests : TestKit lock (ctx.ReceivedEvents) { Assert.IsType<AttributeValueChanged>(ctx.ReceivedEvents[1]); } } + /// <summary>Verifies that a gRPC error causes a reconnect to the other node.</summary> [Fact] public void On_GrpcError_Reconnects_To_Other_Node() { @@ -221,10 +228,11 @@ public class DebugStreamBridgeActorTests : TestKit Assert.Equal("corr-1", ctx.MockGrpcClient.SubscribeCalls[1].CorrelationId); } + /// <summary>Verifies that a gRPC error unsubscribes the old stream before reconnecting.</summary> [Fact] public void On_GrpcError_Unsubscribes_Old_Stream_Before_Reconnect() { - // Communication-002 regression: a reconnect must unsubscribe the previous + // Regression: a reconnect must unsubscribe the previous // stream so the old node does not keep a zombie relay actor / subscription. var ctx = CreateBridgeActor(); ctx.CommProbe.ExpectMsg<SiteEnvelope>(); @@ -249,6 +257,7 @@ public class DebugStreamBridgeActorTests : TestKit Assert.Contains("corr-1", ctx.MockGrpcClient.UnsubscribedCorrelationIds); } + /// <summary>Verifies that the actor terminates after the maximum number of retries.</summary> [Fact] public void After_MaxRetries_Terminates() { @@ -282,6 +291,7 @@ public class DebugStreamBridgeActorTests : TestKit Assert.True(ctx.TerminatedFlag[0]); } + /// <summary>Verifies that stopping the debug stream cancels the gRPC subscription and sends an unsubscribe.</summary> [Fact] public void StopDebugStream_Cancels_Grpc_And_Sends_Unsubscribe() { @@ -312,6 +322,7 @@ public class DebugStreamBridgeActorTests : TestKit ExpectTerminated(ctx.BridgeActor); } + /// <summary>Verifies that a debug stream terminated message stops the actor idempotently.</summary> [Fact] public void DebugStreamTerminated_Stops_Actor_Idempotently() { @@ -325,10 +336,11 @@ public class DebugStreamBridgeActorTests : TestKit Assert.True(ctx.TerminatedFlag[0]); } + /// <summary>Verifies that a flapping stream that delivers events between failures still terminates after the maximum retries.</summary> [Fact] public void FlappingStream_DeliveringEventsBetweenFailures_StillTerminatesAfterMaxRetries() { - // Communication-008 regression: a stream that connects, delivers an event, + // Regression: a stream that connects, delivers an event, // then fails — repeatedly — must still trip MaxRetries. The retry count is // NO LONGER reset by a received event (only by the stability window). The // previous behaviour reset _retryCount on every event, so a flapping site @@ -367,10 +379,11 @@ public class DebugStreamBridgeActorTests : TestKit Assert.True(ctx.TerminatedFlag[0]); } + /// <summary>Verifies that a gRPC error reconnects through a client bound to the other node's endpoint.</summary> [Fact] public void On_GrpcError_Reconnects_To_Other_Node_Endpoint() { - // Communication-015 regression: drive the bridge actor through a node flip + // Regression: drive the bridge actor through a node flip // with an endpoint-aware factory (one distinct mock client per endpoint). // The first subscribe targets NodeA; after a gRPC error the bridge must // reconnect via a client bound to the *NodeB* endpoint. @@ -407,8 +420,9 @@ public class DebugStreamBridgeActorTests : TestKit Assert.Equal("corr-1", factory.ClientFor(GrpcNodeB).SubscribeCalls[0].CorrelationId); } - // ── WP1.1: graceful (status OK) stream completion is a reconnect trigger ── + // ── graceful (status OK) stream completion is a reconnect trigger ── + /// <summary>Verifies that a graceful stream completion reopens the stream on the same node.</summary> [Fact] public void On_GracefulStreamCompletion_Reopens_On_The_Same_Node() { @@ -429,6 +443,7 @@ public class DebugStreamBridgeActorTests : TestKit Assert.Contains("corr-1", factory.ClientFor(GrpcNodeA).UnsubscribedCorrelationIds); } + /// <summary>Verifies that repeated graceful completions do not consume the error retry budget.</summary> [Fact] public void RepeatedGracefulCompletions_DoNotConsume_TheErrorRetryBudget() { @@ -449,6 +464,7 @@ public class DebugStreamBridgeActorTests : TestKit Assert.False(ctx.TerminatedFlag[0]); } + /// <summary>Verifies that a late completion from a previous stream generation is ignored.</summary> [Fact] public void LateCompletionFromAPreviousStreamGeneration_IsIgnored() { @@ -461,14 +477,14 @@ public class DebugStreamBridgeActorTests : TestKit AwaitCondition(() => factory.ClientFor(GrpcNodeB).SubscribeCalls.Count == 1, TimeSpan.FromSeconds(5)); - // A completion racing out of the dead gen-1 stream must not tear down the live one. + // A completion racing out of the dead first-generation stream must not tear down the live one. firstSub.OnCompleted(); Thread.Sleep(300); Assert.DoesNotContain("corr-1", factory.ClientFor(GrpcNodeB).UnsubscribedCorrelationIds); Assert.Single(factory.ClientFor(GrpcNodeB).SubscribeCalls); } - // ── Task 6 (arch review 02, High): teardown/failover unsubscribe is endpoint-safe ── + // ── teardown/failover unsubscribe is endpoint-safe ── // Both paths use TryGet, never GetOrCreate, so cleanup can never open a fresh // channel or (with (site,endpoint) keying) touch another session's channel. @@ -485,6 +501,7 @@ public class DebugStreamBridgeActorTests : TestKit return (actor, factory); } + /// <summary>Verifies that gRPC cleanup unsubscribes through TryGet without opening a channel.</summary> [Fact] public void CleanupGrpc_UnsubscribesViaTryGet_WithoutOpeningAChannel() { @@ -501,6 +518,7 @@ public class DebugStreamBridgeActorTests : TestKit Assert.Equal(createdBefore, factory.CreatedCount); } + /// <summary>Verifies that a session failover unsubscribes the failed endpoint through TryGet and opens only the reconnect channel.</summary> [Fact] public void SessionFailover_UnsubscribesFailedEndpointViaTryGet_OpensOnlyTheReconnectChannel() { @@ -523,13 +541,14 @@ public class DebugStreamBridgeActorTests : TestKit } // --------------------------------------------------------------------- - // M2.18 (#26) — stream-first + replay/dedup + // Stream-first + replay/dedup // --------------------------------------------------------------------- + /// <summary>Verifies that PreStart opens the gRPC stream before the snapshot arrives.</summary> [Fact] public void PreStart_Opens_GrpcStream_Before_Snapshot_Arrives() { - // M2.18: the gRPC subscription must be opened in PreStart (stream-first), + // Stream-first: the gRPC subscription must be opened in PreStart (stream-first), // BEFORE the snapshot is delivered, so live events start flowing during the // snapshot-build + network-transit window. The old lifecycle opened the // stream only after the snapshot arrived, losing gap-window events. @@ -545,10 +564,11 @@ public class DebugStreamBridgeActorTests : TestKit lock (ctx.ReceivedEvents) { Assert.Empty(ctx.ReceivedEvents); } } + /// <summary>Verifies that an event buffered in the gap window before the snapshot is delivered exactly once after the snapshot.</summary> [Fact] public void GapWindow_Event_Buffered_Before_Snapshot_Is_Delivered_Exactly_Once_After_Snapshot() { - // M2.18: an event arriving DURING the snapshot window (before the snapshot + // An event arriving DURING the snapshot window (before the snapshot // is delivered) is buffered, then flushed exactly once AFTER the snapshot. var ctx = CreateBridgeActor(); ctx.CommProbe.ExpectMsg<SiteEnvelope>(); @@ -581,10 +601,11 @@ public class DebugStreamBridgeActorTests : TestKit } } + /// <summary>Verifies that a buffered event already reflected in the snapshot is dropped.</summary> [Fact] public void Buffered_Event_Already_Reflected_In_Snapshot_Is_Dropped() { - // M2.18 dedup: a buffered event whose entity is in the snapshot with an equal + // Dedup: a buffered event whose entity is in the snapshot with an equal // or newer snapshot timestamp (buffered.Timestamp <= snapshot.Timestamp) is // already reflected and must be DROPPED. var ctx = CreateBridgeActor(); @@ -618,10 +639,11 @@ public class DebugStreamBridgeActorTests : TestKit } } + /// <summary>Verifies that a buffered event strictly newer than the snapshot entity is delivered.</summary> [Fact] public void Buffered_Event_Strictly_Newer_Than_Snapshot_Entity_Is_Delivered() { - // M2.18 dedup: a buffered event strictly newer than the snapshot's entry for + // Dedup: a buffered event strictly newer than the snapshot's entry for // the same entity (buffered.Timestamp > snapshot.Timestamp) is NOT a dup and // must be DELIVERED after the snapshot. var ctx = CreateBridgeActor(); @@ -655,10 +677,11 @@ public class DebugStreamBridgeActorTests : TestKit } } + /// <summary>Verifies that buffered alarm deduplication uses the alarm identity and timestamp.</summary> [Fact] public void Buffered_Alarm_Dedup_Uses_AlarmIdentity_And_Timestamp() { - // M2.18 dedup for alarms: identity = (instance, alarm name, source reference). + // Dedup for alarms: identity = (instance, alarm name, source reference). // A buffered alarm older-or-equal to the snapshot's same-identity alarm is // dropped; a strictly-newer one is delivered. var ctx = CreateBridgeActor(); @@ -701,10 +724,11 @@ public class DebugStreamBridgeActorTests : TestKit } } + /// <summary>Verifies that buffered events are flushed in arrival order.</summary> [Fact] public void Buffered_Events_Flushed_In_Arrival_Order() { - // M2.18: ordering preserved across multiple buffered events (none are dups — + // Ordering preserved across multiple buffered events (none are dups — // their entities are absent from the snapshot). var ctx = CreateBridgeActor(); ctx.CommProbe.ExpectMsg<SiteEnvelope>(); @@ -735,10 +759,11 @@ public class DebugStreamBridgeActorTests : TestKit } } + /// <summary>Verifies that after the flush the actor passes subsequent events through immediately.</summary> [Fact] public void PassThrough_After_Flush_Delivers_Subsequent_Events_Immediately() { - // M2.18: after the snapshot+flush the actor switches to pass-through — later + // After the snapshot+flush the actor switches to pass-through — later // events go straight to _onEvent (no buffering, no dup). var ctx = CreateBridgeActor(); ctx.CommProbe.ExpectMsg<SiteEnvelope>(); @@ -767,10 +792,11 @@ public class DebugStreamBridgeActorTests : TestKit } } + /// <summary>Verifies that an instance-not-found snapshot after the stream was opened first tears down the stream and does not enter pass-through.</summary> [Fact] public void InstanceNotFound_After_StreamFirst_Tears_Down_Stream_And_Does_Not_PassThrough() { - // M2.18 + M2.11: stream-first means the gRPC subscription is already open + // Stream-first means the gRPC subscription is already open // when an InstanceNotFound snapshot arrives. The bridge must tear that stream // down (Unsubscribe the just-opened correlation), deliver the not-found // snapshot, NOT enter pass-through, and stop cleanly. @@ -812,10 +838,11 @@ public class DebugStreamBridgeActorTests : TestKit lock (ctx.ReceivedEvents) { Assert.Single(ctx.ReceivedEvents); } } + /// <summary>Verifies that a reconnect during the buffering phase keeps buffering until the snapshot arrives.</summary> [Fact] public void Reconnect_During_Buffering_Phase_Keeps_Buffering_Until_Snapshot() { - // M2.18: a gRPC error/reconnect BEFORE the snapshot arrives must remain in the + // A gRPC error/reconnect BEFORE the snapshot arrives must remain in the // buffering phase — events on the new stream are still buffered, then flushed // when the snapshot finally arrives. var ctx = CreateBridgeActor(); @@ -849,10 +876,11 @@ public class DebugStreamBridgeActorTests : TestKit } } + /// <summary>Verifies that a reconnect after the snapshot resumes pass-through rather than buffering.</summary> [Fact] public void Reconnect_After_Snapshot_Resumes_PassThrough_Not_Buffering() { - // M2.18: a mid-session reconnect (after the snapshot was already delivered) + // A mid-session reconnect (after the snapshot was already delivered) // must resume pass-through — the snapshot is a one-time thing and events on // the reconnected stream are delivered immediately, not re-buffered. var ctx = CreateBridgeActor(); @@ -885,10 +913,11 @@ public class DebugStreamBridgeActorTests : TestKit } } + /// <summary>Verifies that the retry count is recovered only after the stream stays stable for the stability window.</summary> [Fact] public void RetryCount_RecoveredOnlyAfterStreamStaysStableForStabilityWindow() { - // Communication-008: after a stream has been connected for the stability + // After a stream has been connected for the stability // window, the retry budget is recovered — a later transient fault then gets // a fresh set of retries rather than being counted against the old budget. DebugStreamBridgeActor.StabilityWindow = TimeSpan.FromMilliseconds(300); @@ -928,8 +957,9 @@ public class DebugStreamBridgeActorTests : TestKit } } - // ── WP2.3: bounded pre-snapshot buffer, hard snapshot deadline, timeout hygiene ── + // ── bounded pre-snapshot buffer, hard snapshot deadline, timeout hygiene ── + /// <summary>Verifies that the pre-snapshot buffer is capped, drops the oldest events and counts the drops.</summary> [Fact] public void PreSnapshotBuffer_IsCapped_DropsOldest_AndCountsTheDrops() { @@ -986,6 +1016,7 @@ public class DebugStreamBridgeActorTests : TestKit }, TimeSpan.FromSeconds(10)); } + /// <summary>Verifies that a missing snapshot within the deadline fails the session instead of buffering forever.</summary> [Fact] public void NoSnapshotWithinDeadline_FailsTheSession_InsteadOfBufferingForever() { @@ -1013,6 +1044,7 @@ public class DebugStreamBridgeActorTests : TestKit } } + /// <summary>Verifies that the arrival of the snapshot stands down the deadline.</summary> [Fact] public void SnapshotArrival_StandsDownTheDeadline() { @@ -1048,6 +1080,7 @@ public class DebugStreamBridgeActorTests : TestKit } } + /// <summary>Verifies that stream events still reach the consumer through the wrapped callback path.</summary> [Fact] public void StreamEvents_StillReachTheConsumer_ThroughTheWrappedCallbackPath() { @@ -1076,6 +1109,7 @@ public class DebugStreamBridgeActorTests : TestKit // ----- Orphan net: measures the CONSUMER, not the mailbox ----- // + /// <summary>Verifies that a healthy session streaming events with consumer keepalives survives well past the orphan window.</summary> [Fact] public void HealthySession_StreamingEvents_WithConsumerKeepalives_SurvivesWellPastTheOrphanWindow() { @@ -1132,6 +1166,7 @@ public class DebugStreamBridgeActorTests : TestKit } } + /// <summary>Verifies that an orphaned session whose consumer is gone still terminates even while events keep arriving.</summary> [Fact] public void OrphanedSession_ConsumerGone_StillTerminates_EvenWhileEventsKeepArriving() { @@ -1173,6 +1208,7 @@ public class DebugStreamBridgeActorTests : TestKit } } + /// <summary>Verifies that a consumer keepalive renews the orphan window after a near miss.</summary> [Fact] public void ConsumerKeepalive_RenewsTheWindow_AfterANearMiss() { @@ -1227,10 +1263,12 @@ internal class MockSiteStreamGrpcClient : SiteStreamGrpcClient private MockSiteStreamGrpcClient(bool _) : base() { } + /// <summary>Initializes a mock client that records subscribe and unsubscribe calls.</summary> public MockSiteStreamGrpcClient() : base() { } + /// <inheritdoc /> public override Task SubscribeAsync( string correlationId, string instanceUniqueName, @@ -1249,6 +1287,7 @@ internal class MockSiteStreamGrpcClient : SiteStreamGrpcClient return tcs.Task; } + /// <inheritdoc /> public override void Unsubscribe(string correlationId) { lock (_lock) { _unsubscribedCorrelationIds.Add(correlationId); } @@ -1269,14 +1308,18 @@ internal record MockSubscription( internal class MockSiteStreamGrpcClientFactory : SiteStreamGrpcClientFactory { private readonly MockSiteStreamGrpcClient _mockClient; + /// <summary>Gets the endpoints requested through <c>GetOrCreate</c>, in call order.</summary> public List<string> RequestedEndpoints { get; } = new(); + /// <summary>Initializes the factory with the mock client it always returns.</summary> + /// <param name="mockClient">The mock client returned for every endpoint.</param> public MockSiteStreamGrpcClientFactory(MockSiteStreamGrpcClient mockClient) : base(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance) { _mockClient = mockClient; } + /// <inheritdoc /> public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint) { RequestedEndpoints.Add(grpcEndpoint); @@ -1285,7 +1328,8 @@ internal class MockSiteStreamGrpcClientFactory : SiteStreamGrpcClientFactory // Mirrors the real factory's TryGet: returns the client only for an endpoint // already opened via GetOrCreate, never creating one. Lets teardown/failover - // unsubscribe paths (now TryGet-based, Task 6) resolve the mock client. + // unsubscribe paths (now TryGet-based) resolve the mock client. + /// <inheritdoc /> public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint) => RequestedEndpoints.Contains(grpcEndpoint) ? _mockClient : null; } @@ -1293,27 +1337,33 @@ internal class MockSiteStreamGrpcClientFactory : SiteStreamGrpcClientFactory /// <summary> /// Endpoint-aware mock factory: hands out a distinct <see cref="MockSiteStreamGrpcClient"/> /// per endpoint, mirroring the real factory's corrected NodeA→NodeB failover behaviour -/// so node-flip coverage is meaningful (Communication-015). +/// so node-flip coverage is meaningful. /// </summary> internal class EndpointTrackingGrpcClientFactory : SiteStreamGrpcClientFactory { private readonly System.Collections.Concurrent.ConcurrentDictionary<string, MockSiteStreamGrpcClient> _byEndpoint = new(); + /// <summary>Initializes an empty endpoint-aware factory.</summary> public EndpointTrackingGrpcClientFactory() : base(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance) { } + /// <summary>Gets the mock client bound to an endpoint, creating it on first use.</summary> + /// <param name="endpoint">The gRPC endpoint address.</param> + /// <returns>The mock client for the endpoint.</returns> public MockSiteStreamGrpcClient ClientFor(string endpoint) => _byEndpoint.GetOrAdd(endpoint, _ => new MockSiteStreamGrpcClient()); /// <summary>Number of distinct endpoint clients created so far (channels opened).</summary> public int CreatedCount => _byEndpoint.Count; + /// <inheritdoc /> public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint) => ClientFor(grpcEndpoint); // TryGet never creates: returns the client only for an already-opened endpoint. + /// <inheritdoc /> public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint) => _byEndpoint.TryGetValue(grpcEndpoint, out var client) ? client : null; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/GrpcOptionsWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/GrpcOptionsWiringTests.cs index 1420f272..24bb8eea 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/GrpcOptionsWiringTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/GrpcOptionsWiringTests.cs @@ -7,13 +7,14 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; /// <summary> -/// Regression tests for Communication-005 — the gRPC keepalive and +/// Regression tests: the gRPC keepalive and /// max-stream-lifetime / max-concurrent-stream options defined on /// <see cref="CommunicationOptions"/> must actually be applied to the /// gRPC client and server rather than hard-coded. /// </summary> public class GrpcOptionsWiringTests { + /// <summary>Verifies that the site stream gRPC client applies its keepalive settings from the options.</summary> [Fact] public void SiteStreamGrpcClient_AppliesKeepAliveFromOptions() { @@ -30,6 +31,7 @@ public class GrpcOptionsWiringTests Assert.Equal(TimeSpan.FromSeconds(7), client.KeepAlivePingTimeout); } + /// <summary>Verifies that the client factory flows the options to the clients it creates.</summary> [Fact] public void SiteStreamGrpcClientFactory_FlowsOptionsToCreatedClients() { @@ -48,6 +50,7 @@ public class GrpcOptionsWiringTests Assert.Equal(TimeSpan.FromSeconds(11), client.KeepAlivePingTimeout); } + /// <summary>Verifies that the gRPC server binds its maximum concurrent streams and stream lifetime from the options.</summary> [Fact] public void SiteStreamGrpcServer_BindsMaxConcurrentStreamsAndLifetimeFromOptions() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/NoClusterClientStreamingRegressionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/NoClusterClientStreamingRegressionTests.cs index 63845413..7db9203d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/NoClusterClientStreamingRegressionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/NoClusterClientStreamingRegressionTests.cs @@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; /// </summary> public class NoClusterClientStreamingRegressionTests { + /// <summary>Verifies that <c>CentralCommunicationActor</c> has no <c>HandleDebugStreamEvent</c> member.</summary> [Fact] public void CentralCommunicationActor_DoesNotHave_HandleDebugStreamEvent() { @@ -22,6 +23,7 @@ public class NoClusterClientStreamingRegressionTests Assert.Null(method); } + /// <summary>Verifies that <c>SiteCommunicationActor</c> has no <c>HandleDebugStreamEvent</c> member.</summary> [Fact] public void SiteCommunicationActor_DoesNotHave_HandleDebugStreamEvent() { @@ -31,6 +33,7 @@ public class NoClusterClientStreamingRegressionTests Assert.Null(method); } + /// <summary>Verifies that <c>CentralCommunicationActor</c> has no <c>ForwardDebugStreamEvent</c> member.</summary> [Fact] public void CentralCommunicationActor_DoesNotHave_ForwardDebugStreamEvent() { @@ -40,6 +43,7 @@ public class NoClusterClientStreamingRegressionTests Assert.Null(method); } + /// <summary>Verifies that the Communication assembly contains no <c>DebugStreamEvent</c> type.</summary> [Fact] public void Communication_Assembly_DoesNotContain_DebugStreamEvent_Type() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoContractTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoContractTests.cs index b24a243e..b1150016 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoContractTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoContractTests.cs @@ -31,6 +31,7 @@ public class ProtoContractTests SiteStreamEvent.EventOneofCase.Batch ]; + /// <summary>Verifies that every oneof variant has a conversion handler.</summary> [Fact] public void AllOneofVariants_HaveConversionHandlers() { @@ -44,6 +45,7 @@ public class ProtoContractTests Assert.Contains(c, accountedFor); } + /// <summary>Verifies that a batch frame is unpacked into per-event cases rather than converted whole.</summary> [Fact] public void BatchFrame_IsUnpackedIntoPerEventCases_NotConvertedWhole() { @@ -71,6 +73,8 @@ public class ProtoContractTests seen); } + /// <summary>Verifies that ConvertToDomainEvent handles every oneof variant.</summary> + /// <param name="eventCase">The event case.</param> [Theory] [InlineData(SiteStreamEvent.EventOneofCase.AttributeChanged)] [InlineData(SiteStreamEvent.EventOneofCase.AlarmChanged)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoDescriptorContractTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoDescriptorContractTests.cs index dde83411..3799c85b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoDescriptorContractTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoDescriptorContractTests.cs @@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; /// <summary>Compiles source descriptors and compares the complete vendored wire contract.</summary> public class ProtoDescriptorContractTests { + /// <summary>Verifies that the source descriptors match the vendored messages and services.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task XC4_SourceDescriptorsMatchVendoredMessagesAndServices() { @@ -17,6 +19,10 @@ public class ProtoDescriptorContractTests Assert.Equal(CanonicalGenerated(generated), compiled.File.Single(f => f.Name == generated.Name)); } + /// <summary>Verifies that the guard detects drift in names, types and services.</summary> + /// <param name="before">The descriptor text before the change.</param> + /// <param name="after">The descriptor text after the change.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("string site_identifier = 1;", "string renamed_site = 1;")] [InlineData("string site_identifier = 1;", "bytes site_identifier = 1;")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoRoundtripTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoRoundtripTests.cs index 548726e8..5872c5a8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoRoundtripTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoRoundtripTests.cs @@ -6,6 +6,8 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; public class ProtoRoundtripTests { + /// <summary>Verifies that an attribute value update survives a protobuf round trip.</summary> + /// <param name="quality">The test input.</param> [Theory] [InlineData(Quality.Good)] [InlineData(Quality.Uncertain)] @@ -39,6 +41,8 @@ public class ProtoRoundtripTests Assert.Equal(timestamp.Nanos, deserialized.Timestamp.Nanos); } + /// <summary>Verifies that an alarm state update survives a protobuf round trip.</summary> + /// <param name="state">The test input.</param> [Theory] [InlineData(AlarmStateEnum.AlarmStateNormal)] [InlineData(AlarmStateEnum.AlarmStateActive)] @@ -67,6 +71,7 @@ public class ProtoRoundtripTests Assert.Equal(original.Timestamp, deserialized.Timestamp); } + /// <summary>Verifies that a site stream event carrying an attribute change sets the matching oneof case.</summary> [Fact] public void SiteStreamEvent_OneOf_AttributeChanged() { @@ -97,6 +102,7 @@ public class ProtoRoundtripTests Assert.Equal("42.5", deserialized.AttributeChanged.Value); } + /// <summary>Verifies that a site stream event carrying an alarm change sets the matching oneof case.</summary> [Fact] public void SiteStreamEvent_OneOf_AlarmChanged() { @@ -127,6 +133,7 @@ public class ProtoRoundtripTests Assert.Equal(1, deserialized.AlarmChanged.Priority); } + /// <summary>Verifies that a DateTimeOffset timestamp survives a full protobuf round trip.</summary> [Fact] public void Timestamp_DateTimeOffset_FullRoundTrip() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteAlarmAggregatorActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteAlarmAggregatorActorTests.cs index 867ba87f..aca1557b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteAlarmAggregatorActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteAlarmAggregatorActorTests.cs @@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; /// <summary> -/// Tests for <see cref="SiteAlarmAggregatorActor"/> (plan #10, Task 4) — the transient, +/// Tests for <see cref="SiteAlarmAggregatorActor"/> — the transient, /// in-memory, per-site live alarm aggregator. Covers the tricky bits: seed-then-stream /// ordering (a delta arriving before the seed completes is neither lost nor double-applied), /// dedup by AlarmKey, placeholder seeded-but-not-on-stream coherence, NodeA↔NodeB reconnect @@ -31,6 +31,7 @@ public class SiteAlarmAggregatorActorTests : TestKit private const string GrpcNodeB = "http://localhost:5200"; private const string Instance = "Site1.Pump01"; + /// <summary>Initializes the test class with a quiet Akka log level.</summary> public SiteAlarmAggregatorActorTests() : base(@"akka.loglevel = WARNING") { } @@ -50,8 +51,12 @@ public class SiteAlarmAggregatorActorTests : TestKit private readonly ConcurrentQueue<TaskCompletionSource<IReadOnlyList<AlarmStateChanged>>> _pending = new(); private int _callCount; + /// <summary>Gets the number of seed calls made so far.</summary> public int CallCount => Volatile.Read(ref _callCount); + /// <summary>Starts a seed call that stays pending until the test completes it.</summary> + /// <param name="ct">A token that cancels the pending seed call.</param> + /// <returns>A task that completes with the seeded alarm rows once the test completes it.</returns> public Task<IReadOnlyList<AlarmStateChanged>> Seed(CancellationToken ct) { Interlocked.Increment(ref _callCount); @@ -63,6 +68,7 @@ public class SiteAlarmAggregatorActorTests : TestKit } /// <summary>Completes the oldest not-yet-completed seed call with the given rows.</summary> + /// <param name="rows">The alarm rows the pending seed call completes with.</param> public void CompleteNext(params AlarmStateChanged[] rows) { SpinWait.SpinUntil(() => _pending.TryPeek(out _), TimeSpan.FromSeconds(3)); @@ -86,8 +92,12 @@ public class SiteAlarmAggregatorActorTests : TestKit private sealed class PublishSink { private readonly object _lock = new(); + /// <summary>Gets every snapshot published so far, in publish order.</summary> public List<IReadOnlyList<AlarmStateChanged>> Snapshots { get; } = new(); + /// <summary>Records a published snapshot and its stream liveness.</summary> + /// <param name="snapshot">The published alarm snapshot.</param> + /// <param name="streamLive">Whether the stream was live at publish time.</param> public void Publish(IReadOnlyList<AlarmStateChanged> snapshot, bool streamLive) { lock (_lock) { Snapshots.Add(snapshot); StreamLive = streamLive; } @@ -96,11 +106,13 @@ public class SiteAlarmAggregatorActorTests : TestKit /// <summary>Liveness reported with the most recent publish.</summary> public bool StreamLive { get; private set; } + /// <summary>Gets the most recently published snapshot, or <c>null</c> if nothing has been published.</summary> public IReadOnlyList<AlarmStateChanged>? Latest { get { lock (_lock) { return Snapshots.Count == 0 ? null : Snapshots[^1]; } } } + /// <summary>Gets the number of snapshots published so far.</summary> public int Count { get { lock (_lock) { return Snapshots.Count; } } } } @@ -114,15 +126,19 @@ public class SiteAlarmAggregatorActorTests : TestKit private readonly List<SiteSub> _subs = new(); private readonly List<string> _unsubscribed = new(); + /// <summary>Gets a copy of the subscriptions the aggregator opened.</summary> public List<SiteSub> Subs { get { lock (_lock) { return _subs.ToList(); } } } + /// <summary>Gets a copy of the correlation ids that were unsubscribed.</summary> public List<string> Unsubscribed { get { lock (_lock) { return _unsubscribed.ToList(); } } } + /// <summary>Initializes a mock stream client with no real channel.</summary> public MockSiteAlarmStreamClient() : base() { } /// <summary>When false the stream never reports connected — the site accepted the TCP /// call but never answered, so the aggregator's connect-driven re-seed must not run.</summary> public bool AutoConnect { get; set; } = true; + /// <inheritdoc /> public override Task SubscribeSiteAsync( string correlationId, Action<AlarmStateChanged> onAlarmEvent, Action<Exception> onError, Action onCompleted, CancellationToken ct, Action? onConnected = null) @@ -139,6 +155,7 @@ public class SiteAlarmAggregatorActorTests : TestKit return tcs.Task; // never completes until cancelled (simulates a live stream) } + /// <inheritdoc /> public override void Unsubscribe(string correlationId) { lock (_lock) { _unsubscribed.Add(correlationId); } @@ -149,18 +166,24 @@ public class SiteAlarmAggregatorActorTests : TestKit { private readonly ConcurrentDictionary<string, MockSiteAlarmStreamClient> _byEndpoint = new(); + /// <summary>Initializes the factory with a null logger factory.</summary> public MockSiteAlarmStreamClientFactory() : base(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance) { } /// <summary>Applied to every client this factory hands out (set before the actor starts).</summary> public bool AutoConnect { get; set; } = true; + /// <summary>Gets the mock client for an endpoint, creating it on first use.</summary> + /// <param name="endpoint">The gRPC endpoint the client is keyed by.</param> + /// <returns>The mock client for that endpoint.</returns> public MockSiteAlarmStreamClient ClientFor(string endpoint) => _byEndpoint.GetOrAdd(endpoint, _ => new MockSiteAlarmStreamClient { AutoConnect = AutoConnect }); + /// <inheritdoc /> public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint) => ClientFor(grpcEndpoint); + /// <inheritdoc /> public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint) => _byEndpoint.TryGetValue(grpcEndpoint, out var c) ? c : null; } @@ -203,7 +226,7 @@ public class SiteAlarmAggregatorActorTests : TestKit rows?.FirstOrDefault(r => r.AlarmName == alarmName && r.SourceReference == sourceRef); /// <summary> - /// Row-40 house pattern for an ABSENCE assertion: proves <paramref name="checks"/> keep + /// House pattern for an ABSENCE assertion: proves <paramref name="checks"/> keep /// holding throughout <paramref name="window"/> by polling, rather than a single blind /// Thread.Sleep(window) followed by one end-of-window sample. A bare sleep-then-assert /// cannot distinguish "the mechanism correctly stayed quiet" from "the mechanism just @@ -226,6 +249,7 @@ public class SiteAlarmAggregatorActorTests : TestKit // ── Tests ─────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a delta arriving before the seed completes is buffered and applied exactly once.</summary> [Fact] public void Delta_Arriving_Before_Seed_Completes_Is_Buffered_And_Applied_Exactly_Once() { @@ -250,6 +274,7 @@ public class SiteAlarmAggregatorActorTests : TestKit Assert.Single(latest, r => r.AlarmName == "Overheat"); // exactly once } + /// <summary>Verifies that a buffered delta older than the seeded entry is dropped.</summary> [Fact] public void Buffered_Delta_Older_Than_Seed_Entry_Is_Dropped() { @@ -273,6 +298,7 @@ public class SiteAlarmAggregatorActorTests : TestKit Assert.Equal(500, pumpFault!.Priority); // seed value kept; older buffered delta dropped } + /// <summary>Verifies that a buffered delta newer than the seeded entry is applied.</summary> [Fact] public void Buffered_Delta_Newer_Than_Seed_Entry_Is_Applied() { @@ -287,6 +313,7 @@ public class SiteAlarmAggregatorActorTests : TestKit TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that deduplication by alarm key distinguishes alarms by their source reference.</summary> [Fact] public void Dedup_By_AlarmKey_Distinguishes_SourceReference() { @@ -306,6 +333,7 @@ public class SiteAlarmAggregatorActorTests : TestKit Assert.NotNull(Find(latest, "LevelAlarm", "Tank01.Level.LoLo")); } + /// <summary>Verifies that a live update with the same key replaces the existing row in place.</summary> [Fact] public void Same_Key_Live_Update_Replaces_In_Place() { @@ -324,6 +352,7 @@ public class SiteAlarmAggregatorActorTests : TestKit Assert.Single(sink.Latest!); // still exactly one row for the key } + /// <summary>Verifies that a seeded native alarm and its live delta collapse onto one key.</summary> [Fact] public void Seeded_Native_Alarm_And_Its_Live_Delta_Collapse_Onto_One_Key() { @@ -348,6 +377,7 @@ public class SiteAlarmAggregatorActorTests : TestKit Assert.Single(sink.Latest!); // exactly one row — no seed/live ghost duplicate } + /// <summary>Verifies that a seeded placeholder coexists with a live real alarm and is not wiped.</summary> [Fact] public void Placeholder_Seeded_Coexists_With_Live_Real_Alarm_And_Is_Not_Wiped() { @@ -372,6 +402,7 @@ public class SiteAlarmAggregatorActorTests : TestKit Assert.NotNull(Find(latest, "Motor1.MotorAlarms", "Motor1.MotorAlarms.HiHi")); } + /// <summary>Verifies that a gRPC error flips to the other node, reconnects and re-seeds.</summary> [Fact] public void GrpcError_Flips_Node_Reconnects_And_ReSeeds() { @@ -393,6 +424,7 @@ public class SiteAlarmAggregatorActorTests : TestKit Assert.Contains("corr-1", factory.ClientFor(GrpcNodeA).Unsubscribed); } + /// <summary>Verifies that a periodic reconcile authoritatively replaces stale rows.</summary> [Fact] public void Periodic_Reconcile_Authoritatively_Replaces_Stale_Rows() { @@ -403,7 +435,7 @@ public class SiteAlarmAggregatorActorTests : TestKit seed.CompleteNext(Alarm("PumpFault", "", 500, t)); // seed has a row AwaitCondition(() => Find(sink.Latest, "PumpFault", "") is not null, TimeSpan.FromSeconds(3)); - // Reconcile tick fires → seed #2 (e.g. instance was disabled) returns EMPTY. + // Reconcile tick fires → the second seed (e.g. instance was disabled) returns EMPTY. AwaitCondition(() => seed.CallCount == 2, TimeSpan.FromSeconds(3)); seed.CompleteNext(); // empty @@ -411,6 +443,7 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitCondition(() => sink.Latest is { } l && l.Count == 0, TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that a failed initial seed publishes nothing but a later reconcile recovers.</summary> [Fact] public void Failed_Initial_Seed_Does_Not_Publish_But_A_Later_Reconcile_Recovers() { @@ -431,6 +464,7 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitCondition(() => Find(sink.Latest, "PumpFault", "") is not null, TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that a stream given up after the maximum retries is reopened on the next reconcile tick.</summary> [Fact] public void Stream_GivenUp_After_MaxRetries_Reopens_On_Reconcile_Tick() { @@ -461,6 +495,7 @@ public class SiteAlarmAggregatorActorTests : TestKit // ── R2 T11: queued re-seed after reconnect + stream-generation stamp (N7.1/N7.2) ── + /// <summary>Verifies that a re-seed requested during an in-flight fan-out is queued rather than dropped.</summary> [Fact] public void ReseedRequestedDuringInFlightFanout_IsQueued_NotDropped() { @@ -470,7 +505,7 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitAssert(() => Assert.Equal(1, sink.Count)); AwaitCondition(() => factory.ClientFor(GrpcNodeA).Subs.Count == 1, TimeSpan.FromSeconds(3)); - // The first tick after a fan-out is deliberately skipped (WP2.3: the initial seed + // The first tick after a fan-out is deliberately skipped (the initial seed // already covered this window); the second one actually fans out. actor.Tell(new RunReconcile()); // skipped — consumes the "already fanned out" flag actor.Tell(new RunReconcile()); // reconcile fan-out now in flight (CallCount 2) @@ -486,6 +521,7 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitAssert(() => Assert.Equal(3, seed.CallCount)); // queued re-seed ran immediately after } + /// <summary>Verifies that a late error from a previous stream generation is ignored.</summary> [Fact] public void LateErrorFromAPreviousStreamGeneration_IsIgnored() { @@ -501,14 +537,15 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitCondition(() => factory.ClientFor(GrpcNodeB).Subs.Count == 1, TimeSpan.FromSeconds(5)); // gen 2 stream open AwaitAssert(() => Assert.Equal(2, TotalSubs())); - firstSub.OnError(new Exception("late zombie fault")); // stale gen-1 error races in + firstSub.OnError(new Exception("late zombie fault")); // stale first-generation error races in // Pre-fix this burns retry budget and opens a THIRD stream; post-fix it is ignored. AssertHoldsFor(TimeSpan.FromMilliseconds(400), (() => TotalSubs() == 2, "a stale gen-1 error must not open a third stream")); } - // ── WP1.1: graceful (status OK) stream completion is a reconnect trigger ── + // ── Graceful (status OK) stream completion is a reconnect trigger ── + /// <summary>Verifies that a graceful stream completion reopens on the next reconcile tick on the same node.</summary> [Fact] public void GracefulStreamCompletion_ReopensOnReconcileTick_OnTheSameNode() { @@ -530,6 +567,7 @@ public class SiteAlarmAggregatorActorTests : TestKit Assert.Contains("corr-1", factory.ClientFor(GrpcNodeA).Unsubscribed); } + /// <summary>Verifies that a graceful stream completion neither spends nor refunds the error retry budget.</summary> [Fact] public void GracefulStreamCompletion_NeitherSpendsNorRefunds_TheErrorRetryBudget() { @@ -563,6 +601,7 @@ public class SiteAlarmAggregatorActorTests : TestKit (() => TotalSubs() == before, "budget exhausted — the 4th error must not reconnect")); } + /// <summary>Verifies that a late completion from a previous stream generation is ignored.</summary> [Fact] public void LateCompletionFromAPreviousStreamGeneration_IsIgnored() { @@ -575,8 +614,8 @@ public class SiteAlarmAggregatorActorTests : TestKit firstSub.OnError(new Exception("real fault")); // gen 1 dies → gen 2 opens on NodeB AwaitCondition(() => factory.ClientFor(GrpcNodeB).Subs.Count == 1, TimeSpan.FromSeconds(5)); - // A completion racing out of the dead gen-1 stream must not tear down the live - // gen-2 stream (which would go deltaless until the next reconcile tick). + // A completion racing out of the dead first-generation stream must not tear down the live + // second-generation stream (which would go deltaless until the next reconcile tick). firstSub.OnCompleted(); AssertHoldsFor(TimeSpan.FromMilliseconds(300), (() => !factory.ClientFor(GrpcNodeB).Unsubscribed.Contains("corr-1"), @@ -587,6 +626,7 @@ public class SiteAlarmAggregatorActorTests : TestKit // ── R2 T10: live-delta publish coalescing (N6) ── + /// <summary>Verifies that a burst of deltas is coalesced into a few publishes that together contain every row.</summary> [Fact] public void DeltaBurst_IsCoalesced_IntoFewPublishes_ContainingEveryRow() { @@ -608,6 +648,7 @@ public class SiteAlarmAggregatorActorTests : TestKit }, TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that a zero coalesce window preserves the legacy publish-per-delta behavior.</summary> [Fact] public void ZeroCoalesce_PreservesLegacyPerDeltaPublish() { @@ -624,6 +665,7 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitAssert(() => Assert.Equal(3, sink.Count)); // 1 seed + 1 per delta } + /// <summary>Verifies that the stop message tears down the gRPC stream and stops the actor.</summary> [Fact] public void Stop_Message_TearsDown_Grpc_And_Stops_Actor() { @@ -640,12 +682,13 @@ public class SiteAlarmAggregatorActorTests : TestKit TimeSpan.FromSeconds(3)); } - // -- WP2.3: seed once per SUCCESSFUL (re)connect, and stream-truthful liveness -- + // -- Seed once per SUCCESSFUL (re)connect, and stream-truthful liveness -- + /// <summary>Verifies that reconnect attempts do not each re-seed; only the successful connect does.</summary> [Fact] public void ReconnectAttempts_DoNotEachReSeed_OnlyTheSuccessfulConnectDoes() { - // Finding #10's per-attempt re-fan-out: every reconnect ATTEMPT used to kick a + // The per-attempt re-fan-out: every reconnect ATTEMPT used to kick a // whole-site snapshot fan-out - against a site that is, by definition of the // reconnect, unreachable. Now the re-seed is owed to the connect that succeeds. var (_, seed, _, factory) = CreateActor( @@ -673,6 +716,7 @@ public class SiteAlarmAggregatorActorTests : TestKit (() => seed.CallCount == 2, "the successful connect must re-seed exactly once")); } + /// <summary>Verifies that a reconcile tick is skipped when a fan-out already ran within the window.</summary> [Fact] public void ReconcileTick_IsSkipped_WhenAFanoutAlreadyRanInTheWindow() { @@ -692,6 +736,7 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitCondition(() => seed.CallCount == 2, TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that an unchanged reconcile snapshot is not republished.</summary> [Fact] public void UnchangedReconcileSnapshot_DoesNotRepublish() { @@ -720,10 +765,11 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitAssert(() => Assert.Equal(2, sink.Count)); } + /// <summary>Verifies that a stream completion drops liveness and the reopen restores it.</summary> [Fact] public void StreamCompletion_DropsLiveness_AndTheReopenRestoresIt() { - // WP2.3 carried residual: a completed (or given-up) stream kept reporting live + // Carried residual: a completed (or given-up) stream kept reporting live // until the next reconcile publish, so the page grafted a freezing snapshot over // fresh poll data. // Reconcile is held far away so the drop can be observed before the reopen @@ -746,6 +792,7 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitCondition(() => sink.StreamLive, TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that a stream fault drops liveness immediately.</summary> [Fact] public void StreamFault_DropsLiveness_Immediately() { @@ -761,6 +808,7 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitCondition(() => !sink.StreamLive, TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that consecutive reconcile ticks with no reconnect each run a fan-out.</summary> [Fact] public void ConsecutiveReconcileTicks_WithNoReconnect_EachRunAFanout() { @@ -789,6 +837,7 @@ public class SiteAlarmAggregatorActorTests : TestKit Assert.Equal(5, seed.CallCount); } + /// <summary>Verifies that a connect-driven re-seed still stands the next reconcile tick down.</summary> [Fact] public void ConnectDrivenReseed_StillStandsTheNextTickDown() { @@ -827,6 +876,7 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitCondition(() => seed.CallCount == 4, TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that a cancellation error not caused by the actor drops liveness and reopens on the other node.</summary> [Fact] public void ForeignCancelledStreamError_DropsLiveness_AndReopensOnTheOtherNode() { @@ -854,10 +904,10 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitCondition(() => seed.CallCount == 2, TimeSpan.FromSeconds(3)); } - // ── P3-4: the stream open attempt is deadline-bounded ─────────────────────── + // ── The stream open attempt is deadline-bounded ─────────────────────── /// <summary> - /// Proof of the P3-4 starvation and of its fix. + /// Proof of the starvation and of its fix. /// <para> /// An open attempt is cleared by exactly three peer-produced outcomes: connected, error, /// or graceful completion. A stream that produces NONE of them pins <c>_openInFlight</c>. @@ -914,6 +964,7 @@ public class SiteAlarmAggregatorActorTests : TestKit AwaitCondition(() => sink.StreamLive, TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that the open deadline does not fire on a stream that connects, flip nodes or burn the retry budget.</summary> [Fact] public void OpenDeadline_DoesNotFire_OnAStreamThatConnects_NorFlipNodeOrBurnBudget() { @@ -946,6 +997,7 @@ public class SiteAlarmAggregatorActorTests : TestKit Assert.Single(factory.ClientFor(GrpcNodeA).Subs); } + /// <summary>Verifies that a failed seed is retried with backoff before the next reconcile.</summary> [Fact] public void FailedSeed_IsRetried_WithBackoff_BeforeTheNextReconcile() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteCommandDtoMapperSecretsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteCommandDtoMapperSecretsTests.cs index ba735273..47644649 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteCommandDtoMapperSecretsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteCommandDtoMapperSecretsTests.cs @@ -7,7 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; /// <summary> /// Round-trip tests for the additive <c>Secrets</c> / <c>SecretsApplied</c> wire contract /// on <see cref="DeployArtifactsCommand"/> / <see cref="ArtifactDeploymentResponse"/> -/// (proto field 9 / field 6). Site secrets design §3.3, task-2 of +/// (proto field 9 / field 6). Site secrets design §3.3, /// docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md. /// </summary> public class SiteCommandDtoMapperSecretsTests @@ -16,6 +16,7 @@ public class SiteCommandDtoMapperSecretsTests [1, 2, 3], [4], [5], [6, 7], [8], [9], "kek-A", 7, DateTimeOffset.Parse("2026-09-01T00:00:00Z"), DateTimeOffset.Parse("2026-09-02T00:00:00Z"), "d"); + /// <summary>Verifies that deploy-artifacts secrets round-trip verbatim through the DTO mapper.</summary> [Fact] public void DeployArtifacts_Secrets_RoundTrip_Verbatim() { @@ -37,6 +38,7 @@ public class SiteCommandDtoMapperSecretsTests Assert.Equal(7, back.Secrets[1].Revision); } + /// <summary>Verifies that null deploy-artifacts secrets stay null through the mapper.</summary> [Fact] public void DeployArtifacts_NullSecrets_StaysNull() { @@ -46,6 +48,7 @@ public class SiteCommandDtoMapperSecretsTests Assert.Null(((DeployArtifactsCommand)SiteCommandDtoMapper.FromProto(dto)).Secrets); } + /// <summary>Verifies that empty deploy-artifacts secrets stay empty rather than becoming null.</summary> [Fact] public void DeployArtifacts_EmptySecrets_StaysEmpty_NotNull() { @@ -56,6 +59,7 @@ public class SiteCommandDtoMapperSecretsTests Assert.Empty(back.Secrets!); } + /// <summary>Verifies that a command DTO that lacks the secrets field maps to null secrets.</summary> [Fact] public void DeployArtifacts_CommandDtoWithoutField9_MapsToNullSecrets() { @@ -74,6 +78,8 @@ public class SiteCommandDtoMapperSecretsTests Assert.Null(back.Secrets); } + /// <summary>Verifies that the applied-secrets count of an artifact response round-trips through the mapper.</summary> + /// <param name="applied">The applied-secrets count, or <see langword="null"/> when the site did not report one.</param> [Theory] [InlineData(null)] [InlineData(3)] @@ -83,6 +89,7 @@ public class SiteCommandDtoMapperSecretsTests Assert.Equal(applied, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(r)).SecretsApplied); } + /// <summary>Verifies that the removed-secrets list of an artifact response round-trips through the mapper.</summary> [Fact] public void ArtifactResponse_SecretsRemoved_RoundTrips() { @@ -91,6 +98,7 @@ public class SiteCommandDtoMapperSecretsTests Assert.Equal(["old-1", "old-2"], SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(r)).SecretsRemoved!); } + /// <summary>Verifies that an artifact response with no removed secrets is null on both sides of the mapper.</summary> [Fact] public void ArtifactResponse_NoSecretsRemoved_IsNullOnBothSides() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SitePairChannelProviderDisposalTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SitePairChannelProviderDisposalTests.cs index 30dcc7df..671ec934 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SitePairChannelProviderDisposalTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SitePairChannelProviderDisposalTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; /// <summary> -/// Arch-review 2026-08-19 P3-2 — a channel replaced by the 60 s DB address refresh (or an admin +/// A channel replaced by the 60 s DB address refresh (or an admin /// edit) must not be disposed out from under an in-flight <see cref="SitePairChannelProvider.ExecuteAsync{T}"/> /// call. <c>ExecuteAsync</c> reads the channel under the pair gate but runs the RPC outside it, so an /// inline dispose aborted whatever was running — a multi-minute <c>DeployArtifactsCommand</c> @@ -19,6 +19,8 @@ public class SitePairChannelProviderDisposalTests Options.Create(new CommunicationOptions()), NullLogger<SitePairChannelProvider>.Instance); + /// <summary>Verifies that shutdown retains the active channel and disposes it only once the call completes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Shutdown_RetainsActiveChannelThenDisposesItAtCompletion() { @@ -47,6 +49,8 @@ public class SitePairChannelProviderDisposalTests Assert.Throws<ObjectDisposedException>(() => channel.CreateCallInvoker()); } + /// <summary>Verifies that a sweep keeps a retired channel alive until the in-flight call actually completes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetiredChannel_SweepKeepsItAliveUntilActualCallCompletion() { @@ -77,6 +81,8 @@ public class SitePairChannelProviderDisposalTests Assert.Throws<ObjectDisposedException>(() => held!.CreateCallInvoker()); } + /// <summary>Verifies that an operation that may have been applied before the channel became unavailable is not replayed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyThenUnavailable_DoesNotReplayAnAmbiguousOperation() { @@ -94,6 +100,8 @@ public class SitePairChannelProviderDisposalTests Assert.NotNull(error); } + /// <summary>Verifies that a secondary node that applies the operation but loses the response reports an unknown outcome without another replay.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SecondaryAppliesThenLosesResponse_AlsoReportsUnknownWithoutAnotherReplay() { @@ -113,6 +121,9 @@ public class SitePairChannelProviderDisposalTests Assert.Equal(1, effects); } + /// <summary>Verifies that a safe read or an explicit pre-dispatch rejection can be retried on the other node.</summary> + /// <param name="safeRead">Whether the call is a safe read rather than an explicit pre-dispatch rejection.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] @@ -132,6 +143,8 @@ public class SitePairChannelProviderDisposalTests Assert.Equal(2, attempts); } + /// <summary>Verifies that updating a site during an in-flight call parks the old channel and lets the call complete.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateSite_WhileCallInFlight_ParksTheOldChannelAndLetsTheCallComplete() { @@ -172,6 +185,7 @@ public class SitePairChannelProviderDisposalTests Assert.Throws<ObjectDisposedException>(() => inFlightChannel!.CreateCallInvoker()); } + /// <summary>Verifies that removing a site parks both channels rather than disposing them inline.</summary> [Fact] public void RemoveSite_ParksBothChannelsRatherThanDisposingThemInline() { @@ -187,6 +201,7 @@ public class SitePairChannelProviderDisposalTests Assert.Equal(0, provider.ParkedChannelCount); } + /// <summary>Verifies that updating a site with unchanged endpoints parks no channels.</summary> [Fact] public void UpdateSite_UnchangedEndpoints_ParksNothing() { @@ -197,6 +212,7 @@ public class SitePairChannelProviderDisposalTests Assert.Equal(0, provider.ParkedChannelCount); } + /// <summary>Verifies that disposing the provider drains parked channels regardless of the remaining grace period.</summary> [Fact] public void Dispose_DrainsParkedChannelsRegardlessOfRemainingGrace() { @@ -212,7 +228,9 @@ public class SitePairChannelProviderDisposalTests private sealed class NoKeyProvider : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new("k"); + /// <inheritdoc /> public void Invalidate(string siteId) { } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamEventBatcherTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamEventBatcherTests.cs index 18c25a39..fdc63fb0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamEventBatcherTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamEventBatcherTests.cs @@ -70,6 +70,8 @@ public class SiteStreamEventBatcherTests // ── Size cap ──────────────────────────────────────────────────────────────── + /// <summary>Verifies that a backlog is split into frames of at most the maximum event count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SizeCap_SplitsABacklogIntoFramesOfAtMostMaxEvents() { @@ -89,6 +91,8 @@ public class SiteStreamEventBatcherTests Assert.Equal(Enumerable.Range(0, 250), run.Frames.SelectMany(Seqs)); } + /// <summary>Verifies that a size cap of one emits plain per-event frames, the unnegotiated shape.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SizeCapOfOne_EmitsPlainPerEventFrames_TheUnnegotiatedShape() { @@ -112,6 +116,8 @@ public class SiteStreamEventBatcherTests // ── Time cap ──────────────────────────────────────────────────────────────── + /// <summary>Verifies that an underfull batch is closed when the batching window elapses.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TimeCap_ClosesAnUnderfullBatchWhenTheWindowElapses() { @@ -152,6 +158,8 @@ public class SiteStreamEventBatcherTests $"batch closed after {elapsed.TotalMilliseconds:0.0} ms, before the {window.TotalMilliseconds:0} ms window"); } + /// <summary>Verifies that a lone event on a quiet stream is never delayed by the batching window.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoneEventOnAQuietStream_IsNeverDelayedByTheWindow() { @@ -187,6 +195,8 @@ public class SiteStreamEventBatcherTests // ── Flush on stream close ─────────────────────────────────────────────────── + /// <summary>Verifies that completing the writer flushes the in-flight batch before the pump returns.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WriterCompletion_FlushesTheInFlightBatchBeforeReturning() { @@ -213,6 +223,8 @@ public class SiteStreamEventBatcherTests Assert.Equal([0, 1], Seqs(run.Frames[0])); } + /// <summary>Verifies that completing the writer with nothing buffered returns without emitting a frame.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WriterCompletionWithNothingBuffered_ReturnsWithoutEmitting() { @@ -225,6 +237,8 @@ public class SiteStreamEventBatcherTests Assert.Empty(run.Frames); } + /// <summary>Verifies that cancellation ends the pump with an operation-canceled exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Cancellation_EndsThePumpWithOperationCanceled() { @@ -243,6 +257,8 @@ public class SiteStreamEventBatcherTests // ── Ordering ──────────────────────────────────────────────────────────────── + /// <summary>Verifies that event ordering is preserved across many batches while producers race.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Ordering_IsPreservedAcrossManyBatchesUnderAProducerRace() { @@ -273,6 +289,8 @@ public class SiteStreamEventBatcherTests // ── Frame shape ───────────────────────────────────────────────────────────── + /// <summary>Verifies that a batch frame carries the correlation id once and blanks it on the inner events.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BatchFrame_CarriesTheCorrelationIdOnceAndBlanksItOnInnerEvents() { @@ -292,6 +310,8 @@ public class SiteStreamEventBatcherTests Assert.All(frame.Batch.Events, e => Assert.Equal(string.Empty, e.CorrelationId)); } + /// <summary>Verifies that per-event timestamps survive batching.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PerEventTimestampsSurviveBatching() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryDisposeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryDisposeTests.cs index 210a4f23..c32d0155 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryDisposeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryDisposeTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; /// <summary> -/// Regression tests for Communication-007 — the factory's synchronous +/// Regression tests: the factory's synchronous /// <see cref="SiteStreamGrpcClientFactory.Dispose"/> must not block on the /// async disposal path (sync-over-async). It must dispose each client through /// the client's synchronous <see cref="SiteStreamGrpcClient.Dispose"/>. @@ -17,11 +17,15 @@ public class SiteStreamGrpcClientFactoryDisposeTests /// </summary> private sealed class TrackingClient : SiteStreamGrpcClient { + /// <summary>Gets a value indicating whether the synchronous dispose path was called.</summary> public bool SyncDisposeCalled { get; private set; } + /// <summary>Gets a value indicating whether the asynchronous dispose path was called.</summary> public bool AsyncDisposeCalled { get; private set; } + /// <inheritdoc /> public override void Dispose() => SyncDisposeCalled = true; + /// <inheritdoc /> public override ValueTask DisposeAsync() { AsyncDisposeCalled = true; @@ -37,10 +41,13 @@ public class SiteStreamGrpcClientFactoryDisposeTests { private readonly ConcurrentBag<TrackingClient> _created = new(); + /// <summary>Initializes the factory with a null logger factory.</summary> public TrackingFactory() : base(NullLoggerFactory.Instance) { } + /// <summary>Gets the clients this factory has created.</summary> public IReadOnlyCollection<TrackingClient> Created => _created.ToList(); + /// <inheritdoc /> protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint) { var client = new TrackingClient(); @@ -49,6 +56,7 @@ public class SiteStreamGrpcClientFactoryDisposeTests } } + /// <summary>Verifies that Dispose disposes clients synchronously and not via the async path.</summary> [Fact] public void Dispose_DisposesClientsSynchronously_NotViaAsyncPath() { @@ -69,12 +77,14 @@ public class SiteStreamGrpcClientFactoryDisposeTests /// <summary> /// Test client whose disposal always throws — stands in for a client whose /// <c>ReleaseResources</c> hit an <see cref="ObjectDisposedException"/> on a subscription - /// token source (arch-review 2026-08-19 P3-1). + /// token source. /// </summary> private sealed class ThrowingClient : SiteStreamGrpcClient { + /// <inheritdoc /> public override void Dispose() => throw new ObjectDisposedException(nameof(CancellationTokenSource)); + /// <inheritdoc /> public override ValueTask DisposeAsync() => throw new ObjectDisposedException(nameof(CancellationTokenSource)); } @@ -85,10 +95,13 @@ public class SiteStreamGrpcClientFactoryDisposeTests private readonly ConcurrentBag<TrackingClient> _created = new(); private int _next; + /// <summary>Initializes the factory with a null logger factory.</summary> public OneThrowingClientFactory() : base(NullLoggerFactory.Instance) { } + /// <summary>Gets the clients this factory has created.</summary> public IReadOnlyCollection<TrackingClient> Created => _created.ToList(); + /// <inheritdoc /> protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint) { if (Interlocked.Increment(ref _next) == 1) @@ -102,6 +115,8 @@ public class SiteStreamGrpcClientFactoryDisposeTests } } + /// <summary>Verifies that DisposeAsync still disposes every other client when one client throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DisposeAsync_OneClientThrowing_StillDisposesEveryOtherClient() { @@ -119,6 +134,7 @@ public class SiteStreamGrpcClientFactoryDisposeTests Assert.True(c.AsyncDisposeCalled, "a throwing client must not strand its peers undisposed")); } + /// <summary>Verifies that Dispose still disposes every other client when one client throws.</summary> [Fact] public void Dispose_OneClientThrowing_StillDisposesEveryOtherClient() { @@ -134,6 +150,7 @@ public class SiteStreamGrpcClientFactoryDisposeTests Assert.True(c.SyncDisposeCalled, "a throwing client must not strand its peers undisposed")); } + /// <summary>Verifies that Dispose does not deadlock under a single-threaded synchronization context.</summary> [Fact] public void Dispose_DoesNotDeadlock_UnderSingleThreadedSynchronizationContext() { @@ -173,6 +190,7 @@ public class SiteStreamGrpcClientFactoryDisposeTests private readonly BlockingCollection<(SendOrPostCallback cb, object? state)> _queue = new(); private readonly Thread _thread; + /// <summary>Initializes the synchronization context.</summary> public SingleThreadSyncContext() { _thread = new Thread(Run) { IsBackground = true }; @@ -186,8 +204,10 @@ public class SiteStreamGrpcClientFactoryDisposeTests cb(state); } + /// <inheritdoc /> public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state)); + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { _queue.CompleteAdding(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryTests.cs index 8b09067d..901c0521 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryTests.cs @@ -8,6 +8,7 @@ public class SiteStreamGrpcClientFactoryTests { private readonly ILoggerFactory _loggerFactory = NullLoggerFactory.Instance; + /// <summary>Verifies that requesting the same site twice returns the same client.</summary> [Fact] public void GetOrCreate_ReturnsSameClientForSameSite() { @@ -19,6 +20,7 @@ public class SiteStreamGrpcClientFactoryTests Assert.Same(client1, client2); } + /// <summary>Verifies that different sites get different clients.</summary> [Fact] public void GetOrCreate_ReturnsDifferentClientsForDifferentSites() { @@ -30,6 +32,8 @@ public class SiteStreamGrpcClientFactoryTests Assert.NotSame(client1, client2); } + /// <summary>Verifies that removing a site disposes its client.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RemoveSite_DisposesClient() { @@ -43,6 +47,8 @@ public class SiteStreamGrpcClientFactoryTests Assert.NotSame(client1, client2); } + /// <summary>Verifies that removing a site that does not exist does not throw.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RemoveSite_NonExistent_DoesNotThrow() { @@ -50,6 +56,8 @@ public class SiteStreamGrpcClientFactoryTests await factory.RemoveSiteAsync("does-not-exist"); // Should not throw } + /// <summary>Verifies that disposing the factory disposes all clients.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DisposeAsync_DisposesAllClients() { @@ -64,10 +72,11 @@ public class SiteStreamGrpcClientFactoryTests // This tests that Dispose doesn't throw } + /// <summary>Verifies that a changed endpoint yields a client bound to the new endpoint.</summary> [Fact] public void GetOrCreate_EndpointChanged_ReturnsClientBoundToNewEndpoint() { - // Communication-012 regression: when the same site is requested with a + // Regression: when the same site is requested with a // *different* endpoint (the NodeA→NodeB failover flip), the factory must // hand back a client bound to the new endpoint, not the stale cached one. using var factory = new TrackingEndpointFactory(); @@ -80,13 +89,14 @@ public class SiteStreamGrpcClientFactoryTests Assert.Equal("http://localhost:5200", nodeB.Endpoint); } + /// <summary>Verifies that a different endpoint for the same site keeps both clients alive.</summary> [Fact] public void GetOrCreate_DifferentEndpointSameSite_KeepsBothClientsAlive() { // Arch review 02 (High): the cache is keyed by (site, endpoint). Both node // channels for a site coexist, so a per-session NodeA→NodeB failover flip // must NOT dispose the other endpoint's channel — another debug session may - // be using it. (Supersedes Communication-013's dispose-on-endpoint-change.) + // be using it. (This supersedes the earlier dispose-on-endpoint-change behaviour.) using var factory = new TrackingEndpointFactory(); var a = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100"); @@ -97,6 +107,7 @@ public class SiteStreamGrpcClientFactoryTests Assert.Same(a, factory.GetOrCreate("site-a", "http://localhost:5100")); // still cached } + /// <summary>Verifies that TryGet returns the cached client or null without creating one.</summary> [Fact] public void TryGet_ReturnsCachedClientOrNull_WithoutCreating() { @@ -109,6 +120,8 @@ public class SiteStreamGrpcClientFactoryTests Assert.Equal(1, factory.CreatedCount); // TryGet never creates } + /// <summary>Verifies that removing a site disposes every endpoint client for that site and no other.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RemoveSiteAsync_DisposesAllEndpointsForTheSite_OnlyThatSite() { @@ -124,6 +137,7 @@ public class SiteStreamGrpcClientFactoryTests Assert.False(other.Disposed); } + /// <summary>Verifies that requesting the same endpoint again neither disposes nor recreates the client.</summary> [Fact] public void GetOrCreate_SameEndpoint_DoesNotDisposeOrRecreate() { @@ -140,9 +154,14 @@ public class SiteStreamGrpcClientFactoryTests /// <summary>Test client that records its endpoint and disposal (no real channel).</summary> private sealed class TrackingEndpointClient : SiteStreamGrpcClient { + /// <summary>Initializes the client for the given endpoint without opening a real channel.</summary> + /// <param name="endpoint">The gRPC endpoint the client is bound to.</param> public TrackingEndpointClient(string endpoint) : base(endpoint) { } + /// <summary>Gets a value indicating whether the client has been disposed.</summary> public bool Disposed { get; private set; } + /// <inheritdoc /> public override void Dispose() => Disposed = true; + /// <inheritdoc /> public override ValueTask DisposeAsync() { Disposed = true; @@ -153,8 +172,11 @@ public class SiteStreamGrpcClientFactoryTests /// <summary>Factory that hands out endpoint-tracking clients.</summary> private sealed class TrackingEndpointFactory : SiteStreamGrpcClientFactory { + /// <summary>Initializes the factory with a null logger factory.</summary> public TrackingEndpointFactory() : base(NullLoggerFactory.Instance) { } + /// <summary>Gets the number of clients this factory has created.</summary> public int CreatedCount { get; private set; } + /// <inheritdoc /> protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint) { CreatedCount++; diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientTests.cs index efe9cf54..ce47892e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientTests.cs @@ -8,6 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; public class SiteStreamGrpcClientTests { + /// <summary>Verifies that the connected-header timeout is fixed for the lifetime of a client.</summary> [Fact] public void ConnectedHeaderTimeout_IsImmutablePerClient() { @@ -17,6 +18,7 @@ public class SiteStreamGrpcClientTests Assert.Null(property.SetMethod); } + /// <summary>Verifies that an attribute-changed stream event converts to the matching domain event.</summary> [Fact] public void ConvertToDomainEvent_AttributeChanged_MapsCorrectly() { @@ -46,6 +48,7 @@ public class SiteStreamGrpcClientTests Assert.Equal(ts, attr.Timestamp); } + /// <summary>Verifies that an alarm-changed stream event converts to the matching domain event.</summary> [Fact] public void ConvertToDomainEvent_AlarmChanged_MapsCorrectly() { @@ -104,6 +107,7 @@ public class SiteStreamGrpcClientTests Assert.Equal("100", alarm.LimitValue); } + /// <summary>Verifies that an unknown stream event converts to null.</summary> [Fact] public void ConvertToDomainEvent_UnknownEvent_ReturnsNull() { @@ -118,6 +122,9 @@ public class SiteStreamGrpcClientTests Assert.Null(result); } + /// <summary>Verifies that each gRPC quality value maps to the expected quality string.</summary> + /// <param name="input">The gRPC quality value.</param> + /// <param name="expected">The expected quality string.</param> [Theory] [InlineData(Quality.Good, "Good")] [InlineData(Quality.Uncertain, "Uncertain")] @@ -129,6 +136,9 @@ public class SiteStreamGrpcClientTests Assert.Equal(expected, result); } + /// <summary>Verifies that each gRPC alarm state maps to the expected domain alarm state.</summary> + /// <param name="input">The gRPC alarm state.</param> + /// <param name="expected">The expected domain alarm state.</param> [Theory] [InlineData(AlarmStateEnum.AlarmStateNormal, AlarmState.Normal)] [InlineData(AlarmStateEnum.AlarmStateActive, AlarmState.Active)] @@ -139,6 +149,9 @@ public class SiteStreamGrpcClientTests Assert.Equal(expected, result); } + /// <summary>Verifies that each gRPC alarm level maps to the expected domain alarm level.</summary> + /// <param name="input">The gRPC alarm level.</param> + /// <param name="expected">The expected domain alarm level.</param> [Theory] [InlineData(AlarmLevelEnum.AlarmLevelNone, AlarmLevel.None)] [InlineData(AlarmLevelEnum.AlarmLevelLow, AlarmLevel.Low)] @@ -151,6 +164,7 @@ public class SiteStreamGrpcClientTests Assert.Equal(expected, result); } + /// <summary>Verifies that converting an alarm-changed event preserves the alarm level.</summary> [Fact] public void ConvertToDomainEvent_AlarmChanged_PreservesLevel() { @@ -177,6 +191,7 @@ public class SiteStreamGrpcClientTests Assert.Equal(900, domain.Priority); } + /// <summary>Verifies that unsubscribing cancels the subscription.</summary> [Fact] public void Unsubscribe_CancelsSubscription() { @@ -195,6 +210,7 @@ public class SiteStreamGrpcClientTests Assert.True(cts.IsCancellationRequested); } + /// <summary>Verifies that unsubscribing from a nonexistent subscription does not throw.</summary> [Fact] public void Unsubscribe_NonExistent_DoesNotThrow() { @@ -202,6 +218,8 @@ public class SiteStreamGrpcClientTests client.Unsubscribe("does-not-exist"); // Should not throw } + /// <summary>Verifies that disposing the client cancels all subscriptions.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DisposeAsync_CancelsAllSubscriptions() { @@ -218,8 +236,9 @@ public class SiteStreamGrpcClientTests Assert.True(cts2.IsCancellationRequested); } - // --- Site-wide (SubscribeSite) alarm-only stream (plan #10 T3) --- + // --- Site-wide (SubscribeSite) alarm-only stream --- + /// <summary>Verifies that an alarm-changed event converts to the mapped alarm event.</summary> [Fact] public void ConvertToAlarmEvent_AlarmChanged_ReturnsMappedAlarm() { @@ -251,6 +270,7 @@ public class SiteStreamGrpcClientTests Assert.Equal("T01.Hi", alarm.SourceReference); } + /// <summary>Verifies that an attribute-changed event converts to no alarm event.</summary> [Fact] public void ConvertToAlarmEvent_AttributeChanged_ReturnsNull() { @@ -273,6 +293,7 @@ public class SiteStreamGrpcClientTests Assert.Null(SiteStreamGrpcClient.ConvertToAlarmEvent(evt)); } + /// <summary>Verifies that an unknown event converts to no alarm event.</summary> [Fact] public void ConvertToAlarmEvent_UnknownEvent_ReturnsNull() { @@ -280,6 +301,8 @@ public class SiteStreamGrpcClientTests Assert.Null(SiteStreamGrpcClient.ConvertToAlarmEvent(evt)); } + /// <summary>Verifies that subscribing a site on a test-only client throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SubscribeSiteAsync_OnTestOnlyClient_Throws() { @@ -290,8 +313,10 @@ public class SiteStreamGrpcClientTests client.SubscribeSiteAsync("corr", _ => { }, _ => { }, () => { }, CancellationToken.None)); } - // --- WP1.1: graceful (status OK) stream completion is reported, not swallowed --- + // --- Graceful (status OK) stream completion is reported, not swallowed --- + /// <summary>Verifies that a stream the server ends with an OK status invokes the completed callback and not the error callback.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConsumeStream_ServerEndsStreamWithOk_InvokesOnCompleted_NotOnError() { @@ -316,6 +341,8 @@ public class SiteStreamGrpcClientTests Assert.Equal(1, completed); } + /// <summary>Verifies that a faulting stream invokes the error callback and not the completed callback.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConsumeStream_StreamFaults_InvokesOnError_NotOnCompleted() { @@ -337,6 +364,8 @@ public class SiteStreamGrpcClientTests Assert.Equal(0, completed); } + /// <summary>Verifies that cancelling the client's own token invokes neither the error nor the completed callback.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConsumeStream_OwnCancellation_InvokesNeitherCallback() { @@ -362,6 +391,8 @@ public class SiteStreamGrpcClientTests // --- Foreign vs. own Cancelled (review F2) --- + /// <summary>Verifies that a cancellation not initiated by the client invokes the error callback.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConsumeStream_ForeignCancelled_InvokesOnError() { @@ -390,6 +421,8 @@ public class SiteStreamGrpcClientTests Assert.False(cts.IsCancellationRequested); } + /// <summary>Verifies that a cancelled RPC exception caused by the client's own cancellation invokes neither callback.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConsumeStream_OwnCancelledRpcException_InvokesNeitherCallback() { @@ -415,6 +448,8 @@ public class SiteStreamGrpcClientTests // --- onConnected is only ever raised on real proof of a live peer (review F3) --- + /// <summary>Verifies that the connected callback is invoked exactly once when the response headers arrive.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConsumeStream_HeadersArrive_InvokesOnConnectedOnce() { @@ -439,6 +474,8 @@ public class SiteStreamGrpcClientTests Assert.Equal(1, connected); } + /// <summary>Verifies that a header timeout on a dead site never reports a connected state.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConsumeStream_HeaderTimeoutOnADeadSite_NeverReportsConnected() { @@ -464,6 +501,8 @@ public class SiteStreamGrpcClientTests Assert.Equal(0, connected); } + /// <summary>Verifies that a header timeout followed by an event reports connected exactly once, triggered by the event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConsumeStream_HeaderTimeoutThenEvent_ReportsConnectedOnceFromTheEvent() { @@ -514,6 +553,7 @@ public class SiteStreamGrpcClientTests } }; + /// <summary>Verifies that a plain frame is delivered unchanged.</summary> [Fact] public void ForEachEvent_PlainFrame_IsDeliveredAsIs() { @@ -528,6 +568,7 @@ public class SiteStreamGrpcClientTests Assert.Same(frame, Assert.Single(seen)); } + /// <summary>Verifies that a batch frame is unpacked in order with each event's own timestamp preserved.</summary> [Fact] public void ForEachEvent_BatchFrame_UnpacksInOrderPreservingPerEventTimestamps() { @@ -558,6 +599,7 @@ public class SiteStreamGrpcClientTests seen.Select(e => e.AttributeChanged.Timestamp.ToDateTimeOffset())); } + /// <summary>Verifies that nested batches and unknown inner cases are ignored.</summary> [Fact] public void ForEachEvent_IgnoresNestedAndUnknownInnerCases() { @@ -585,6 +627,8 @@ public class SiteStreamGrpcClientTests Assert.Equal(["0", "1"], seen.Select(e => e.AttributeChanged.Value)); } + /// <summary>Verifies that a stream mixing batched and plain frames delivers every event in order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConsumeStream_MixedBatchedAndPlainFrames_DeliverEveryEventInOrder() { @@ -638,19 +682,27 @@ public class SiteStreamGrpcClientTests private readonly Queue<SiteStreamEvent> _events; private readonly Exception? _fault; + /// <summary>Initializes the reader to yield the given events and then end the stream.</summary> + /// <param name="events">The events to yield.</param> public StubStreamReader(params SiteStreamEvent[] events) { _events = new Queue<SiteStreamEvent>(events); } + /// <summary>Initializes the reader to throw the given fault when read.</summary> + /// <param name="fault">The exception to throw.</param> public StubStreamReader(Exception fault) : this() { _fault = fault; } + /// <summary>Gets the event most recently read.</summary> public SiteStreamEvent Current { get; private set; } = null!; + /// <summary>Advances to the next queued event, or faults or completes as configured.</summary> + /// <param name="cancellationToken">A token to cancel the operation.</param> + /// <returns>A task whose result is <see langword="true"/> if an event was read; otherwise <see langword="false"/>.</returns> public Task<bool> MoveNext(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -665,14 +717,15 @@ public class SiteStreamGrpcClientTests } } - // --- Communication-003 regression tests --- + // --- Subscription registration regression tests --- + /// <summary>Verifies that reusing a correlation id cancels the prior token source without disposing it.</summary> [Fact] public void RegisterSubscription_ReusedCorrelationId_CancelsPriorCts_WithoutDisposingIt() { // Two SubscribeAsync calls briefly sharing a correlation ID (reconnect race). - // Inserting the second must CANCEL the first — and must NOT dispose it (arch-review - // 2026-08-19 P3-1, re-pinning the earlier cancel-AND-dispose contract): the prior + // Inserting the second must CANCEL the first — and must NOT dispose it (re-pinning the earlier + // cancel-AND-dispose contract): the prior // handler's ConsumeStreamAsync is still running and still reads prior.Token, so // disposing from the replacing path throws ObjectDisposedException inside that loop. // The owning SubscribeAsync/SubscribeSiteAsync disposes it as its loop unwinds. @@ -692,6 +745,7 @@ public class SiteStreamGrpcClientTests Assert.False(second.IsCancellationRequested); } + /// <summary>Verifies that replacing an already-disposed prior token source does not throw into the new subscription.</summary> [Fact] public void RegisterSubscription_ReplacingAnAlreadyDisposedPriorCts_DoesNotThrowIntoTheNewSubscription() { @@ -714,6 +768,7 @@ public class SiteStreamGrpcClientTests Assert.True(replacement.IsCancellationRequested); } + /// <summary>Verifies that unsubscribing after the client is disposed does nothing.</summary> [Fact] public void Unsubscribe_AfterClientDisposal_IsANoOp() { @@ -731,6 +786,7 @@ public class SiteStreamGrpcClientTests client.Unsubscribe("never-registered"); } + /// <summary>Verifies that disposing the client still cancels the remaining subscriptions when one token source is already disposed.</summary> [Fact] public void Dispose_WithAnAlreadyDisposedSubscriptionCts_StillCancelsTheRest() { @@ -753,6 +809,7 @@ public class SiteStreamGrpcClientTests Assert.True(live.IsCancellationRequested); } + /// <summary>Verifies that removing a subscription removes only its own token source and not a replacement registered under the same id.</summary> [Fact] public void RemoveSubscription_OnlyRemovesOwnCts_NotAReplacement() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcServerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcServerTests.cs index abdfb87a..d5457507 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcServerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcServerTests.cs @@ -23,6 +23,7 @@ public class SiteStreamGrpcServerTests : TestKit private readonly ISiteStreamSubscriber _subscriber; private readonly ILogger<SiteStreamGrpcServer> _logger; + /// <summary>Initializes a new instance of the <c>SiteStreamGrpcServerTests</c> class.</summary> public SiteStreamGrpcServerTests() { _subscriber = Substitute.For<ISiteStreamSubscriber>(); @@ -47,6 +48,8 @@ public class SiteStreamGrpcServerTests : TestKit }; } + /// <summary>Verifies that a stream request is rejected while the server is not ready.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RejectsWhenNotReady() { @@ -62,6 +65,8 @@ public class SiteStreamGrpcServerTests : TestKit Assert.Equal(StatusCode.Unavailable, ex.StatusCode); } + /// <summary>Verifies that a stream request is rejected once the maximum number of streams is reached.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RejectsWhenMaxStreamsReached() { @@ -93,6 +98,8 @@ public class SiteStreamGrpcServerTests : TestKit await stream1Task; } + /// <summary>Verifies that a second stream with the same correlation id cancels the first.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CancelsDuplicateCorrelationId() { @@ -128,6 +135,8 @@ public class SiteStreamGrpcServerTests : TestKit await stream2Task; } + /// <summary>Verifies that cancelling a stream cleans up its server-side state.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CleansUpOnCancellation() { @@ -149,12 +158,14 @@ public class SiteStreamGrpcServerTests : TestKit Assert.Equal(0, server.ActiveStreamCount); } - // --- Host-017 / REQ-HOST-7: site-shutdown ordering --- + // --- Site-shutdown ordering --- + /// <summary>Verifies that cancelling all streams cancels the active streams and refuses new ones.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Host017_CancelAllStreams_CancelsActiveStreamsAndRefusesNewOnes() { - // REQ-HOST-7 step (1)+(2): on CoordinatedShutdown the gRPC server must + // Shutdown steps (1)+(2): on CoordinatedShutdown the gRPC server must // stop accepting new streams AND cancel every active stream so the // client observes a clean Cancelled (not a silent stream that only // times out via keepalive). Program.cs registers @@ -192,6 +203,7 @@ public class SiteStreamGrpcServerTests : TestKit Assert.Contains("shutting", ex.Status.Detail, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that cancelling all streams a second time has no further effect.</summary> [Fact] public void Host017_CancelAllStreams_IsIdempotent() { @@ -206,6 +218,8 @@ public class SiteStreamGrpcServerTests : TestKit Assert.Equal(0, server.ActiveStreamCount); } + /// <summary>Verifies that a stream subscribes to the stream manager and is removed from it afterwards.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SubscribesAndRemovesFromStreamManager() { @@ -231,6 +245,8 @@ public class SiteStreamGrpcServerTests : TestKit _subscriber.Received(1).RemoveSubscriber(Arg.Any<IActorRef>()); } + /// <summary>Verifies that published events are written to the response stream.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WritesEventsToResponseStream() { @@ -275,11 +291,13 @@ public class SiteStreamGrpcServerTests : TestKit await streamTask; } - // --- SubscribeSite (site-wide, alarm-only aggregated stream, plan #10 T2) --- + // --- SubscribeSite (site-wide, alarm-only aggregated stream) --- private static SiteStreamRequest MakeSiteRequest(string correlationId = "site-corr-1") => new() { CorrelationId = correlationId }; + /// <summary>Verifies that a site subscription subscribes to site alarms and is removed on cancellation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SubscribeSite_SubscribesSiteAlarmsAndRemovesOnCancel() { @@ -307,6 +325,8 @@ public class SiteStreamGrpcServerTests : TestKit Assert.Equal(0, server.ActiveStreamCount); } + /// <summary>Verifies that a site subscription rejects an unsafe correlation id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SubscribeSite_RejectsUnsafeCorrelationId() { @@ -323,6 +343,8 @@ public class SiteStreamGrpcServerTests : TestKit Assert.Equal(0, server.ActiveStreamCount); } + /// <summary>Verifies that a site subscription relays an alarm state change as an alarm state update.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SubscribeSite_RelaysAlarmStateChangedAsAlarmStateUpdate() { @@ -371,6 +393,9 @@ public class SiteStreamGrpcServerTests : TestKit await streamTask; } + /// <summary>Verifies that a correlation id that is not safe as an actor name is rejected.</summary> + /// <param name="badCorrelationId">A correlation id that is not safe to use as an actor name.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("corr/with/slash")] [InlineData("corr with space")] @@ -378,7 +403,7 @@ public class SiteStreamGrpcServerTests : TestKit [InlineData("$weird")] public async Task RejectsCorrelationIdThatIsNotActorNameSafe(string badCorrelationId) { - // Communication-014 regression: a public gRPC SubscribeInstance must not feed + // Regression: a public gRPC SubscribeInstance must not feed // an untrusted correlation_id straight into an Akka actor name. An unsafe id // must be rejected cleanly with InvalidArgument rather than escaping as an // unhandled InvalidActorNameException. @@ -395,6 +420,8 @@ public class SiteStreamGrpcServerTests : TestKit Assert.Equal(0, server.ActiveStreamCount); } + /// <summary>Verifies that a correlation id that is safe as an actor name is accepted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AcceptsActorNameSafeCorrelationId() { @@ -415,10 +442,12 @@ public class SiteStreamGrpcServerTests : TestKit await streamTask; } + /// <summary>Verifies that when subscribing throws, the relay actor is stopped and the active stream entry is removed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Comm021_SubscribeThrows_StopsRelayActorAndRemovesActiveStreamEntry() { - // Communication-021 regression: SubscribeInstance creates a StreamRelayActor + // Regression: SubscribeInstance creates a StreamRelayActor // and registers an _activeStreams entry BEFORE calling _streamSubscriber.Subscribe. // If Subscribe throws (e.g. stale instance, site runtime shutting down) and the // pre-fix code lets the throw escape without the wrapping try, the relay actor @@ -449,6 +478,8 @@ public class SiteStreamGrpcServerTests : TestKit subscriber.DidNotReceive().RemoveSubscriber(Arg.Any<IActorRef>()); } + /// <summary>Verifies that the active subscribers gauge rises to one on connect and returns to zero on cancellation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiteStreamSubscribersActiveGauge_GoesToOneOnConnect_AndBackToZeroOnCancel() { @@ -512,6 +543,7 @@ public class SiteStreamGrpcServerTests : TestKit AwaitAssert(() => Assert.Equal(baseline, ReadGauge()), TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that marking the server ready allows streams to be created.</summary> [Fact] public void SetReady_AllowsStreamCreation() { @@ -540,8 +572,9 @@ public class SiteStreamGrpcServerTests : TestKit Assert.True(condition(), $"Condition not met within {timeoutMs}ms"); } - // ── WP2.3: the site-wide alarm feed gets its OWN, larger send channel ── + // ── The site-wide alarm feed gets its OWN, larger send channel ── + /// <summary>Verifies that the site alarm stream gets its own channel, larger than the debug view channel.</summary> [Fact] public void SiteAlarmStream_HasItsOwnLargerChannel_ThanTheDebugView() { @@ -556,6 +589,7 @@ public class SiteStreamGrpcServerTests : TestKit Assert.True(server.SiteAlarmChannelCapacity > server.InstanceChannelCapacity); } + /// <summary>Verifies that channel capacities are bound from options and never drop below one.</summary> [Fact] public void ChannelCapacities_AreBoundFromOptions_AndFloorAtOne() { @@ -581,6 +615,7 @@ public class SiteStreamGrpcServerTests : TestKit Assert.Equal(1, degenerate.SiteAlarmChannelCapacity); } + /// <summary>Verifies that the dropped stream event count starts at zero.</summary> [Fact] public void DroppedStreamEventCount_StartsAtZero() { @@ -590,6 +625,8 @@ public class SiteStreamGrpcServerTests : TestKit Assert.Equal(0, server.DroppedStreamEventCount); } + /// <summary>Verifies that a duplicate replacement cancels the replaced stream without disposing its cancellation token source.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DuplicateReplacement_CancelsTheReplacedStream_WithoutDisposingItsCts() { @@ -653,6 +690,7 @@ public class SiteStreamGrpcServerTests : TestKit // ── R2: gRPC event batching, and its negotiation ──────────────────────────── + /// <summary>Verifies that batch options are bound from configuration and degenerate values are clamped.</summary> [Fact] public void BatchOptions_AreBoundFromOptions_AndClampDegenerateValues() { @@ -674,6 +712,8 @@ public class SiteStreamGrpcServerTests : TestKit Assert.Equal(TimeSpan.Zero, degenerate.StreamBatchWindow); } + /// <summary>Verifies that a subscription that did not negotiate batching never receives a batch frame.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UnnegotiatedSubscription_NeverEmitsABatchFrame() { @@ -704,6 +744,8 @@ public class SiteStreamGrpcServerTests : TestKit GC.KeepAlive(server); } + /// <summary>Verifies that a batching subscription coalesces a backlog into fewer frames than events.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NegotiatedSubscription_CoalescesABacklogIntoFewerFramesThanEvents() { @@ -733,13 +775,15 @@ public class SiteStreamGrpcServerTests : TestKit $"batching produced {frames.Count} frames for {burst} events — no coalescing happened"); Assert.Contains(frames, f => f.EventCase == SiteStreamEvent.EventOneofCase.Batch); - // Order is preserved end to end: the values arrive 0..burst-1 exactly once each. + // Order is preserved end to end: the values arrive 0 to burst minus 1 exactly once each. var values = frames.SelectMany(FlattenAttributeValues).ToArray(); Assert.Equal(Enumerable.Range(0, burst).Select(i => i.ToString()), values); GC.KeepAlive(server); } + /// <summary>Verifies that the batch size histogram is recorded only for streams that negotiated batching.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BatchSizeHistogram_IsRecordedOnlyForNegotiatedStreams() { @@ -795,7 +839,7 @@ public class SiteStreamGrpcServerTests : TestKit } } - // ── P3-5: the concurrency cap must never refuse a REPLACEMENT ─────────────── + // ── The concurrency cap must never refuse a REPLACEMENT ─────────────── /// <summary> /// A <see cref="ServerCallContext"/> whose <see cref="ServerCallContext.CancellationToken"/> @@ -811,24 +855,38 @@ public class SiteStreamGrpcServerTests : TestKit private readonly Metadata _trailers = new(); private readonly Dictionary<object, object> _userState = new(); + /// <summary>Initializes a new instance of the <c>GatedServerCallContext</c> class.</summary> + /// <param name="token">The cancellation token the context exposes.</param> + /// <param name="onCancellationTokenRead">Callback invoked when the cancellation token is read.</param> public GatedServerCallContext(CancellationToken token, Action onCancellationTokenRead) { _token = token; _onCancellationTokenRead = onCancellationTokenRead; } + /// <inheritdoc /> protected override string MethodCore => "/sitestream.SiteStreamService/SubscribeInstance"; + /// <inheritdoc /> protected override string HostCore => "localhost"; + /// <inheritdoc /> protected override string PeerCore => "ipv4:127.0.0.1:0"; + /// <inheritdoc /> protected override DateTime DeadlineCore => DateTime.MaxValue; + /// <inheritdoc /> protected override Metadata RequestHeadersCore => Metadata.Empty; + /// <inheritdoc /> protected override Metadata ResponseTrailersCore => _trailers; + /// <inheritdoc /> protected override GrpcStatus StatusCore { get; set; } + /// <inheritdoc /> protected override WriteOptions? WriteOptionsCore { get; set; } + /// <inheritdoc /> protected override AuthContext AuthContextCore => new(null, new Dictionary<string, List<AuthProperty>>()); + /// <inheritdoc /> protected override IDictionary<object, object> UserStateCore => _userState; + /// <inheritdoc /> protected override CancellationToken CancellationTokenCore { get @@ -838,17 +896,21 @@ public class SiteStreamGrpcServerTests : TestKit } } + /// <inheritdoc /> protected override ContextPropagationToken CreatePropagationTokenCore( ContextPropagationOptions? options) => throw new NotSupportedException(); + /// <inheritdoc /> protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; } + /// <summary>Verifies that a duplicate reconnect at the cap is not refused and leaves exactly one live stream.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AtCapOvershoot_DuplicateReconnect_IsNotRefused_AndLeavesExactlyOneLiveStream() { - // P3-5. The duplicate-replacement path cancels the incumbent; the cap check then had + // The duplicate-replacement path cancels the incumbent; the cap check then had // to decide whether the REPLACEMENT may take its place. Checking `Count >= max` after // the removal is right at exactly the cap but wrong above it — and above it is // reachable, because the cap check is documented check-then-act: two subscribes that @@ -913,10 +975,12 @@ public class SiteStreamGrpcServerTests : TestKit await WaitForConditionAsync(() => server.ActiveStreamCount == 0); } + /// <summary>Verifies that a genuinely new subscriber at the cap is still refused and the incumbent survives.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AtCap_AGenuinelyNewSubscriber_IsStillRefused_AndTheIncumbentSurvives() { - // The other half of P3-5: excluding replacements from the cap must NOT weaken the cap + // The other half of that rule: excluding replacements from the cap must NOT weaken the cap // for a new correlation id, and a refusal must leave the existing streams untouched // (TryRemove found nothing, so nothing was cancelled before the throw). var server = CreateServer(maxStreams: 1); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs index 97fbcc36..0d8a2248 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs @@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; public class StreamRelayActorTests : TestKit { + /// <summary>Verifies that an attribute value change is relayed as a proto event.</summary> [Fact] public void RelaysAttributeValueChanged_ToProtoEvent() { @@ -49,6 +50,7 @@ public class StreamRelayActorTests : TestKit Assert.Equal(Timestamp.FromDateTimeOffset(timestamp), attr.Timestamp); } + /// <summary>Verifies that an alarm state change is relayed as a proto event.</summary> [Fact] public void RelaysAlarmStateChanged_ToProtoEvent() { @@ -113,10 +115,11 @@ public class StreamRelayActorTests : TestKit Assert.Equal("90", alarm.LimitValue); } + /// <summary>Verifies that the native binding linkage of an alarm state change survives a full round trip.</summary> [Fact] public void RelaysAlarmStateChanged_NativeBindingLinkage_SurvivesFullRoundTrip() { - // DV-1: the native source-binding canonical name must pack into AlarmStateUpdate + // The native source-binding canonical name must pack into AlarmStateUpdate // (StreamRelayActor) and unpack back out (SiteStreamGrpcClient.ConvertToDomainEvent) // — the full cross-process round trip for a real (non-placeholder) native alarm. var channel = Channel.CreateUnbounded<SiteStreamEvent>(); @@ -159,6 +162,7 @@ public class StreamRelayActorTests : TestKit Assert.False(roundTripped.IsConfiguredPlaceholder); } + /// <summary>Verifies that the acknowledgement time of an alarm state change survives a full round trip.</summary> [Fact] public void RelaysAlarmStateChanged_AckTime_SurvivesFullRoundTrip() { @@ -222,6 +226,7 @@ public class StreamRelayActorTests : TestKit return protoEvent!; } + /// <summary>Verifies that an alarm state change for a configured placeholder is dropped.</summary> [Fact] public void DropsAlarmStateChanged_WhenIsConfiguredPlaceholder() { @@ -255,6 +260,7 @@ public class StreamRelayActorTests : TestKit "Placeholder AlarmStateChanged must not be relayed to the gRPC stream"); } + /// <summary>Verifies that the correlation id is set on all relayed events.</summary> [Fact] public void SetsCorrelationId_OnAllEvents() { @@ -282,6 +288,7 @@ public class StreamRelayActorTests : TestKit Assert.All(events, e => Assert.Equal(correlationId, e.CorrelationId)); } + /// <summary>Verifies that an event is dropped when the channel is full.</summary> [Fact] public void DropsEvent_WhenChannelFull() { @@ -311,6 +318,9 @@ public class StreamRelayActorTests : TestKit Assert.False(channel.Reader.TryRead(out _)); } + /// <summary>Verifies that a quality string is mapped to the matching proto enum value.</summary> + /// <param name="qualityString">The quality string on the domain event.</param> + /// <param name="expectedProto">The expected proto quality value.</param> [Theory] [InlineData("Good", Quality.Good)] [InlineData("Uncertain", Quality.Uncertain)] @@ -332,6 +342,7 @@ public class StreamRelayActorTests : TestKit Assert.Equal(expectedProto, evt.AttributeChanged.Quality); } + /// <summary>Verifies that a null value is mapped to an empty string.</summary> [Fact] public void NullValue_MapsToEmptyString() { @@ -355,6 +366,7 @@ public class StreamRelayActorTests : TestKit Assert.Equal("", evt.AttributeChanged.Value); } + /// <summary>Verifies that a list value is encoded as a JSON array.</summary> [Fact] public void ListValue_EncodesAsJsonArray() { @@ -372,6 +384,7 @@ public class StreamRelayActorTests : TestKit Assert.Equal("[\"a\",\"b\"]", evt.AttributeChanged.Value); } + /// <summary>Verifies that a scalar string value passes through unchanged.</summary> [Fact] public void ScalarStringValue_PassesThroughUnchanged() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/GrpcSiteTransportDeadlineTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/GrpcSiteTransportDeadlineTests.cs index 44f79d1d..664eb1dd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/GrpcSiteTransportDeadlineTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/GrpcSiteTransportDeadlineTests.cs @@ -40,6 +40,8 @@ public class GrpcSiteTransportDeadlineTests private static readonly DateTimeOffset T = DateTimeOffset.UtcNow; + /// <summary>Supplies the command and expected deadline pairs for the deadline theory.</summary> + /// <returns>The theory data rows.</returns> public static IEnumerable<object[]> Cases() { // Lifecycle group — note it is NOT one deadline class. @@ -84,6 +86,10 @@ public class GrpcSiteTransportDeadlineTests yield return [new TriggerSiteFailover("c", "s"), Opts.QueryTimeout]; } + /// <summary>Verifies that ResolveDeadline returns the same timeout the Ask-based path used for each command.</summary> + /// <param name="command">The command.</param> + /// <param name="expected">The expected.</param> + /// <returns>The result.</returns> [Theory] [MemberData(nameof(Cases))] public void ResolveDeadline_MatchesTodaysAskTimeout(object command, TimeSpan expected) @@ -91,6 +97,8 @@ public class GrpcSiteTransportDeadlineTests Assert.Equal(expected, Transport.ResolveDeadline(command)); } + /// <summary>Verifies that a wait-for-attribute request uses its own timeout plus the integration slack as the deadline.</summary> + /// <returns>The result.</returns> [Fact] public void WaitForAttribute_UsesItsDynamicTimeoutPlusIntegrationSlack() { @@ -101,7 +109,9 @@ public class GrpcSiteTransportDeadlineTests private sealed class NoKeyProvider : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new("k"); + /// <inheritdoc /> public void Invalidate(string siteId) { } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/GrpcSiteTransportFailFastTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/GrpcSiteTransportFailFastTests.cs index fb934e0c..58d65b8c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/GrpcSiteTransportFailFastTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/GrpcSiteTransportFailFastTests.cs @@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <summary> -/// WP1.6: a send to a site with no configured gRPC channel must fail the caller's Ask immediately +/// A send to a site with no configured gRPC channel must fail the caller's Ask immediately /// (<see cref="Status.Failure"/> wrapping <see cref="SiteChannelUnavailableException"/>) instead of /// warning-and-dropping the message and leaving the Ask to expire at its full timeout. /// </summary> @@ -31,6 +31,7 @@ public class GrpcSiteTransportFailFastTests : TestKit Opts, NullLogger<GrpcSiteTransport>.Instance); + /// <summary>Verifies that a send to an unconfigured site faults the Ask quickly instead of timing out.</summary> [Fact] public void Send_ToUnconfiguredSite_FaultsTheAskFastInsteadOfTimingOut() { @@ -57,6 +58,8 @@ public class GrpcSiteTransportFailFastTests : TestKit Assert.Contains("unconfigured-site", cause.Message); } + /// <summary>Verifies that an Ask against an unconfigured site completes faulted and fast via a Tell of a status failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Ask_AgainstUnconfiguredSite_CompletesFaultedFastViaTellStatusFailure() { @@ -81,6 +84,8 @@ public class GrpcSiteTransportFailFastTests : TestKit /// <summary>Relays an Ask into the transport, exactly as <c>CentralCommunicationActor</c> does.</summary> private sealed class EchoingAskTarget : ReceiveActor { + /// <summary>Initializes the actor to relay every site envelope into the given transport.</summary> + /// <param name="transport">The transport that receives each relayed envelope.</param> public EchoingAskTarget(GrpcSiteTransport transport) { Receive<SiteEnvelope>(env => transport.Send(env, Sender)); @@ -89,7 +94,9 @@ public class GrpcSiteTransportFailFastTests : TestKit private sealed class NoKeyProvider : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new("k"); + /// <inheritdoc /> public void Invalidate(string siteId) { } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/HealthReportAckTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/HealthReportAckTests.cs index 31067de0..5143d191 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/HealthReportAckTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/HealthReportAckTests.cs @@ -28,6 +28,7 @@ public class HealthReportAckTests : TestKit new Dictionary<string, TagResolutionStatus>(), 0, 0, new Dictionary<string, int>(), 0, 0, 0, 0); + /// <summary>Verifies that the site communication actor replies with a failure rather than staying silent when no transport is configured.</summary> [Fact] public void SiteCommunicationActor_NoTransport_RepliesFailure_NotSilence() { @@ -42,6 +43,7 @@ public class HealthReportAckTests : TestKit ExpectMsg<Status.Failure>(); } + /// <summary>Verifies that the central communication actor processes a health report and acknowledges it.</summary> [Fact] public void CentralCommunicationActor_OnReport_ProcessesAndAcks() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/LooseValueCodecTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/LooseValueCodecTests.cs index c6d02191..9c57eb2a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/LooseValueCodecTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/LooseValueCodecTests.cs @@ -17,6 +17,8 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// </remarks> public class LooseValueCodecTests { + /// <summary>Provides scalar values of each supported CLR type for the round-trip theory.</summary> + /// <returns>The scalar test values, one per row.</returns> public static IEnumerable<object[]> TaggedScalars() => [ [true], @@ -36,6 +38,8 @@ public class LooseValueCodecTests [new byte[] { 1, 2, 3 }] ]; + /// <summary>Verifies that a tagged value keeps its runtime CLR type after a round trip through the codec.</summary> + /// <param name="value">The boxed value to round-trip.</param> [Theory] [MemberData(nameof(TaggedScalars))] public void TaggedValues_KeepTheirClrType(object value) diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/LowReviewTransportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/LowReviewTransportTests.cs index 541d0299..1941c449 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/LowReviewTransportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/LowReviewTransportTests.cs @@ -12,6 +12,9 @@ public class LowReviewTransportTests private static SiteStreamGrpcServer Server() => new( Substitute.For<ISiteStreamSubscriber>(), NullLogger<SiteStreamGrpcServer>.Instance); + /// <summary>Verifies that pull requests with an oversized batch size are rejected before any rows are read.</summary> + /// <param name="size">The oversized batch size requested.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(2001)] [InlineData(int.MaxValue)] @@ -33,6 +36,8 @@ public class LowReviewTransportTests Assert.Empty(store.ReceivedCalls()); } + /// <summary>Verifies that the retired site ingest RPCs return Unimplemented even for empty batches.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetiredSiteIngestRpcs_ReturnUnimplementedEvenForEmptyBatches() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/MessageContractTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/MessageContractTests.cs index 7e18509e..e23d219d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/MessageContractTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/MessageContractTests.cs @@ -3,10 +3,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <summary> -/// WP-1: Tests that message contracts have correlation IDs and proper structure. +/// Tests that message contracts have correlation IDs and proper structure. /// </summary> public class MessageContractTests { + /// <summary>Verifies that the event log query request has a correlation id.</summary> [Fact] public void EventLogQueryRequest_HasCorrelationId() { @@ -16,6 +17,7 @@ public class MessageContractTests Assert.Equal("corr-456", msg.CorrelationId); } + /// <summary>Verifies that the event log query response has a correlation id.</summary> [Fact] public void EventLogQueryResponse_HasCorrelationId() { @@ -25,6 +27,7 @@ public class MessageContractTests Assert.Equal("corr-456", msg.CorrelationId); } + /// <summary>Verifies that the parked message query request has a correlation id.</summary> [Fact] public void ParkedMessageQueryRequest_HasCorrelationId() { @@ -34,6 +37,7 @@ public class MessageContractTests Assert.Equal("corr-789", msg.CorrelationId); } + /// <summary>Verifies that the parked message query response has a correlation id.</summary> [Fact] public void ParkedMessageQueryResponse_HasCorrelationId() { @@ -43,6 +47,7 @@ public class MessageContractTests Assert.Equal("corr-789", msg.CorrelationId); } + /// <summary>Verifies that every message pattern exists as a record type.</summary> [Fact] public void AllMessagePatterns_ExistAsRecordTypes() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/PendingDeploymentPurgeActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/PendingDeploymentPurgeActorTests.cs index a9e5f893..8735703a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/PendingDeploymentPurgeActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/PendingDeploymentPurgeActorTests.cs @@ -39,6 +39,7 @@ public class PendingDeploymentPurgeActorTests : TestKit sp, options, NullLogger<PendingDeploymentPurgeActor>.Instance))); } + /// <summary>Verifies that a tick purges expired rows using the current time as the threshold.</summary> [Fact] public void Tick_PurgesExpiredRows_WithNowThreshold() { @@ -72,6 +73,7 @@ public class PendingDeploymentPurgeActorTests : TestKit Assert.InRange(captured, before, after); } + /// <summary>Verifies that the actor survives a purge failure and retries on the next interval.</summary> [Fact] public void Tick_PurgeThrows_ActorSurvives_AndRetriesOnNextInterval() { @@ -99,7 +101,7 @@ public class PendingDeploymentPurgeActorTests : TestKit } finally { - // Row-95/row-82 pattern: a 100ms self-scheduling ticker left running strands + // Pattern: a 100ms self-scheduling ticker left running strands // later tests in the same assembly (it keeps firing — and keeps calling the // now-disposed repo/service-provider — for the rest of the process). Stop it // deterministically before the test ends. diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Protos/AuditEventProtoTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Protos/AuditEventProtoTests.cs index 0c53dafc..28b9a85f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Protos/AuditEventProtoTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Protos/AuditEventProtoTests.cs @@ -5,12 +5,13 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Protos; /// <summary> -/// Wire-format round-trip tests for the Audit Log (#23) telemetry proto messages +/// Wire-format round-trip tests for the Audit Log telemetry proto messages /// (<see cref="AuditEventDto"/>, <see cref="AuditEventBatch"/>, <see cref="IngestAck"/>). /// Locks the additive contract the site → central audit pipeline depends on. /// </summary> public class AuditEventProtoTests { + /// <summary>Verifies that an <c>AuditEventDto</c> round trip preserves all fields.</summary> [Fact] public void AuditEventDto_RoundTrip_PreservesAllFields() { @@ -66,6 +67,7 @@ public class AuditEventProtoTests Assert.Equal(original.Extra, deserialized.Extra); } + /// <summary>Verifies that an unset nullable integer is absent from the wire encoding.</summary> [Fact] public void AuditEventDto_NullableInt_AbsentByDefault_NotIncludedInWire() { @@ -90,6 +92,7 @@ public class AuditEventProtoTests Assert.Null(deserialized.DurationMs); } + /// <summary>Verifies that an empty <c>AuditEventBatch</c> round-trips to an empty event list.</summary> [Fact] public void AuditEventBatch_Empty_RoundTrip_Yields_EmptyEvents() { @@ -102,6 +105,7 @@ public class AuditEventProtoTests Assert.Empty(deserialized.Events); } + /// <summary>Verifies that an <c>IngestAck</c> preserves its accepted event ids.</summary> [Fact] public void IngestAck_PreservesAcceptedEventIds() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Protos/CachedTelemetryProtoTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Protos/CachedTelemetryProtoTests.cs index ae0e58d7..5688b6b9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Protos/CachedTelemetryProtoTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Protos/CachedTelemetryProtoTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Protos; /// <summary> -/// Wire-format round-trip tests for the Audit Log (#23) M3 cached-telemetry +/// Wire-format round-trip tests for the Audit Log cached-telemetry /// proto messages (<see cref="SiteCallOperationalDto"/>, /// <see cref="CachedTelemetryPacket"/>, <see cref="CachedTelemetryBatch"/>). /// Locks the additive contract the central dual-write transaction depends on. @@ -23,6 +23,7 @@ public class CachedTelemetryProtoTests SourceSiteId = "site-1", }; + /// <summary>Verifies that a <c>SiteCallOperationalDto</c> round trip preserves all fields.</summary> [Fact] public void SiteCallOperationalDto_RoundTrip_PreservesAllFields() { @@ -66,6 +67,7 @@ public class CachedTelemetryProtoTests Assert.Equal(original.TerminalAtUtc, deserialized.TerminalAtUtc); } + /// <summary>Verifies that the terminal timestamp is absent when the call is not terminal.</summary> [Fact] public void SiteCallOperationalDto_TerminalAt_AbsentWhenNotTerminal() { @@ -91,6 +93,7 @@ public class CachedTelemetryProtoTests Assert.Null(deserialized.TerminalAtUtc); } + /// <summary>Verifies that the nullable HTTP status is absent by default.</summary> [Fact] public void SiteCallOperationalDto_NullableHttpStatus_AbsentByDefault() { @@ -116,6 +119,7 @@ public class CachedTelemetryProtoTests Assert.Null(deserialized.HttpStatus); } + /// <summary>Verifies that a <c>CachedTelemetryPacket</c> round trip preserves its nested entities.</summary> [Fact] public void CachedTelemetryPacket_RoundTrip_PreservesNestedEntities() { @@ -161,6 +165,7 @@ public class CachedTelemetryProtoTests Assert.Equal(operationalDto.LastError, deserialized.Operational.LastError); } + /// <summary>Verifies that an empty <c>CachedTelemetryBatch</c> round trip yields no packets.</summary> [Fact] public void CachedTelemetryBatch_Empty_RoundTrip_Yields_EmptyPackets() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Protos/PullAuditEventsProtoTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Protos/PullAuditEventsProtoTests.cs index 9619cbb6..52dc2426 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Protos/PullAuditEventsProtoTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Protos/PullAuditEventsProtoTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Protos; /// <summary> -/// Wire-format round-trip tests for the Audit Log (#23) M6 reconciliation +/// Wire-format round-trip tests for the Audit Log reconciliation /// pull proto messages (<see cref="PullAuditEventsRequest"/>, /// <see cref="PullAuditEventsResponse"/>). Locks the additive contract the /// central→site reconciliation puller depends on. @@ -23,6 +23,7 @@ public class PullAuditEventsProtoTests SourceSiteId = "site-1", }; + /// <summary>Verifies that a <c>PullAuditEventsRequest</c> survives a serialize and parse round trip unchanged.</summary> [Fact] public void PullAuditEventsRequest_RoundTrip() { @@ -42,6 +43,7 @@ public class PullAuditEventsProtoTests Assert.Equal(250, deserialized.BatchSize); } + /// <summary>Verifies that a <c>PullAuditEventsResponse</c> with events and the more-available flag survives a round trip.</summary> [Fact] public void PullAuditEventsResponse_RoundTrip_WithEvents_And_MoreAvailable() { @@ -67,6 +69,7 @@ public class PullAuditEventsProtoTests } } + /// <summary>Verifies that an empty <c>PullAuditEventsResponse</c> parses back with no events.</summary> [Fact] public void PullAuditEventsResponse_Empty_Yields_EmptyEvents() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/ReconcileServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/ReconcileServiceTests.cs index 15eb13d2..13227a73 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/ReconcileServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/ReconcileServiceTests.cs @@ -62,6 +62,8 @@ public class ReconcileServiceTests new(SiteIdentifier, "node-a", local.ToDictionary(x => x.Name, x => x.Rev)); + /// <summary>Verifies that reconciliation reports missing and stale instances as gaps with fresh tokens and snapshot deployment ids, and omits current ones.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_GapIsMissingAndStale_CurrentOmitted_WithFreshTokensAndSnapshotDeploymentIds() { @@ -99,6 +101,8 @@ public class ReconcileServiceTests Assert.Empty(response.OrphanNames); } + /// <summary>Verifies that a local instance name that is not expected is reported as an orphan.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_LocalNameNotInExpected_IsReportedAsOrphan() { @@ -116,6 +120,8 @@ public class ReconcileServiceTests Assert.Equal("inst-Z", orphan); } + /// <summary>Verifies that when staging a pending deployment returns false and a row already exists, the gap carries the existing token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_StagePendingReturnsFalse_ExistingRowPresent_ReturnsExistingTokenInGap() { @@ -162,6 +168,8 @@ public class ReconcileServiceTests Assert.True(gapC.IsEnabled); } + /// <summary>Verifies that when staging a pending deployment returns false and no row exists, that gap item is omitted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_StagePendingReturnsFalse_NoExistingRow_OmitsThatGapItem() { @@ -192,6 +200,8 @@ public class ReconcileServiceTests Assert.DoesNotContain(response.Gap, g => g.InstanceUniqueName == "inst-C"); } + /// <summary>Verifies that a gap item whose snapshot is missing is skipped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_SnapshotMissing_SkipsGapItem() { @@ -207,6 +217,8 @@ public class ReconcileServiceTests Assert.Empty(response.OrphanNames); } + /// <summary>Verifies that reconciling an unknown site returns an empty response without throwing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_UnknownSite_ReturnsEmptyResponse_NoThrow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs index e0f164bf..caf02437 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs @@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <summary> -/// Tests for <see cref="SiteAlarmLiveCacheService"/> (plan #10, Task 4) — the DI singleton +/// Tests for <see cref="SiteAlarmLiveCacheService"/> — the DI singleton /// that reference-counts Alarm Summary viewers over one shared per-site aggregator. Covers /// shared start (one aggregator for many viewers), last-out linger stop, and re-subscribe /// cancelling a pending stop. Uses a real (in-memory) ActorSystem and a mock gRPC factory @@ -30,6 +30,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit private readonly List<Action> _connects = new(); private readonly List<Action> _completions = new(); + /// <summary>Initializes a hanging client with no pending subscriptions.</summary> public HangingClient() : base() { } /// <summary>When false the site never accepts the subscription (no connect signal).</summary> @@ -44,7 +45,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit private int _streamEnds; /// <summary> - /// Register row 40: how many site-wide subscriptions have been torn down (their + /// How many site-wide subscriptions have been torn down (their /// cancellation token fired). This is the aggregator-stop observable — the linger /// stop cancels the aggregator's token, which ends the hanging subscribe below. /// Tests that must show an aggregator was NOT stopped assert on this rather than @@ -52,6 +53,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit /// </summary> public int StreamEndCount => Volatile.Read(ref _streamEnds); + /// <inheritdoc /> public override Task SubscribeSiteAsync( string correlationId, Action<Commons.Messages.Streaming.AlarmStateChanged> onAlarmEvent, Action<Exception> onError, Action onCompleted, CancellationToken ct, @@ -73,6 +75,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit }); return tcs.Task; } + /// <inheritdoc /> public override void Unsubscribe(string correlationId) { } } @@ -84,14 +87,17 @@ public class SiteAlarmLiveCacheServiceTests : TestKit /// <summary>The single client this factory hands out, for connect/complete control.</summary> public HangingClient Client => _client; + /// <summary>Initializes a counting factory backed by a single hanging client.</summary> public CountingFactory() : base(NullLoggerFactory.Instance) { } + /// <inheritdoc /> public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint) { Interlocked.Increment(ref GetOrCreateCount); return _client; } + /// <inheritdoc /> public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint) => _client; } @@ -137,7 +143,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit } /// <summary> - /// Row-40 house pattern for an ABSENCE assertion (mirrors the Medium-wave + /// House pattern for an ABSENCE assertion (mirrors the /// <c>SiteAlarmAggregatorActorTests.AssertHoldsFor</c> treatment in this same test /// project): proves every check in <paramref name="checks"/> keeps holding throughout /// <paramref name="window"/> by polling, rather than a single blind @@ -160,10 +166,11 @@ public class SiteAlarmLiveCacheServiceTests : TestKit } } + /// <summary>Verifies that the seed fan-out publishes the instances that failed to answer.</summary> [Fact] public void Seed_FanOut_Publishes_The_Instances_That_Failed_To_Answer() { - // Arch-review phase-2 residual #4: the seed/reconcile fan-out already knows which + // The seed/reconcile fan-out already knows which // Enabled instances failed to answer and used to discard it, forcing the Alarm Summary // page to run a SECOND identical fan-out purely to rebuild that list. It is now // published alongside the snapshot. @@ -191,6 +198,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit Assert.Equal(new[] { "inst-a", "inst-b" }, service.GetNotReportingInstances(SiteId)); } + /// <summary>Verifies that the not-reporting set is empty for an unknown site.</summary> [Fact] public void NotReporting_Is_Empty_For_An_Unknown_Site() { @@ -199,6 +207,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit Assert.Empty(service.GetNotReportingInstances(SiteId)); } + /// <summary>Verifies that the first subscriber starts one aggregator that is shared by multiple viewers.</summary> [Fact] public void First_Subscriber_Starts_One_Aggregator_Shared_By_Multiple_Viewers() { @@ -220,6 +229,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit AwaitCondition(() => changes1 >= 1 && changes2 >= 1, TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that the aggregator stops after the linger period once the last viewer leaves.</summary> [Fact] public void Last_Viewer_Leaving_Stops_Aggregator_After_Linger() { @@ -232,7 +242,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit sub1.Dispose(); - // Register row 40: one viewer remains, so the aggregator must NOT be stopped. The old + // One viewer remains, so the aggregator must NOT be stopped. The old // test slept 300 ms and sampled IsLive once — a teardown that happened at any other // instant (or one that stopped and restarted the stream) was invisible to it. Poll // CONTINUOUSLY across three linger windows instead, bailing out the moment either @@ -259,6 +269,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit Assert.Empty(service.GetCurrentAlarms(SiteId)); } + /// <summary>Verifies that resubscribing within the linger period cancels the pending stop.</summary> [Fact] public void Resubscribe_Within_Linger_Cancels_The_Pending_Stop() { @@ -271,7 +282,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit sub1.Dispose(); // schedules linger stop var sub2 = service.Subscribe(SiteId, () => { }); // returns within the linger window → cancels it - // Register row 40: the pending stop must be CANCELLED, not merely outrun. The old test + // The pending stop must be CANCELLED, not merely outrun. The old test // slept 700 ms and sampled once — it could not see a stream that was torn down and // silently replaced inside that window, because GetOrCreateCount is checked only at the // end and the mock hands back the same client object either way. Poll continuously past @@ -295,6 +306,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit AwaitCondition(() => factory.Client.StreamEndCount > 0, TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that disposing the service more than once is safe.</summary> [Fact] public void Dispose_Is_Idempotent() { @@ -308,6 +320,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit AwaitCondition(() => !service.IsLive(SiteId), TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that a transient aggregator start failure self-heals on retry while the viewer cohort is stable.</summary> [Fact] public void Transient_Start_Failure_Self_Heals_On_Retry_For_A_Stable_Viewer_Cohort() { @@ -355,6 +368,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit AwaitCondition(() => service.IsLive(SiteId), TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that a viewer over the per-site cap is rejected safely and not counted.</summary> [Fact] public void Viewer_Over_The_Per_Site_Cap_Is_Rejected_Safely_And_Not_Counted() { @@ -387,6 +401,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit AwaitCondition(() => !service.IsLive(SiteId), TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that an unknown site registers the viewer but never goes live.</summary> [Fact] public void Unknown_Site_Registers_Viewer_But_Never_Goes_Live() { @@ -400,6 +415,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit // ── R2 T15: single-endpoint sites can go live (N9) ── + /// <summary>Verifies that a site with only a node A endpoint starts an aggregator against that endpoint.</summary> [Fact] public void SiteWithOnlyNodeAEndpoint_StartsAnAggregator_AgainstThatEndpoint() { @@ -444,6 +460,8 @@ public class SiteAlarmLiveCacheServiceTests : TestKit // ── R2 T10: deathwatch un-sticks IsLive on aggregator death (N6) ── + /// <summary>Verifies that the death of the aggregator resets the live flag and clears the snapshot.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Aggregator_Death_Resets_IsLive_And_Clears_The_Snapshot() { @@ -463,6 +481,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit }, TimeSpan.FromSeconds(10)); } + /// <summary>Verifies that a linger stop does not resurrect the aggregator.</summary> [Fact] public void Linger_Stop_Does_Not_Resurrect_The_Aggregator() { @@ -478,8 +497,9 @@ public class SiteAlarmLiveCacheServiceTests : TestKit (() => factory.GetOrCreateCount == startsAfterStop, "no self-heal restart fired")); } - // ── WP2.3: IsLive tracks the STREAM, not merely "a snapshot was published once" ── + // ── IsLive tracks the STREAM, not merely "a snapshot was published once" ── + /// <summary>Verifies that the live flag stays false until the site accepts the stream.</summary> [Fact] public void IsLive_StaysFalse_UntilTheSiteAcceptsTheStream() { @@ -501,6 +521,7 @@ public class SiteAlarmLiveCacheServiceTests : TestKit AwaitCondition(() => service.IsLive(SiteId), TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that the live flag drops when the stream completes.</summary> [Fact] public void IsLive_DropsWhenTheStreamCompletes() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheTelemetryTests.cs index 90bca491..42c3c62c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheTelemetryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheTelemetryTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Observability; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <summary> -/// Telemetry guards for the aggregated live alarm cache (plan #10, Task 6): the +/// Telemetry guards for the aggregated live alarm cache: the /// active-aggregator observable gauge reflects Started/Stopped, and the reconnect /// counter increments on each recorded reconnect. Mirrors the MeterListener read /// pattern used by <c>SiteStreamGrpcServerTests.SiteStreamSubscribersActiveGauge_*</c>; reads are @@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <see cref="ScadaBridgeTelemetry.LiveAlarmAggregatorStarted"/>/ /// <see cref="ScadaBridgeTelemetry.LiveAlarmAggregatorStopped"/> from its own /// PreStart/PostStop, so every actor <c>Grpc.SiteAlarmAggregatorActorTests</c> spins up -/// moves this exact gauge — serialized into the same collection (row-96 pattern) so the +/// moves this exact gauge — serialized into the same collection (the same pattern the other telemetry tests use) so the /// two classes never interleave. /// </remarks> [Collection("SiteAlarmAggregatorGauge")] @@ -61,6 +61,7 @@ public class SiteAlarmLiveCacheTelemetryTests return delta; } + /// <summary>Verifies that the active aggregator gauge reflects aggregator start and stop.</summary> [Fact] public void ActiveAggregatorGauge_ReflectsStartAndStop() { @@ -92,6 +93,7 @@ public class SiteAlarmLiveCacheTelemetryTests Assert.Equal(baseline, ReadGauge(gauge)); } + /// <summary>Verifies that the reconnect counter increments on each recorded reconnect.</summary> [Fact] public void ReconnectCounter_IncrementsOnEachRecordedReconnect() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCallDtoMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCallDtoMapperTests.cs index e3a71dff..ea752211 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCallDtoMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCallDtoMapperTests.cs @@ -15,6 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// </summary> public class SiteCallDtoMapperTests { + /// <summary>Verifies the case: from Dto, fully Populated, maps Every Field.</summary> [Fact] public void FromDto_FullyPopulated_MapsEveryField() { @@ -55,6 +56,7 @@ public class SiteCallDtoMapperTests Assert.Equal(terminalAt, entity.TerminalAtUtc); } + /// <summary>Verifies the case: from Dto, empty Last Error, becomes Null.</summary> [Fact] public void FromDto_EmptyLastError_BecomesNull() { @@ -66,6 +68,7 @@ public class SiteCallDtoMapperTests Assert.Null(entity.LastError); } + /// <summary>Verifies the case: from Dto, absent Http Status, stays Null.</summary> [Fact] public void FromDto_AbsentHttpStatus_StaysNull() { @@ -80,6 +83,7 @@ public class SiteCallDtoMapperTests Assert.Null(entity.HttpStatus); } + /// <summary>Verifies the case: from Dto, absent Terminal At, stays Null.</summary> [Fact] public void FromDto_AbsentTerminalAt_StaysNull() { @@ -93,6 +97,7 @@ public class SiteCallDtoMapperTests Assert.Null(entity.TerminalAtUtc); } + /// <summary>Verifies the case: from Dto, timestamps, rehydrate As Utc Kind.</summary> [Fact] public void FromDto_Timestamps_RehydrateAsUtcKind() { @@ -104,6 +109,7 @@ public class SiteCallDtoMapperTests Assert.Equal(DateTimeKind.Utc, entity.UpdatedAtUtc.Kind); } + /// <summary>Verifies the case: from Dto, ingested At Utc, stamped As Recent Placeholder.</summary> [Fact] public void FromDto_IngestedAtUtc_StampedAsRecentPlaceholder() { @@ -118,12 +124,14 @@ public class SiteCallDtoMapperTests Assert.Equal(DateTimeKind.Utc, entity.IngestedAtUtc.Kind); } + /// <summary>Verifies the case: from Dto, null, throws.</summary> [Fact] public void FromDto_Null_Throws() { Assert.Throws<ArgumentNullException>(() => SiteCallDtoMapper.FromDto(null!)); } + /// <summary>Verifies the case: site Call Operational Dto, round, trip, preserves, source Node.</summary> [Fact] public void SiteCallOperationalDto_round_trip_preserves_SourceNode() { @@ -141,6 +149,7 @@ public class SiteCallDtoMapperTests Assert.Equal("node-a", entity.SourceNode); } + /// <summary>Verifies the case: site Call Operational Dto, round, trip, preserves, null, source Node.</summary> [Fact] public void SiteCallOperationalDto_round_trip_preserves_null_SourceNode() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDispatcherTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDispatcherTests.cs index 6a5da87f..c9b97138 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDispatcherTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDispatcherTests.cs @@ -24,6 +24,7 @@ public class SiteCommandDispatcherTests : TestKit // ── The 19 commands that forward to the Deployment Manager singleton proxy ── /// <summary>The proxy-routed commands (lifecycle, OPC UA, debug snapshot/subscribe, route).</summary> + /// <returns>The proxy-routed command types, one per xunit member-data row.</returns> public static IEnumerable<object[]> ProxyCommandTypes() => new[] { typeof(Commons.Messages.Deployment.RefreshDeploymentCommand), @@ -47,6 +48,8 @@ public class SiteCommandDispatcherTests : TestKit typeof(Commons.Messages.InboundApi.RouteToWaitForAttributeRequest), }.Select(t => new object[] { t }); + /// <summary>Verifies that each proxy-routed command is forwarded to the Deployment Manager proxy.</summary> + /// <param name="commandType">The command message type under test.</param> [Theory] [MemberData(nameof(ProxyCommandTypes))] public void ProxyCommands_ForwardToDeploymentManager(Type commandType) @@ -61,6 +64,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.Same(dm.Ref, route.Target); } + /// <summary>Verifies that unsubscribing a debug view is forwarded to the Deployment Manager as fire-and-forget and answered with a synthetic acknowledgement.</summary> [Fact] public void UnsubscribeDebugView_IsFireAndForget_ToDeploymentManager_WithSyntheticAck() { @@ -79,6 +83,7 @@ public class SiteCommandDispatcherTests : TestKit // ── Artifact handler (null-guarded) ── + /// <summary>Verifies that a deploy-artifacts command with a handler is forwarded to the artifact handler and not to the proxy.</summary> [Fact] public void DeployArtifacts_WithHandler_ForwardsToArtifactHandler_NotTheProxy() { @@ -94,6 +99,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.NotSame(dm.Ref, route.Target); } + /// <summary>Verifies that a deploy-artifacts command without a handler replies that the handler is not available.</summary> [Fact] public void DeployArtifacts_WithoutHandler_RepliesHandlerNotAvailable() { @@ -113,6 +119,7 @@ public class SiteCommandDispatcherTests : TestKit // ── Event-log handler (null-guarded) ── + /// <summary>Verifies that an event log query with a handler is forwarded to the event log handler.</summary> [Fact] public void EventLogQuery_WithHandler_ForwardsToEventLogHandler() { @@ -127,6 +134,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.Same(eventLog.Ref, route.Target); } + /// <summary>Verifies that an event log query without a handler replies that the handler is not available.</summary> [Fact] public void EventLogQuery_WithoutHandler_RepliesHandlerNotAvailable() { @@ -145,6 +153,7 @@ public class SiteCommandDispatcherTests : TestKit // ── Parked handler (null-guarded) — stays NODE-LOCAL on purpose (replicated store) ── /// <summary>The five parked commands, each of which routes to the per-node parked handler.</summary> + /// <returns>The parked command types, one per xunit member-data row.</returns> public static IEnumerable<object[]> ParkedCommandTypes() => new[] { typeof(ParkedMessageQueryRequest), @@ -154,6 +163,8 @@ public class SiteCommandDispatcherTests : TestKit typeof(DiscardParkedOperation), }.Select(t => new object[] { t }); + /// <summary>Verifies that each parked command with a handler is routed to the node-local parked handler and never to the proxy.</summary> + /// <param name="commandType">The command message type under test.</param> [Theory] [MemberData(nameof(ParkedCommandTypes))] public void ParkedCommands_WithHandler_RouteToNodeLocalParkedHandler_NeverTheProxy(Type commandType) @@ -173,6 +184,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.NotSame(dm.Ref, route.Target); } + /// <summary>Verifies that a parked message query without a handler replies that the handler is not available.</summary> [Fact] public void ParkedMessageQuery_WithoutHandler_RepliesHandlerNotAvailable() { @@ -187,6 +199,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.Equal(command.PageNumber, reply.PageNumber); } + /// <summary>Verifies that a parked message retry without a handler replies that the handler is not available.</summary> [Fact] public void ParkedMessageRetry_WithoutHandler_RepliesHandlerNotAvailable() { @@ -198,6 +211,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.Equal(command.CorrelationId, reply.CorrelationId); } + /// <summary>Verifies that a parked message discard without a handler replies that the handler is not available.</summary> [Fact] public void ParkedMessageDiscard_WithoutHandler_RepliesHandlerNotAvailable() { @@ -209,6 +223,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.Equal(command.CorrelationId, reply.CorrelationId); } + /// <summary>Verifies that retrying a parked operation without a handler replies with a not-applied acknowledgement.</summary> [Fact] public void RetryParkedOperation_WithoutHandler_RepliesNotAppliedAck() { @@ -221,6 +236,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.NotNull(reply.ErrorMessage); } + /// <summary>Verifies that discarding a parked operation without a handler replies with a not-applied acknowledgement.</summary> [Fact] public void DiscardParkedOperation_WithoutHandler_RepliesNotAppliedAck() { @@ -234,6 +250,7 @@ public class SiteCommandDispatcherTests : TestKit // ── Commands that must NOT enter ResolveRoute ── + /// <summary>Verifies that route resolution rejects the failover command, which must go through PrepareFailover instead.</summary> [Fact] public void ResolveRoute_RejectsFailover_ItGoesThroughPrepareFailover() { @@ -244,6 +261,7 @@ public class SiteCommandDispatcherTests : TestKit // ── Failover (local path) ── + /// <summary>Verifies that handling a failover resolves the target, leaves the cluster, and then acknowledges with the target.</summary> [Fact] public void HandleFailover_ResolvesAndLeaves_ThenAcksWithTheTarget() { @@ -269,6 +287,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.True(leaveIssued); } + /// <summary>Verifies that handling a failover is refused when there is no peer node.</summary> [Fact] public void HandleFailover_RefusesWhenThereIsNoPeer() { @@ -281,6 +300,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.NotNull(ack.ErrorMessage); } + /// <summary>Verifies that a failover command addressed to another site is refused without touching the resolver.</summary> [Fact] public void HandleFailover_RefusesACommandAddressedToAnotherSite_WithoutTouchingTheResolver() { @@ -294,6 +314,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.False(invoked); } + /// <summary>Verifies that a fault while leaving the cluster is reported in the reply rather than thrown.</summary> [Fact] public void HandleFailover_FaultInTheLeave_IsReportedNotThrown() { @@ -306,6 +327,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.Contains("cluster unavailable", ack.ErrorMessage); } + /// <summary>Verifies that PrepareFailover builds the acknowledgement from a dry run and does not leave the cluster until the commit-leave step runs.</summary> [Fact] public void PrepareFailover_BuildsTheAckFromADryRun_WithoutLeaving_UntilCommitLeaveRuns() { @@ -334,6 +356,7 @@ public class SiteCommandDispatcherTests : TestKit Assert.Equal(new[] { "resolve", "leave" }, events); } + /// <summary>Verifies that a refused PrepareFailover has no deferred leave to commit.</summary> [Fact] public void PrepareFailover_WhenRefused_HasNoDeferredLeave() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDtoMapperGoldenTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDtoMapperGoldenTests.cs index 6013ca64..51dc16fc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDtoMapperGoldenTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDtoMapperGoldenTests.cs @@ -47,6 +47,9 @@ public class SiteCommandDtoMapperGoldenTests .SelectMany(kv => Enumerable.Range(0, kv.Value.Length) .Select(i => new object[] { kv.Key.FullName!, i })); + /// <summary>Verifies the case: round Trips, unchanged.</summary> + /// <param name="typeName">The typeName test input.</param> + /// <param name="sampleIndex">The sampleIndex test input.</param> [Theory] [MemberData(nameof(AllSamples))] public void RoundTrips_Unchanged(string typeName, int sampleIndex) @@ -133,6 +136,7 @@ public class SiteCommandDtoMapperGoldenTests /// declared in the four multi-command request envelopes. A new proto case /// with no producing command fails here. /// </summary> + /// <param name="envelopeName">The name of the request envelope under test.</param> [Theory] [MemberData(nameof(RequestEnvelopes))] public void EveryRequestOneofCase_IsProducedByACommand(string envelopeName) @@ -152,6 +156,7 @@ public class SiteCommandDtoMapperGoldenTests } /// <summary>The reply-envelope mirror of <see cref="EveryRequestOneofCase_IsProducedByACommand"/>.</summary> + /// <param name="envelopeName">The name of the reply envelope under test.</param> [Theory] [MemberData(nameof(ReplyEnvelopes))] public void EveryReplyOneofCase_IsProducedByAReply(string envelopeName) @@ -232,6 +237,8 @@ public class SiteCommandDtoMapperGoldenTests // Enum exhaustiveness // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies the case: deployment Status, round Trips.</summary> + /// <param name="value">The value test input.</param> [Theory] [InlineData(DeploymentStatus.Pending)] [InlineData(DeploymentStatus.InProgress)] @@ -240,6 +247,8 @@ public class SiteCommandDtoMapperGoldenTests public void DeploymentStatus_RoundTrips(DeploymentStatus value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); + /// <summary>Verifies the case: browse Node Class, round Trips.</summary> + /// <param name="value">The value test input.</param> [Theory] [InlineData(BrowseNodeClass.Object)] [InlineData(BrowseNodeClass.Variable)] @@ -248,6 +257,8 @@ public class SiteCommandDtoMapperGoldenTests public void BrowseNodeClass_RoundTrips(BrowseNodeClass value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); + /// <summary>Verifies the case: browse Failure Kind, round Trips.</summary> + /// <param name="value">The value test input.</param> [Theory] [InlineData(BrowseFailureKind.ConnectionNotFound)] [InlineData(BrowseFailureKind.ConnectionNotConnected)] @@ -257,6 +268,8 @@ public class SiteCommandDtoMapperGoldenTests public void BrowseFailureKind_RoundTrips(BrowseFailureKind value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); + /// <summary>Verifies the case: read Tag Values Failure Kind, round Trips.</summary> + /// <param name="value">The value test input.</param> [Theory] [InlineData(ReadTagValuesFailureKind.ConnectionNotFound)] [InlineData(ReadTagValuesFailureKind.ConnectionNotConnected)] @@ -265,6 +278,8 @@ public class SiteCommandDtoMapperGoldenTests public void ReadTagValuesFailureKind_RoundTrips(ReadTagValuesFailureKind value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); + /// <summary>Verifies the case: verify Failure Kind, round Trips.</summary> + /// <param name="value">The value test input.</param> [Theory] [InlineData(VerifyFailureKind.Unreachable)] [InlineData(VerifyFailureKind.AuthFailed)] @@ -274,6 +289,8 @@ public class SiteCommandDtoMapperGoldenTests public void VerifyFailureKind_RoundTrips(VerifyFailureKind value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); + /// <summary>Verifies the case: store And Forward Category, round Trips.</summary> + /// <param name="value">The value test input.</param> [Theory] [InlineData(StoreAndForwardCategory.ExternalSystem)] [InlineData(StoreAndForwardCategory.Notification)] @@ -281,12 +298,16 @@ public class SiteCommandDtoMapperGoldenTests public void StoreAndForwardCategory_RoundTrips(StoreAndForwardCategory value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); + /// <summary>Verifies the case: alarm State, round Trips.</summary> + /// <param name="value">The value test input.</param> [Theory] [InlineData(AlarmState.Active)] [InlineData(AlarmState.Normal)] public void AlarmState_RoundTrips(AlarmState value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); + /// <summary>Verifies the case: alarm Level, round Trips.</summary> + /// <param name="value">The value test input.</param> [Theory] [InlineData(AlarmLevel.None)] [InlineData(AlarmLevel.Low)] @@ -296,6 +317,8 @@ public class SiteCommandDtoMapperGoldenTests public void AlarmLevel_RoundTrips(AlarmLevel value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); + /// <summary>Verifies the case: alarm Kind, round Trips.</summary> + /// <param name="value">The value test input.</param> [Theory] [InlineData(AlarmKind.Computed)] [InlineData(AlarmKind.NativeOpcUa)] @@ -303,6 +326,8 @@ public class SiteCommandDtoMapperGoldenTests public void AlarmKind_RoundTrips(AlarmKind value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); + /// <summary>Verifies the case: alarm Shelve State, round Trips.</summary> + /// <param name="value">The value test input.</param> [Theory] [InlineData(AlarmShelveState.Unshelved)] [InlineData(AlarmShelveState.OneShotShelved)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDtoMapperTraceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDtoMapperTraceTests.cs index 55725b41..4a1664b5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDtoMapperTraceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDtoMapperTraceTests.cs @@ -17,6 +17,7 @@ public class SiteCommandDtoMapperTraceTests { private static readonly DateTimeOffset T = new(2026, 9, 5, 12, 0, 0, TimeSpan.Zero); + /// <summary>Verifies that a route-to-call response round-trips its child execution id and trace events.</summary> [Fact] public void RouteToCallResponse_RoundTrips_ChildExecutionId_AndTraceEvents() { @@ -43,6 +44,7 @@ public class SiteCommandDtoMapperTraceTests Assert.Equal(run, restored.TraceEvents[0]); } + /// <summary>Verifies that old-shape DTOs map to untraced defaults.</summary> [Fact] public void OldShapeDtos_MapToUntracedDefaults() { @@ -70,6 +72,7 @@ public class SiteCommandDtoMapperTraceTests Assert.Null(reply.TraceEvents); } + /// <summary>Verifies that the trace flag propagates on both request types.</summary> [Fact] public void TraceFlag_PropagatesOnBothRequests() { @@ -82,6 +85,7 @@ public class SiteCommandDtoMapperTraceTests Assert.True(SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(set)).Trace); } + /// <summary>Verifies that an empty trace events list maps to null rather than an empty list.</summary> [Fact] public void EmptyTraceEventsList_MapsToNull_NotEmpty() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandSamples.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandSamples.cs index 02be954a..c1497b37 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandSamples.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandSamples.cs @@ -28,7 +28,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// The two-sample rule is the point. A single "typical" sample proves only that /// the happy path survives; the pairs are what catch a dropped optional field or /// a null/empty collapse — the class of silent data loss the transport -/// round-trip guard exposed in PLAN-05 T8, which per-field unit tests had all +/// round-trip guard exposed by the transport round-trip guard, which per-field unit tests had all /// missed. /// </para> /// <para> @@ -222,7 +222,7 @@ internal static class SiteCommandSamples private static SharedScriptArtifact SharedScript() => new("Calc", "return 1;", "{\"p\":\"int\"}", "{\"r\":\"int\"}"); - // MaxRetries/RetryDelay are non-default on purpose: they are the deferred-work-row-27 + // MaxRetries/RetryDelay are non-default on purpose: they are the deferred-work // additions (proto fields 7-8), and the generic round-trip assertion is what pins them // to the wire. A sample left at the defaults would round-trip vacuously. private static ExternalSystemArtifact ExternalSystem() => diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommunicationActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommunicationActorTests.cs index 76b71d38..57199896 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommunicationActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommunicationActorTests.cs @@ -13,17 +13,19 @@ using ZB.MOM.WW.ScadaBridge.Communication.Actors; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <summary> -/// WP-4: Tests for SiteCommunicationActor message routing to local actors. +/// Tests for SiteCommunicationActor message routing to local actors. /// </summary> public class SiteCommunicationActorTests : TestKit { private readonly CommunicationOptions _options = new(); + /// <summary>Initializes the test kit and the actor under test.</summary> public SiteCommunicationActorTests() : base(@"akka.loglevel = DEBUG") { } + /// <summary>Verifies that a refresh deployment command is forwarded to the Deployment Manager.</summary> [Fact] public void RefreshDeploymentCommand_ForwardedToDeploymentManager() { @@ -39,6 +41,7 @@ public class SiteCommunicationActorTests : TestKit dmProbe.ExpectMsg<RefreshDeploymentCommand>(msg => msg.DeploymentId == "dep1"); } + /// <summary>Verifies that lifecycle commands are forwarded to the Deployment Manager.</summary> [Fact] public void LifecycleCommands_ForwardedToDeploymentManager() { @@ -56,10 +59,11 @@ public class SiteCommunicationActorTests : TestKit dmProbe.ExpectMsg<DeleteInstanceCommand>(); } + /// <summary>Verifies that a deployment state query is forwarded to the Deployment Manager.</summary> [Fact] public void DeploymentStateQuery_ForwardedToDeploymentManager() { - // DeploymentManager-006: the site-before-redeploy query travels over the + // The site-before-redeploy query travels over the // gRPC command/control transport and is routed to the local // Deployment Manager, which owns the deployed-config store. var dmProbe = CreateTestProbe(); @@ -78,6 +82,7 @@ public class SiteCommunicationActorTests : TestKit // owns the wire, it delegates to an injected ICentralTransport, and those seven sends (plus the // reply routing and the fail-loud fallback) are now covered by SiteCommunicationActorTransportTests. + /// <summary>Verifies that an event log query without a registered handler returns a failure.</summary> [Fact] public void EventLogQuery_WithoutHandler_ReturnsFailure() { @@ -93,8 +98,9 @@ public class SiteCommunicationActorTests : TestKit ExpectMsg<EventLogQueryResponse>(msg => !msg.Success); } - // ── Task 5 (#22): central→site Retry/Discard relay for parked cached calls ── + // ── Central→site Retry/Discard relay for parked cached calls ── + /// <summary>Verifies that a retry of a parked operation is forwarded to the parked message handler when one is registered.</summary> [Fact] public void RetryParkedOperation_WithHandler_ForwardedToParkedMessageHandler() { @@ -112,6 +118,7 @@ public class SiteCommunicationActorTests : TestKit msg.CorrelationId == "corr-rp" && msg.TrackedOperationId.Equals(id)); } + /// <summary>Verifies that a discard of a parked operation is forwarded to the parked message handler when one is registered.</summary> [Fact] public void DiscardParkedOperation_WithHandler_ForwardedToParkedMessageHandler() { @@ -129,6 +136,7 @@ public class SiteCommunicationActorTests : TestKit msg.CorrelationId == "corr-dp" && msg.TrackedOperationId.Equals(id)); } + /// <summary>Verifies that a retry of a parked operation without a handler replies with a not-applied acknowledgement.</summary> [Fact] public void RetryParkedOperation_WithoutHandler_RepliesNotAppliedAck() { @@ -147,6 +155,7 @@ public class SiteCommunicationActorTests : TestKit Assert.NotNull(ack.ErrorMessage); } + /// <summary>Verifies that a discard of a parked operation without a handler replies with a not-applied acknowledgement.</summary> [Fact] public void DiscardParkedOperation_WithoutHandler_RepliesNotAppliedAck() { @@ -161,15 +170,16 @@ public class SiteCommunicationActorTests : TestKit Assert.NotNull(ack.ErrorMessage); } - // ── M7 OPC UA cross-cluster routing: Search (T15), WriteTag (T14), Verify (T17) ── + // ── OPC UA cross-cluster routing: Search, WriteTag, Verify ── // - // Regression guard for the M7 dead-letter defect. These three interactive + // Regression guard for the OPC UA dead-letter defect. These three interactive // commands have downstream handlers in DataConnectionManagerActor but were NOT // forwarded through SiteCommunicationActor → Deployment Manager, so they // dead-lettered and the central Ask timed out in the real cluster. They must // forward to the Deployment Manager proxy exactly like BrowseNodeCommand, with // the original Ask sender preserved so the result routes straight back. + /// <summary>Verifies that a search address space command is forwarded to the Deployment Manager with the original sender preserved.</summary> [Fact] public void SearchAddressSpaceCommand_ForwardedToDeploymentManager_SenderPreserved() { @@ -185,6 +195,7 @@ public class SiteCommunicationActorTests : TestKit Assert.Equal(senderProbe.Ref, dmProbe.LastSender); } + /// <summary>Verifies that a write tag request is forwarded to the Deployment Manager with the original sender preserved.</summary> [Fact] public void WriteTagRequest_ForwardedToDeploymentManager_SenderPreserved() { @@ -201,6 +212,7 @@ public class SiteCommunicationActorTests : TestKit Assert.Equal(senderProbe.Ref, dmProbe.LastSender); } + /// <summary>Verifies that a verify endpoint command is forwarded to the Deployment Manager with the original sender preserved.</summary> [Fact] public void VerifyEndpointCommand_ForwardedToDeploymentManager_SenderPreserved() { @@ -216,14 +228,18 @@ public class SiteCommunicationActorTests : TestKit Assert.Equal(senderProbe.Ref, dmProbe.LastSender); } - // ── Communication-018: heartbeat IsActive reflects this node's cluster role ── + // ── Heartbeat: IsActive reflects this node's cluster role ── + /// <summary> + /// Verifies that the heartbeat stamps IsActive from the injected active-node check. + /// </summary> + /// <param name="isActive">The active-node verdict that the injected check returns.</param> [Theory] [InlineData(true)] [InlineData(false)] public void Heartbeat_StampsIsActive_FromInjectedCheck(bool isActive) { - // Communication-018: HeartbeatMessage.IsActive must reflect the actual + // HeartbeatMessage.IsActive must reflect the actual // active/standby role of this node, not a hard-coded `true`. The actor // now takes a Func<bool> override (defaulting to a real Akka.Cluster // leader check in production); tests inject a stub so they do not need @@ -247,12 +263,13 @@ public class SiteCommunicationActorTests : TestKit TimeSpan.FromSeconds(3)); } - // ---- Central→site failover relay (Task 10) ---------------------------------- + // ---- Central→site failover relay ---------------------------------- // The failover action is injected for the same reason isActiveCheck is: performing a // real Cluster.Leave would require Akka.Cluster in the TestKit ActorSystem. These // tests pin the ROUTING and GUARD logic; the actual Leave against a real two-node // site cluster is covered by SiteFailoverRelayTests in the integration suite. + /// <summary>Verifies that triggering a site failover issues the leave and acknowledges with the target node.</summary> [Fact] public void TriggerSiteFailover_IssuesTheLeave_AndAcksWithTheTarget() { @@ -278,6 +295,7 @@ public class SiteCommunicationActorTests : TestKit Assert.Equal("site-site1", roleAskedFor); } + /// <summary>Verifies that triggering a site failover is refused when there is no peer node.</summary> [Fact] public void TriggerSiteFailover_RefusesWhenThereIsNoPeer() { @@ -294,6 +312,7 @@ public class SiteCommunicationActorTests : TestKit Assert.NotNull(ack.ErrorMessage); } + /// <summary>Verifies that a failover command addressed to another site is refused.</summary> [Fact] public void TriggerSiteFailover_RefusesACommandAddressedToAnotherSite() { @@ -317,6 +336,7 @@ public class SiteCommunicationActorTests : TestKit Assert.False(invoked); } + /// <summary>Verifies that a fault while issuing the leave is reported rather than thrown.</summary> [Fact] public void TriggerSiteFailover_FaultInTheLeave_IsReportedNotThrown() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamIngestAuditEventsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamIngestAuditEventsTests.cs index 7fd77ab8..42fcedcf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamIngestAuditEventsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamIngestAuditEventsTests.cs @@ -7,6 +7,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; public class SiteStreamIngestAuditEventsTests { + /// <summary>Verifies that the retired ingest method rejects both empty and non-empty batches.</summary> + /// <param name="nonEmpty">Whether the submitted batch contains events.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamIngestCachedTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamIngestCachedTelemetryTests.cs index d3927c78..f3884325 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamIngestCachedTelemetryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamIngestCachedTelemetryTests.cs @@ -7,6 +7,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; public class SiteStreamIngestCachedTelemetryTests { + /// <summary>Verifies that the retired method rejects both empty and non-empty batches.</summary> + /// <param name="nonEmpty">Whether the batch under test contains any entries.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullAuditEventsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullAuditEventsTests.cs index fe6c1bc1..c39c8671 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullAuditEventsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullAuditEventsTests.cs @@ -14,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <summary> /// Tests for <see cref="SiteStreamGrpcServer.PullAuditEvents"/>: the request → -/// <c>ISiteAuditQueue.ReadPendingSinceAsync</c> → response round-trip, plus the WP2.3 +/// <c>ISiteAuditQueue.ReadPendingSinceAsync</c> → response round-trip, plus the /// at-least-once contract — rows are retired by the NEXT pull's cursor /// (<c>MarkReconciledUpToAsync</c>), never by the act of serving them. The queue is an /// NSubstitute stub so the tests never touch SQLite. @@ -33,7 +33,7 @@ public class SiteStreamPullAuditEventsTests : TestKit return context; } - // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory. + // Canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory. // ForwardState is no longer a record field — it is a site-storage-only concern. private static AuditEvent NewEvent(DateTime? occurredAt = null) => ScadaBridgeAuditEventFactory.Create( @@ -44,6 +44,8 @@ public class SiteStreamPullAuditEventsTests : TestKit ?? DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc), sourceSiteId: "site-1"); + /// <summary>Verifies that large audit rows are paged by wire bytes and cursor makes progress.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LargeAuditRows_ArePagedByWireBytes_AndCursorMakesProgress() { @@ -72,6 +74,8 @@ public class SiteStreamPullAuditEventsTests : TestKit Assert.Equal(256, seen.Count); } + /// <summary>Verifies that pull audit events no queue wired returns empty response.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullAuditEvents_NoQueueWired_ReturnsEmptyResponse() { @@ -91,6 +95,8 @@ public class SiteStreamPullAuditEventsTests : TestKit Assert.False(response.MoreAvailable); } + /// <summary>Verifies that pull audit events with 5 pending rows returns all five dtos and does not flip them.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullAuditEvents_With5PendingRows_ReturnsAllFiveDtos_AndDoesNotFlipThem() { @@ -122,6 +128,8 @@ public class SiteStreamPullAuditEventsTests : TestKit Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that pull audit events fault between response and next pull reserves the same rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullAuditEvents_FaultBetweenResponseAndNextPull_ReservesTheSameRows() { @@ -161,6 +169,8 @@ public class SiteStreamPullAuditEventsTests : TestKit since, null, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that pull audit events advanced cursor retires everything up to it before reading.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullAuditEvents_AdvancedCursor_RetiresEverythingUpToIt_BeforeReading() { @@ -193,6 +203,8 @@ public class SiteStreamPullAuditEventsTests : TestKit cursorTime, 100, cursorId, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that pull audit events first ever pull does not flip anything.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullAuditEvents_FirstEverPull_DoesNotFlipAnything() { @@ -212,6 +224,8 @@ public class SiteStreamPullAuditEventsTests : TestKit Arg.Any<DateTime>(), Arg.Any<string?>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that pull audit events rows older than since utc excluded.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullAuditEvents_RowsOlderThanSinceUtc_Excluded() { @@ -246,6 +260,8 @@ public class SiteStreamPullAuditEventsTests : TestKit Assert.Equal(since, capturedSince); } + /// <summary>Verifies that pull audit events batch size 3 returns 3 rows more available true.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullAuditEvents_BatchSize3_Returns3Rows_MoreAvailableTrue() { @@ -271,6 +287,8 @@ public class SiteStreamPullAuditEventsTests : TestKit Assert.True(response.MoreAvailable); } + /// <summary>Verifies that pull audit events retires before it serves so this batch is never self retired.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullAuditEvents_RetiresBeforeItServes_SoThisBatchIsNeverSelfRetired() { @@ -310,6 +328,8 @@ public class SiteStreamPullAuditEventsTests : TestKit Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that pull audit events mark reconciled up to throws response still returned.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullAuditEvents_MarkReconciledUpToThrows_ResponseStillReturned() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullSiteCallsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullSiteCallsTests.cs index 022bbd2a..46b65ec4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullSiteCallsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullSiteCallsTests.cs @@ -12,7 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <summary> /// Tests for <see cref="SiteStreamGrpcServer.PullSiteCalls"/> (Site Call Audit -/// #22 reconciliation handler). Verifies the request → +/// reconciliation handler). Verifies the request → /// <see cref="IOperationTrackingStore.ReadChangedSinceAsync"/> → response /// round-trip through the gRPC handler. The store is an NSubstitute stub so the /// tests never touch SQLite. Mirrors <see cref="SiteStreamPullAuditEventsTests"/> @@ -48,6 +48,8 @@ public class SiteStreamPullSiteCallsTests : TestKit UpdatedAtUtc: DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 1, 0), DateTimeKind.Utc), TerminalAtUtc: null); + /// <summary>Verifies that pulling site calls with no tracking store wired returns an empty response.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullSiteCalls_NoStoreWired_ReturnsEmptyResponse() { @@ -67,6 +69,8 @@ public class SiteStreamPullSiteCallsTests : TestKit Assert.False(response.MoreAvailable); } + /// <summary>Verifies that five stored rows are returned as five DTOs.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullSiteCalls_With5Rows_ReturnsAllFiveDtos() { @@ -92,6 +96,8 @@ public class SiteStreamPullSiteCallsTests : TestKit Assert.True(expectedIds.SetEquals(response.Operationals.Select(d => d.TrackedOperationId).ToHashSet())); } + /// <summary>Verifies that the since timestamp is passed to the store verbatim.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullSiteCalls_PassesSinceUtcThroughVerbatim() { @@ -121,10 +127,12 @@ public class SiteStreamPullSiteCallsTests : TestKit Assert.Equal(since, capturedSince); } + /// <summary>Verifies that a non-empty after id is passed to the store as the cursor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullSiteCalls_AfterIdSet_PassesCursorThroughToStore() { - // Task 15: a non-empty after_id is forwarded verbatim to the store as the + // A non-empty after_id is forwarded verbatim to the store as the // composite-keyset cursor. var store = Substitute.For<IOperationTrackingStore>(); string? capturedAfterId = "SENTINEL"; @@ -151,6 +159,8 @@ public class SiteStreamPullSiteCallsTests : TestKit Assert.Equal(cursor, capturedAfterId); } + /// <summary>Verifies that an unset after id is passed to the store as null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullSiteCalls_AfterIdUnset_PassesNullToStore() { @@ -180,6 +190,8 @@ public class SiteStreamPullSiteCallsTests : TestKit Assert.Null(capturedAfterId); } + /// <summary>Verifies that an unset since timestamp is passed to the store as <see cref="DateTime.MinValue"/>.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullSiteCalls_SinceUtcUnset_PassesDateTimeMinValue() { @@ -212,6 +224,8 @@ public class SiteStreamPullSiteCallsTests : TestKit Assert.Equal(DateTime.MinValue, captured); } + /// <summary>Verifies that a batch size of three with more rows available returns three rows and flags that more are available.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullSiteCalls_BatchSize3_Returns3Rows_MoreAvailableTrue() { @@ -236,6 +250,8 @@ public class SiteStreamPullSiteCallsTests : TestKit Assert.True(response.MoreAvailable); } + /// <summary>Verifies that a non-positive batch size throws an invalid-argument error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullSiteCalls_NonPositiveBatchSize_ThrowsInvalidArgument() { @@ -254,6 +270,8 @@ public class SiteStreamPullSiteCallsTests : TestKit Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode); } + /// <summary>Verifies that a store read failure returns an empty response.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullSiteCalls_ReadThrows_ReturnsEmptyResponse() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullTraceEventsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullTraceEventsTests.cs index 230a37e9..78a39e96 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullTraceEventsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullTraceEventsTests.cs @@ -34,6 +34,7 @@ public class SiteStreamPullTraceEventsTests : TestKit AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: new DateTime(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc), executionId: executionId, sourceSiteId: "site-a"); + /// <summary>Verifies that the pull-trace-events RPC sits under the preshared-key-gated service prefix.</summary> [Fact] public void PullTraceEvents_IsUnderThePskGatedServicePrefix() { @@ -42,6 +43,8 @@ public class SiteStreamPullTraceEventsTests : TestKit Assert.Equal("sitestream.SiteStreamService", SiteStreamService.Descriptor.FullName); } + /// <summary>Verifies that the RPC returns an empty result when no queue is wired.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoQueueWired_ReturnsEmpty() { @@ -52,6 +55,8 @@ public class SiteStreamPullTraceEventsTests : TestKit Assert.Empty(response.Events); } + /// <summary>Verifies that the RPC reads events by id, projects them to DTOs and never changes their state.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadsByIds_ProjectsDtos_AndNeverFlipsState() { @@ -83,6 +88,10 @@ public class SiteStreamPullTraceEventsTests : TestKit await queue.DidNotReceiveWithAnyArgs().MarkReconciledAsync(default!, default); } + /// <summary>Verifies that invalid arguments are rejected.</summary> + /// <param name="maxEvents">The requested maximum number of events.</param> + /// <param name="idCount">The number of ids supplied in the request.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(0, 1)] [InlineData(2001, 1)] @@ -99,6 +108,8 @@ public class SiteStreamPullTraceEventsTests : TestKit Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode); } + /// <summary>Verifies that an id that is not a GUID is rejected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NonGuidId_IsRejected() { @@ -109,6 +120,8 @@ public class SiteStreamPullTraceEventsTests : TestKit Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode); } + /// <summary>Verifies that a queue fault collapses to an empty result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueueFault_CollapsesToEmpty() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/StreamRelayTraceStampTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/StreamRelayTraceStampTests.cs index 7f3c807c..6b481775 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/StreamRelayTraceStampTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/StreamRelayTraceStampTests.cs @@ -27,6 +27,7 @@ public class StreamRelayTraceStampTests : TestKit return evt!; } + /// <summary>Verifies that a traced change is stamped on the wire and parsed back.</summary> [Fact] public void TracedChange_IsStampedOnTheWire_AndParsedBack() { @@ -43,6 +44,7 @@ public class StreamRelayTraceStampTests : TestKit Assert.True(back.Trace); } + /// <summary>Verifies that an unstamped change round-trips to null and false.</summary> [Fact] public void UnstampedChange_RoundTripsToNullAndFalse() { @@ -57,6 +59,7 @@ public class StreamRelayTraceStampTests : TestKit Assert.False(back.Trace); } + /// <summary>Verifies that an older site frame without the trace fields parses to null and false.</summary> [Fact] public void OlderSiteFrame_WithoutTheFields_ParsesToNullAndFalse() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SyntheticHeartbeatTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SyntheticHeartbeatTests.cs index e23b45f1..3efceaac 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SyntheticHeartbeatTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SyntheticHeartbeatTests.cs @@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// <summary> -/// Arch-review phase-2 residual #3: <see cref="CentralChannelProvider"/>'s failback probe reuses +/// <see cref="CentralChannelProvider"/>'s failback probe reuses /// the <c>Heartbeat</c> RPC to ask "does the preferred central endpoint answer again?". It is /// emitted by a site's TRANSPORT layer, not by any node's heartbeat timer, so central must not /// count it as liveness — otherwise a site whose real heartbeats had stopped keeps looking alive @@ -33,6 +33,7 @@ public class SyntheticHeartbeatTests : TestKit // ── The consumer: central skips liveness bookkeeping for a synthetic heartbeat ─── + /// <summary>Verifies that a synthetic heartbeat does not mark the health aggregator.</summary> [Fact] public void SyntheticHeartbeat_DoesNotMarkTheHealthAggregator() { @@ -46,6 +47,7 @@ public class SyntheticHeartbeatTests : TestKit aggregator.DidNotReceiveWithAnyArgs().MarkHeartbeat(default!, default); } + /// <summary>Verifies that a real heartbeat still marks the health aggregator.</summary> [Fact] public void RealHeartbeat_StillMarksTheHealthAggregator() { @@ -58,6 +60,7 @@ public class SyntheticHeartbeatTests : TestKit AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site-1", T0)); } + /// <summary>Verifies that a replicated synthetic heartbeat is also skipped.</summary> [Fact] public void SyntheticHeartbeatReplica_IsAlsoSkipped() { @@ -75,6 +78,8 @@ public class SyntheticHeartbeatTests : TestKit // ── The wire contract: the flag survives the round trip ───────────────────────── + /// <summary>Verifies that the synthetic flag round-trips through the heartbeat DTO.</summary> + /// <param name="synthetic">The synthetic flag value under test.</param> [Theory] [InlineData(true)] [InlineData(false)] @@ -89,6 +94,7 @@ public class SyntheticHeartbeatTests : TestKit Assert.Equal(msg with { Synthetic = synthetic }, back); } + /// <summary>Verifies that a DTO from an older site defaults to not synthetic.</summary> [Fact] public void ADtoFromAnOlderSite_DefaultsToNotSynthetic() { @@ -106,6 +112,8 @@ public class SyntheticHeartbeatTests : TestKit // ── The producer: the failback probe marks itself synthetic ───────────────────── + /// <summary>Verifies that the failback probe marks its heartbeat as synthetic.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TheFailbackProbe_MarksItsHeartbeatSynthetic() { @@ -155,7 +163,9 @@ public class SyntheticHeartbeatTests : TestKit private sealed class FixedPskProvider(string key) : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key); + /// <inheritdoc /> public void Invalidate(string siteId) { } } @@ -169,6 +179,9 @@ public class SyntheticHeartbeatTests : TestKit private readonly TaskCompletionSource<HeartbeatDto> _captured = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// <summary>Waits for the captured heartbeat request.</summary> + /// <param name="timeout">The maximum time to wait.</param> + /// <returns>The decoded heartbeat DTO.</returns> public async Task<HeartbeatDto> WaitForHeartbeatAsync(TimeSpan timeout) { var completed = await Task.WhenAny(_captured.Task, Task.Delay(timeout)); @@ -176,6 +189,7 @@ public class SyntheticHeartbeatTests : TestKit return await _captured.Task; } + /// <inheritdoc /> protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/WireSerializationPinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/WireSerializationPinTests.cs index c99598c4..c9bbb6bf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/WireSerializationPinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/WireSerializationPinTests.cs @@ -36,6 +36,7 @@ public class WireSerializationPinTests : TestKit return (T)serialization.Deserialize(bytes, serializer.Identifier, message!.GetType()); } + /// <summary>Verifies that a heartbeat message round-trips on the wire.</summary> [Fact] public void HeartbeatMessage_RoundTripsOnTheWire() { @@ -44,6 +45,7 @@ public class WireSerializationPinTests : TestKit Assert.Equal(original, back); // all-scalar record → structural equality holds } + /// <summary>Verifies that a notification submit message round-trips on the wire.</summary> [Fact] public void NotificationSubmit_RoundTripsOnTheWire() { @@ -77,6 +79,7 @@ public class WireSerializationPinTests : TestKit Assert.Equal(original.SourceNode, back.SourceNode); } + /// <summary>Verifies that an ingest-audit-events command round-trips on the wire.</summary> [Fact] public void IngestAuditEventsCommand_RoundTripsOnTheWire() { @@ -130,6 +133,7 @@ public class WireSerializationPinTests : TestKit Assert.Equal(o.ErrorMessage, rt.ErrorMessage); } + /// <summary>Verifies that a site health report round-trips on the wire.</summary> [Fact] public void SiteHealthReport_RoundTripsOnTheWire() { @@ -181,6 +185,9 @@ public class WireSerializationPinTests : TestKit // central↔site communication. If one of these assertions fails, you are making // a wire-breaking change — coordinate a full-fleet upgrade or add an explicit // serialization binding before renaming/moving the type. + /// <summary>Verifies that each cross-cluster contract type keeps its pinned full name.</summary> + /// <param name="type">The contract type under test.</param> + /// <param name="expectedFullName">The full type name that must stay stable on the wire.</param> [Theory] [InlineData(typeof(HeartbeatMessage), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.HeartbeatMessage")] [InlineData(typeof(NotificationSubmit), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification.NotificationSubmit")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditLogAppendOnlyGuardTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditLogAppendOnlyGuardTests.cs index b1e04368..1dc373e8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditLogAppendOnlyGuardTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditLogAppendOnlyGuardTests.cs @@ -3,7 +3,7 @@ using System.Text.RegularExpressions; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// <summary> -/// Code-level guard for the AuditLog append-only invariant (task M2.10, #18). +/// Code-level guard for the AuditLog append-only invariant. /// /// The DB-role control (DENY UPDATE / DENY DELETE on dbo.AuditLog in migration /// 20260602174346_CollapseAuditLogToCanonical) is the runtime enforcement layer. @@ -36,12 +36,12 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// <see cref="AuditPurgeAllowedMarker"/> trailing comment: /// <list type="bullet"> /// <item><description> -/// M5.5 (T3) — <c>AuditLogRepository.PurgeChannelOlderThanAsync</c>: the +/// <c>AuditLogRepository.PurgeChannelOlderThanAsync</c>: the /// one sanctioned batched <c>DELETE TOP (@batch) FROM dbo.AuditLog</c>, /// running on the purge/maintenance connection. /// </description></item> /// <item><description> -/// M5.6 (T5) — <c>AuditLogRepository.BackfillSourceNodeAsync</c>: the +/// <c>AuditLogRepository.BackfillSourceNodeAsync</c>: the /// one sanctioned batched <c>UPDATE TOP (@batch) dbo.AuditLog SET SourceNode</c>, /// running on the maintenance connection. The sentinel backfill is a /// one-time ops procedure; the append-only invariant still applies to all @@ -60,8 +60,8 @@ public class AuditLogAppendOnlyGuardTests /// The exact trailing-comment marker that exempts a single sanctioned /// maintenance-path DML line from the append-only guard. Carried at the END of /// the SQL constant string in both <c>AuditLogRepository.PurgeChannelOlderThanAsync</c> - /// (M5.5 T3 batched DELETE) and <c>AuditLogRepository.BackfillSourceNodeAsync</c> - /// (M5.6 T5 batched UPDATE). Kept deliberately specific so it cannot be pasted + /// (batched DELETE) and <c>AuditLogRepository.BackfillSourceNodeAsync</c> + /// (batched UPDATE). Kept deliberately specific so it cannot be pasted /// onto an unrelated mutation without a reviewer noticing. /// </summary> internal const string AuditPurgeAllowedMarker = "AUDIT-PURGE-ALLOWED"; @@ -166,7 +166,7 @@ public class AuditLogAppendOnlyGuardTests // The DELETE branch tolerates an optional TOP (...) batch-size clause between // DELETE and the (optional) FROM — e.g. "DELETE TOP (@batch) FROM dbo.AuditLog" - // (the M5.5 T3 batched purge shape). Without this the guard would silently miss a + // (the batched purge shape). Without this the guard would silently miss a // batched row DELETE against AuditLog, which is exactly the kind of mutation it // must catch. The TOP sub-pattern is (?:TOP\s*\(.*?\)\s+)? — optional, lazy inside // the parens so it never swallows past the matching ')'. @@ -174,7 +174,7 @@ public class AuditLogAppendOnlyGuardTests // The UPDATE branch similarly tolerates an optional TOP (...) clause between // UPDATE and (optional schema.) AuditLog — e.g. // "UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel …" - // (the M5.6 T5 batched backfill shape). + // (the batched backfill shape). private static readonly Regex AuditLogMutationPattern = new( @"\bUPDATE\s+(?:TOP\s*\(.*?\)\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b" + @"|\bDELETE\s+(?:TOP\s*\(.*?\)\s+)?(?:FROM\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b", @@ -184,8 +184,8 @@ public class AuditLogAppendOnlyGuardTests /// Returns <see langword="true"/> when <paramref name="line"/> carries the narrow /// <see cref="AuditPurgeAllowedMarker"/> exemption. Sanctioned uses are: /// <list type="bullet"> - /// <item><description>M5.5 T3 — the per-channel maintenance-path batched DELETE.</description></item> - /// <item><description>M5.6 T5 — the SourceNode sentinel batched UPDATE.</description></item> + /// <item><description>The per-channel maintenance-path batched DELETE.</description></item> + /// <item><description>The SourceNode sentinel batched UPDATE.</description></item> /// </list> /// A flagged line that lacks the marker is NOT allow-listed. The mutation matcher /// itself stays marker-blind; the allow-list is applied only by the file-scan test, @@ -201,6 +201,7 @@ public class AuditLogAppendOnlyGuardTests // Designer/Snapshot EF artefacts and the obj/ directory). // --------------------------------------------------------------------------- + /// <summary>Verifies that no source file in the ConfigurationDatabase project contains an unsanctioned UPDATE or DELETE against the AuditLog table.</summary> [Fact] public void ConfigurationDatabase_ShouldNotContainAuditLogMutations() { @@ -245,6 +246,7 @@ public class AuditLogAppendOnlyGuardTests // Self-verifying matcher unit tests — prove the helper does what it claims. // --------------------------------------------------------------------------- + /// <summary>Verifies that the mutation matcher returns false for source lines that do not mutate AuditLog.</summary> [Fact] public void ContainsAuditLogMutation_ReturnsFalse_ForCleanSource() { @@ -296,6 +298,7 @@ public class AuditLogAppendOnlyGuardTests "TRUNCATE TABLE dbo.AuditLog_Staging_abc123;")); } + /// <summary>Verifies that the mutation matcher returns true for planted UPDATE and DELETE violations against AuditLog.</summary> [Fact] public void ContainsAuditLogMutation_ReturnsTrue_ForPlantedViolations() { @@ -344,7 +347,7 @@ public class AuditLogAppendOnlyGuardTests Assert.True(ContainsAuditLogMutation( "DELETE FROM [AuditLog] WHERE OccurredAtUtc < @threshold;")); - // ---- Batched DELETE TOP (...) forms (M5.5 T3 purge shape) ---- + // ---- Batched DELETE TOP (...) forms (purge shape) ---- // The matcher must catch a batched DELETE against AuditLog regardless of the // marker — the allow-list (IsAllowListed) is what forgives the ONE sanctioned // line, not the matcher. @@ -355,7 +358,7 @@ public class AuditLogAppendOnlyGuardTests Assert.True(ContainsAuditLogMutation( "DELETE TOP(100) FROM [dbo].[AuditLog] WHERE Status = 'Parked';")); - // ---- Batched UPDATE TOP (...) forms (M5.6 T5 backfill shape) ---- + // ---- Batched UPDATE TOP (...) forms (backfill shape) ---- // The matcher must also catch a batched UPDATE against AuditLog, regardless of // the marker — the allow-list is what forgives the ONE sanctioned backfill line. Assert.True(ContainsAuditLogMutation( @@ -366,6 +369,7 @@ public class AuditLogAppendOnlyGuardTests "UPDATE TOP(100) [dbo].[AuditLog] SET SourceNode = @s WHERE SourceNode IS NULL;")); } + /// <summary>Verifies that the mutation matcher returns false for DENY, GRANT and partition-switch statements.</summary> [Fact] public void ContainsAuditLogMutation_ReturnsFalse_ForDenyGrantAndPartitionSwitchSamples() { @@ -396,10 +400,11 @@ public class AuditLogAppendOnlyGuardTests } // --------------------------------------------------------------------------- - // Allow-list self-tests (M5.5 T3 / M5.6 T5) — prove the narrow exemption only + // Allow-list self-tests — prove the narrow exemption only // forgives the marked maintenance-path DML and still blocks everything else. // --------------------------------------------------------------------------- + /// <summary>Verifies that the allow-list forgives a marked purge DELETE while the raw matcher still flags it.</summary> [Fact] public void AllowList_ForgivesMarkedPurgeDelete_ButMatcherStillTrips() { @@ -415,6 +420,7 @@ public class AuditLogAppendOnlyGuardTests Assert.True(IsAllowListed(sanctioned)); } + /// <summary>Verifies that the allow-list forgives a marked backfill UPDATE while the raw matcher still flags it.</summary> [Fact] public void AllowList_ForgivesMarkedBackfillUpdate_ButMatcherStillTrips() { @@ -430,6 +436,7 @@ public class AuditLogAppendOnlyGuardTests Assert.True(IsAllowListed(sanctioned)); } + /// <summary>Verifies that the allow-list does not forgive a stray DELETE that lacks the marker.</summary> [Fact] public void AllowList_DoesNotForgive_UnmarkedStrayDelete() { @@ -443,6 +450,7 @@ public class AuditLogAppendOnlyGuardTests "A DELETE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed."); } + /// <summary>Verifies that the allow-list does not forgive a stray UPDATE that lacks the marker.</summary> [Fact] public void AllowList_DoesNotForgive_UnmarkedStrayUpdate() { @@ -454,6 +462,7 @@ public class AuditLogAppendOnlyGuardTests "An UPDATE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed."); } + /// <summary>Verifies that the allow-list does not forgive a batched UPDATE that lacks the marker.</summary> [Fact] public void AllowList_DoesNotForgive_BatchedUpdateWithoutMarker() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditServiceTests.cs index 91d7f97f..00413c5c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditServiceTests.cs @@ -14,6 +14,7 @@ public class AuditServiceTests : IDisposable private readonly AuditCorrelationContext _correlationContext; private readonly AuditService _auditService; + /// <summary>Initializes the test class with an in-memory SQLite database and an audit service.</summary> public AuditServiceTests() { var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>() @@ -27,12 +28,15 @@ public class AuditServiceTests : IDisposable _auditService = new AuditService(_context, _correlationContext); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that logging creates an audit entry committed together with the entity change.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogAsync_CreatesAuditEntry_CommittedWithEntityChange() { @@ -56,6 +60,8 @@ public class AuditServiceTests : IDisposable Assert.Single(await _context.Templates.ToListAsync()); } + /// <summary>Verifies that a rollback undoes both the entity change and its audit entry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogAsync_Rollback_BothChangeAndAuditRolledBack() { @@ -78,6 +84,8 @@ public class AuditServiceTests : IDisposable Assert.Empty(await _context.Templates.Where(t => t.Name == "RollbackTemplate").ToListAsync()); } + /// <summary>Verifies that the after-state is serialized as JSON.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogAsync_SerializesAfterStateAsJson() { @@ -93,6 +101,8 @@ public class AuditServiceTests : IDisposable Assert.Equal(42, deserialized.GetProperty("Value").GetInt32()); } + /// <summary>Verifies that a null after-state is stored for deletes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogAsync_NullAfterState_ForDeletes() { @@ -104,6 +114,8 @@ public class AuditServiceTests : IDisposable Assert.Equal("Delete", audit.Action); } + /// <summary>Verifies that the audit timestamp is set to the current UTC time.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogAsync_SetsTimestampToUtcNow() { @@ -118,6 +130,7 @@ public class AuditServiceTests : IDisposable Assert.True(audit.Timestamp <= after.AddSeconds(2)); } + /// <summary>Verifies that the audit service is append-only and exposes no update or delete methods.</summary> [Fact] public void AuditService_IsAppendOnly_NoUpdateOrDeleteMethods() { @@ -128,6 +141,8 @@ public class AuditServiceTests : IDisposable Assert.DoesNotContain(methods, m => m.Name.Contains("Delete", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that the bundle import id is stamped from the correlation context.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogAsync_StampsBundleImportId_FromCorrelationContext() { @@ -145,6 +160,8 @@ public class AuditServiceTests : IDisposable Assert.Equal(bundleId, audit.BundleImportId); } + /// <summary>Verifies that the bundle import id stays null when the correlation context has no value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogAsync_LeavesBundleImportIdNull_WhenCorrelationContextHasNoValue() { @@ -164,14 +181,18 @@ public class AuditServiceTests : IDisposable // Self-referential POCO used to reproduce a reference cycle in afterState. private sealed class CyclicNode { + /// <summary>Gets or sets the node name.</summary> public string Name { get; set; } = "node"; + /// <summary>Gets or sets the self-referencing node that forms the cycle.</summary> public CyclicNode? Self { get; set; } } + /// <summary>Verifies that an after-state containing a reference cycle neither throws nor rolls back the audited operation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogAsync_AfterStateWithReferenceCycle_DoesNotThrow_AndDoesNotRollBackOperation() { - // Regression guard for ConfigurationDatabase-007: serializing an afterState + // Regression guard for serializing an afterState // object that contains a reference cycle must not throw a JsonException — // that would roll back the entire business operation it is auditing. var node = new CyclicNode(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ConcurrencyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ConcurrencyTests.cs index 2be9d353..02dd432b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ConcurrencyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ConcurrencyTests.cs @@ -17,8 +17,11 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// </summary> public class ConcurrencyTestDbContext : ScadaBridgeDbContext { + /// <summary>Initializes the context with the given options.</summary> + /// <param name="options">The context options.</param> public ConcurrencyTestDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options) { } + /// <inheritdoc /> protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -41,13 +44,16 @@ public class ConcurrencyTestDbContext : ScadaBridgeDbContext /// A SQLite-friendly DbContext that keeps <see cref="DeploymentRecord.RowVersion"/> as /// the optimistic-concurrency token but disables auto-generation (SQLite cannot /// auto-populate a rowversion column). The caller sets RowVersion explicitly, which -/// is sufficient to exercise the production stub-attach delete path under CD-017's +/// is sufficient to exercise the production stub-attach delete path under the /// concurrency rule. /// </summary> public class RowVersionConcurrencyTestDbContext : ScadaBridgeDbContext { + /// <summary>Initializes the context with the given options.</summary> + /// <param name="options">The context options.</param> public RowVersionConcurrencyTestDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options) { } + /// <inheritdoc /> protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -66,11 +72,13 @@ public class ConcurrencyTests : IDisposable { private readonly string _dbPath; + /// <summary>Creates a unique temporary SQLite database path for each test.</summary> public ConcurrencyTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db"); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { if (File.Exists(_dbPath)) @@ -86,6 +94,8 @@ public class ConcurrencyTests : IDisposable return new ConcurrencyTestDbContext(options); } + /// <summary>Verifies that a second update of a deployment record from a stale copy throws a concurrency exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploymentRecord_OptimisticConcurrency_SecondUpdateThrows() { @@ -138,6 +148,8 @@ public class ConcurrencyTests : IDisposable () => ctx2.SaveChangesAsync()); } + /// <summary>Verifies that templates have no optimistic concurrency, so the last write wins.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Template_NoOptimisticConcurrency_LastWriteWins() { @@ -175,10 +187,12 @@ public class ConcurrencyTests : IDisposable Assert.Equal("Second update", loaded.Description); } + /// <summary>Verifies that deleting a deployment record with a stale row version throws a concurrency exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteDeploymentRecord_StaleRowVersion_ThrowsConcurrencyException() { - // CD-017: Verifies the stub-attach delete path enforces optimistic concurrency + // Verifies the stub-attach delete path enforces optimistic concurrency // when the caller passes a RowVersion that no longer matches the row's current // RowVersion. Uses a SQLite fixture where DeploymentRecord.RowVersion is an // explicit, caller-managed concurrency token (no SQL Server auto-generation). @@ -232,6 +246,7 @@ public class ConcurrencyTests : IDisposable .Options; } + /// <summary>Verifies that the <c>DeploymentRecord</c> row version is configured as a concurrency token.</summary> [Fact] public void DeploymentRecord_HasRowVersionConfigured() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs index 28ce7cf0..d7f77c15 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs @@ -8,7 +8,7 @@ using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Configurations; /// <summary> -/// Schema-level tests for the C5 (Task 2.5) <see cref="AuditLogEntityTypeConfiguration"/>. +/// Schema-level tests for the C5 <see cref="AuditLogEntityTypeConfiguration"/>. /// After the central <c>dbo.AuditLog</c> collapse the table is the 10 canonical /// <c>ZB.MOM.WW.Audit.AuditEvent</c> columns plus six read-only <b>persisted computed /// columns</b> derived from <c>DetailsJson</c>. Verifies the PK, the canonical/computed @@ -22,17 +22,20 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable { private readonly ScadaBridgeDbContext _context; + /// <summary>Creates the test fixture and its database context.</summary> public AuditLogEntityTypeConfigurationTests() { _context = SqliteTestHelper.CreateInMemoryContext(); } + /// <summary>Closes the connection and disposes the database context.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that the configuration maps to the AuditLog table with a composite primary key.</summary> [Fact] public void Configure_MapsToAuditLogTable_WithCompositePrimaryKey() { @@ -51,10 +54,11 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable Assert.Equal(new[] { nameof(AuditLogRow.EventId), nameof(AuditLogRow.OccurredAtUtc) }, pkPropertyNames); } + /// <summary>Verifies that no non-aligned EventId index is declared and uniqueness rides the aligned clustered key.</summary> [Fact] public void Configure_DeclaresNoNonAlignedEventIdIndex_UniquenessRidesTheAlignedClusteredKey() { - // WP2.2 (AlignAuditLogEventIdUniqueness): the standalone non-aligned + // AlignAuditLogEventIdUniqueness: the standalone non-aligned // UX_AuditLog_EventId is gone. EventId uniqueness now rides the clustered // PK (EventId, OccurredAtUtc), which is partition-aligned on // ps_AuditLog_Month — so ALTER TABLE ... SWITCH PARTITION no longer needs @@ -79,6 +83,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable pk!.Properties.Select(p => p.Name).ToArray()); } + /// <summary>Verifies that the configuration maps the expected number of properties.</summary> [Fact] public void Configure_HasExpectedPropertyCount() { @@ -96,6 +101,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable Assert.Equal(16, properties.Count); } + /// <summary>Verifies that the channel property maps to the canonical category column.</summary> [Fact] public void Configure_ChannelMapsToCanonicalCategoryColumn() { @@ -109,6 +115,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable Assert.Equal("Category", channel!.GetColumnName()); } + /// <summary>Verifies that each computed column has the expected persisted SQL expression.</summary> + /// <param name="propertyName">The name of the computed property.</param> + /// <param name="expectedSql">The expected computed-column SQL.</param> [Theory] [InlineData(nameof(AuditLogRow.Kind), "JSON_VALUE(DetailsJson,'$.kind')")] [InlineData(nameof(AuditLogRow.Status), "JSON_VALUE(DetailsJson,'$.status')")] @@ -132,6 +141,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable Assert.Equal(Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate, property.ValueGenerated); } + /// <summary>Verifies that the IngestedAtUtc property is a non-persisted computed column.</summary> [Fact] public void Configure_IngestedAtUtc_IsNonPersistedComputedColumn() { @@ -150,6 +160,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable Assert.Equal(Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate, property.ValueGenerated); } + /// <summary>Verifies that the expected indexes exist with the correct names.</summary> [Fact] public void Configure_ExpectedIndexes_WithCorrectNames() { @@ -180,6 +191,8 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable Assert.Equal(expected, indexNames); } + /// <summary>Verifies that each enum column is stored as a varchar(32).</summary> + /// <param name="propertyName">The name of the enum property.</param> [Theory] [InlineData(nameof(AuditLogRow.Kind))] [InlineData(nameof(AuditLogRow.Status))] @@ -198,6 +211,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable Assert.False(property.IsUnicode() ?? true); } + /// <summary>Verifies that the outcome column is stored as a varchar(16).</summary> [Fact] public void Configure_Outcome_StoredAsVarchar16() { @@ -211,6 +225,8 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable Assert.False(property.IsUnicode() ?? true); } + /// <summary>Verifies that the UTC converter hydrates OccurredAtUtc with a UTC kind.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc() { @@ -252,6 +268,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable Assert.Equal(unspecifiedOccurred.Ticks, loaded.OccurredAtUtc.Ticks); } + /// <summary>Verifies that OccurredAtUtc and IngestedAtUtc have UTC value converters.</summary> [Fact] public void Configure_OccurredAtUtcAndIngestedAtUtc_HaveUtcValueConverters() { @@ -269,6 +286,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable Assert.NotNull(ingestedAt!.GetValueConverter()); } + /// <summary>Verifies that the filtered indexes have the expected filter expressions.</summary> [Fact] public void Configure_FilteredIndexes_HaveExpectedFilters() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/SiteCallEntityTypeConfigurationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/SiteCallEntityTypeConfigurationTests.cs index cafa21fb..ee18547f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/SiteCallEntityTypeConfigurationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/SiteCallEntityTypeConfigurationTests.cs @@ -6,28 +6,31 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Configurations; /// <summary> -/// Schema-level tests for <see cref="SiteCallEntityTypeConfiguration"/> (#22 / #23 M3 Bundle B). +/// Schema-level tests for <see cref="SiteCallEntityTypeConfiguration"/>. /// Verifies the <see cref="SiteCall"/> record maps to the <c>SiteCalls</c> table with the /// expected primary key, value conversion on <c>TrackedOperationId</c>, and the two named /// indexes that back the "calls from this site" and "calls in this status" Central UI queries. -/// Mirrors the AuditLog Bundle B test pattern — inspects EF model metadata via the existing +/// Mirrors the AuditLog test pattern — inspects EF model metadata via the existing /// in-memory SQLite test context, no database round-trips required. /// </summary> public class SiteCallEntityTypeConfigurationTests : IDisposable { private readonly ScadaBridgeDbContext _context; + /// <summary>Initializes an in-memory SQLite context for the schema tests.</summary> public SiteCallEntityTypeConfigurationTests() { _context = SqliteTestHelper.CreateInMemoryContext(); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that the <c>SiteCall</c> entity maps to the <c>SiteCalls</c> table.</summary> [Fact] public void Configure_MapsToSiteCallsTable() { @@ -37,6 +40,7 @@ public class SiteCallEntityTypeConfigurationTests : IDisposable Assert.Equal("SiteCalls", entity!.GetTableName()); } + /// <summary>Verifies that the primary key is <c>TrackedOperationId</c>.</summary> [Fact] public void Configure_PrimaryKey_TrackedOperationId() { @@ -50,6 +54,7 @@ public class SiteCallEntityTypeConfigurationTests : IDisposable Assert.Equal(new[] { nameof(SiteCall.TrackedOperationId) }, pkPropertyNames); } + /// <summary>Verifies that the expected indexes exist with the right names and column order.</summary> [Fact] public void Configure_HasIndexes_NamedAndOrdered() { @@ -71,6 +76,7 @@ public class SiteCallEntityTypeConfigurationTests : IDisposable Assert.Equal(new[] { nameof(SiteCall.Status), nameof(SiteCall.UpdatedAtUtc) }, statusUpdatedProps); } + /// <summary>Verifies that <c>TrackedOperationId</c> is converted to a string column of length 36.</summary> [Fact] public void Configure_TrackedOperationId_ConvertedToString_Length36() { @@ -90,6 +96,9 @@ public class SiteCallEntityTypeConfigurationTests : IDisposable Assert.False(property.IsUnicode() ?? true); } + /// <summary>Verifies that each ASCII column is bounded to its expected maximum length.</summary> + /// <param name="propertyName">The entity property name.</param> + /// <param name="expectedMaxLength">The expected maximum column length.</param> [Theory] [InlineData(nameof(SiteCall.Channel), 32)] [InlineData(nameof(SiteCall.SourceSite), 64)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DataProtectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DataProtectionTests.cs index 716a1b0d..4bbd625e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DataProtectionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DataProtectionTests.cs @@ -9,17 +9,20 @@ public class DataProtectionTests : IDisposable { private readonly string _dbPath; + /// <summary>Creates a unique temporary database path for each test.</summary> public DataProtectionTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_dp_test_{Guid.NewGuid()}.db"); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { if (File.Exists(_dbPath)) File.Delete(_dbPath); } + /// <summary>Verifies that data protected in one container can be unprotected in another that shares the same key store.</summary> [Fact] public void SharedDataProtection_ProtectAndUnprotect_AcrossContainers() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DeployedRevisionHashesRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DeployedRevisionHashesRepositoryTests.cs index 912bf4b0..fa99b164 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DeployedRevisionHashesRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DeployedRevisionHashesRepositoryTests.cs @@ -62,6 +62,8 @@ public class DeployedRevisionHashesRepositoryTests : IDisposable await _context.SaveChangesAsync(); } + /// <summary>Verifies that the repository returns every deployed snapshot keyed by instance.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeployedRevisionHashes_returns_every_snapshot_keyed_by_instance() { @@ -77,6 +79,8 @@ public class DeployedRevisionHashesRepositoryTests : IDisposable Assert.Equal("hash-two", hashes[second]); } + /// <summary>Verifies that the repository returns an empty result when nothing is deployed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeployedRevisionHashes_returns_empty_when_nothing_is_deployed() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DeploymentListQueryRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DeploymentListQueryRepositoryTests.cs index e9cccd12..9337164f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DeploymentListQueryRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DeploymentListQueryRepositoryTests.cs @@ -36,12 +36,14 @@ public class DeploymentListQueryRepositoryTests : IDisposable private readonly DeploymentManagerRepository _repository; private readonly DateTimeOffset _base = new(2026, 8, 15, 12, 0, 0, TimeSpan.Zero); + /// <summary>Initializes a new instance of the <c>DeploymentListQueryRepositoryTests</c> class.</summary> public DeploymentListQueryRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new DeploymentManagerRepository(_context); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { _context.Database.CloseConnection(); @@ -91,6 +93,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable // ── Paging ──────────────────────────────────────────────────────────────── + /// <summary>Verifies that a page query returns the requested slice together with the total count of the whole set.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryPage_ReturnsRequestedSlice_AndTheTotalCountOfTheWholeSet() { @@ -108,11 +112,13 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.Equal(10, page2.Rows.Count); Assert.Equal(3, page2.PageCount(10)); - // Newest first: page 2 of 10 starts at the 11th newest, dep-014. + // Newest first: page 2 of 10 starts at the 11th newest deployment. Assert.Equal("dep-014", page2.Rows[0].DeploymentId); Assert.Equal("dep-005", page2.Rows[^1].DeploymentId); } + /// <summary>Verifies that consecutive pages neither overlap nor drop rows, even when rows share a deployed-at timestamp.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryPage_PagesDoNotOverlapOrDropRows_EvenWhenDeployedAtTies() { @@ -138,6 +144,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.Equal(9, seen.Distinct().Count()); } + /// <summary>Verifies that a non-positive page number is floored at page one.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryPage_NonPositivePageNumber_IsFlooredAtPageOne() { @@ -151,6 +159,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.Single(page.Rows); } + /// <summary>Verifies that an oversized page size is clamped to the maximum.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryPage_OversizedPageSize_IsClampedToTheMaximum() { @@ -167,6 +177,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.True(DeploymentListPage.MaxPageSize > 0); } + /// <summary>Verifies that a page past the end returns no rows but still reports the total.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryPage_PastTheEnd_ReturnsNoRowsButStillReportsTheTotal() { @@ -183,6 +195,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable // ── Joined projection ───────────────────────────────────────────────────── + /// <summary>Verifies that the page query joins the instance so its name and site travel with each row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryPage_JoinsTheInstance_SoNameAndSiteTravelWithTheRow() { @@ -205,6 +219,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable // ── Filters ─────────────────────────────────────────────────────────────── + /// <summary>Verifies that the status filter is applied in the database query.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryPage_StatusFilter_IsAppliedInTheDatabase() { @@ -220,6 +236,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.Equal("dep-bad", Assert.Single(page.Rows).DeploymentId); } + /// <summary>Verifies that the instance filter restricts results to a single instance's history.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryPage_InstanceFilter_RestrictsToOneInstanceHistory() { @@ -235,6 +253,10 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.Equal("dep-b", Assert.Single(page.Rows).DeploymentId); } + /// <summary>Verifies that the search term matches across the identifiers a user can look up.</summary> + /// <param name="term">The term.</param> + /// <param name="expected">The expected.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("Filler", "dep-a")] // instance unique name [InlineData("dep-b", "dep-b")] // deployment id @@ -254,6 +276,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.Equal(expected, Assert.Single(page.Rows).DeploymentId); } + /// <summary>Verifies that a whitespace-only search is treated as no constraint.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryPage_WhitespaceSearch_IsTreatedAsUnconstrained() { @@ -267,6 +291,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.Equal(1, page.TotalCount); } + /// <summary>Verifies that the site scope restricts results to the granted sites.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryPage_SiteScope_RestrictsToTheGrantedSites() { @@ -284,6 +310,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.Equal("dep-b", Assert.Single(page.Rows).DeploymentId); } + /// <summary>Verifies that an empty site scope is a real filter that matches nothing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryPage_EmptySiteScope_IsARealFilterThatMatchesNothing() { @@ -300,6 +328,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.Empty(page.Rows); } + /// <summary>Verifies that site scope and status filters compose correctly for the CLI query path.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryPage_SiteScopeAndStatus_Compose_ForTheCliQueryPath() { @@ -333,6 +363,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable // ── Status counts ───────────────────────────────────────────────────────── + /// <summary>Verifies that status counts group every status and report absent statuses as zero.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StatusCounts_GroupsEveryStatus_WithAbsentOnesReportedAsZero() { @@ -352,6 +384,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.Equal(4, counts.Total); } + /// <summary>Verifies that status counts ignore the status filter so each tile keeps its own total.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StatusCounts_IgnoreTheStatusFilter_SoEachTileKeepsItsOwnTotal() { @@ -371,6 +405,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.Equal(2, counts.Total); } + /// <summary>Verifies that status counts honor every filter dimension other than status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StatusCounts_HonourEveryOtherFilterDimension() { @@ -396,6 +432,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable Assert.Equal(2, byInstance.Total); } + /// <summary>Verifies that status counts over an empty set are all zero.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StatusCounts_EmptySet_IsAllZeros() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DesignTimeDbContextFactoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DesignTimeDbContextFactoryTests.cs index c4bb7a58..9e7a8cfc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DesignTimeDbContextFactoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DesignTimeDbContextFactoryTests.cs @@ -7,20 +7,23 @@ public class DesignTimeDbContextFactoryTests : IDisposable private const string EnvVar = "SCADABRIDGE_DESIGNTIME_CONNECTIONSTRING"; private readonly string? _originalEnv; + /// <summary>Initializes the test and captures the original connection string environment variable.</summary> public DesignTimeDbContextFactoryTests() { _originalEnv = Environment.GetEnvironmentVariable(EnvVar); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { Environment.SetEnvironmentVariable(EnvVar, _originalEnv); } + /// <summary>Verifies that creating a context with no connection string configured throws a clear exception.</summary> [Fact] public void CreateDbContext_NoConnectionStringConfigured_ThrowsClearException() { - // Regression guard for ConfigurationDatabase-002: the factory must not fall back + // Regression guard: the factory must not fall back // to a hardcoded `sa`/password literal. With nothing configured it must fail loudly // with an actionable message instead of silently pointing tooling at a guessed DB. Environment.SetEnvironmentVariable(EnvVar, null); @@ -33,6 +36,7 @@ public class DesignTimeDbContextFactoryTests : IDisposable Assert.DoesNotContain("sa", ex.Message.Split(' '), StringComparer.OrdinalIgnoreCase); } + /// <summary>Verifies that a connection string from the environment variable is used.</summary> [Fact] public void CreateDbContext_ConnectionStringFromEnvironmentVariable_IsUsed() { @@ -47,6 +51,7 @@ public class DesignTimeDbContextFactoryTests : IDisposable Assert.NotNull(context); } + /// <summary>Verifies that the factory source contains no hardcoded SA credential.</summary> [Fact] public void DesignTimeDbContextFactory_SourceContainsNoHardcodedSaCredential() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/EphemeralEncryptionFallbackTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/EphemeralEncryptionFallbackTests.cs index 9b10a52e..0c04f354 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/EphemeralEncryptionFallbackTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/EphemeralEncryptionFallbackTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// <summary> -/// Regression guard for ConfigurationDatabase-013: a <see cref="ScadaBridgeDbContext"/> +/// Regression guard: a <see cref="ScadaBridgeDbContext"/> /// constructed without an explicit Data Protection provider (the single-argument /// constructor) must NOT silently encrypt secret columns with a throwaway ephemeral /// key — that would persist ciphertext that becomes permanently undecryptable on the @@ -22,6 +22,8 @@ public class EphemeralEncryptionFallbackTests .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) .Options; + /// <summary>Verifies that writing a secret column through the single-argument constructor fails fast and does not persist throwaway ciphertext.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SingleArgConstructor_WritingSecretColumn_FailsFast_DoesNotPersistThrowawayCiphertext() { @@ -44,6 +46,8 @@ public class EphemeralEncryptionFallbackTests Assert.Contains("Data Protection", ex.Message); } + /// <summary>Verifies that writing a non-secret column through the single-argument constructor succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SingleArgConstructor_WritingNonSecretColumn_Succeeds() { @@ -61,6 +65,8 @@ public class EphemeralEncryptionFallbackTests Assert.True(ext.Id > 0); } + /// <summary>Verifies that writing a secret column through the provider constructor still succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ProviderConstructor_WritingSecretColumn_StillSucceeds() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/InboundApiRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/InboundApiRepositoryTests.cs index 2daba38c..cdeafcad 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/InboundApiRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/InboundApiRepositoryTests.cs @@ -15,18 +15,22 @@ public class InboundApiRepositoryTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly InboundApiRepository _repository; + /// <summary>Initializes a new test instance with its own database context.</summary> public InboundApiRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new InboundApiRepository(_context); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that an added API method can be retrieved by name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddApiMethod_AndGetByName_RoundTrips() { @@ -39,6 +43,8 @@ public class InboundApiRepositoryTests : IDisposable Assert.Equal(method.Id, loaded!.Id); } + /// <summary>Verifies that deleting an API method removes the entity.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteApiMethod_RemovesEntity() { @@ -52,6 +58,7 @@ public class InboundApiRepositoryTests : IDisposable Assert.Null(await _repository.GetApiMethodByIdAsync(method.Id)); } + /// <summary>Verifies that constructing the repository with a null context throws.</summary> [Fact] public void Constructor_NullContext_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ListValueNormalizerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ListValueNormalizerTests.cs index fc224c46..3fcd301d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ListValueNormalizerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ListValueNormalizerTests.cs @@ -16,11 +16,13 @@ public class ListValueNormalizerTests : IDisposable { private readonly ScadaBridgeDbContext _context; + /// <summary>Initializes a new instance of the <see cref="ListValueNormalizerTests"/> class.</summary> public ListValueNormalizerTests() { _context = SqliteTestHelper.CreateInMemoryContext(); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); @@ -52,6 +54,8 @@ public class ListValueNormalizerTests : IDisposable return instance.Id; } + /// <summary>Verifies the case: template Attribute, old String Form, rewritten To Native.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TemplateAttribute_OldStringForm_RewrittenToNative() { @@ -71,6 +75,8 @@ public class ListValueNormalizerTests : IDisposable Assert.Equal("[10,20]", reloaded.Value); } + /// <summary>Verifies the case: template Attribute, already Native, is Not Rewritten.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TemplateAttribute_AlreadyNative_IsNotRewritten() { @@ -96,6 +102,8 @@ public class ListValueNormalizerTests : IDisposable Assert.Equal("[10,20]", reloaded.Value); } + /// <summary>Verifies the case: template Attribute, string List, is Unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TemplateAttribute_StringList_IsUnchanged() { @@ -115,6 +123,8 @@ public class ListValueNormalizerTests : IDisposable Assert.Equal("[\"a\",\"b\"]", reloaded.Value); } + /// <summary>Verifies the case: template Attribute, malformed, is Skipped, and Sibling Still Normalized.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TemplateAttribute_Malformed_IsSkipped_AndSiblingStillNormalized() { @@ -147,6 +157,8 @@ public class ListValueNormalizerTests : IDisposable Assert.Equal("[5]", good.Value); // normalized } + /// <summary>Verifies the case: template Attribute, non List Row, is Unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TemplateAttribute_NonListRow_IsUnchanged() { @@ -166,6 +178,8 @@ public class ListValueNormalizerTests : IDisposable Assert.Equal("42", reloaded.Value); } + /// <summary>Verifies the case: instance Attribute Override, old String Form, rewritten To Native.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceAttributeOverride_OldStringForm_RewrittenToNative() { @@ -184,6 +198,8 @@ public class ListValueNormalizerTests : IDisposable Assert.Equal("[5]", reloaded.OverrideValue); } + /// <summary>Verifies the case: instance Attribute Override, null Element Type, is Untouched.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceAttributeOverride_NullElementType_IsUntouched() { @@ -202,6 +218,8 @@ public class ListValueNormalizerTests : IDisposable Assert.Equal("42", reloaded.OverrideValue); } + /// <summary>Verifies the case: normalize Async, is Idempotent, second Run Changes Nothing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NormalizeAsync_IsIdempotent_SecondRunChangesNothing() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogPartitionMaintenanceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogPartitionMaintenanceTests.cs index 52dddb78..23d8d23d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogPartitionMaintenanceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogPartitionMaintenanceTests.cs @@ -9,7 +9,7 @@ using Xunit; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance; /// <summary> -/// Bundle D (#23 M6-T5) integration tests for +/// Integration tests for /// <see cref="AuditLogPartitionMaintenance"/>. Uses the same /// <see cref="MsSqlMigrationFixture"/> as the AuditLog migration / repository /// tests so the ALTER PARTITION FUNCTION DDL runs against the actual seeded @@ -27,6 +27,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix { private readonly MsSqlMigrationFixture _fixture; + /// <summary>Initializes the tests with the shared SQL Server migration fixture.</summary> + /// <param name="fixture">The fixture that provides the migrated test database.</param> public AuditLogPartitionMaintenanceTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -80,6 +82,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix return firstOfThisMonth.AddMonths(1); } + /// <summary>Verifies that no split occurs and an empty result is returned when the future range already exists.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task EnsureLookahead_AlreadyHasFutureRange_NoSplit_ReturnsEmpty() { @@ -105,6 +109,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix Assert.Equal(max, maxAfter); } + /// <summary>Verifies that one missing boundary is split and returned as a single boundary.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task EnsureLookahead_NeedsOneMoreBoundary_Splits_Returns1Boundary() { @@ -128,6 +134,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix Assert.Equal(expectedAdded, maxAfter); } + /// <summary>Verifies that three missing boundaries are split and returned as three boundaries.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task EnsureLookahead_NeedsThreeBoundaries_Splits_Returns3Boundaries() { @@ -153,8 +161,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix } /// <summary> - /// ConfigurationDatabase-024: CD-019 removed the try/catch around the per-month - /// SPLIT call so a genuine SQL failure (deadlock, permission, log full, transient + /// The try/catch around the per-month + /// SPLIT call was removed, so a genuine SQL failure (deadlock, permission, log full, transient /// connection drop) now aborts the loop instead of leaving partition holes. This /// test pins that abort behaviour: with an interceptor that throws on the SECOND /// SPLIT, the call must propagate the exception AND the first SPLIT's boundary @@ -163,10 +171,11 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix /// commit boundary N before throwing, and that the next tick can resume from /// boundary N+1 at-least-once with no holes. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted() { - // STM: CD-024-SecondSplitThrowsAbortsLoop marker. + // STM: SecondSplitThrowsAbortsLoop marker. Skip.IfNot(_fixture.Available, _fixture.SkipReason); // Baseline max-boundary observed via a clean context. @@ -180,7 +189,7 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix // Build a fresh context with an interceptor that throws on the 2nd ALTER // PARTITION FUNCTION SPLIT RANGE. EF Core surfaces the throw through // ExecuteSqlRawAsync exactly as a SqlException would — the loop has no - // try/catch (CD-019), so the exception propagates after the first SPLIT + // try/catch, so the exception propagates after the first SPLIT // has already committed. var interceptor = new SecondSplitThrowsInterceptor(); var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>() @@ -194,8 +203,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix () => maintenance.EnsureLookaheadAsync(lookahead)); // Verify exactly one ALTER PARTITION FUNCTION SPLIT RANGE actually ran - // before the interceptor's throw: split #1 committed, split #2 threw, - // split #3 was never attempted. + // before the interceptor's throw: the first split committed, the second threw, + // and the third was never attempted. Assert.Equal(1, interceptor.SuccessfulSplits); // And verify the first boundary IS now persisted — the loop aborted but @@ -217,12 +226,14 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix /// </summary> private sealed class SecondSplitThrowsInterceptor : DbCommandInterceptor { + /// <summary>Gets the number of partition splits that were allowed to run.</summary> public int SuccessfulSplits { get; private set; } private bool IsTargetSplit(DbCommand command) => command.CommandText.Contains("SPLIT RANGE", StringComparison.OrdinalIgnoreCase) && command.CommandText.Contains("pf_AuditLog_Month", StringComparison.OrdinalIgnoreCase); + /// <inheritdoc /> public override InterceptionResult<int> NonQueryExecuting( DbCommand command, CommandEventData eventData, @@ -232,6 +243,7 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix return base.NonQueryExecuting(command, eventData, result); } + /// <inheritdoc /> public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync( DbCommand command, CommandEventData eventData, @@ -242,6 +254,7 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); } + /// <inheritdoc /> public override int NonQueryExecuted( DbCommand command, CommandExecutedEventData eventData, @@ -254,6 +267,7 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix return base.NonQueryExecuted(command, eventData, result); } + /// <inheritdoc /> public override ValueTask<int> NonQueryExecutedAsync( DbCommand command, CommandExecutedEventData eventData, @@ -275,7 +289,7 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix } // Allow the first SPLIT through; throw on the second so the loop's - // post-CD-019 "let it propagate" behaviour can be asserted. + // "let it propagate" behaviour can be asserted. if (SuccessfulSplits >= 1) { throw new InvalidOperationException( @@ -284,6 +298,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix } } + /// <summary>Verifies that a boundary that already exists causes no error, making the call idempotent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task EnsureLookahead_BoundaryAlreadyExists_NoError_Idempotent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/BackfillSourceNodeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/BackfillSourceNodeTests.cs index 5df48d53..50e18234 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/BackfillSourceNodeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/BackfillSourceNodeTests.cs @@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance; /// <summary> /// Integration tests for <see cref="AuditLogRepository.BackfillSourceNodeAsync"/> -/// (M5.6 T5 — SourceNode sentinel backfill). +/// (SourceNode sentinel backfill). /// /// <para> /// These tests exercise the real <see cref="AuditLogRepository"/> against a @@ -21,6 +21,8 @@ public class BackfillSourceNodeTests : IClassFixture<MsSqlMigrationFixture> { private readonly MsSqlMigrationFixture _fixture; + /// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary> + /// <param name="fixture">The shared MSSQL migration fixture.</param> public BackfillSourceNodeTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -83,6 +85,8 @@ VALUES // 1. SetsNullRowsBeforeThreshold // ------------------------------------------------------------------ + /// <summary>Verifies that the backfill sets rows with a null source node that are before the threshold.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task BackfillSourceNode_SetsNullRowsBeforeThreshold() { @@ -118,6 +122,8 @@ VALUES // 2. LeavesNonNullRowsUntouched // ------------------------------------------------------------------ + /// <summary>Verifies that the backfill leaves rows that already have a source node untouched.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task BackfillSourceNode_LeavesNonNullRowsUntouched() { @@ -144,6 +150,8 @@ VALUES // 3. Idempotent_SecondRunUpdatesZeroRows // ------------------------------------------------------------------ + /// <summary>Verifies that the backfill is idempotent, so a second run updates zero rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task BackfillSourceNode_Idempotent_SecondRunUpdatesZeroRows() { @@ -181,6 +189,8 @@ VALUES // 4. CustomSentinelIsWritten // ------------------------------------------------------------------ + /// <summary>Verifies that a custom sentinel value is written.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task BackfillSourceNode_CustomSentinel_IsWritten() { @@ -206,6 +216,8 @@ VALUES // 5. ArgumentValidation // ------------------------------------------------------------------ + /// <summary>Verifies that an empty sentinel throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BackfillSourceNode_EmptySentinel_Throws() { @@ -222,6 +234,8 @@ VALUES () => repo.BackfillSourceNodeAsync("", DateTime.UtcNow, 1000)); } + /// <summary>Verifies that a zero batch size throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BackfillSourceNode_ZeroBatchSize_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/MediumPersistenceRegressionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/MediumPersistenceRegressionTests.cs index 6d51c85f..024af38e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/MediumPersistenceRegressionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/MediumPersistenceRegressionTests.cs @@ -18,6 +18,8 @@ public class MediumPersistenceRegressionTests return db; } + /// <summary>Verifies that an instance unique name cannot exist at two different sites.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DL1_UniqueNameCannotExistAtTwoSites() { @@ -28,6 +30,8 @@ public class MediumPersistenceRegressionTests await Assert.ThrowsAsync<InstanceNameConflictException>(() => db.SaveChangesAsync()); } + /// <summary>Verifies that a context with a different data protection key cannot read protected connection configuration.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DL2_ConvertersDoNotShareKeysAcrossProviders() { @@ -45,6 +49,8 @@ public class MediumPersistenceRegressionTests wrongKey.DataConnections.SingleAsync(c => c.Name == "key-isolation")); } + /// <summary>Verifies that the legacy upgrade protects both configuration columns and is safe to run twice.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DL2_LegacyUpgradeProtectsBothColumnsAndIsIdempotent() { @@ -63,6 +69,9 @@ public class MediumPersistenceRegressionTests Assert.Contains("backup-secret", row.BackupConfiguration); } + /// <summary>Verifies that data connection configuration is stored encrypted and reads back unchanged.</summary> + /// <param name="backup">True to exercise the backup configuration column, false for the primary.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] @@ -83,6 +92,9 @@ public class MediumPersistenceRegressionTests Assert.Equal(secret, backup ? loaded.BackupConfiguration : loaded.PrimaryConfiguration); } + /// <summary>Verifies that a context without a data protection provider refuses to write connection configuration.</summary> + /// <param name="backup">True to exercise the backup configuration column, false for the primary.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NativeAlarmSourceSchemaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NativeAlarmSourceSchemaTests.cs index d3cf5192..8fade519 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NativeAlarmSourceSchemaTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NativeAlarmSourceSchemaTests.cs @@ -17,6 +17,8 @@ public class NativeAlarmSourceSchemaTests return ctx; } + /// <summary>Verifies that a template native alarm source persists through its template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TemplateNativeAlarmSource_PersistsViaTemplate() { @@ -30,6 +32,8 @@ public class NativeAlarmSourceSchemaTests Assert.Single(ctx.TemplateNativeAlarmSources); } + /// <summary>Verifies that the instance native alarm source override DbSet is mapped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceNativeAlarmSourceOverride_DbSetIsMapped() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryKpiQueryShapeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryKpiQueryShapeTests.cs index f6fe9500..fd79c4f8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryKpiQueryShapeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryKpiQueryShapeTests.cs @@ -7,8 +7,8 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// <summary> -/// Query-shape coverage for the Notification Outbox KPI snapshots (arch-review 04, -/// Task 20). Each snapshot must be a single-query conditional aggregation with a +/// Query-shape coverage for the Notification Outbox KPI snapshots (arch-review 04). +/// Each snapshot must be a single-query conditional aggregation with a /// bounded oldest-pending — the old shape issued 5-7 sequential COUNT round trips /// and materialized the whole live queue to find the oldest row. /// </summary> @@ -25,6 +25,8 @@ public class NotificationOutboxRepositoryKpiQueryShapeTests SourceNode = sourceNode, }; + /// <summary>Verifies that all three KPI snapshots issue at most two queries and bound the oldest-pending lookup.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AllThreeKpiSnapshots_IssueAtMostTwoQueries_AndBoundTheOldestLookup() { @@ -50,7 +52,7 @@ public class NotificationOutboxRepositoryKpiQueryShapeTests $"ComputeKpisAsync issued {counter.Count} queries against Notifications; expected <= 2"); // The oldest lookup must be bounded (LIMIT/TOP), never a full non-terminal SELECT. Assert.Contains(counter.Commands, sql => sql.Contains("LIMIT", StringComparison.OrdinalIgnoreCase)); - // WP2.2: the aggregation must be PREDICATE-RESTRICTED, not an unrestricted + // The aggregation must be PREDICATE-RESTRICTED, not an unrestricted // scan. Every KPI here concerns the live queue, the parked backlog or the // last delivery interval; historical Delivered/Discarded rows — which are // the overwhelming bulk of a retained table — contribute to none of them. @@ -90,10 +92,13 @@ public class NotificationOutboxRepositoryKpiQueryShapeTests { private readonly List<string> _commands = new(); + /// <summary>The text of every reader command recorded so far.</summary> public IReadOnlyList<string> Commands => _commands; + /// <summary>The number of reader commands recorded so far.</summary> public int Count => _commands.Count; + /// <summary>Clears all recorded commands.</summary> public void Reset() => _commands.Clear(); private void Record(DbCommand command) @@ -104,6 +109,7 @@ public class NotificationOutboxRepositoryKpiQueryShapeTests } } + /// <inheritdoc /> public override InterceptionResult<DbDataReader> ReaderExecuting( DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result) { @@ -111,6 +117,7 @@ public class NotificationOutboxRepositoryKpiQueryShapeTests return base.ReaderExecuting(command, eventData, result); } + /// <inheritdoc /> public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default) diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerNodeKpiTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerNodeKpiTests.cs index 0d2737f3..1009828a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerNodeKpiTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerNodeKpiTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; // Coverage for per-node KPI aggregation in the Notification Outbox repository -// (T6: M5.2 per-node stuck-count KPIs). +// (per-node stuck-count KPIs). public class NotificationOutboxRepositoryPerNodeKpiTests { private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext(); @@ -27,6 +27,8 @@ public class NotificationOutboxRepositoryPerNodeKpiTests }; } + /// <summary>Verifies that per-node KPIs are aggregated separately for each source node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputePerNodeKpisAsync_AggregatesMetricsPerNode() { @@ -70,6 +72,8 @@ public class NotificationOutboxRepositoryPerNodeKpiTests Assert.NotNull(b.OldestPendingAge); } + /// <summary>Verifies that notifications with a null source node are excluded from the per-node KPIs.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputePerNodeKpisAsync_ExcludesNullSourceNode() { @@ -88,6 +92,8 @@ public class NotificationOutboxRepositoryPerNodeKpiTests Assert.Empty(result); } + /// <summary>Verifies that having no notifications yields an empty per-node KPI result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputePerNodeKpisAsync_ReturnsEmpty_WhenNoNotifications() { @@ -98,6 +104,8 @@ public class NotificationOutboxRepositoryPerNodeKpiTests Assert.Empty(result); } + /// <summary>Verifies that the oldest pending age reflects the older row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputePerNodeKpisAsync_OldestPendingAge_ReflectsOlderRow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerSiteKpiTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerSiteKpiTests.cs index 0b5442e7..59b6ef33 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerSiteKpiTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerSiteKpiTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; // Coverage for per-site KPI aggregation in the Notification Outbox repository -// (Task 2 of the notifications-nav-group feature). +// (per-site KPIs). public class NotificationOutboxRepositoryPerSiteKpiTests { private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext(); @@ -27,6 +27,8 @@ public class NotificationOutboxRepositoryPerSiteKpiTests }; } + /// <summary>Verifies that per-site KPIs are aggregated separately for each site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputePerSiteKpisAsync_AggregatesMetricsPerSite() { @@ -79,6 +81,8 @@ public class NotificationOutboxRepositoryPerSiteKpiTests $"expected OldestPendingAge < 95m, got {c.OldestPendingAge}"); } + /// <summary>Verifies that having no notifications yields an empty per-site KPI result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputePerSiteKpisAsync_ReturnsEmpty_WhenNoNotifications() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/PendingDeploymentRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/PendingDeploymentRepositoryTests.cs index 6b77f9e9..d6765140 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/PendingDeploymentRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/PendingDeploymentRepositoryTests.cs @@ -19,12 +19,14 @@ public class PendingDeploymentRepositoryTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly DeploymentManagerRepository _repository; + /// <summary>Initializes the test class with an in-memory context and repository.</summary> public PendingDeploymentRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new DeploymentManagerRepository(_context); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); @@ -58,6 +60,8 @@ public class PendingDeploymentRepositoryTests : IDisposable createdAtUtc: now, expiresAtUtc: now.AddMinutes(10)); } + /// <summary>Verifies that a second pending deployment for the same instance supersedes the prior row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddPendingDeployment_SecondDeployForSameInstance_SupersedesPriorRow() { @@ -78,6 +82,8 @@ public class PendingDeploymentRepositoryTests : IDisposable Assert.Equal(instanceId, current.InstanceId); } + /// <summary>Verifies that pending deployments for different instances coexist.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddPendingDeployment_DifferentInstances_Coexist() { @@ -96,6 +102,8 @@ public class PendingDeploymentRepositoryTests : IDisposable Assert.Equal(instance2, b!.InstanceId); } + /// <summary>Verifies that purging expired pending deployments removes only the expired rows and returns the count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PurgeExpiredPendingDeployments_RemovesOnlyExpired_ReturnsCount() { @@ -123,6 +131,8 @@ public class PendingDeploymentRepositoryTests : IDisposable Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("live-1")); } + /// <summary>Verifies that purging expired pending deployments returns zero when none are expired.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PurgeExpiredPendingDeployments_NoneExpired_ReturnsZero() { @@ -138,6 +148,8 @@ public class PendingDeploymentRepositoryTests : IDisposable Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("live-1")); } + /// <summary>Verifies that deleting a pending deployment by id removes the row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeletePendingDeploymentById_RemovesRow() { @@ -151,6 +163,8 @@ public class PendingDeploymentRepositoryTests : IDisposable Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep1")); } + /// <summary>Verifies that deleting a pending deployment with an unknown id does not throw.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeletePendingDeploymentById_UnknownId_NoThrow() { @@ -159,6 +173,8 @@ public class PendingDeploymentRepositoryTests : IDisposable Assert.Null(await _repository.GetPendingDeploymentByIdAsync("does-not-exist")); } + /// <summary>Verifies that getting a pending deployment by instance id returns the row when present.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetPendingDeploymentByInstanceId_ReturnsRow_WhenPresent() { @@ -178,6 +194,8 @@ public class PendingDeploymentRepositoryTests : IDisposable Assert.Equal("{\"v\":9}", row.ConfigurationJson); } + /// <summary>Verifies that getting a pending deployment by instance id returns null when absent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetPendingDeploymentByInstanceId_ReturnsNull_WhenAbsent() { @@ -189,6 +207,8 @@ public class PendingDeploymentRepositoryTests : IDisposable Assert.Null(row); } + /// <summary>Verifies that staging is not blocked by an expired row with a different deployment id, which is removed and replaced by the live row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StagePendingIfAbsent_ExpiredRowDifferentDeploymentId_DoesNotBlock_RemovesExpiredAndStagesLiveRow() { @@ -219,6 +239,8 @@ public class PendingDeploymentRepositoryTests : IDisposable Assert.True(row.ExpiresAtUtc > now); // the new row is live } + /// <summary>Verifies that staging over an expired row with the same deployment id causes no unique-key collision and stages the new row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StagePendingIfAbsent_ExpiredRowSameDeploymentId_NoUniqueCollision_StagesNew() { @@ -245,6 +267,8 @@ public class PendingDeploymentRepositoryTests : IDisposable Assert.True(row.ExpiresAtUtc > now); } + /// <summary>Verifies that a live row blocks staging and is left untouched.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StagePendingIfAbsent_LiveRow_Blocks_LeavesUntouched() { @@ -269,6 +293,8 @@ public class PendingDeploymentRepositoryTests : IDisposable Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-new")); } + /// <summary>Verifies that getting a pending deployment by instance id with an explicit current time returns the live, unexpired row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetPendingDeploymentByInstanceId_WithNowUtc_ReturnsLiveRow_NotExpired() { @@ -294,6 +320,8 @@ public class PendingDeploymentRepositoryTests : IDisposable Assert.Equal("dep-getlive", live!.DeploymentId); } + /// <summary>Verifies that adding a pending deployment supersedes all prior rows for the same instance.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddPendingDeployment_MultiplePriorRowsSameInstance_AllSuperseded() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ReconcileRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ReconcileRepositoryTests.cs index f99e299b..556a71e1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ReconcileRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ReconcileRepositoryTests.cs @@ -22,12 +22,14 @@ public class ReconcileRepositoryTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly DeploymentManagerRepository _repository; + /// <summary>Initializes the test with an in-memory database context and repository.</summary> public ReconcileRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new DeploymentManagerRepository(_context); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); @@ -69,6 +71,8 @@ public class ReconcileRepositoryTests : IDisposable // --- GetExpectedDeploymentsForSiteAsync --- + /// <summary>Verifies that only deployed instances belonging to the site are returned as expected deployments.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetExpectedDeployments_ReturnsOnlyDeployedInstancesForSite() { @@ -115,6 +119,8 @@ public class ReconcileRepositoryTests : IDisposable Assert.False(a2.IsEnabled); } + /// <summary>Verifies that no expected deployments are returned when the site has no deployed instances.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetExpectedDeployments_NoDeployedInstances_ReturnsEmpty() { @@ -129,6 +135,8 @@ public class ReconcileRepositoryTests : IDisposable // --- StagePendingIfAbsentAsync --- + /// <summary>Verifies that staging a pending deployment with no prior row stages it and returns true.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StagePendingIfAbsent_NoPriorRow_StagesAndReturnsTrue() { @@ -157,6 +165,8 @@ public class ReconcileRepositoryTests : IDisposable Assert.Equal("rev-new", row.RevisionHash); } + /// <summary>Verifies that staging a pending deployment when a row already exists returns false and does not overwrite it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StagePendingIfAbsent_PriorRowExists_ReturnsFalseAndDoesNotOverwrite() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index fd5b94a1..5a03b193 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -12,7 +12,7 @@ using Xunit; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories; /// <summary> -/// Bundle D (#23 M1) integration tests for <see cref="AuditLogRepository"/>. Uses +/// Integration tests for <see cref="AuditLogRepository"/>. Uses /// the same <see cref="MsSqlMigrationFixture"/> as the Bundle C migration tests so /// raw-SQL paths (the IF NOT EXISTS insert, partition switch) execute against a /// real partitioned schema. Tests scope all queries by a per-test @@ -23,11 +23,15 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> { private readonly MsSqlMigrationFixture _fixture; + /// <summary>Creates the test class with the shared migrated SQL Server fixture.</summary> + /// <param name="fixture">The shared migration fixture.</param> public AuditLogRepositoryTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } + /// <summary>Verifies that inserting a fresh event writes one row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertIfNotExistsAsync_FreshEvent_WritesOneRow() { @@ -51,6 +55,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(evt.EventId, loaded[0].EventId); } + /// <summary>Verifies that inserting an event persists its source node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertIfNotExistsAsync_PersistsSourceNode() { @@ -75,6 +81,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal("central-a", loaded[0].SourceNode); } + /// <summary>Verifies that inserting an event with no source node persists a null source node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertIfNotExistsAsync_PersistsNullSourceNode() { @@ -101,6 +109,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Null(loaded[0].SourceNode); } + /// <summary>Verifies that inserting a duplicate event id is a no-op that neither throws nor duplicates the row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertIfNotExistsAsync_DuplicateEventId_IsNoOp_NoExceptionNoDuplicate() { @@ -115,7 +125,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> await repo.InsertIfNotExistsAsync(first); // Same EventId, different payload — first-write-wins, the second call is silently a no-op. - // C3 (Task 2.5): ErrorMessage rides in DetailsJson on the canonical record, so rebuild + // ErrorMessage rides in DetailsJson on the canonical record, so rebuild // a sibling row carrying the same EventId via the factory (rather than a top-level `with`). var second = ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiOutbound, @@ -133,11 +143,13 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> .ToListAsync(); Assert.Single(loaded); - // C5 (Task 2.5): ErrorMessage rides in DetailsJson now — decode it to assert + // ErrorMessage rides in DetailsJson — decode it to assert // first-write-wins kept the original payload. Assert.Equal("first", AuditDetailsCodec.Deserialize(loaded[0].DetailsJson).ErrorMessage); } + /// <summary>Verifies that a query returns rows in descending occurred-time order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_ReturnsRowsInOccurredDescOrder() { @@ -161,6 +173,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.True(rows[1].OccurredAtUtc > rows[2].OccurredAtUtc); } + /// <summary>Verifies that a query can filter by channel.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_FilterByChannel() { @@ -185,6 +199,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.AsRow().Channel)); } + /// <summary>Verifies that filtering by several channels returns their union.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_FilterByMultipleChannels_ReturnsUnion() { @@ -212,6 +228,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.DoesNotContain(rows, r => r.AsRow().Channel == AuditChannel.DbOutbound); } + /// <summary>Verifies that filtering by several statuses returns their union.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_FilterByMultipleStatuses_ReturnsUnion() { @@ -238,6 +256,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.DoesNotContain(rows, r => r.AsRow().Status == AuditStatus.Delivered); } + /// <summary>Verifies that filtering by several source site ids returns their union.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_FilterByMultipleSourceSiteIds_ReturnsUnion() { @@ -263,6 +283,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.DoesNotContain(rows, r => r.AsRow().SourceSiteId == siteC); } + /// <summary>Verifies that an empty channel list does not constrain the query.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_EmptyChannelList_DoesNotConstrain() { @@ -286,6 +308,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(2, rows.Count); } + /// <summary>Verifies that a query can filter by source site id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_FilterBySourceSiteId() { @@ -310,12 +334,14 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> } // ────────────────────────────────────────────────────────────────────── - // Task 15: SourceNodes filter + GetDistinctSourceNodesAsync. Pins the new + // SourceNodes filter + GetDistinctSourceNodesAsync. Pins the new // Node multi-select contract — non-empty list → SQL IN (…); NULL // SourceNode rows are excluded when the filter is set; the distinct // enumeration omits nulls and orders ascending. // ────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that filtering by source node returns the matching rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_FilterBySourceNode_ReturnsMatchingRows() { @@ -340,6 +366,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal("central-a", rows[0].SourceNode); } + /// <summary>Verifies that filtering by several source nodes returns their union.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_FilterByMultipleSourceNodes_ReturnsUnion() { @@ -364,6 +392,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.All(rows, r => Assert.Contains(r.SourceNode, new[] { "central-a", "central-b" })); } + /// <summary>Verifies that the distinct source nodes are returned without nulls and in ascending order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task GetDistinctSourceNodesAsync_ReturnsDistinctNonNullValues_Ordered() { @@ -394,6 +424,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(nodes.OrderBy(n => n, StringComparer.Ordinal), nodes); } + /// <summary>Verifies that filtering by execution id returns the matching rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows() { @@ -422,6 +454,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.All(rows, r => Assert.Equal(executionId, r.AsRow().ExecutionId)); } + /// <summary>Verifies that filtering by parent execution id returns the matching rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_FilterByParentExecutionId_ReturnsMatchingRows() { @@ -451,6 +485,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.All(rows, r => Assert.Equal(parentExecutionId, r.AsRow().ParentExecutionId)); } + /// <summary>Verifies that a query can filter by time range.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_FilterByTimeRange() { @@ -476,6 +512,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(t0.AddMinutes(30), rows[0].OccurredAtUtc); } + /// <summary>Verifies that the next keyset page starts after the cursor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_Keyset_NextPageStartsAfterCursor() { @@ -525,6 +563,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(t0.AddMinutes(0), page3[0].OccurredAtUtc); } + /// <summary>Verifies that concurrent duplicate inserts produce exactly one row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertIfNotExistsAsync_ConcurrentDuplicateInserts_ProduceExactlyOneRow() { @@ -561,9 +601,11 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> } // ------------------------------------------------------------------------ - // WP2.2: set-based InsertManyIfNotExistsAsync + // Set-based InsertManyIfNotExistsAsync // ------------------------------------------------------------------------ + /// <summary>Verifies that a batch insert writes every distinct event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertManyIfNotExistsAsync_WritesEveryDistinctEvent() { @@ -593,6 +635,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> rows.Select(r => r.EventId).OrderBy(g => g).ToArray()); } + /// <summary>Verifies that duplicate event ids within one batch collapse to a single row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertManyIfNotExistsAsync_DuplicateEventIdsWithinOnePacket_CollapseToOneRow() { @@ -638,6 +682,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Null(AuditDetailsCodec.Deserialize(stored.DetailsJson).ErrorMessage); } + /// <summary>Verifies that duplicate event ids across batches are inserted idempotently.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertManyIfNotExistsAsync_DuplicateEventIdsAcrossPackets_AreIdempotent() { @@ -675,6 +721,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(6, rows.Count); } + /// <summary>Verifies that a batch larger than the SQL parameter ceiling is inserted in chunks.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertManyIfNotExistsAsync_ChunksBeyondTheParameterCeiling() { @@ -718,6 +766,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> /// server raise 2628/8152. Reject-everywhere is the contract; this test pins /// both halves of it. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertManyIfNotExistsAsync_TargetLongerThanColumn_IsRejected_NotTruncated() { @@ -736,7 +785,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> var batchRepo = new AuditLogRepository(batchContext); // 2628 = "String or binary data would be truncated in table …" (SQL 2019+), - // 8152 = its pre-2019 predecessor. Either proves the server saw the full value. + // 8152 = its predecessor on SQL Server versions before 2019. Either proves the server saw the full value. var batchEx = await Assert.ThrowsAsync<SqlException>( () => batchRepo.InsertManyIfNotExistsAsync(new[] { evt })); Assert.Contains(batchEx.Number, new[] { 2628, 8152 }); @@ -761,6 +810,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> /// The companion to the rejection test: a value that FITS must still round-trip /// through the set-based path byte for byte, at the exact column boundary. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertManyIfNotExistsAsync_TargetAtColumnLimit_RoundTripsIntact() { @@ -786,6 +836,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(boundaryTarget, loaded[0].Target); } + /// <summary>Verifies that inserting an empty batch is a no-op.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertManyIfNotExistsAsync_EmptyBatch_IsANoOp() { @@ -797,6 +849,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(0, await repo.InsertManyIfNotExistsAsync(Array.Empty<AuditEvent>())); } + /// <summary>Verifies that keyset paging breaks ties on event id when rows share an occurred time.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_Keyset_SameOccurredAtUtc_TiebreaksOnEventId() { @@ -855,12 +909,12 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> } // ------------------------------------------------------------------------ - // M6-T4 Bundle C: SwitchOutPartitionAsync drop-and-rebuild integration tests + // SwitchOutPartitionAsync drop-and-rebuild integration tests // ------------------------------------------------------------------------ // - // The partition-switch path replaces M1's NotSupportedException stub with + // The partition-switch path replaces the earlier NotSupportedException stub with // the production CREATE-staging → SWITCH PARTITION → DROP-staging batch - // documented in alog.md §4. WP2.2 removed the index drop/rebuild that used to + // documented in alog.md §4. The design removed the index drop/rebuild that used to // bracket it: uniqueness rides the partition-ALIGNED clustered PK // (EventId, OccurredAtUtc), so SWITCH has nothing to object to. These tests // verify the side effects an outsider can observe: @@ -871,6 +925,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> // after a switch (the aligned key really does enforce it) // * a thrown SqlException leaves the table intact with no orphaned staging + /// <summary>Verifies that switching out an old partition removes its rows and keeps newer partitions.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SwitchOutPartitionAsync_OldPartition_RemovesRows_NewPartitionsKept() { @@ -906,6 +962,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Contains(remaining, e => e.EventId == marEvt.EventId); } + /// <summary>Verifies that a partition switch leaves no non-aligned unique index behind.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SwitchOutPartitionAsync_LeavesNoNonAlignedUniqueIndex_AfterSwitch() { @@ -918,7 +976,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> // the fixture's MSSQL database) don't tread on each other. await repo.SwitchOutPartitionAsync(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); - // WP2.2: the switch must NOT recreate UX_AuditLog_EventId. Re-adding it + // The switch must NOT recreate UX_AuditLog_EventId. Re-adding it // would reinstate the offline drop/rebuild the purge was freed from and // block the next SWITCH until something dropped it again. await using var verifyContext = CreateContext(); @@ -937,6 +995,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(1, pkExists); } + /// <summary>Verifies that first-write-wins insertion is still enforced after a partition switch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SwitchOutPartitionAsync_InsertIfNotExistsAsync_StillEnforcesFirstWriteWins_AfterSwitch() { @@ -959,7 +1019,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> // column instead of a dedicated unique index; if the switch had disturbed // that key, this insert would silently produce a duplicate row and the // count assertion below would catch it. - // C3 (Task 2.5): rebuild a sibling row with the same EventId via the factory + // Rebuild a sibling row with the same EventId via the factory // (ErrorMessage rides in DetailsJson, so a top-level `with` no longer applies). var dup = ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiOutbound, @@ -978,11 +1038,13 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Single(rows); Assert.Equal(preExisting.EventId, rows[0].EventId); - // First-write-wins: the original ErrorMessage (null) survives. C5 (Task 2.5): + // First-write-wins: the original ErrorMessage (null) survives. // ErrorMessage rides in DetailsJson — decode it to assert. Assert.Null(AuditDetailsCodec.Deserialize(rows[0].DetailsJson).ErrorMessage); } + /// <summary>Verifies that a partial partition-switch failure raises an exception and leaves no orphaned staging table.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SwitchOutPartitionAsync_PartialFailure_RaisesException_LeavesNoOrphanedStaging() { @@ -1045,8 +1107,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> "SELECT COUNT(*) FROM sys.tables WHERE name LIKE 'AuditLog\\_Staging\\_%' ESCAPE '\\';"); Assert.Equal(0, orphanedStaging); - // And it must NOT have (re)created the non-aligned unique index: WP2.2 - // deleted that rebuild entirely, so a failed switch cannot reintroduce + // And it must NOT have (re)created the non-aligned unique index: the + // design deleted that rebuild entirely, so a failed switch cannot reintroduce // the object that blocks the NEXT switch. var indexExists = await ScalarAsync<int>( verifyContext, @@ -1056,9 +1118,11 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> } // ------------------------------------------------------------------------ - // M6-T4 Bundle C: GetPartitionBoundariesOlderThanAsync + // GetPartitionBoundariesOlderThanAsync // ------------------------------------------------------------------------ + /// <summary>Verifies that only partition boundaries whose newest row is older than the threshold are returned.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task GetPartitionBoundariesOlderThanAsync_ReturnsBoundaries_WithMaxOccurredOlderThanThreshold() { @@ -1087,7 +1151,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> } // ------------------------------------------------------------------------ - // M7-T13 Bundle E: GetKpiSnapshotAsync — Health-dashboard Audit KPI tiles + // GetKpiSnapshotAsync — Health-dashboard Audit KPI tiles // ------------------------------------------------------------------------ // // The dashboard's "Audit volume" tile reads TotalEventsLastHour and the @@ -1102,6 +1166,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> // narrow band centred on that anchor — no other test in this class seeds // there, so the global count equals the seeded count for that band. + /// <summary>Verifies that the KPI snapshot reports correct totals and error counts for rows of mixed status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task GetKpiSnapshotAsync_WithMixedStatusRows_ReturnsCorrectTotalsAndErrors() { @@ -1141,6 +1207,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(nowUtc, snapshot.AsOfUtc); } + /// <summary>Verifies that the KPI snapshot of an empty window reports zero totals.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task GetKpiSnapshotAsync_EmptyWindow_ReturnsZeroTotals() { @@ -1163,7 +1231,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> } // ------------------------------------------------------------------------ - // Audit Log ParentExecutionId (Task 8): GetExecutionTreeAsync + // Audit Log ParentExecutionId: GetExecutionTreeAsync // ------------------------------------------------------------------------ // // GetExecutionTreeAsync walks UP from any node to the chain root, then walks @@ -1175,6 +1243,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> // * pathological cyclic data is bounded by the MAXRECURSION guard and // surfaces a SqlException rather than hanging + /// <summary>Verifies that the execution tree resolves a multi-level parent chain.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task GetExecutionTree_MultiLevelChain() { @@ -1267,6 +1337,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> } } + /// <summary>Verifies that the execution tree edge scan is bounded to the root window.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task ExecutionTree_Bounds_EdgeScan_To_Root_Window() { @@ -1310,6 +1382,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(2, tree.Count); } + /// <summary>Verifies that the execution tree includes a stub node for a parent with no audit rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task GetExecutionTree_StubParentNode() { @@ -1351,6 +1425,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(stubParentExec, child.ParentExecutionId); } + /// <summary>Verifies that the execution tree walk respects the maximum recursion depth.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task GetExecutionTree_RespectsMaxRecursion() { @@ -1413,7 +1489,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture> private static string NewSiteId() => "test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8); - // C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared + // Build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared // factory; the repository's transitional shim decomposes it into the 24-column // AuditLogRow on INSERT and recomposes the canonical record on QUERY. private static AuditEvent NewEvent( diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs index 738ac703..33384112 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs @@ -17,11 +17,15 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories; public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFixture> { private readonly MsSqlMigrationFixture _fixture; + /// <summary>Initializes the test class with the shared SQL Server migration fixture.</summary> + /// <param name="fixture">The shared SQL Server migration fixture.</param> public AuditLogRepositoryTraceEventsTests(MsSqlMigrationFixture fixture) => _fixture = fixture; private ScadaBridgeDbContext CreateContext() => new( new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlServer(_fixture.ConnectionString).Options); + /// <summary>Verifies that scoped tree and replay reads exclude other sites' rows and retain central rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task ScopedTreeAndReplay_ExcludeOtherSite_AndRetainCentralRows() { @@ -40,6 +44,10 @@ public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFi Assert.DoesNotContain(replay, row => row.DetailsJson?.Contains("sensitive-sql-values") == true); } + /// <summary>Verifies that replay bounds the number of rows and their UTF-8 byte size while keeping the root row.</summary> + /// <param name="count">The number of audit rows to seed.</param> + /// <param name="payloadLength">The length of each row's details payload.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableTheory] [InlineData(600, 0)] [InlineData(200, 10000)] @@ -64,6 +72,8 @@ public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFi Assert.True(batch.Sum(e => System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(e).Length) <= 1024 * 1024); } + /// <summary>Verifies that the tree read returns the inbound row plus chain trace rows in order from any entry point.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task ReturnsInboundPlusChainTraceRows_Ordered_FromAnyEntry() { @@ -118,10 +128,11 @@ public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFi } /// <summary> - /// Outbound-calls plan (2026-09-06, D6): replay returns the chain's ScriptTrace, ApiOutbound, + /// Replay returns the chain's ScriptTrace, ApiOutbound, /// DbOutbound and Notification rows plus the InboundRequest row, in (OccurredAtUtc, EventId) /// order; SecuredWrite, Cluster and InboundAuthFailure rows in the same tree stay excluded. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task IncludesOutboundApiDbAndNotificationRows_ExcludesOtherChannels() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditPurgeBudgetTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditPurgeBudgetTests.cs index e0b06cc9..3fe398fc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditPurgeBudgetTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditPurgeBudgetTests.cs @@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories; public class AuditPurgeBudgetTests { + /// <summary>Verifies that a backlogged channel purge stops at the row budget, including a partial final batch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BackloggedChannel_StopsAtBudgetIncludingPartialFinalBatch() { @@ -24,32 +26,53 @@ public class AuditPurgeBudgetTests private sealed class BackloggedConnection : DbConnection { + /// <summary>Gets the batch sizes requested by the purge, in order.</summary> public List<int> RequestedBatches { get; } = new(); private ConnectionState _state; + /// <inheritdoc /> [AllowNull] public override string ConnectionString { get; set; } = "Server=localhost;Database=test"; + /// <inheritdoc /> public override string Database => "test"; + /// <inheritdoc /> public override string DataSource => "localhost"; + /// <inheritdoc /> public override string ServerVersion => "16.0"; + /// <inheritdoc /> public override ConnectionState State => _state; + /// <inheritdoc /> public override void Open() => _state = ConnectionState.Open; + /// <inheritdoc /> public override void Close() => _state = ConnectionState.Closed; + /// <inheritdoc /> public override void ChangeDatabase(string databaseName) => throw new NotSupportedException(); + /// <inheritdoc /> protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => throw new NotSupportedException(); + /// <inheritdoc /> protected override DbCommand CreateDbCommand() => new BackloggedCommand(this); } private sealed class BackloggedCommand(BackloggedConnection owner) : DbCommand { private readonly SqlCommand _parameters = new(); + /// <inheritdoc /> [AllowNull] public override string CommandText { get; set; } = ""; + /// <inheritdoc /> public override int CommandTimeout { get; set; } + /// <inheritdoc /> public override CommandType CommandType { get; set; } + /// <inheritdoc /> public override bool DesignTimeVisible { get; set; } + /// <inheritdoc /> public override UpdateRowSource UpdatedRowSource { get; set; } + /// <inheritdoc /> protected override DbConnection? DbConnection { get; set; } + /// <inheritdoc /> protected override DbTransaction? DbTransaction { get; set; } + /// <inheritdoc /> protected override DbParameterCollection DbParameterCollection => _parameters.Parameters; + /// <inheritdoc /> protected override DbParameter CreateDbParameter() => new SqlParameter(); + /// <inheritdoc /> public override int ExecuteNonQuery() { // A server with more eligible rows than any tick is allowed to delete. @@ -60,10 +83,15 @@ public class AuditPurgeBudgetTests owner.RequestedBatches.Add(requested); return requested; } + /// <inheritdoc /> public override object? ExecuteScalar() => throw new NotSupportedException(); + /// <inheritdoc /> protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => throw new NotSupportedException(); + /// <inheritdoc /> public override void Cancel() { } + /// <inheritdoc /> public override void Prepare() { } + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) _parameters.Dispose(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/BundleUnlockAttemptRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/BundleUnlockAttemptRepositoryTests.cs index 20882494..bdf01f9a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/BundleUnlockAttemptRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/BundleUnlockAttemptRepositoryTests.cs @@ -15,6 +15,8 @@ public class BundleUnlockAttemptRepositoryTests private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext(); + /// <summary>Verifies that counting attempts includes only the given bucket within the window.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CountAttemptsSinceAsync_CountsOnlyTheGivenBucketInsideTheWindow() { @@ -32,6 +34,8 @@ public class BundleUnlockAttemptRepositoryTests Assert.Equal(2, count); } + /// <summary>Verifies that counting attempts is inclusive at the window floor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CountAttemptsSinceAsync_IsInclusiveAtTheWindowFloor() { @@ -46,6 +50,8 @@ public class BundleUnlockAttemptRepositoryTests Assert.Equal(1, await repo.CountAttemptsSinceAsync("10.0.0.1", Base.AddHours(-1))); } + /// <summary>Verifies that counting attempts for an unknown bucket returns zero.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CountAttemptsSinceAsync_UnknownBucket_ReturnsZero() { @@ -55,6 +61,8 @@ public class BundleUnlockAttemptRepositoryTests Assert.Equal(0, await repo.CountAttemptsSinceAsync("<unattributed>", Base.AddHours(-1))); } + /// <summary>Verifies that pruning deletes only aged rows and respects the batch bound.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PruneAttemptsOlderThanAsync_DeletesOnlyAgedRows_AndIsBounded() { @@ -78,6 +86,8 @@ public class BundleUnlockAttemptRepositoryTests Assert.Equal(0, await repo.PruneAttemptsOlderThanAsync(Base.AddHours(-2), maxRows: 3)); } + /// <summary>Verifies that pruning rejects a non-positive batch size.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PruneAttemptsOlderThanAsync_RejectsANonPositiveBatchSize() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs index fddf5028..95248e40 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories; /// <summary> -/// M6 (K2) coverage for <see cref="KpiHistoryRepository"/> over the in-memory SQLite +/// Coverage for <see cref="KpiHistoryRepository"/> over the in-memory SQLite /// harness. Exercises the bulk write, the single-series query (including the /// null-ScopeKey Global match), and the retention purge. /// </summary> @@ -31,6 +31,8 @@ public class KpiHistoryRepositoryTests CapturedAtUtc = capturedAtUtc, }; + /// <summary>Verifies that the raw series query returns only the matching series in ascending time order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetRawSeriesAsync_ReturnsOnlyMatchingSeries_InAscendingTimeOrder() { @@ -64,6 +66,8 @@ public class KpiHistoryRepositoryTests Assert.Equal(7, series[1].Value); } + /// <summary>Verifies that a site-scoped raw series query matches only rows with that scope key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetRawSeriesAsync_SiteScopedKey_MatchesOnlyThatKey() { @@ -87,6 +91,8 @@ public class KpiHistoryRepositoryTests Assert.Equal(5, series[0].Value); } + /// <summary>Verifies that the raw series query honors the time window bounds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetRawSeriesAsync_HonorsTimeWindowBounds() { @@ -109,6 +115,8 @@ public class KpiHistoryRepositoryTests Assert.Equal(new[] { 1d, 2d }, series.Select(p => p.Value).ToArray()); } + /// <summary>Verifies that recording an empty batch is a no-op and querying an empty table returns an empty series.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RecordSamplesAsync_EmptyBatch_IsNoOp_AndGetRawSeriesAsync_EmptyTable_ReturnsEmpty() { @@ -127,6 +135,8 @@ public class KpiHistoryRepositoryTests Assert.Empty(series); } + /// <summary>Verifies that the purge deletes only rows older than the cutoff and returns the deleted count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PurgeOlderThanAsync_DeletesOnlyRowsOlderThanCutoff_AndReturnsCount() { @@ -158,6 +168,8 @@ public class KpiHistoryRepositoryTests Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray()); } + /// <summary>Verifies that a multi-window purge issues more than one delete and returns the total deleted count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PurgeOlderThanAsync_MultiWindow_IssuesMoreThanOneDelete_AndReturnsTotal() { @@ -196,8 +208,10 @@ public class KpiHistoryRepositoryTests Assert.Equal(new[] { 4d, 5d }, remaining.Select(p => p.Value).ToArray()); } - // ---- Hourly rollup fold / query / purge (plan #22 T3) ------------------------------ + // ---- Hourly rollup fold / query / purge ------------------------------ + /// <summary>Verifies that a gauge metric folds to its last value per hour.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FoldHourlyRollupsAsync_GaugeMetric_FoldsToLastValuePerHour() { @@ -224,6 +238,8 @@ public class KpiHistoryRepositoryTests Assert.Equal(9, row.Value); // last-value (max CapturedAtUtc) } + /// <summary>Verifies that a rate metric folds to its sum per hour along with the minimum, maximum and count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FoldHourlyRollupsAsync_RateMetric_FoldsToSumPerHour_WithMinMaxCount() { @@ -249,6 +265,8 @@ public class KpiHistoryRepositoryTests Assert.Equal(3, rollup.SampleCount); } + /// <summary>Verifies that the fold splits global-scope samples into separate hour buckets.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FoldHourlyRollupsAsync_SplitsIntoSeparateHourBuckets_ForGlobalScope() { @@ -277,6 +295,8 @@ public class KpiHistoryRepositoryTests Assert.Equal(2, series[1].Value); // hour 1 last-value } + /// <summary>Verifies that the fold excludes an incomplete hour at the exclusive upper bound.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FoldHourlyRollupsAsync_ExcludesIncompleteHour_AtExclusiveUpperBound() { @@ -303,6 +323,8 @@ public class KpiHistoryRepositoryTests Assert.Equal(3, row.Value); } + /// <summary>Verifies that repeated folds over the same window are idempotent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FoldHourlyRollupsAsync_IsIdempotent_AcrossRepeatedRunsOverSameWindow() { @@ -325,10 +347,12 @@ public class KpiHistoryRepositoryTests Assert.Equal(2, row.SampleCount); } + /// <summary>Verifies that the fold preloads existing rollups instead of probing once per series and hour.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FoldHourlyRollupsAsync_PreloadsExistingRollups_WithoutAPerSeriesHourProbe() { - // WP2.2: the fold used to issue one FirstOrDefaultAsync existence probe per + // The fold used to issue one FirstOrDefaultAsync existence probe per // (series, hour) group before writing anything — an N+1 that scaled with // the metric catalogue times the lookback window. It now preloads the whole // window in a single query and resolves each group from a dictionary. @@ -376,6 +400,8 @@ public class KpiHistoryRepositoryTests Assert.All(rollups, r => Assert.Equal(1, r.SampleCount)); } + /// <summary>Verifies that the hourly series query returns rows in ascending order and distinguishes a null scope key from a site scope key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetHourlySeriesAsync_ReturnsAscending_AndHonorsNullVsSiteScopeKey() { @@ -409,6 +435,8 @@ public class KpiHistoryRepositoryTests Assert.Equal(42, Assert.Single(site).Value); } + /// <summary>Verifies that the rollup purge deletes only rollups older than the cutoff.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PurgeRollupsOlderThanAsync_DeletesOnlyRollupsOlderThanCutoff() { @@ -437,6 +465,8 @@ public class KpiHistoryRepositoryTests Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray()); } + /// <summary>Verifies that the fold does not leave the sample entities tracked by the change tracker.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FoldHourlyRollups_DoesNotTrack_KpiSampleEntities() { @@ -457,6 +487,8 @@ public class KpiHistoryRepositoryTests Assert.Empty(ctx.ChangeTracker.Entries<KpiSample>()); } + /// <summary>Verifies that the latest rollup hour is null when no rollups exist.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetLatestRollupHour_ReturnsNull_WhenNoRollupsExist() { @@ -465,6 +497,8 @@ public class KpiHistoryRepositoryTests Assert.Null(await repo.GetLatestRollupHourAsync()); } + /// <summary>Verifies that the latest rollup hour is the newest hour start across all series.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetLatestRollupHour_ReturnsNewestHourStart_AcrossAllSeries() { @@ -480,8 +514,10 @@ public class KpiHistoryRepositoryTests Assert.Equal(Base.AddHours(3), await repo.GetLatestRollupHourAsync()); } - // ---- Task 9: failover fold-race failure grain is the pass, not the row (R5) ---- + // ---- Failover fold-race failure grain is the pass, not the row (R5) ---- + /// <summary>Verifies that a fold that loses a failover upsert race does not throw and the next fold self-heals.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Fold_LosingFailoverUpsertRace_DoesNotThrow_AndNextFoldSelfHeals() { @@ -556,12 +592,16 @@ public class KpiHistoryRepositoryTests private readonly Func<KpiRollupHourly> _rowFactory; private bool _fired; + /// <summary>Initializes the interceptor.</summary> + /// <param name="connection">The shared database connection used for the conflicting side write.</param> + /// <param name="rowFactory">Creates the conflicting rollup row to insert.</param> public InsertConflictingRollupOnFirstSave(DbConnection connection, Func<KpiRollupHourly> rowFactory) { _connection = connection; _rowFactory = rowFactory; } + /// <inheritdoc /> public override async ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) @@ -584,13 +624,6 @@ public class KpiHistoryRepositoryTests } } - /// <summary> - /// EF command interceptor that counts the DELETE statements actually issued - /// to the store, so a test can prove the purge is sliced into multiple - /// windowed DELETEs rather than one mega-statement. Threads both the sync and - /// async non-query entry points (<c>ExecuteDeleteAsync</c> routes through the - /// async path). - /// </summary> /// <summary> /// Counts reader commands that SELECT from <c>KpiRollupHourly</c>, so a test /// can prove the hourly fold resolves existing rows from a single preload @@ -598,8 +631,10 @@ public class KpiHistoryRepositoryTests /// </summary> private sealed class RollupSelectCountingInterceptor : DbCommandInterceptor { + /// <summary>Number of reader commands that selected from the rollup table.</summary> public int RollupSelectCount { get; private set; } + /// <summary>Resets the select counter to zero.</summary> public void Reset() => RollupSelectCount = 0; private void CountIfRollupSelect(DbCommand command) @@ -611,6 +646,7 @@ public class KpiHistoryRepositoryTests } } + /// <inheritdoc /> public override InterceptionResult<DbDataReader> ReaderExecuting( DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result) { @@ -618,6 +654,7 @@ public class KpiHistoryRepositoryTests return base.ReaderExecuting(command, eventData, result); } + /// <inheritdoc /> public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default) @@ -627,10 +664,19 @@ public class KpiHistoryRepositoryTests } } + /// <summary> + /// EF command interceptor that counts the DELETE statements actually issued + /// to the store, so a test can prove the purge is sliced into multiple + /// windowed DELETEs rather than one mega-statement. Threads both the sync and + /// async non-query entry points (<c>ExecuteDeleteAsync</c> routes through the + /// async path). + /// </summary> private sealed class DeleteCountingInterceptor : DbCommandInterceptor { + /// <summary>Number of DELETE statements issued so far.</summary> public int DeleteCount { get; private set; } + /// <summary>Resets the delete counter to zero.</summary> public void Reset() => DeleteCount = 0; private void CountIfDelete(DbCommand command) @@ -641,6 +687,7 @@ public class KpiHistoryRepositoryTests } } + /// <inheritdoc /> public override InterceptionResult<int> NonQueryExecuting( DbCommand command, CommandEventData eventData, InterceptionResult<int> result) { @@ -648,6 +695,7 @@ public class KpiHistoryRepositoryTests return base.NonQueryExecuting(command, eventData, result); } + /// <inheritdoc /> public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/NotificationOutboxRepositoryIntegrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/NotificationOutboxRepositoryIntegrationTests.cs index 8587b73c..4acba710 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/NotificationOutboxRepositoryIntegrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/NotificationOutboxRepositoryIntegrationTests.cs @@ -10,7 +10,7 @@ using Xunit; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories; /// <summary> -/// CD-015 race-fix integration tests for +/// Race-fix integration tests for /// <see cref="NotificationOutboxRepository.InsertIfNotExistsAsync"/>. The method /// is raw-SQL (<c>IF NOT EXISTS … INSERT</c>) matching the AuditLog and SiteCalls /// idempotent-insert pattern; it must execute against a real SQL Server schema, @@ -21,11 +21,15 @@ public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlM { private readonly MsSqlMigrationFixture _fixture; + /// <summary>Initializes a new instance of the <c>NotificationOutboxRepositoryIntegrationTests</c> class.</summary> + /// <param name="fixture">The shared database fixture.</param> public NotificationOutboxRepositoryIntegrationTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } + /// <summary>Verifies that inserting a fresh id inserts the row and returns true.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertIfNotExistsAsync_FreshId_InsertsAndReturnsTrue() { @@ -45,6 +49,8 @@ public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlM Assert.Equal("Subject", loaded!.Subject); } + /// <summary>Verifies that inserting a duplicate id returns false and leaves the existing row untouched.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertIfNotExistsAsync_DuplicateId_ReturnsFalseAndLeavesExistingRow() { @@ -70,12 +76,14 @@ public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlM Assert.Equal("Original", loaded!.Subject); } + /// <summary>Verifies that concurrent inserts of the same id produce exactly one row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task InsertIfNotExistsAsync_ConcurrentInserts_SameId_OnlyOneRow() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); - // CD-015 race coverage. The IF NOT EXISTS … INSERT pattern has a + // Race coverage. The IF NOT EXISTS … INSERT pattern has a // check-then-act window: two concurrent sessions can both pass the // EXISTS check and both attempt the INSERT — the loser surfaces as a // SqlException with Number 2601/2627. The site→central handoff is @@ -102,6 +110,8 @@ public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlM Assert.Equal(1, count); } + /// <summary>Verifies that filtering by notification id returns exactly that row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_NotificationIdFilter_ReturnsExactlyThatRow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/PendingDeploymentRepositoryIntegrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/PendingDeploymentRepositoryIntegrationTests.cs index ce76e3bf..58cf956b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/PendingDeploymentRepositoryIntegrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/PendingDeploymentRepositoryIntegrationTests.cs @@ -25,11 +25,15 @@ public class PendingDeploymentRepositoryIntegrationTests : IClassFixture<MsSqlMi { private readonly MsSqlMigrationFixture _fixture; + /// <summary>Initializes the test class with the shared SQL Server migration fixture.</summary> + /// <param name="fixture">The shared SQL Server migration fixture.</param> public PendingDeploymentRepositoryIntegrationTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } + /// <summary>Verifies that staging over an expired row with the same deployment id re-stages it without a unique violation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task StagePendingIfAbsent_ExpiredRowSameDeploymentId_ReStages_NoUniqueViolation() { @@ -74,6 +78,8 @@ public class PendingDeploymentRepositoryIntegrationTests : IClassFixture<MsSqlMi } } + /// <summary>Verifies on a real SQL Server that a new deploy with a different id supersedes the prior pending row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task AddPendingDeployment_NewDeployDifferentId_SupersedesPriorRow_OnRealSqlServer() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs index b912cf91..a1c32413 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs @@ -12,8 +12,8 @@ using Xunit; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories; /// <summary> -/// Bundle B3 (#22, #23 M3) integration tests for <see cref="SiteCallAuditRepository"/>. -/// Uses the same <see cref="MsSqlMigrationFixture"/> as the Bundle B2 migration tests so +/// Integration tests for <see cref="SiteCallAuditRepository"/>. +/// Uses the same <see cref="MsSqlMigrationFixture"/> as the migration tests so /// the monotonic-upsert SQL executes against the real <c>SiteCalls</c> schema. Each test /// scopes its data by minting a fresh <see cref="TrackedOperationId"/> (or a per-test /// <c>SourceSite</c> suffix) so tests neither collide nor require teardown. @@ -22,11 +22,15 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> { private readonly MsSqlMigrationFixture _fixture; + /// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary> + /// <param name="fixture">The shared MS SQL migration fixture.</param> public SiteCallAuditRepositoryTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } + /// <summary>Verifies that an existing operation cannot be advanced by a packet from another site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task CM9_ExistingOperationCannotBeAdvancedByAnotherSite() { @@ -46,6 +50,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(0, stored.RetryCount); } + /// <summary>Verifies that upserting a fresh id inserts exactly one row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_FreshId_InsertsOneRow() { @@ -68,6 +74,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(0, loaded[0].RetryCount); } + /// <summary>Verifies that upserting an advanced status updates the existing row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_AdvancedStatus_UpdatesRow() { @@ -90,6 +98,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal("transient 503", loaded.LastError); } + /// <summary>Verifies that upserting an older status is a no-op and leaves the row unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_OlderStatus_IsNoOp_RowUnchanged() { @@ -115,6 +125,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(attemptedSnapshot!.UpdatedAtUtc, afterStale.UpdatedAtUtc); } + /// <summary>Verifies that an upsert rejected by the monotonic rule does not fall through to an insert.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_RejectedMonotonicUpdate_DoesNotFallThroughToInsert() { @@ -161,6 +173,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> /// any row existed and then skipped its INSERT, dropping the packet's /// Status/RetryCount/HttpStatus/TerminalAtUtc on the floor. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_RowAlreadyCreatedByAnotherWriter_StillAppliesThisPacketsState() { @@ -215,6 +228,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> /// test itself is not flaky — only its coverage of the rare branch is /// probabilistic. /// </remarks> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_TwoConcurrentFirstWrites_NewerStateSurvives() { @@ -256,6 +270,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> } } + /// <summary>Verifies that upserting the same status with an equal update time is a no-op.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_SameStatus_EqualUpdatedAt_IsNoOp() { @@ -272,7 +288,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> // Same rank (2) AND an EQUAL UpdatedAtUtc — an idempotent replay of the // same telemetry packet. The same-rank freshness tiebreaker only fires // for a STRICTLY-newer stamp, so a true duplicate stays a no-op (no - // fields move). Task 11: newest-within-rank wins, equal stamps are inert. + // fields move). Newest-within-rank wins, equal stamps are inert. await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "second", updatedAtUtc: stamp)); var afterDuplicate = await repo.GetAsync(id); @@ -283,6 +299,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(snapshot!.UpdatedAtUtc, afterDuplicate.UpdatedAtUtc); } + /// <summary>Verifies that a same-rank upsert with a newer update time refreshes the progress fields.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_SameRank_NewerUpdatedAt_RefreshesProgressFields() { @@ -295,7 +313,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> // Two Attempted packets (rank 2) from one retrying call; the second // carries a strictly-newer UpdatedAtUtc, so its live RetryCount/LastError - // must land — the row no longer freezes at first-Attempted (Task 11). + // must land — the row no longer freezes at first-Attempted. await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "timeout #1", updatedAtUtc: t0)); await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "timeout #5", updatedAtUtc: t0.AddMinutes(5))); @@ -306,6 +324,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(t0.AddMinutes(5), row.UpdatedAtUtc); } + /// <summary>Verifies that a same-rank upsert with an older update time is a no-op.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_SameRank_OlderUpdatedAt_IsNoOp() { @@ -316,7 +336,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> await using var context = CreateContext(); var repo = new SiteCallAuditRepository(context); - // Land retry 5 (stamp t0+5), then an out-of-order retry-2 packet with an + // Land retry 5 (stamp t0+5), then an out-of-order packet for retry 2 with an // OLDER stamp (t0). Same rank, older UpdatedAtUtc → rejected; stays at 5. await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "timeout #5", updatedAtUtc: t0.AddMinutes(5))); await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "timeout #2", updatedAtUtc: t0)); @@ -328,6 +348,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(t0.AddMinutes(5), row.UpdatedAtUtc); } + /// <summary>Verifies that a lower-rank upsert is still a no-op even with a newer update time.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_LowerRank_NewerUpdatedAt_IsStillNoOp() { @@ -350,13 +372,15 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(0, row.RetryCount); } + /// <summary>Verifies that one terminal status cannot overwrite another terminal status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_TerminalOverTerminal_IsNoOp() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); - // Bundle B3 plan: terminal statuses share rank 3 and are mutually - // exclusive — Delivered cannot overwrite Parked. Task 11 note: the + // Terminal statuses share rank 3 and are mutually + // exclusive — Delivered cannot overwrite Parked. Note that the // Delivered packet carries a STRICTLY-NEWER UpdatedAtUtc, yet the row // stays Parked — the same-rank freshness tiebreaker is scoped to // NON-terminal ranks (< 3), so terminal immutability is preserved. @@ -377,6 +401,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(afterPark!.UpdatedAtUtc, afterDeliveredAttempt.UpdatedAtUtc); } + /// <summary>Verifies that concurrent inserts of the same id leave only one row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_ConcurrentInserts_SameId_OnlyOneRow() { @@ -406,6 +432,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(1, count); } + /// <summary>Verifies that getting a known id returns its row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task GetAsync_KnownId_ReturnsRow() { @@ -422,6 +450,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(id, loaded!.TrackedOperationId); } + /// <summary>Verifies that getting an unknown id returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task GetAsync_UnknownId_ReturnsNull() { @@ -434,6 +464,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Null(loaded); } + /// <summary>Verifies that filtering by source site returns only the matching rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_FilterBySourceSite_ReturnsMatchingRows() { @@ -457,10 +489,12 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.All(rows, r => Assert.Equal(siteA, r.SourceSite)); } + /// <summary>Verifies that filtering by tracked operation id returns exactly that row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_FilterByTrackedOperationId_ReturnsExactlyThatRow() { - // Flow Debugger follow-ups Task 2: SiteCallQueryFilter.TrackedOperationId is + // SiteCallQueryFilter.TrackedOperationId is // an exact PK match (the ?correlationId= deep link). The column is varchar(36) // holding the "D"-format GUID string, so the predicate must compare as a // string — a uniqueidentifier parameter would convert the column side and @@ -491,6 +525,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Empty(none); } + /// <summary>Verifies that keyset paging returns consecutive pages with no overlap.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_KeysetPaging_NoOverlap() { @@ -542,6 +578,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(5, allIds.Count); } + /// <summary>Verifies that the stuck cutoff composes with keyset paging without producing empty pages.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_StuckCutoff_ComposesWithKeysetPaging_NoEmptyPages() { @@ -603,6 +641,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(stuckIds.ToHashSet(), returned); } + /// <summary>Verifies that the purge removes rows that are both terminal and old.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task PurgeTerminalAsync_RemovesTerminalAndOld() { @@ -631,6 +671,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Null(await repo.GetAsync(oldId)); } + /// <summary>Verifies that the purge keeps non-terminal and recent rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task PurgeTerminalAsync_KeepsNonTerminalAndRecent() { @@ -670,6 +712,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.NotNull(await repo.GetAsync(recentTerminalId)); } + /// <summary>Verifies that the purge slices a multi-day backlog into several bounded deletes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task PurgeTerminal_SlicesMultiDayBacklog_IntoBoundedDeletes() { @@ -735,6 +779,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> // --- KPI snapshot tests ------------------------------------------------- + /// <summary>Verifies that the KPI computation counts buffered, parked, failed, delivered and stuck rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task ComputeKpisAsync_CountsBufferedParkedFailedDeliveredAndStuck() { @@ -789,6 +835,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.True(snapshot.OldestPendingAge >= TimeSpan.FromMinutes(25)); } + /// <summary>Verifies that the per-site KPI computation scopes counts to each site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task ComputePerSiteKpisAsync_ScopesCountsToEachSite() { @@ -831,6 +879,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Null(b.OldestPendingAge); } + /// <summary>Verifies that the per-node KPI computation scopes counts to each node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task ComputePerNodeKpisAsync_ScopesCountsToEachNode() { @@ -930,14 +980,16 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> }; } - // --- SourceNode-stamping (Task 14) -------------------------------------- + // --- SourceNode-stamping -------------------------------------- + /// <summary>Verifies that a fresh insert persists the source node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_PersistsSourceNode_OnFreshInsert() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); - // SourceNode-stamping (Task 14): a fresh INSERT must persist the + // SourceNode stamping: a fresh INSERT must persist the // SourceNode column verbatim — the central row carries the originating // site node name end-to-end. var id = TrackedOperationId.New(); @@ -951,12 +1003,14 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal("node-a", loaded!.SourceNode); } + /// <summary>Verifies that a later packet carrying a null source node does not blank the stored value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_PreservesSourceNode_WhenLaterPacketCarriesNull() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); - // SourceNode-stamping (Task 14): the UPDATE uses + // SourceNode stamping: the UPDATE uses // COALESCE(@SourceNode, SourceNode) so a subsequent packet that does // NOT carry a SourceNode (legacy / reconciliation pull from an // unstamped node) MUST NOT blank out the value the first packet set. @@ -993,12 +1047,14 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> } } + /// <summary>Verifies that a non-null incoming source node overwrites the previous value when the rank advances.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_NonNullIncomingSourceNode_OverwritesPreviousValueOnRankAdvance() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); - // SourceNode-stamping (Task 14): per the COALESCE(@SourceNode, + // SourceNode stamping: per the COALESCE(@SourceNode, // SourceNode) semantics the column protects against a *null* // incoming value blanking a previously-stamped one, but a non-null // incoming value DOES replace the existing value on a rank-advancing @@ -1037,12 +1093,14 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> } } + /// <summary>Verifies that a later packet fills the source node when the insert left it null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertAsync_FillsSourceNode_WhenInsertWasNullAndLaterPacketCarriesValue() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); - // SourceNode-stamping (Task 14): when the column was left NULL by an + // SourceNode stamping: when the column was left NULL by an // earlier unstamped packet, a later rank-advancing packet with a // non-null SourceNode fills it — the COALESCE(@SourceNode, SourceNode) // SQL operator returns @SourceNode when @SourceNode is non-null, so @@ -1093,8 +1151,10 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> /// </summary> private sealed class DeleteCountingCommandInterceptor : DbCommandInterceptor { + /// <summary>Gets the number of DELETE statements issued since the last reset.</summary> public int DeleteCount { get; private set; } + /// <summary>Resets the DELETE count to zero.</summary> public void Reset() => DeleteCount = 0; private void CountIfDelete(DbCommand command) @@ -1105,6 +1165,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> } } + /// <inheritdoc /> public override InterceptionResult<int> NonQueryExecuting( DbCommand command, CommandEventData eventData, InterceptionResult<int> result) { @@ -1112,6 +1173,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture> return base.NonQueryExecuting(command, eventData, result); } + /// <inheritdoc /> public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs index de28404a..8efd568e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs @@ -14,26 +14,30 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; -// Regression coverage for ConfigurationDatabase-010 (repositories / InstanceLocator lacked -// direct tests) and ConfigurationDatabase-011 (inconsistent constructor null-guarding). +// Regression coverage for repositories / InstanceLocator that lacked direct tests, and for +// inconsistent constructor null-guarding. public class ExternalSystemRepositoryTests : IDisposable { private readonly ScadaBridgeDbContext _context; private readonly ExternalSystemRepository _repository; + /// <summary>Initializes a new instance of the <c>ExternalSystemRepositoryTests</c> class.</summary> public ExternalSystemRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new ExternalSystemRepository(_context); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that an added external system round-trips through get by id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddExternalSystem_AndGetById_RoundTrips() { @@ -46,6 +50,8 @@ public class ExternalSystemRepositoryTests : IDisposable Assert.Equal("Sys", loaded!.Name); } + /// <summary>Verifies that the external system timeout seconds value round-trips.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddExternalSystem_TimeoutSeconds_RoundTrips() { @@ -61,6 +67,8 @@ public class ExternalSystemRepositoryTests : IDisposable Assert.Equal(15, loaded!.TimeoutSeconds); } + /// <summary>Verifies that getting methods by external system id returns only the parent system's methods.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetMethodsByExternalSystemId_FiltersByParent() { @@ -76,6 +84,8 @@ public class ExternalSystemRepositoryTests : IDisposable Assert.Single(methods); } + /// <summary>Verifies that deleting a database connection removes the entity.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteDatabaseConnection_RemovesEntity() { @@ -89,14 +99,17 @@ public class ExternalSystemRepositoryTests : IDisposable Assert.Null(await _repository.GetDatabaseConnectionByIdAsync(conn.Id)); } + /// <summary>Verifies that constructing an ExternalSystemRepository with a null context throws an ArgumentNullException.</summary> [Fact] public void Constructor_NullContext_Throws() { Assert.Throws<ArgumentNullException>(() => new ExternalSystemRepository(null!)); } - // ── ExternalSystemGateway-011: name-keyed repository lookups ── + // ── Name-keyed repository lookups ── + /// <summary>Verifies that getting an external system by name returns the matching row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetExternalSystemByName_ReturnsMatchingRow() { @@ -113,6 +126,8 @@ public class ExternalSystemRepositoryTests : IDisposable Assert.Equal("https://beta.test", loaded.EndpointUrl); } + /// <summary>Verifies that getting an external system by a missing name returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetExternalSystemByName_MissingName_ReturnsNull() { @@ -123,6 +138,8 @@ public class ExternalSystemRepositoryTests : IDisposable Assert.Null(await _repository.GetExternalSystemByNameAsync("DoesNotExist")); } + /// <summary>Verifies that getting a method by name returns the method scoped to its parent system.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetMethodByName_ReturnsMethodScopedToParentSystem() { @@ -147,6 +164,8 @@ public class ExternalSystemRepositoryTests : IDisposable Assert.Equal("/b", method.Path); } + /// <summary>Verifies that getting a method by a missing name returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetMethodByName_MissingMethod_ReturnsNull() { @@ -157,6 +176,8 @@ public class ExternalSystemRepositoryTests : IDisposable Assert.Null(await _repository.GetMethodByNameAsync(sys.Id, "noSuchMethod")); } + /// <summary>Verifies that getting a database connection by name returns the matching row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDatabaseConnectionByName_ReturnsMatchingRow() { @@ -173,6 +194,8 @@ public class ExternalSystemRepositoryTests : IDisposable Assert.Equal("Server=hist;Database=h;", loaded.ConnectionString); } + /// <summary>Verifies that getting a database connection by a missing name returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDatabaseConnectionByName_MissingName_ReturnsNull() { @@ -189,18 +212,22 @@ public class NotificationRepositoryTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly NotificationRepository _repository; + /// <summary>Initializes a new instance of the <c>NotificationRepositoryTests</c> class.</summary> public NotificationRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new NotificationRepository(_context); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that an added notification list with recipients round-trips.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddNotificationList_WithRecipients_RoundTrips() { @@ -217,6 +244,8 @@ public class NotificationRepositoryTests : IDisposable Assert.Single(all[0].Recipients); } + /// <summary>Verifies that an added SMTP configuration round-trips through get by id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddSmtpConfiguration_AndGetById_RoundTrips() { @@ -229,6 +258,8 @@ public class NotificationRepositoryTests : IDisposable Assert.Equal("smtp.example.test", loaded!.Host); } + /// <summary>Verifies that an added SMS configuration round-trips through get by id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddSmsConfiguration_AndGetById_RoundTrips() { @@ -241,6 +272,8 @@ public class NotificationRepositoryTests : IDisposable Assert.Equal("ACbyid123", loaded!.AccountSid); } + /// <summary>Verifies that deleting a notification list removes the entity.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteNotificationList_RemovesEntity() { @@ -254,6 +287,8 @@ public class NotificationRepositoryTests : IDisposable Assert.Null(await _repository.GetNotificationListByIdAsync(list.Id)); } + /// <summary>Verifies that an added SMS configuration round-trips.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddSmsConfiguration_RoundTrips() { @@ -282,6 +317,8 @@ public class NotificationRepositoryTests : IDisposable Assert.Null(await _repository.GetSmsConfigurationAsync()); } + /// <summary>Verifies that a notification list persists its type.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NotificationList_PersistsType() { @@ -293,6 +330,7 @@ public class NotificationRepositoryTests : IDisposable Assert.Equal(NotificationType.Email, loaded!.Type); } + /// <summary>Verifies that constructing a NotificationRepository with a null context throws an ArgumentNullException.</summary> [Fact] public void Constructor_NullContext_Throws() { @@ -304,17 +342,21 @@ public class NotificationOutboxConfigurationTests : IDisposable { private readonly ScadaBridgeDbContext _context; + /// <summary>Initializes a new instance of the <c>NotificationOutboxConfigurationTests</c> class.</summary> public NotificationOutboxConfigurationTests() { _context = SqliteTestHelper.CreateInMemoryContext(); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that a fully populated notification round-trips.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Notification_FullyPopulated_RoundTrips() { @@ -375,10 +417,12 @@ public class NotificationOutboxConfigurationTests : IDisposable Assert.Equal(originParentExecutionId, loaded.OriginParentExecutionId); } + /// <summary>Verifies that a null origin execution id round-trips as null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Notification_NullOriginExecutionId_RoundTripsAsNull() { - // Audit Log #23: OriginExecutionId is an additive nullable column — + // OriginExecutionId is an additive nullable column — // notifications raised outside a script execution (or submitted before // the column existed) persist and reload it as null. var id = Guid.NewGuid().ToString(); @@ -395,6 +439,8 @@ public class NotificationOutboxConfigurationTests : IDisposable Assert.Null(loaded!.OriginExecutionId); } + /// <summary>Verifies that a null origin parent execution id round-trips as null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Notification_NullOriginParentExecutionId_RoundTripsAsNull() { @@ -415,6 +461,8 @@ public class NotificationOutboxConfigurationTests : IDisposable Assert.Null(loaded!.OriginParentExecutionId); } + /// <summary>Verifies that the notification status is persisted as a string.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Notification_StatusPersistsAsString() { @@ -437,18 +485,20 @@ public class NotificationOutboxConfigurationTests : IDisposable } } -// Coverage for the Notification Outbox repository (Task 5 of the notification-outbox feature). +// Coverage for the Notification Outbox repository (notification-outbox feature). public class NotificationOutboxRepositoryTests : IDisposable { private readonly ScadaBridgeDbContext _context; private readonly NotificationOutboxRepository _repository; + /// <summary>Initializes a new instance of the <c>NotificationOutboxRepositoryTests</c> class.</summary> public NotificationOutboxRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new NotificationOutboxRepository(_context); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); @@ -480,6 +530,8 @@ public class NotificationOutboxRepositoryTests : IDisposable // — the method is raw-SQL (IF NOT EXISTS … INSERT) so it must execute against // SQL Server, not the SQLite in-memory provider this class uses. + /// <summary>Verifies that get-due returns pending and due retrying notifications, ordered by creation time and capped at the batch size.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDueAsync_ReturnsPendingAndDueRetrying_OrderedByCreatedAt_CappedAtBatchSize() { @@ -515,6 +567,8 @@ public class NotificationOutboxRepositoryTests : IDisposable Assert.Equal(retryingDue.NotificationId, capped[1].NotificationId); } + /// <summary>Verifies that get by id returns the row when present and null otherwise.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetByIdAsync_ReturnsRowOrNull() { @@ -527,6 +581,8 @@ public class NotificationOutboxRepositoryTests : IDisposable Assert.Null(await _repository.GetByIdAsync("does-not-exist")); } + /// <summary>Verifies that an update persists the status transition.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAsync_PersistsStatusTransition() { @@ -553,6 +609,7 @@ public class NotificationOutboxRepositoryTests : IDisposable /// retry/discard one-shots) depend on it to avoid reporting success against a /// purged notification. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAsync_RowPurgedBeforeWrite_ReturnsFalse() { @@ -573,6 +630,8 @@ public class NotificationOutboxRepositoryTests : IDisposable Assert.False(await _repository.UpdateAsync(loaded)); } + /// <summary>Verifies that a conditional update from Parked applies and returns true when the status is Parked.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TryUpdateFromParkedAsync_StatusIsParked_UpdatesAndReturnsTrue() { @@ -596,8 +655,9 @@ public class NotificationOutboxRepositoryTests : IDisposable /// <summary> /// Direct CAS-miss: the row is currently Delivered (never Parked in this /// scenario), so the conditional WHERE Status == Parked matches nothing and - /// the row is left completely untouched — arch-review 2026-08-19 P5-4. + /// the row is left completely untouched. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TryUpdateFromParkedAsync_StatusIsNotParked_ReturnsFalse_AndLeavesRowUnchanged() { @@ -617,6 +677,8 @@ public class NotificationOutboxRepositoryTests : IDisposable Assert.Equal(deliveredAt, reloaded.DeliveredAt); } + /// <summary>Verifies that a conditional update from Parked returns false when the row was purged before the write.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TryUpdateFromParkedAsync_RowPurgedBeforeWrite_ReturnsFalse() { @@ -636,7 +698,7 @@ public class NotificationOutboxRepositoryTests : IDisposable } /// <summary> - /// Deterministic (non-sleep) proof of the arch-review 2026-08-19 P5-4 race: + /// Deterministic (non-sleep) proof of the race: /// the operator reads a Parked row, but before its write lands the /// dispatcher (or a concurrent second operator action) has already moved /// the row to Delivered. <see cref="TryUpdateFromParkedAsync"/>'s CAS must @@ -646,6 +708,7 @@ public class NotificationOutboxRepositoryTests : IDisposable /// operator path used PRE-FIX — has no such guard, which is the mechanism /// the finding describes. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TryUpdateFromParkedAsync_ConcurrentlyDelivered_ReturnsFalse_AndDoesNotOverwrite() { @@ -683,13 +746,14 @@ public class NotificationOutboxRepositoryTests : IDisposable /// <see cref="NotificationOutboxRepository.UpdateAsync"/> instead — the /// method the operator Retry/Discard path called pre-fix — and shows it /// silently clobbers the Delivered row. This documents the exact defect - /// arch-review 2026-08-19 P5-4 fixed by routing the operator path through + /// fixed by routing the operator path through /// <see cref="TryUpdateFromParkedAsync"/>: UpdateAsync itself is unchanged /// (the dispatcher still needs an unconditional write), so this scenario /// remains "true" by design — it is the operator actor's call-site choice /// that was the bug, proven fixed by the actor-level tests in /// NotificationOutbox.Tests (Discard_ConcurrentlyDelivered_...). /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAsync_Unconditional_WouldOverwriteConcurrentlyDeliveredRow_DemonstratingP5_4Mechanism() { @@ -711,7 +775,7 @@ public class NotificationOutboxRepositoryTests : IDisposable operatorRead!.Status = NotificationStatus.Discarded; // The unconditional UPDATE has no status guard, so it "succeeds" here — // this is the bug. It clobbers the Delivered row to Discarded and drops - // DeliveredAt, which is exactly the silent-overwrite hazard P5-4 closes + // DeliveredAt, which is exactly the silent-overwrite hazard the conditional update closes // by moving the operator path to TryUpdateFromParkedAsync. Assert.True(await _repository.UpdateAsync(operatorRead)); @@ -721,6 +785,8 @@ public class NotificationOutboxRepositoryTests : IDisposable Assert.Null(reloaded.DeliveredAt); } + /// <summary>Verifies that a query applies its filters, orders by creation time descending and paginates.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_AppliesFilters_OrdersByCreatedAtDescending_AndPaginates() { @@ -755,6 +821,8 @@ public class NotificationOutboxRepositoryTests : IDisposable Assert.Equal("Tank Level 0", page2[1].Subject); } + /// <summary>Verifies that a query's subject keyword filter matches by containment.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_SubjectKeyword_UsesContains() { @@ -770,6 +838,8 @@ public class NotificationOutboxRepositoryTests : IDisposable Assert.Equal("Tank Level High", rows[0].Subject); } + /// <summary>Verifies that a stuck-only query returns non-terminal rows older than the cutoff.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_StuckOnly_ReturnsNonTerminalRowsOlderThanCutoff() { @@ -798,6 +868,8 @@ public class NotificationOutboxRepositoryTests : IDisposable rows.Select(r => r.Subject).OrderBy(s => s).ToArray()); } + /// <summary>Verifies that a query's from/to filter applies to the creation time.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_FromTo_FilterAgainstCreatedAt() { @@ -820,6 +892,8 @@ public class NotificationOutboxRepositoryTests : IDisposable rows.Select(r => r.Subject).OrderBy(s => s).ToArray()); } + /// <summary>Verifies that a query's status and type filters apply.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_StatusAndTypeFilters() { @@ -838,6 +912,8 @@ public class NotificationOutboxRepositoryTests : IDisposable Assert.Equal("Parked Row", rows[0].Subject); } + /// <summary>Verifies that deleting terminal rows older than the cutoff removes only those rows and leaves the others.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteTerminalOlderThanAsync_DeletesTerminalRowsOlderThanCutoff_LeavesOthers() { @@ -867,6 +943,8 @@ public class NotificationOutboxRepositoryTests : IDisposable Assert.Contains(oldPending.NotificationId, remaining); } + /// <summary>Verifies that computing KPIs populates the snapshot fields.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputeKpisAsync_ComputesSnapshotFields() { @@ -903,6 +981,8 @@ public class NotificationOutboxRepositoryTests : IDisposable Assert.True(kpis.OldestPendingAge!.Value >= TimeSpan.FromMinutes(115)); } + /// <summary>Verifies that computing KPIs with no non-terminal rows yields a null oldest-pending age.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputeKpisAsync_NoNonTerminalRows_OldestPendingAgeIsNull() { @@ -919,6 +999,7 @@ public class NotificationOutboxRepositoryTests : IDisposable Assert.Null(kpis.OldestPendingAge); } + /// <summary>Verifies that constructing a NotificationOutboxRepository with a null context throws an ArgumentNullException.</summary> [Fact] public void Constructor_NullContext_Throws() { @@ -931,18 +1012,22 @@ public class SiteRepositoryTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly SiteRepository _repository; + /// <summary>Initializes a new instance of the <c>SiteRepositoryTests</c> class.</summary> public SiteRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new SiteRepository(_context); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that an added site round-trips through get by identifier.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddSite_AndGetByIdentifier_RoundTrips() { @@ -955,6 +1040,8 @@ public class SiteRepositoryTests : IDisposable Assert.Equal("Site1", loaded!.Name); } + /// <summary>Verifies that deleting a site through the stub-attach path removes the entity.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteSite_ViaStubAttachPath_RemovesEntity() { @@ -972,6 +1059,8 @@ public class SiteRepositoryTests : IDisposable Assert.Null(await _repository.GetSiteByIdAsync(id)); } + /// <summary>Verifies that deleting a data connection through the stub-attach path removes the entity.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteDataConnection_ViaStubAttachPath_RemovesEntity() { @@ -991,6 +1080,8 @@ public class SiteRepositoryTests : IDisposable Assert.Null(await _repository.GetDataConnectionByIdAsync(id)); } + /// <summary>Verifies that getting instances by site id returns only that site's instances.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstancesBySiteId_FiltersBySite() { @@ -1007,6 +1098,8 @@ public class SiteRepositoryTests : IDisposable Assert.Single(instances); } + /// <summary>Verifies that getting instances referencing a data connection returns an empty list when there are no bindings.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstancesReferencingDataConnection_NoBindings_ReturnsEmpty() { @@ -1025,6 +1118,8 @@ public class SiteRepositoryTests : IDisposable Assert.Empty(result); } + /// <summary>Verifies that getting instances referencing a data connection returns the instance bound to it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstancesReferencingDataConnection_OneBinding_ReturnsThatInstance() { @@ -1049,6 +1144,8 @@ public class SiteRepositoryTests : IDisposable Assert.Equal(instance.Id, result[0].Id); } + /// <summary>Verifies that getting instances referencing a data connection returns one instance when two bindings belong to the same instance.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstancesReferencingDataConnection_TwoBindingsSameInstance_ReturnsOne() { @@ -1077,6 +1174,7 @@ public class SiteRepositoryTests : IDisposable Assert.Equal(instance.Id, result[0].Id); } + /// <summary>Verifies that constructing a SiteRepository with a null context throws an ArgumentNullException.</summary> [Fact] public void Constructor_NullContext_Throws() { @@ -1093,6 +1191,7 @@ public class SiteRepositoryTests : IDisposable /// up to date. (The bump is unattributed — a connection has no owning template — /// so both the global and structure counters must move.) /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SaveChanges_DataConnectionEdit_BumpsWatermark() { @@ -1134,6 +1233,7 @@ public class SiteRepositoryTests : IDisposable /// that touches no data connection must leave the watermark alone, or every /// site/area edit would needlessly invalidate the whole flatten cache. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SaveChanges_WithoutDataConnectionChange_DoesNotBumpWatermark() { @@ -1154,12 +1254,14 @@ public class DeploymentManagerRepositoryTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly DeploymentManagerRepository _repository; + /// <summary>Initializes a new instance of the <c>DeploymentManagerRepositoryTests</c> class.</summary> public DeploymentManagerRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new DeploymentManagerRepository(_context); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); @@ -1180,6 +1282,8 @@ public class DeploymentManagerRepositoryTests : IDisposable return instance; } + /// <summary>Verifies that getting the current deployment status returns the most recently added record.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddDeploymentRecord_AndGetCurrentStatus_ReturnsMostRecent() { @@ -1196,10 +1300,12 @@ public class DeploymentManagerRepositoryTests : IDisposable Assert.Equal("d-002", current!.DeploymentId); } + /// <summary>Verifies that when records share a timestamp, the current deployment status picks the highest id as a deterministic tiebreaker.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetCurrentDeploymentStatus_SameTickRecords_DeterministicTiebreakerPicksHighestId() { - // DeploymentManager-026: deployments are insert-only, so two records for the + // Deployments are insert-only, so two records for the // same instance can tie on DeployedAt when created within the same clock tick // (rapid redeploy, or redeploy right after a timed-out attempt). Without a // secondary sort key the "current" read is non-deterministic. The repository @@ -1221,6 +1327,8 @@ public class DeploymentManagerRepositoryTests : IDisposable Assert.Equal("d-new", current!.DeploymentId); } + /// <summary>Verifies that deleting a deployment record through the stub-attach path removes the entity.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteDeploymentRecord_ViaStubAttachPath_RemovesEntity() { @@ -1239,7 +1347,7 @@ public class DeploymentManagerRepositoryTests : IDisposable } /// <summary> - /// ConfigurationDatabase-024: CD-017 added optimistic-concurrency to + /// Optimistic concurrency was added to /// <c>DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion)</c> — the stub-attach /// path now seeds <c>OriginalValues["RowVersion"]</c> from the caller's last-observed /// value so the generated SQL becomes <c>DELETE … WHERE Id = @id AND RowVersion = @prior</c>. @@ -1248,10 +1356,10 @@ public class DeploymentManagerRepositoryTests : IDisposable /// admin / handler shape), calls Delete with that token, and the delete completes /// without throwing <see cref="Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException"/>. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds() { - // STM: CD-024-RowVersionDeleteHappyPath marker. var instance = await SeedInstanceAsync(); var record = new DeploymentRecord("d-rv-001", "admin") { @@ -1276,6 +1384,8 @@ public class DeploymentManagerRepositoryTests : IDisposable Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id)); } + /// <summary>Verifies that deleting an instance first removes its deployment records, which have a restrict foreign key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteInstance_RemovesRestrictFkDeploymentRecordsFirst() { @@ -1293,10 +1403,12 @@ public class DeploymentManagerRepositoryTests : IDisposable Assert.Empty(await _repository.GetDeploymentsByInstanceIdAsync(instance.Id)); } + /// <summary>Verifies that getting an instance by id includes its native alarm source overrides.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceByIdAsync_IncludesNativeAlarmSourceOverrides() { - // Regression for #226: NativeAlarmSourceOverrides was omitted from the Include chain. + // Regression: NativeAlarmSourceOverrides was omitted from the Include chain. var instance = await SeedInstanceAsync(); instance.NativeAlarmSourceOverrides.Add( new InstanceNativeAlarmSourceOverride("src/AlarmA") { InstanceId = instance.Id }); @@ -1310,10 +1422,12 @@ public class DeploymentManagerRepositoryTests : IDisposable Assert.Equal("src/AlarmA", loaded.NativeAlarmSourceOverrides.First().SourceCanonicalName); } + /// <summary>Verifies that getting an instance by unique name includes its native alarm source overrides.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceByUniqueNameAsync_IncludesNativeAlarmSourceOverrides() { - // Regression for #226: NativeAlarmSourceOverrides was omitted from the Include chain. + // Regression: NativeAlarmSourceOverrides was omitted from the Include chain. var instance = await SeedInstanceAsync(); instance.NativeAlarmSourceOverrides.Add( new InstanceNativeAlarmSourceOverride("src/AlarmB") { InstanceId = instance.Id }); @@ -1327,6 +1441,7 @@ public class DeploymentManagerRepositoryTests : IDisposable Assert.Equal("src/AlarmB", loaded.NativeAlarmSourceOverrides.First().SourceCanonicalName); } + /// <summary>Verifies that constructing a DeploymentManagerRepository with a null context throws an ArgumentNullException.</summary> [Fact] public void Constructor_NullContext_Throws() { @@ -1339,18 +1454,22 @@ public class InstanceLocatorTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly InstanceLocator _locator; + /// <summary>Initializes a new instance of the <c>InstanceLocatorTests</c> class.</summary> public InstanceLocatorTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _locator = new InstanceLocator(_context); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that getting the site id for an existing instance returns the site identifier.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSiteIdForInstance_WhenFound_ReturnsSiteIdentifier() { @@ -1367,6 +1486,8 @@ public class InstanceLocatorTests : IDisposable Assert.Equal("SITE-001", result); } + /// <summary>Verifies that getting the site id for a missing instance returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetSiteIdForInstance_WhenInstanceNotFound_ReturnsNull() { @@ -1374,6 +1495,7 @@ public class InstanceLocatorTests : IDisposable Assert.Null(result); } + /// <summary>Verifies that constructing an InstanceLocator with a null context throws an ArgumentNullException.</summary> [Fact] public void Constructor_NullContext_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryTests.cs index 0defd9f0..9939f08c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryTests.cs @@ -17,6 +17,7 @@ public class SecurityRepositoryTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly SecurityRepository _repository; + /// <summary>Initializes the test class with a fresh in-memory database context.</summary> public SecurityRepositoryTests() { var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>() @@ -29,12 +30,15 @@ public class SecurityRepositoryTests : IDisposable _repository = new SecurityRepository(_context); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that an added mapping can be retrieved by its id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddMapping_AndGetById_ReturnsMapping() { @@ -48,6 +52,8 @@ public class SecurityRepositoryTests : IDisposable Assert.Equal("Administrator", loaded.Role); } + /// <summary>Verifies that getting all mappings returns every mapping.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAllMappings_ReturnsAll() { @@ -60,6 +66,8 @@ public class SecurityRepositoryTests : IDisposable Assert.True(all.Count >= 2); } + /// <summary>Verifies that getting mappings by role returns only the matching mappings.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetMappingsByRole_FiltersCorrectly() { @@ -73,6 +81,8 @@ public class SecurityRepositoryTests : IDisposable Assert.Contains(designMappings, m => m.LdapGroupName == "Designers"); } + /// <summary>Verifies that updating a mapping persists the change.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateMapping_PersistsChange() { @@ -89,6 +99,8 @@ public class SecurityRepositoryTests : IDisposable Assert.Equal("Designer", loaded!.Role); } + /// <summary>Verifies that deleting a mapping removes the entity.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteMapping_RemovesEntity() { @@ -103,6 +115,8 @@ public class SecurityRepositoryTests : IDisposable Assert.Null(loaded); } + /// <summary>Verifies that an added scope rule can be retrieved for its mapping.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddScopeRule_AndGetForMapping() { @@ -121,6 +135,8 @@ public class SecurityRepositoryTests : IDisposable Assert.Equal(site.Id, rules[0].SiteId); } + /// <summary>Verifies that getting a scope rule by id returns the rule.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetScopeRuleById_ReturnsRule() { @@ -139,6 +155,8 @@ public class SecurityRepositoryTests : IDisposable Assert.Equal(mapping.Id, loaded.LdapGroupMappingId); } + /// <summary>Verifies that updating a scope rule persists the change.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateScopeRule_PersistsChange() { @@ -162,6 +180,8 @@ public class SecurityRepositoryTests : IDisposable Assert.Equal(site2.Id, loaded!.SiteId); } + /// <summary>Verifies that deleting a scope rule removes the entity.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteScopeRule_RemovesEntity() { @@ -188,18 +208,22 @@ public class CentralUiRepositoryTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly CentralUiRepository _repository; + /// <summary>Initializes the test class with a fresh in-memory database context.</summary> public CentralUiRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new CentralUiRepository(_context); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that getting all sites returns them ordered by name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAllSites_ReturnsOrderedByName() { @@ -214,6 +238,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Equal("Zulu", sites[1].Name); } + /// <summary>Verifies that filtered instances can be restricted by site id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstancesFiltered_BySiteId() { @@ -234,6 +260,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Equal("Inst1", instances[0].UniqueName); } + /// <summary>Verifies that filtered instances can be restricted by search term.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstancesFiltered_BySearchTerm() { @@ -252,6 +280,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Single(instances); } + /// <summary>Verifies that recent deployments are returned in reverse chronological order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetRecentDeployments_ReturnsInReverseChronological() { @@ -277,6 +307,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Equal("d-002", recent[1].DeploymentId); } + /// <summary>Verifies that audit log entries can be filtered by user.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAuditLogEntries_FiltersByUser() { @@ -291,6 +323,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Equal("admin", entries[0].User); } + /// <summary>Verifies that audit log entries can be filtered by entity type.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAuditLogEntries_FiltersByEntityType() { @@ -304,6 +338,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Equal(1, total); } + /// <summary>Verifies that audit log entries can be filtered by action type.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAuditLogEntries_FiltersByActionType() { @@ -316,6 +352,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Single(entries); } + /// <summary>Verifies that audit log entries can be filtered by time range.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAuditLogEntries_FiltersByTimeRange() { @@ -329,6 +367,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Single(entries); } + /// <summary>Verifies that audit log entries can be filtered by entity id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAuditLogEntries_FiltersByEntityId() { @@ -341,6 +381,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Single(entries); } + /// <summary>Verifies that audit log entries can be filtered by entity name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAuditLogEntries_FiltersByEntityName() { @@ -353,6 +395,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Single(entries); } + /// <summary>Verifies that audit log entries can be filtered by bundle import id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAuditLogEntries_FiltersByBundleImportId() { @@ -376,6 +420,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Equal(importA, entries[0].BundleImportId); } + /// <summary>Verifies that audit log entries are returned in reverse chronological order with pagination.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAuditLogEntries_ReverseChronologicalWithPagination() { @@ -399,6 +445,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Equal("T6", page2[0].EntityName); } + /// <summary>Verifies that the template tree includes child entities.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTemplateTree_IncludesChildren() { @@ -413,6 +461,8 @@ public class CentralUiRepositoryTests : IDisposable Assert.Single(loaded.Attributes); } + /// <summary>Verifies that the area tree returns the area hierarchy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAreaTree_ReturnsHierarchy() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs index dd3bc104..dfa72c81 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs @@ -18,21 +18,25 @@ public class SchemaConfigurationTests : IDisposable { private readonly ScadaBridgeDbContext _context; + /// <summary>Creates an in-memory SQLite context for each test.</summary> public SchemaConfigurationTests() { _context = SqliteTestHelper.CreateInMemoryContext(); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } - // ConfigurationDatabase-006: the gRPC node-address columns must be length-bounded + // The gRPC node-address columns must be length-bounded // (HasMaxLength(500)) consistently with the sibling NodeAAddress/NodeBAddress columns, // rather than being left to map to nvarchar(max). + /// <summary>Verifies that the gRPC node address columns are limited to 500 characters.</summary> + /// <param name="propertyName">The gRPC node address property under test.</param> [Theory] [InlineData(nameof(Site.GrpcNodeAAddress))] [InlineData(nameof(Site.GrpcNodeBAddress))] @@ -45,6 +49,8 @@ public class SchemaConfigurationTests : IDisposable Assert.Equal(500, property.GetMaxLength()); } + /// <summary>Verifies that the gRPC node address columns match the length bound of the sibling node address columns.</summary> + /// <param name="siblingPropertyName">The sibling node address property to compare against.</param> [Theory] [InlineData(nameof(Site.NodeAAddress))] [InlineData(nameof(Site.NodeBAddress))] @@ -57,12 +63,15 @@ public class SchemaConfigurationTests : IDisposable Assert.Equal(siblingMaxLength, entity.FindProperty(nameof(Site.GrpcNodeBAddress))!.GetMaxLength()); } - // ConfigurationDatabase-014: the encrypting value converter must be applied + // The encrypting value converter must be applied // uniformly to all three secret-bearing columns, including the non-nullable // DatabaseConnectionDefinition.ConnectionString. A regression here (e.g. the // converter dropped from one HasConversion call) would silently store a secret // in plaintext. + /// <summary>Verifies that every secret-bearing column has the encrypted string converter applied.</summary> + /// <param name="entityType">The entity type that owns the column.</param> + /// <param name="propertyName">The secret-bearing property under test.</param> [Theory] [InlineData(typeof(SmtpConfiguration), nameof(SmtpConfiguration.Credentials))] [InlineData(typeof(ExternalSystemDefinition), nameof(ExternalSystemDefinition.AuthConfiguration))] @@ -82,6 +91,7 @@ public class SchemaConfigurationTests : IDisposable // A filtered index over just the non-terminal rows turns each count from a clustered // scan of the 365-day archive into a small seek. Locked shape: // key = CreatedAtUtc, filter = [TerminalAtUtc] IS NULL, INCLUDE SourceSite/SourceNode/Status. + /// <summary>Verifies that the site calls table has a filtered index over non-terminal rows.</summary> [Fact] public void SiteCalls_Has_Filtered_NonTerminal_Index() { @@ -100,9 +110,10 @@ public class SchemaConfigurationTests : IDisposable // arch-review 04 round 2 (R6): the daily terminal retention purge filters on // TerminalAtUtc IS NOT NULL AND TerminalAtUtc < cutoff. A filtered index over the - // terminal population lets the purge (and Task 11's MIN() slice anchor) seek the + // terminal population lets the purge (and the MIN() slice anchor) seek the // expired tail instead of full-scanning the 365-day table. Locked shape: // key = TerminalAtUtc, filter = [TerminalAtUtc] IS NOT NULL. + /// <summary>Verifies that the site calls table has a filtered index over terminal rows.</summary> [Fact] public void SiteCalls_Has_Filtered_Terminal_Index() { @@ -119,6 +130,8 @@ public class SchemaConfigurationTests : IDisposable // .IsMesReportableOverride (null = inherit) must both round-trip through the // configuration database. Backed on SQL Server by the AddAlarmMesReportable // migration; pinned here on the model/persist path. + /// <summary>Verifies that template alarms and their overrides persist the MES-reportable flags.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TemplateAlarm_And_Override_PersistMesReportableFlags() { @@ -159,22 +172,26 @@ public class SplitQueryBehaviourTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly TemplateEngineRepository _repository; + /// <summary>Creates an in-memory SQLite context and a template repository for each test.</summary> public SplitQueryBehaviourTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark()); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } - // ConfigurationDatabase-009: the multi-collection eager-load queries were switched to + // The multi-collection eager-load queries were switched to // AsSplitQuery() to avoid cartesian-product joins. The result set must be unchanged — // every member collection still fully populated, with no row duplication. + /// <summary>Verifies that loading all templates with several members per collection returns every member without duplication.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAllTemplatesAsync_WithMultipleMembersPerCollection_LoadsAllWithoutDuplication() { @@ -199,6 +216,8 @@ public class SplitQueryBehaviourTests : IDisposable Assert.Equal(4, loaded.Scripts.Count); } + /// <summary>Verifies that loading a template by id with several members populates all of its collections.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTemplateByIdAsync_WithMultipleMembers_LoadsAllCollections() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecretEncryptionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecretEncryptionTests.cs index 22255f81..f3ebe79a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecretEncryptionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecretEncryptionTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// <summary> -/// Regression guard for ConfigurationDatabase-004: secret-bearing columns +/// Regression guard: secret-bearing columns /// (SMTP credentials, external-system auth config, database connection strings) /// must be encrypted at rest, not persisted verbatim. /// </summary> @@ -17,6 +17,7 @@ public class SecretEncryptionTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly IDataProtectionProvider _protectionProvider; + /// <summary>Initializes a new instance of <c>SecretEncryptionTests</c>.</summary> public SecretEncryptionTests() { _protectionProvider = new EphemeralDataProtectionProvider(); @@ -31,12 +32,15 @@ public class SecretEncryptionTests : IDisposable _context.Database.EnsureCreated(); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that database connection definition connection string stored encrypted round trips.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DatabaseConnectionDefinition_ConnectionString_StoredEncrypted_RoundTrips() { @@ -57,6 +61,8 @@ public class SecretEncryptionTests : IDisposable Assert.Equal(secret, loaded.ConnectionString); } + /// <summary>Verifies that smtp configuration credentials stored encrypted round trips.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SmtpConfiguration_Credentials_StoredEncrypted_RoundTrips() { @@ -78,6 +84,8 @@ public class SecretEncryptionTests : IDisposable Assert.Equal(secret, loaded.Credentials); } + /// <summary>Verifies that external system definition auth configuration stored encrypted round trips.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExternalSystemDefinition_AuthConfiguration_StoredEncrypted_RoundTrips() { @@ -99,6 +107,8 @@ public class SecretEncryptionTests : IDisposable Assert.Equal(secret, loaded.AuthConfiguration); } + /// <summary>Verifies that smtp configuration null credentials round trips as null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SmtpConfiguration_NullCredentials_RoundTripsAsNull() { @@ -114,6 +124,8 @@ public class SecretEncryptionTests : IDisposable Assert.Null(loaded.Credentials); } + /// <summary>Verifies that sms configuration auth token stored encrypted round trips.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SmsConfiguration_AuthToken_StoredEncrypted_RoundTrips() { @@ -141,6 +153,8 @@ public class SecretEncryptionTests : IDisposable Assert.Equal("AC0123456789", rawSid); } + /// <summary>Verifies that sms configuration null auth token round trips as null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SmsConfiguration_NullAuthToken_RoundTripsAsNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs index db02de37..9ab62fd7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs @@ -8,8 +8,8 @@ using Xunit; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// <summary> -/// Integration tests for <see cref="SecuredWriteRepository"/> (M7 OPC UA / MxGateway -/// UX, Task T14b). Uses the same <see cref="MsSqlMigrationFixture"/> as the migration +/// Integration tests for <see cref="SecuredWriteRepository"/> (OPC UA / MxGateway +/// secured writes). Uses the same <see cref="MsSqlMigrationFixture"/> as the migration /// tests so the EF reads/writes execute against the real <c>PendingSecuredWrites</c> /// schema produced by the migration. Each test mints a fresh per-test /// <c>SiteId</c>/<c>Status</c> suffix so tests neither collide nor require teardown. @@ -20,11 +20,15 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture> { private readonly MsSqlMigrationFixture _fixture; + /// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary> + /// <param name="fixture">The shared MS SQL migration fixture.</param> public SecuredWriteRepositoryTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } + /// <summary>Verifies that a pending secured write round-trips through add, get, update and query.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task Lifecycle_AddGetUpdateQuery_RoundTrips() { @@ -92,6 +96,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture> } } + /// <summary>Verifies that null filters match every status for the site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_NullFilters_MatchEveryStatusForSite() { @@ -109,6 +115,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Contains(all, p => p.Id == executedId); } + /// <summary>Verifies that a pending row flips to expired exactly once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task TryMarkExpiredAsync_PendingRow_FlipsToExpired_Once() { @@ -132,6 +140,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Null(row.VerifierUser); // system decision, no verifier } + /// <summary>Verifies that marking an already decided row expired returns false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task TryMarkExpiredAsync_DecidedRow_ReturnsFalse() { @@ -148,6 +158,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.False(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow)); } + /// <summary>Verifies that a pending row flips to rejected exactly once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task TryMarkRejectedAsync_PendingRow_FlipsToRejected_Once() { @@ -177,10 +189,12 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Null(row.ExecutionError); } + /// <summary>Verifies that when an approve is followed by a reject on one pending row, the approve wins and the reject returns false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task ApproveThenReject_OnOnePendingRow_ApproveWins_RejectReturnsFalse() { - // arch-review 2026-08-19 P6-1: approve and reject both transition out of Pending, + // Approve and reject both transition out of Pending, // so exactly one may win. This is the ordering that used to falsify the // two-person-control record — a blind reject update landing after approve had // already committed (and, in production, while its multi-second site relay was @@ -205,6 +219,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal("ok", row.VerifierComment); } + /// <summary>Verifies that when a reject is followed by an approve on one pending row, the reject wins and the approve returns false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task RejectThenApprove_OnOnePendingRow_RejectWins_ApproveReturnsFalse() { @@ -229,6 +245,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal("no", row.VerifierComment); } + /// <summary>Verifies that rejecting an expired row returns false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task TryMarkRejectedAsync_ExpiredRow_ReturnsFalse() { @@ -250,6 +268,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Null(row.VerifierUser); } + /// <summary>Verifies that the count applies the same filters as the query.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task CountAsync_FiltersLikeQueryAsync() { @@ -266,6 +286,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(0, await repo.CountAsync(status: "Executed", siteId: siteId)); } + /// <summary>Verifies that a query with permitted site ids returns only rows for those sites.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_PermittedSiteIds_ReturnsOnlyPermittedRows() { @@ -290,6 +312,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.DoesNotContain(rows, r => r.SiteId == site2); } + /// <summary>Verifies that a count with permitted site ids counts only rows for those sites.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task CountAsync_PermittedSiteIds_CountsOnlyPermittedRows() { @@ -311,6 +335,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture> Assert.Equal(3, count); } + /// <summary>Verifies that a null permitted-site list leaves the query unfiltered, for backward compatibility.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task QueryAsync_NullPermittedSiteIds_IsUnfiltered_BackCompat() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SeedDataTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SeedDataTests.cs index 8f7286ca..5f6fc57d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SeedDataTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SeedDataTests.cs @@ -7,6 +7,7 @@ public class SeedDataTests : IDisposable { private readonly ScadaBridgeDbContext _context; + /// <summary>Initializes an in-memory SQLite database with the schema and seed data applied.</summary> public SeedDataTests() { var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>() @@ -18,12 +19,15 @@ public class SeedDataTests : IDisposable _context.Database.EnsureCreated(); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that the seeded administrator LDAP group mapping exists.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SeedData_AdminMappingExists() { @@ -31,7 +35,7 @@ public class SeedDataTests : IDisposable .SingleOrDefaultAsync(m => m.LdapGroupName == "SCADA-Admins"); Assert.NotNull(adminMapping); - // Role VALUE canonicalized to "Administrator" (Task 1.7); group NAME unchanged. + // Role VALUE canonicalized to "Administrator"; group NAME unchanged. Assert.Equal("Administrator", adminMapping.Role); Assert.Equal(1, adminMapping.Id); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs index 27d102ee..5c9d4cea 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs @@ -9,6 +9,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; public class ServiceCollectionExtensionsTests { + /// <summary>Verifies that adding the configuration database with a connection string registers the repositories and services.</summary> [Fact] public void AddConfigurationDatabase_WithConnectionString_RegistersRepositoriesAndServices() { @@ -22,10 +23,11 @@ public class ServiceCollectionExtensionsTests Assert.Contains(services, d => d.ServiceType == typeof(IAuditLogRepository)); } + /// <summary>Verifies that the configuration database is configured with a retrying execution strategy.</summary> [Fact] public void AddConfigurationDatabase_Configures_RetryingExecutionStrategy() { - // Task 12 (arch-review 04 S5): the central DbContext must survive transient + // The central DbContext must survive transient // SQL faults. EnableRetryOnFailure installs a non-null ExecutionStrategyFactory // on the SqlServer options extension — covering every read-side path (KPI asks, // outbox queries, Tracking.Status, execution trees) that previously surfaced @@ -130,10 +132,11 @@ public class ServiceCollectionExtensionsTests types: new[] { typeof(IServiceCollection) }, modifiers: null)!; + /// <summary>Verifies that the parameterless overload fails fast with a clear message.</summary> [Fact] public void AddConfigurationDatabase_NoArgOverload_FailsFastWithClearMessage() { - // Regression guard for ConfigurationDatabase-003: the parameterless overload must not + // Regression guard for the original no-op bug: the parameterless overload must not // silently register nothing. Misuse must surface immediately at wire-up time with an // actionable message — not later as an opaque DI resolution failure. var services = new ServiceCollection(); @@ -145,6 +148,7 @@ public class ServiceCollectionExtensionsTests Assert.Contains("connection string", ex.Message, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that the parameterless overload is marked obsolete as a compile-time error.</summary> [Fact] public void AddConfigurationDatabase_NoArgOverload_IsMarkedObsoleteAsError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SharedSchemaRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SharedSchemaRepositoryTests.cs index 55ed070a..79e9b401 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SharedSchemaRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SharedSchemaRepositoryTests.cs @@ -7,8 +7,8 @@ using Xunit; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// <summary> -/// Round-trip tests for <see cref="SharedSchemaRepository"/> (M9 template-level JSON-Schema -/// library, Task T32a). Uses the in-memory SQLite harness (<see cref="SqliteTestHelper"/>) +/// Round-trip tests for <see cref="SharedSchemaRepository"/> (template-level JSON-Schema +/// library). Uses the in-memory SQLite harness (<see cref="SqliteTestHelper"/>) /// — the schema is materialized via <c>EnsureCreated()</c> from the same model the /// migration was generated from, so the tests run unconditionally (no live MSSQL needed) /// while still exercising the real unique-<c>Name</c> index. Mirrors the @@ -19,18 +19,22 @@ public class SharedSchemaRepositoryTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly SharedSchemaRepository _repository; + /// <summary>Initializes a new instance of <c>SharedSchemaRepositoryTests</c>.</summary> public SharedSchemaRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new SharedSchemaRepository(_context); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that add then get by name round trips.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Add_ThenGetByName_RoundTrips() { @@ -58,6 +62,8 @@ public class SharedSchemaRepositoryTests : IDisposable Assert.Equal("TankReading", byId!.Name); } + /// <summary>Verifies that get by name unknown returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetByName_Unknown_ReturnsNull() { @@ -65,6 +71,8 @@ public class SharedSchemaRepositoryTests : IDisposable Assert.Null(missing); } + /// <summary>Verifies that add duplicate name throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Add_DuplicateName_Throws() { @@ -75,6 +83,8 @@ public class SharedSchemaRepositoryTests : IDisposable _repository.AddAsync(new SharedSchema { Name = "Dup", SchemaJson = "{}" })); } + /// <summary>Verifies that nullable scope round trips.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NullableScope_RoundTrips() { @@ -85,6 +95,8 @@ public class SharedSchemaRepositoryTests : IDisposable Assert.Null(loaded!.Scope); } + /// <summary>Verifies that list returns all ordered by name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task List_ReturnsAll_OrderedByName() { @@ -97,6 +109,8 @@ public class SharedSchemaRepositoryTests : IDisposable Assert.Equal("Bravo", all[1].Name); } + /// <summary>Verifies that update persists changes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Update_PersistsChanges() { @@ -114,6 +128,8 @@ public class SharedSchemaRepositoryTests : IDisposable Assert.Equal("Updated", reloaded.Scope); } + /// <summary>Verifies that delete removes row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Delete_RemovesRow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SqliteTestHelper.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SqliteTestHelper.cs index 77e362c1..6e66db30 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SqliteTestHelper.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SqliteTestHelper.cs @@ -14,16 +14,19 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// /// Constructed with an explicit ephemeral Data Protection provider so secret-bearing /// columns are write-capable in tests. The schema-only no-provider constructor would -/// throw on a secret-column write (ConfigurationDatabase-013); passing a provider here +/// throw on a secret-column write; passing a provider here /// makes the test fixture's intent explicit at the call site. /// </summary> public class SqliteTestDbContext : ScadaBridgeDbContext { + /// <summary>Initializes the context with the given SQLite options and an ephemeral data protection provider.</summary> + /// <param name="options">The EF options configured for the SQLite provider.</param> public SqliteTestDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options, new EphemeralDataProtectionProvider()) { } + /// <inheritdoc /> protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -38,7 +41,7 @@ public class SqliteTestDbContext : ScadaBridgeDbContext .ValueGeneratedNever(); }); - // Note (C5 / Task 2.5): the central dbo.AuditLog persisted computed columns + // Note: the central dbo.AuditLog persisted computed columns // (JSON_VALUE-based) are neutralized for non-SQL-Server providers inside // ScadaBridgeDbContext.OnModelCreating itself, so every SQLite test context — // including ones that construct ScadaBridgeDbContext directly rather than via @@ -74,6 +77,8 @@ public class SqliteTestDbContext : ScadaBridgeDbContext public static class SqliteTestHelper { + /// <summary>Creates a context backed by a fresh in-memory SQLite database with the schema created.</summary> + /// <returns>The open in-memory test context.</returns> public static ScadaBridgeDbContext CreateInMemoryContext() { var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>() @@ -92,6 +97,8 @@ public static class SqliteTestHelper /// test observe the actual SQL a repository emits (e.g. count DELETE statements /// issued by a batched purge). /// </summary> + /// <param name="interceptors">The EF interceptors to attach to the context.</param> + /// <returns>The open in-memory test context with the interceptors attached.</returns> public static ScadaBridgeDbContext CreateInMemoryContext(params IInterceptor[] interceptors) { var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>() @@ -106,6 +113,9 @@ public static class SqliteTestHelper return context; } + /// <summary>Creates a context backed by a SQLite database file.</summary> + /// <param name="dbPath">The path of the SQLite database file.</param> + /// <returns>The file-backed test context.</returns> public static ScadaBridgeDbContext CreateFileContext(string dbPath) { var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>() diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs index 000bd3ae..ee82df07 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs @@ -11,6 +11,7 @@ public class TemplateEngineRepositoryTests : IDisposable private readonly ScadaBridgeDbContext _context; private readonly TemplateEngineRepository _repository; + /// <summary>Initializes a new instance of the <see cref="TemplateEngineRepositoryTests"/> class.</summary> public TemplateEngineRepositoryTests() { var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>() @@ -23,12 +24,15 @@ public class TemplateEngineRepositoryTests : IDisposable _repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark()); } + /// <summary>Closes the connection and disposes the database context.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that the topology page is bounded, filtered and returned untracked.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TopologyPage_IsBoundedFilteredAndUntracked() { @@ -53,6 +57,8 @@ public class TemplateEngineRepositoryTests : IDisposable await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => _repository.GetTopologyInstancesAsync(site.Id, null, 0, 51)); } + /// <summary>Verifies that loading a template with children returns it with all member collections populated.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTemplateWithChildrenAsync_ReturnsTemplateWithAllMemberCollectionsPopulated() { @@ -73,7 +79,7 @@ public class TemplateEngineRepositoryTests : IDisposable var loaded = await _repository.GetTemplateWithChildrenAsync(template.Id); // Assert: the method must deliver the template's child members to the caller, - // not silently drop them. Regression guard for ConfigurationDatabase-001. + // not silently drop them. Regression guard for dropped child members. Assert.NotNull(loaded); Assert.Equal(template.Id, loaded!.Id); Assert.Single(loaded.Attributes); @@ -86,10 +92,12 @@ public class TemplateEngineRepositoryTests : IDisposable Assert.Equal("Slot1", loaded.Compositions.First().InstanceName); } + /// <summary>Verifies that a template script's execution timeout in seconds round-trips through EF.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TemplateScript_ExecutionTimeoutSeconds_RoundTripsThroughEf() { - // M2.5 (#9): the nullable per-script execution timeout must persist and + // The nullable per-script execution timeout must persist and // reload through EF — both an explicit value and a null (use-global). var template = new Template("TimeoutTemplate"); template.Scripts.Add(new TemplateScript("WithTimeout", "return 1;") @@ -114,6 +122,8 @@ public class TemplateEngineRepositoryTests : IDisposable Assert.Null(noTimeout.ExecutionTimeoutSeconds); } + /// <summary>Verifies that loading a template with children returns null when the template does not exist.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTemplateWithChildrenAsync_ReturnsNull_WhenTemplateDoesNotExist() { @@ -122,10 +132,12 @@ public class TemplateEngineRepositoryTests : IDisposable Assert.Null(loaded); } + /// <summary>Verifies that the bulk variant fetches every matching template name in one query.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTemplatesWithChildrenAsync_BulkVariant_FetchesEveryMatchingNameInOneQuery() { - // Transport-008 regression: BundleImporter.PreviewAsync previously + // Regression: BundleImporter.PreviewAsync previously // called GetTemplateWithChildrenAsync(stub.Id) per matching template // name. The bulk variant returns every match in a single query. var a = new Template("Alpha"); @@ -150,6 +162,8 @@ public class TemplateEngineRepositoryTests : IDisposable Assert.DoesNotContain(result, t => t.Name == "Gamma"); } + /// <summary>Verifies that the bulk variant returns an empty result for an empty list of names.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTemplatesWithChildrenAsync_EmptyNames_ReturnsEmpty() { @@ -158,6 +172,8 @@ public class TemplateEngineRepositoryTests : IDisposable Assert.Empty(result); } + /// <summary>Verifies that the bulk variant returns an empty result for a null enumerable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTemplatesWithChildrenAsync_NullEnumerable_ReturnsEmpty() { @@ -166,6 +182,8 @@ public class TemplateEngineRepositoryTests : IDisposable Assert.Empty(result); } + /// <summary>Verifies that the bulk variant filters out duplicate and empty names.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTemplatesWithChildrenAsync_FiltersOutDuplicatesAndEmptyStrings() { @@ -182,6 +200,8 @@ public class TemplateEngineRepositoryTests : IDisposable Assert.Equal("Alpha", result[0].Name); } + /// <summary>Verifies that the parent template id is preserved so the inheritance chain can be walked.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTemplateWithChildrenAsync_PreservesParentTemplateId_ForInheritanceChainWalk() { @@ -201,6 +221,8 @@ public class TemplateEngineRepositoryTests : IDisposable Assert.Equal(baseTemplate.Id, loaded!.ParentTemplateId); } + /// <summary>Verifies that native alarm sources added for a template round-trip when queried by template id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAndGetNativeAlarmSourcesByTemplateId_RoundTrips() { @@ -217,6 +239,8 @@ public class TemplateEngineRepositoryTests : IDisposable Assert.Equal("S", list[0].Name); } + /// <summary>Verifies that loading a template with children includes its native alarm sources.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTemplateWithChildren_IncludesNativeAlarmSources() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/UnitTest1.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/UnitTest1.cs index 1fc5dcd0..2397d21d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/UnitTest1.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/UnitTest1.cs @@ -19,17 +19,20 @@ public class DbContextTests : IDisposable { private readonly ScadaBridgeDbContext _context; + /// <summary>Creates the test class with a fresh in-memory database context.</summary> public DbContextTests() { _context = SqliteTestHelper.CreateInMemoryContext(); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that the schema creates all expected tables.</summary> [Fact] public void Schema_CreatesAllTables() { @@ -69,6 +72,7 @@ public class DbContextTests : IDisposable Assert.Empty(_context.AuditLogEntries.ToList()); } + /// <summary>Verifies that a template and its child entities are created together.</summary> [Fact] public void Template_WithChildren_CascadeCreated() { @@ -95,6 +99,7 @@ public class DbContextTests : IDisposable Assert.Equal("Attr1", loaded.Attributes.First().Name); } + /// <summary>Verifies that a template can reference a parent template through inheritance.</summary> [Fact] public void Template_Inheritance_SelfReference() { @@ -110,6 +115,7 @@ public class DbContextTests : IDisposable Assert.Equal(parent.Id, loaded.ParentTemplateId); } + /// <summary>Verifies that a template composition creates the relationship between templates.</summary> [Fact] public void Template_Composition_CreatesRelationship() { @@ -129,6 +135,7 @@ public class DbContextTests : IDisposable Assert.Equal(composedTemplate.Id, loaded.Compositions.First().ComposedTemplateId); } + /// <summary>Verifies that an instance persists its overrides and connection bindings.</summary> [Fact] public void Instance_WithOverridesAndBindings() { @@ -163,6 +170,7 @@ public class DbContextTests : IDisposable Assert.Equal(InstanceState.Enabled, loaded.State); } + /// <summary>Verifies that a deployment record persists with all its fields.</summary> [Fact] public void DeploymentRecord_CreatesWithAllFields() { @@ -192,6 +200,7 @@ public class DbContextTests : IDisposable Assert.Equal("abc123", loaded.RevisionHash); } + /// <summary>Verifies that an audit log entry can be written and queried.</summary> [Fact] public void AuditLogEntry_WritesAndQueries() { @@ -209,6 +218,7 @@ public class DbContextTests : IDisposable Assert.NotNull(loaded.AfterStateJson); } + /// <summary>Verifies that an external system persists with its methods.</summary> [Fact] public void ExternalSystem_WithMethods() { @@ -230,6 +240,7 @@ public class DbContextTests : IDisposable Assert.Single(_context.ExternalSystemMethods.Where(m => m.ExternalSystemDefinitionId == system.Id)); } + /// <summary>Verifies that a notification list persists with its recipients.</summary> [Fact] public void NotificationList_WithRecipients() { @@ -247,6 +258,7 @@ public class DbContextTests : IDisposable Assert.Equal(2, loaded.Recipients.Count); } + /// <summary>Verifies that an LDAP group mapping persists with its site scope rules.</summary> [Fact] public void Security_LdapGroupMapping_WithSiteScopeRules() { @@ -265,6 +277,7 @@ public class DbContextTests : IDisposable Assert.Single(_context.SiteScopeRules.Where(r => r.LdapGroupMappingId == mapping.Id)); } + /// <summary>Verifies that an inbound API method persists.</summary> [Fact] public void InboundApi_Method() { @@ -279,6 +292,7 @@ public class DbContextTests : IDisposable Assert.Single(_context.ApiMethods.Where(m => m.Name == "GetStatus")); } + /// <summary>Verifies that areas form a parent-child hierarchy.</summary> [Fact] public void Area_HierarchyWorks() { @@ -302,6 +316,7 @@ public class DbContextTests : IDisposable Assert.Equal("Floor 1", loaded.Children.First().Name); } + /// <summary>Verifies that a data connection belongs to its site.</summary> [Fact] public void DataConnection_BelongsToSite() { @@ -317,6 +332,7 @@ public class DbContextTests : IDisposable Assert.Equal(site.Id, loaded.SiteId); } + /// <summary>Verifies that enum properties are stored as strings.</summary> [Fact] public void EnumProperties_StoredAsStrings() { @@ -334,6 +350,7 @@ public class DbContextTests : IDisposable Assert.Equal(AlarmTriggerType.RangeViolation, alarm.TriggerType); } + /// <summary>Verifies that the unique constraint on template names is enforced.</summary> [Fact] public void UniqueConstraint_Template_Name_Enforced() { @@ -344,6 +361,7 @@ public class DbContextTests : IDisposable Assert.ThrowsAny<Exception>(() => _context.SaveChanges()); } + /// <summary>Verifies that date-time offset values are mapped correctly.</summary> [Fact] public void DateTimeOffset_MappedCorrectly() { @@ -361,6 +379,7 @@ public class DbContextTests : IDisposable public class ServiceRegistrationTests { + /// <summary>Verifies that registering the configuration database with a connection string registers the database context.</summary> [Fact] public void AddConfigurationDatabase_WithConnectionString_RegistersDbContext() { @@ -373,10 +392,11 @@ public class ServiceRegistrationTests Assert.NotNull(context); } + /// <summary>Verifies that the no-argument registration overload fails fast.</summary> [Fact] public void AddConfigurationDatabase_NoArgs_FailsFast() { - // ConfigurationDatabase-003: the no-arg overload previously silently registered + // The no-arg overload previously silently registered // nothing, which deferred a misconfiguration into an opaque DI failure later. // It is now [Obsolete(error: true)] (compile-time guard) and throws at runtime. // Invoked via reflection because the obsolete-error overload cannot be called @@ -399,6 +419,7 @@ public class MigrationHelperTests : IDisposable { private readonly ScadaBridgeDbContext _context; + /// <summary>Creates the test class with a fresh in-memory database context.</summary> public MigrationHelperTests() { // Use SQLite with PendingModelChangesWarning suppressed because the migration @@ -412,12 +433,15 @@ public class MigrationHelperTests : IDisposable _context.Database.OpenConnection(); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies that production mode throws when migrations are pending.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyOrValidate_ProductionMode_WithPendingMigrations_Throws() { @@ -429,6 +453,7 @@ public class MigrationHelperTests : IDisposable Assert.Contains("pending migration", ex.Message, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that the initial create migration exists.</summary> [Fact] public void MigrationExists_InitialCreate() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionActorBatchTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionActorBatchTests.cs index 30fd13ae..93b149fa 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionActorBatchTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionActorBatchTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// <summary> -/// WP2.1b — the DataConnectionActor against a batch-capable adapter: chunked subscribe, +/// The DataConnectionActor against a batch-capable adapter: chunked subscribe, /// chunked/bounded re-subscribe, the seed deadline, batched tag-resolution probes, the /// partial-failure surface and the coalesced quality push. The existing suite covers the /// same actor against a NON batch-capable adapter, which keeps the per-tag fallback pinned. @@ -42,11 +42,12 @@ public class DataConnectionActorBatchTests : TestKit private static string[] Tags(int count) => Enumerable.Range(1, count).Select(i => $"tag{i}").ToArray(); + /// <summary>Verifies that subscribing issues one adapter round trip per chunk rather than one per tag.</summary> [Fact] public void Subscribe_IssuesOneAdapterRoundTripPerChunk_NotPerTag() { // 12 tags at SubscribeBatchSize 5 → 3 batch calls (5/5/2) and ZERO per-tag - // subscribes. This is finding #3: the old path was one adapter call (and one OPC UA + // subscribes. The old path was one adapter call (and one OPC UA // ApplyChanges) per tag. var adapter = new FakeBatchDataConnection(); var actor = CreateActor(adapter, Options(), "batch-chunking"); @@ -60,6 +61,7 @@ public class DataConnectionActorBatchTests : TestKit Assert.Equal(0, Volatile.Read(ref adapter.SingleSubscribeCalls)); } + /// <summary>Verifies that seeding uses chunked bulk reads rather than per-tag reads.</summary> [Fact] public void Seed_UsesChunkedBulkReads_NotPerTagReads() { @@ -78,6 +80,7 @@ public class DataConnectionActorBatchTests : TestKit ExpectMsg<TagValueUpdate>(u => u.Quality == QualityCode.Good, TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that hanging seed reads return at the overall deadline and the subscribe is still acknowledged.</summary> [Fact] public void Seed_HangingReads_ReturnAtTheOverallDeadline_AndTheSubscribeIsStillAcked() { @@ -102,6 +105,7 @@ public class DataConnectionActorBatchTests : TestKit ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } + /// <summary>Verifies that a per-tag partial failure marks that tag bad while the request still succeeds.</summary> [Fact] public void PartialFailure_PerTagRow_MarksTagBad_ButTheRequestStillSucceeds() { @@ -117,6 +121,7 @@ public class DataConnectionActorBatchTests : TestKit ExpectMsg<SubscribeTagsResponse>(m => m.Success, TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that a batch that throws at connection level fails the request and drives a reconnect.</summary> [Fact] public void ThrownBatch_AtConnectionLevel_FailsTheRequestAndDrivesReconnect() { @@ -135,6 +140,7 @@ public class DataConnectionActorBatchTests : TestKit ExpectMsg<ConnectionQualityChanged>(q => q.Quality == QualityCode.Bad, TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that tag-resolution probes are batched rather than issued one per tag.</summary> [Fact] public void ResolutionProbes_AreBatched_NotOnePerTag() { @@ -162,6 +168,7 @@ public class DataConnectionActorBatchTests : TestKit Assert.Equal(0, Volatile.Read(ref adapter.SingleSubscribeCalls)); } + /// <summary>Verifies that resolution probes back off so a dead device is not probed at the full rate.</summary> [Fact] public void ResolutionProbes_BackOff_SoADeadDeviceIsNotProbedAtFullRate() { @@ -196,7 +203,7 @@ public class DataConnectionActorBatchTests : TestKit } /// <summary> - /// Row-40 house pattern adapted for a RATE assertion: proves a monotonically-increasing + /// House pattern adapted for a RATE assertion: proves a monotonically-increasing /// count stays within <c>[minInclusive, maxInclusive]</c> across the whole /// <paramref name="window"/>, rather than a single blind sleep-then-sample at the end. /// The upper bound is enforced continuously (fails fast the instant it is exceeded, @@ -225,7 +232,7 @@ public class DataConnectionActorBatchTests : TestKit } /// <summary> - /// Row-40 house pattern for an ABSENCE assertion (mirrors the Medium-wave + /// House pattern for an ABSENCE assertion (mirrors the Medium-wave /// <c>SiteAlarmAggregatorActorTests.AssertHoldsFor</c> treatment): proves /// <paramref name="holds"/> keeps being true for the whole <paramref name="window"/> by /// polling, rather than a single blind <c>Thread.Sleep(window)</c> followed by one @@ -246,6 +253,7 @@ public class DataConnectionActorBatchTests : TestKit } } + /// <summary>Verifies that quality counters are pushed only on a transition and are coalesced.</summary> [Fact] public void QualityCounters_ArePushedOnTransitionOnly_AndCoalesced() { @@ -279,6 +287,7 @@ public class DataConnectionActorBatchTests : TestKit "the coalesced push must not repeat"); } + /// <summary>Verifies that quality counters are flushed immediately on disconnect.</summary> [Fact] public void QualityCounters_FlushImmediatelyOnDisconnect() { @@ -301,6 +310,7 @@ public class DataConnectionActorBatchTests : TestKit TimeSpan.FromSeconds(3)); } + /// <summary>Verifies that unsubscribing releases every handle in a single round trip.</summary> [Fact] public void Unsubscribe_ReleasesEveryHandleInOneRoundTrip() { @@ -317,6 +327,7 @@ public class DataConnectionActorBatchTests : TestKit Assert.Equal(6, released!.Count); } + /// <summary>Verifies that a reconnect re-subscribes in paced chunks and restores the resolved count.</summary> [Fact] public void Reconnect_ReSubscribesInPacedChunks_AndRestoresTheResolvedCount() { @@ -353,6 +364,7 @@ public class DataConnectionActorBatchTests : TestKit TimeSpan.FromSeconds(10)); } + /// <summary>Verifies that a resolution probe completing after an unsubscribe is discarded and its handle released.</summary> [Fact] public void ResolutionProbe_CompletingAfterUnsubscribe_IsDiscarded_AndItsHandleIsReleased() { @@ -432,6 +444,7 @@ public class DataConnectionActorBatchTests : TestKit Assert.Equal(0, afterFinalUnsubscribe.ResolvedTags); } + /// <summary>Verifies that a reconnect re-subscribe completing after an unsubscribe is discarded and its handle released.</summary> [Fact] public void ReconnectResubscribe_CompletingAfterUnsubscribe_IsDiscarded_AndItsHandleIsReleased() { @@ -483,7 +496,7 @@ public class DataConnectionActorBatchTests : TestKit // ── Tag-resolution health counts: derived from the per-tag counted set ── // - // Arch-review remediation residual #1. TotalSubscribedTags / ResolvedTags used to be + // TotalSubscribedTags / ResolvedTags used to be // two int fields incremented and decremented at five independent sites. The reconnect // path cleared the very maps those decrements keyed off (_subscriptionIds and // _unresolvedTags), so an unsubscribe landing inside a reconnect window matched NEITHER @@ -491,6 +504,7 @@ public class DataConnectionActorBatchTests : TestKit // under churn. They are now derived at report time from _instancesByTag (the per-tag // counted set) and _subscriptionIds, which makes that class of drift unrepresentable. + /// <summary>Verifies that an unsubscribe inside a reconnect window does not leak a subscribed tag into the counts.</summary> [Fact] public void TagResolutionCounts_UnsubscribeInsideAReconnectWindow_DoesNotLeakASubscribedTag() { @@ -546,6 +560,7 @@ public class DataConnectionActorBatchTests : TestKit } } + /// <summary>Verifies that a shared tag unsubscribed inside a reconnect window still counts for the other instance.</summary> [Fact] public void TagResolutionCounts_SharedTagUnsubscribedInsideAReconnectWindow_StillCountsForTheOtherInstance() { @@ -599,6 +614,7 @@ public class DataConnectionActorBatchTests : TestKit AwaitCondition(() => adapter.UnsubscribeBatches.Any(b => b.Count == 1), TimeSpan.FromSeconds(10)); } + /// <summary>Verifies that after a connection-level failure and recovery the resolved count stays within the total.</summary> [Fact] public void TagResolutionCounts_ConnectionLevelFailureThenRecovery_KeepResolvedWithinTheTotal() { @@ -645,6 +661,7 @@ public class DataConnectionActorBatchTests : TestKit Assert.Equal(0, afterUnsubscribe.ResolvedTags); } + /// <summary>Verifies that tag-resolution counts stay correct across plain subscribe and unsubscribe cycles.</summary> [Fact] public void TagResolutionCounts_SurvivePlainSubscribeUnsubscribeCycles() { @@ -680,6 +697,7 @@ public class DataConnectionActorBatchTests : TestKit } } + /// <summary>Verifies that tag-resolution counts are carried through a completed reconnect re-subscribe.</summary> [Fact] public void TagResolutionCounts_AreCarriedThroughACompletedReconnectReSubscribe() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionDisconnectRaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionDisconnectRaceTests.cs index f8b8e809..57f98aa5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionDisconnectRaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionDisconnectRaceTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// <summary> -/// Arch-review 2026-08-19 <b>P2-6</b> — the <c>AdapterDisconnected</c> gap in the +/// The <c>AdapterDisconnected</c> gap in the /// Connecting/Reconnecting states, and the generation fence that keeps a swapped-out adapter's /// disconnect from disturbing its replacement. /// @@ -28,7 +28,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// /// <para> /// The fix must not weaken the Medium-wave generation fencing: a disconnect raised by an adapter -/// that has since been swapped out (failover, or the row-97 in-place reconfigure) must still be +/// that has since been swapped out (failover, or the in-place reconfigure) must still be /// dropped, or the endpoint the operator has just corrected AWAY from could knock the healthy /// replacement back into a reconnect cycle. /// </para> @@ -113,7 +113,7 @@ public class DataConnectionDisconnectRaceTests : TestKit { const string name = "disconnect-before-connect-result"; var firstConnect = new TaskCompletionSource(); - // Attempt #2 onward never completes, so "the actor did not go Connected on the dead + // Attempt 2 onward never completes, so "the actor did not go Connected on the dead // session" is a stable assertion rather than a race against the next retry tick. var laterConnects = new TaskCompletionSource(); var connects = 0; @@ -158,7 +158,7 @@ public class DataConnectionDisconnectRaceTests : TestKit const string name = "disconnect-before-reconnect-result"; var firstConnect = new TaskCompletionSource(); var secondConnect = new TaskCompletionSource(); - // Attempt #3 onward never completes, so the actor parks in Reconnecting and the + // Attempt 3 onward never completes, so the actor parks in Reconnecting and the // "did not go Connected on the dead session" assertion is stable rather than a race // against the next retry tick. var laterConnects = new TaskCompletionSource(); @@ -181,7 +181,7 @@ public class DataConnectionDisconnectRaceTests : TestKit AwaitCondition(() => HealthTransitions(name, ConnectionHealth.Connected) >= 1, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(10)); - // Drop while Connected → Reconnecting, and the retry timer launches attempt #2. + // Drop while Connected → Reconnecting, and the retry timer launches attempt 2. adapter.Disconnected += Raise.Event<Action>(); AwaitCondition(() => Volatile.Read(ref connects) >= 2, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(10)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs index af885f89..bbcdb478 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// <summary> -/// Task 10 (opcua-tag-browser): the site-side +/// OPC UA tag browser: the site-side /// <see cref="DataConnectionManagerActor"/> + child /// <see cref="DataConnectionActor"/> together resolve /// <see cref="BrowseNodeCommand"/> against the live adapter and surface @@ -29,6 +29,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit private readonly ISiteHealthCollector _healthCollector; private readonly DataConnectionOptions _options; + /// <summary>Initializes a new instance of <c>DataConnectionManagerBrowseHandlerTests</c>.</summary> public DataConnectionManagerBrowseHandlerTests() : base(@"akka.loglevel = WARNING") { @@ -41,6 +42,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit }; } + /// <summary>Verifies that unknown connection name returns connection not found.</summary> [Fact] public void Unknown_connection_name_returns_ConnectionNotFound() { @@ -58,6 +60,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit Assert.Empty(reply.Children); } + /// <summary>Verifies that non browsable adapter returns not browsable.</summary> [Fact] public void Non_browsable_adapter_returns_NotBrowsable() { @@ -88,6 +91,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit Assert.Empty(reply.Children); } + /// <summary>Verifies that success path returns mapped children.</summary> [Fact] public void Success_path_returns_mapped_children() { @@ -130,6 +134,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit Assert.False(reply.Truncated); } + /// <summary>Verifies that connection not connected exception maps to connection not connected.</summary> [Fact] public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected() { @@ -163,6 +168,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit Assert.Empty(reply.Children); } + /// <summary>Verifies that not supported exception maps to not browsable carrying message.</summary> [Fact] public void NotSupportedException_maps_to_NotBrowsable_carrying_message() { @@ -207,6 +213,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit Assert.Empty(reply.Children); } + /// <summary>Verifies that oversized child list is capped to frame budget and marked truncated.</summary> [Fact] public void Oversized_child_list_is_capped_to_frame_budget_and_marked_truncated() { @@ -254,10 +261,11 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit Assert.True(keptBytes <= byteBudget, $"kept estimate {keptBytes} exceeds budget {byteBudget}"); } + /// <summary>Verifies that continuation token round trips through the browse handler.</summary> [Fact] public void ContinuationToken_round_trips_through_the_browse_handler() { - // Task B3 (T15): the BrowseNext continuation token must thread through the + // The BrowseNext continuation token must thread through the // actor plumbing in both directions — the inbound command's token must reach // the adapter, and the adapter's returned token must be surfaced on the reply // so the UI can request the next page. We capture the token the adapter is diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerReadTagValuesHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerReadTagValuesHandlerTests.cs index a4f05dc5..90954144 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerReadTagValuesHandlerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerReadTagValuesHandlerTests.cs @@ -30,6 +30,7 @@ public class DataConnectionManagerReadTagValuesHandlerTests : TestKit private readonly ISiteHealthCollector _healthCollector; private readonly DataConnectionOptions _options; + /// <summary>Initializes the actor system and fakes shared by the read-tag-values handler tests.</summary> public DataConnectionManagerReadTagValuesHandlerTests() : base(@"akka.loglevel = WARNING") { @@ -42,6 +43,7 @@ public class DataConnectionManagerReadTagValuesHandlerTests : TestKit }; } + /// <summary>Verifies that an unknown connection name returns a ConnectionNotFound outcome.</summary> [Fact] public void Unknown_connection_name_returns_ConnectionNotFound() { @@ -59,6 +61,7 @@ public class DataConnectionManagerReadTagValuesHandlerTests : TestKit Assert.Empty(reply.Outcomes); } + /// <summary>Verifies that an adapter that is not connected returns a ConnectionNotConnected outcome.</summary> [Fact] public void Adapter_not_connected_returns_ConnectionNotConnected() { @@ -94,6 +97,7 @@ public class DataConnectionManagerReadTagValuesHandlerTests : TestKit adapter.DidNotReceive().ReadBatchAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a successful read maps the adapter results to tag read outcomes.</summary> [Fact] public void Success_path_maps_results_to_TagReadOutcomes() { @@ -143,6 +147,7 @@ public class DataConnectionManagerReadTagValuesHandlerTests : TestKit Assert.Equal("BadNodeIdUnknown", bOutcome.ErrorMessage); } + /// <summary>Verifies that a cancelled adapter operation is reported as a Timeout outcome.</summary> [Fact] public void Adapter_OperationCancelled_returns_Timeout() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerSearchHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerSearchHandlerTests.cs index 8ac30f3a..c90c3140 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerSearchHandlerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerSearchHandlerTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// <summary> -/// Task B5 (T15): the site-side <see cref="DataConnectionManagerActor"/> + child +/// The site-side <see cref="DataConnectionManagerActor"/> + child /// <see cref="DataConnectionActor"/> together resolve a /// <see cref="SearchAddressSpaceCommand"/> against the live adapter and surface /// every search outcome as a typed <see cref="BrowseFailure"/> (the search path @@ -29,6 +29,7 @@ public class DataConnectionManagerSearchHandlerTests : TestKit private readonly ISiteHealthCollector _healthCollector; private readonly DataConnectionOptions _options; + /// <summary>Initializes a new instance of the <see cref="DataConnectionManagerSearchHandlerTests"/> class.</summary> public DataConnectionManagerSearchHandlerTests() : base(@"akka.loglevel = WARNING") { @@ -41,6 +42,7 @@ public class DataConnectionManagerSearchHandlerTests : TestKit }; } + /// <summary>Verifies that a search against an unknown connection name returns a ConnectionNotFound failure.</summary> [Fact] public void Unknown_connection_name_returns_ConnectionNotFound() { @@ -59,6 +61,7 @@ public class DataConnectionManagerSearchHandlerTests : TestKit Assert.False(reply.CapReached); } + /// <summary>Verifies that a search against an adapter that cannot search returns a NotBrowsable failure.</summary> [Fact] public void Non_searchable_adapter_returns_NotBrowsable() { @@ -87,6 +90,7 @@ public class DataConnectionManagerSearchHandlerTests : TestKit Assert.Empty(reply.Matches); } + /// <summary>Verifies that a successful search returns the mapped matches.</summary> [Fact] public void Success_path_returns_mapped_matches() { @@ -134,6 +138,7 @@ public class DataConnectionManagerSearchHandlerTests : TestKit Assert.True(reply.CapReached); } + /// <summary>Verifies that the query, depth and result limit are passed through to the adapter.</summary> [Fact] public void Query_depth_and_results_thread_through_to_the_adapter() { @@ -178,6 +183,7 @@ public class DataConnectionManagerSearchHandlerTests : TestKit Assert.Empty(reply.Matches); } + /// <summary>Verifies that a ConnectionNotConnectedException maps to a ConnectionNotConnected failure.</summary> [Fact] public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionOpcUaFaultClassificationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionOpcUaFaultClassificationTests.cs index 40ad6104..d8707de4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionOpcUaFaultClassificationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionOpcUaFaultClassificationTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// <summary> -/// Arch-review 2026-08-19 <b>P2-4</b> (classification half) — how a thrown +/// Classification half — how a thrown /// <see cref="ServiceResultException"/> out of a batch subscribe is classified. /// /// <para> diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionProbeSeedTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionProbeSeedTests.cs index fd55b849..ff0a6b4e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionProbeSeedTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionProbeSeedTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// <summary> -/// Arch-review 2026-08-19 <b>P2-5</b> — the tag-resolution PROBE path did not seed the tags it +/// The tag-resolution PROBE path did not seed the tags it /// resolved. /// /// <para> diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionReconfigureTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionReconfigureTests.cs index 0035bd00..4423b6af 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionReconfigureTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionReconfigureTests.cs @@ -43,6 +43,7 @@ public class DataConnectionReconfigureTests : TestKit private readonly ISiteHealthCollector _healthCollector; private readonly IDataConnectionFactory _factory; + /// <summary>Initializes a new instance of the <c>DataConnectionReconfigureTests</c> class.</summary> public DataConnectionReconfigureTests() : base(@"akka.loglevel = DEBUG") { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionStaleCompletionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionStaleCompletionTests.cs index 9b5193ce..374dea26 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionStaleCompletionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionStaleCompletionTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// <summary> -/// Arch-review 2026-08-19 <b>P2-1</b> — an in-flight subscribe that completes AFTER the adapter +/// An in-flight subscribe that completes AFTER the adapter /// has been swapped out (failover, or the in-place reconfigure of register row 97). /// /// <para> diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionStaleConnectResultTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionStaleConnectResultTests.cs index d525c243..c6fc7a64 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionStaleConnectResultTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionStaleConnectResultTests.cs @@ -10,13 +10,13 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// <summary> -/// Arch-review 2026-08-19 <b>P2-3</b> — the connect leg of the stale-completion family that +/// The connect leg of the stale-completion family that /// <c>DataConnectionStaleCompletionTests</c> pins for the subscribe leg. /// /// <para> /// <b>The gap these pin.</b> <c>ConnectResult</c> was piped untagged, so a connect launched /// against adapter generation N and answered after the adapter had been swapped (failover, or the -/// row-97 in-place reconfigure) was applied to its REPLACEMENT. A stale success drove +/// in-place reconfigure) was applied to its REPLACEMENT. A stale success drove /// <c>ReSubscribeAll</c> and <c>BecomeConnected</c> against an adapter that had never connected; /// the replacement's own result then landed in the Connected state, where nothing handles it. With /// a bad new endpoint the batch subscribes failed at connection level with a log line only, no diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/FakeBatchDataConnection.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/FakeBatchDataConnection.cs index 564ded94..51ee60f4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/FakeBatchDataConnection.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/FakeBatchDataConnection.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// <summary> -/// In-memory batch-capable <see cref="IDataConnection"/> for the WP2.1b seam tests. It +/// In-memory batch-capable <see cref="IDataConnection"/> for the batch seam tests. It /// records every batch call (so "one round trip per chunk, not per tag" is assertable), /// can fail individual tags or a whole batch, and can hang its bulk read so the seed /// deadline is observable. @@ -55,15 +55,17 @@ public sealed class FakeBatchDataConnection /// <summary>Callback the last alarm subscribe registered.</summary> public AlarmTransitionCallback? AlarmCallback; + // Fake behaviour: Gets the simulated connection health, which follows connect and disconnect calls. /// <inheritdoc /> public ConnectionHealth Status { get; private set; } = ConnectionHealth.Disconnected; - /// <inheritdoc /> + /// <summary>Occurs when the fake raises a simulated transport fault.</summary> public event Action? Disconnected; /// <summary>Raises <see cref="Disconnected"/> as a real adapter would on a transport fault.</summary> public void RaiseDisconnected() => Disconnected?.Invoke(); + // Fake behaviour: Marks the fake as connected. /// <inheritdoc /> public Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default) { @@ -71,6 +73,7 @@ public sealed class FakeBatchDataConnection return Task.CompletedTask; } + // Fake behaviour: Marks the fake as disconnected. /// <inheritdoc /> public Task DisconnectAsync(CancellationToken cancellationToken = default) { @@ -78,6 +81,7 @@ public sealed class FakeBatchDataConnection return Task.CompletedTask; } + // Fake behaviour: Records the batch, captures the callback and returns a per-tag result, failing the tags configured to fail. /// <inheritdoc /> public async Task<IReadOnlyList<TagSubscribeResult>> SubscribeBatchAsync( IReadOnlyList<string> tagPaths, SubscriptionCallback callback, CancellationToken cancellationToken = default) @@ -100,6 +104,7 @@ public sealed class FakeBatchDataConnection return rows; } + // Fake behaviour: Records the subscription ids passed to the batch unsubscribe. /// <inheritdoc /> public Task UnsubscribeBatchAsync(IReadOnlyList<string> subscriptionIds, CancellationToken cancellationToken = default) { @@ -107,6 +112,7 @@ public sealed class FakeBatchDataConnection return Task.CompletedTask; } + // Fake behaviour: Counts the single-tag subscribe call and captures the callback. /// <inheritdoc /> public Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default) { @@ -115,10 +121,12 @@ public sealed class FakeBatchDataConnection return Task.FromResult($"sub-{Interlocked.Increment(ref _nextId)}"); } + // Fake behaviour: Completes without doing anything. /// <inheritdoc /> public Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default) => Task.CompletedTask; + // Fake behaviour: Counts the single-tag read and returns the seed value, or hangs when reads are configured to hang. /// <inheritdoc /> public async Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default) { @@ -128,6 +136,7 @@ public sealed class FakeBatchDataConnection return new ReadResult(true, new TagValue(SeedValue, QualityCode.Good, DateTimeOffset.UtcNow), null); } + // Fake behaviour: Records the batch read and returns the seed value for every tag, or hangs when reads are configured to hang. /// <inheritdoc /> public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync( IEnumerable<string> tagPaths, CancellationToken cancellationToken = default) @@ -142,22 +151,26 @@ public sealed class FakeBatchDataConnection t => new ReadResult(true, new TagValue(SeedValue, QualityCode.Good, DateTimeOffset.UtcNow), null)); } + // Fake behaviour: Reports a successful write without storing anything. /// <inheritdoc /> public Task<WriteResult> WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default) => Task.FromResult(new WriteResult(true, null)); + // Fake behaviour: Reports a successful write for every entry without storing anything. /// <inheritdoc /> public Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync( IDictionary<string, object?> values, CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyDictionary<string, WriteResult>>( values.ToDictionary(kv => kv.Key, _ => new WriteResult(true, null))); + // Fake behaviour: Reports that the handshake matched without writing anything. /// <inheritdoc /> public Task<bool> WriteBatchAndWaitAsync( IDictionary<string, object?> values, string flagPath, object? flagValue, string responsePath, object? responseValue, TimeSpan timeout, CancellationToken cancellationToken = default) => Task.FromResult(true); + // Fake behaviour: Captures the alarm callback and returns a generated subscription id. /// <inheritdoc /> public Task<string> SubscribeAlarmsAsync( string sourceReference, string? conditionFilter, AlarmTransitionCallback callback, @@ -167,10 +180,12 @@ public sealed class FakeBatchDataConnection return Task.FromResult($"alarm-{Interlocked.Increment(ref _nextId)}"); } + // Fake behaviour: Completes without doing anything. /// <inheritdoc /> public Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default) => Task.CompletedTask; - /// <inheritdoc /> + /// <summary>Completes immediately because the fake holds no resources.</summary> + /// <returns>A completed value task.</returns> public ValueTask DisposeAsync() => ValueTask.CompletedTask; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/HighReviewRegressionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/HighReviewRegressionTests.cs index 7dbfa45a..5438eae8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/HighReviewRegressionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/HighReviewRegressionTests.cs @@ -33,6 +33,7 @@ public class HighReviewRegressionTests : TestKit return ExpectMsg<DataConnectionHealthReport>(); } + /// <summary>Verifies that a resolution tick does not duplicate an initial subscribe that is already in flight.</summary> [Fact] public void ResolutionTick_DoesNotDuplicateAnInitialSubscribeAlreadyInFlight() { @@ -56,6 +57,7 @@ public class HighReviewRegressionTests : TestKit finally { gate.SetResult(); } } + /// <summary>Verifies that an in-flight handle still serves a different instance after its original owner dies.</summary> [Fact] public void OriginalOwnerDies_InFlightHandleStillServesDifferentInstance() { @@ -79,6 +81,7 @@ public class HighReviewRegressionTests : TestKit Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name == "UnsubscribeAsync"); } + /// <summary>Verifies that a subscriber terminating during subscribe releases the late handle without resurrecting the subscription.</summary> [Fact] public void SubscriberTermination_DuringSubscribe_ReleasesLateHandleWithoutResurrection() { @@ -97,6 +100,7 @@ public class HighReviewRegressionTests : TestKit Assert.Equal(0, Health(actor).ResolvedTags); } + /// <summary>Verifies that a replacement during subscribe adopts only the tags that are still requested.</summary> [Fact] public void Replacement_DuringSubscribe_AdoptsOnlyStillRequestedTags() { @@ -117,6 +121,7 @@ public class HighReviewRegressionTests : TestKit Assert.Equal(1, Health(actor).TotalSubscribedTags); } + /// <summary>Verifies that a subscriber terminating during resolution releases the late handle.</summary> [Fact] public void SubscriberTermination_DuringResolution_ReleasesLateHandle() { @@ -139,6 +144,7 @@ public class HighReviewRegressionTests : TestKit Assert.Equal(0, Health(actor).ResolvedTags); } + /// <summary>Verifies that retried subscribes while disconnected keep only the latest request.</summary> [Fact] public void DisconnectedSubscribeRetries_KeepOnlyLatestRequest() { @@ -154,6 +160,8 @@ public class HighReviewRegressionTests : TestKit Assert.Single(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name == "SubscribeAsync"); } + /// <summary>Verifies that writes while disconnected are rejected and never replayed after reconnect.</summary> + /// <param name="reconnect">Whether the connection is re-established after the rejected write.</param> [Theory] [InlineData(false)] [InlineData(true)] @@ -180,6 +188,7 @@ public class HighReviewRegressionTests : TestKit Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name is "WriteAsync" or "WriteBatchAsync"); } + /// <summary>Verifies that a subscriber terminating releases the handle of its last tag.</summary> [Fact] public void SubscriberTermination_ReleasesLastTagHandle() { @@ -192,6 +201,7 @@ public class HighReviewRegressionTests : TestKit AwaitCondition(() => adapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "UnsubscribeAsync" && Equals(c.GetArguments()[0], "handle")), TimeSpan.FromSeconds(1)); } + /// <summary>Verifies that an unsubscribe from the old owner does not remove the replacement's tags.</summary> [Fact] public void OldOwnerUnsubscribe_DoesNotRemoveReplacementTags() { @@ -213,6 +223,7 @@ public class HighReviewRegressionTests : TestKit Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name == "UnsubscribeAsync"); } + /// <summary>Verifies that a duplicate completion with a live handle does not unsubscribe it.</summary> [Fact] public void DuplicateCompletion_WithLiveHandle_DoesNotUnsubscribeIt() { @@ -226,6 +237,7 @@ public class HighReviewRegressionTests : TestKit Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name == "UnsubscribeAsync"); } + /// <summary>Verifies that a source-scoped snapshot end is not broadcast to other sources.</summary> [Fact] public void SourceScopedSnapshotEnd_IsNotBroadcastToOtherSources() { @@ -244,6 +256,8 @@ public class HighReviewRegressionTests : TestKit b.ExpectMsg<NativeAlarmTransitionUpdate>(); a.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } + /// <summary>Verifies that a second subscriber receives the standing alarm snapshot.</summary> + /// <param name="protocol">The data connection protocol under test.</param> [Theory] [InlineData("OpcUa")] [InlineData("MxGateway")] @@ -281,6 +295,8 @@ public class HighReviewRegressionTests : TestKit Assert.Contains(received.OfType<NativeAlarmTransitionUpdate>(), m => m.Transition.Kind == AlarmTransitionKind.SnapshotComplete); } + /// <summary>Verifies that a subscriber joining mid-snapshot receives the prefix already seen.</summary> + /// <param name="scoped">Whether the snapshot is scoped to a single source.</param> [Theory] [InlineData(false)] [InlineData(true)] @@ -313,6 +329,8 @@ public class HighReviewRegressionTests : TestKit Assert.Equal(alarm.SnapshotId, end.Transition.SnapshotId); } + /// <summary>Verifies that replay rejects old-epoch rows and preserves a newer live clear.</summary> + /// <param name="staleEpoch">Whether the replayed rows belong to a stale epoch.</param> [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/BatchSeamPrimitiveTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/BatchSeamPrimitiveTests.cs index 65a71abb..ffc159d7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/BatchSeamPrimitiveTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/BatchSeamPrimitiveTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; /// <summary> -/// WP2.1b — the primitives the batch seam is built from, tested in isolation because the +/// The primitives the batch seam is built from, tested in isolation because the /// OPC Foundation Session/Subscription types they drive cannot be faked without a live /// server: monitored-item shard placement, the apply-serialization gate, the bounded /// pipeline behind the MxGateway supervisory advise, the alarm-stream union prefix, and @@ -14,6 +14,7 @@ public class BatchSeamPrimitiveTests { // ── Shard placement ── + /// <summary>Verifies that sharding, splits items at the budget.</summary> [Fact] public void Sharding_SplitsItemsAtTheBudget() { @@ -31,6 +32,7 @@ public class BatchSeamPrimitiveTests Assert.Equal(2, placement[12_000]); } + /// <summary>Verifies that sharding, fills the first shard with free capacity before creating one.</summary> [Fact] public void Sharding_FillsTheFirstShardWithFreeCapacityBeforeCreatingOne() { @@ -41,6 +43,7 @@ public class BatchSeamPrimitiveTests Assert.Equal([1, 2], placement); } + /// <summary>Verifies that sharding, non positive budget degrades to one item per shard.</summary> [Fact] public void Sharding_NonPositiveBudgetDegradesToOneItemPerShard() { @@ -49,6 +52,8 @@ public class BatchSeamPrimitiveTests // ── Apply serialization ── + /// <summary>Verifies that apply gate, never runs two sections concurrently.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyGate_NeverRunsTwoSectionsConcurrently() { @@ -69,6 +74,8 @@ public class BatchSeamPrimitiveTests Assert.Equal(1, maxObserved); } + /// <summary>Verifies that apply gate, releases when a section throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyGate_ReleasesWhenASectionThrows() { @@ -90,6 +97,8 @@ public class BatchSeamPrimitiveTests // ── Bounded pipeline (MxGateway supervisory advise) ── + /// <summary>Verifies that bulk pipeline, keeps in flight count within the window.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BulkPipeline_KeepsInFlightCountWithinTheWindow() { @@ -116,6 +125,8 @@ public class BatchSeamPrimitiveTests Assert.InRange(maxObserved, 1, parallelism); } + /// <summary>Verifies that bulk pipeline, captures per item faults without aborting the batch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BulkPipeline_CapturesPerItemFaultsWithoutAbortingTheBatch() { @@ -132,6 +143,9 @@ public class BatchSeamPrimitiveTests // ── Alarm-stream union prefix ── + /// <summary>Verifies that alarm prefix, is the longest common prefix.</summary> + /// <param name="sources">Test case input.</param> + /// <param name="expected">Test case input.</param> [Theory] [InlineData(new[] { "Area1.Tank1", "Area1.Tank2" }, "Area1.Tank")] [InlineData(new[] { "Area1.Tank1" }, "Area1.Tank1")] @@ -144,6 +158,7 @@ public class BatchSeamPrimitiveTests Assert.Equal(expected, AlarmFilterPrefix.LongestCommonPrefix(sources)); } + /// <summary>Verifies that alarm prefix, covers only sources under the live prefix.</summary> [Fact] public void AlarmPrefix_CoversOnlySourcesUnderTheLivePrefix() { @@ -155,6 +170,7 @@ public class BatchSeamPrimitiveTests // ── Tag-resolution backoff ── + /// <summary>Verifies that backoff, doubles per failed round and caps at the maximum.</summary> [Fact] public void Backoff_DoublesPerFailedRoundAndCapsAtTheMaximum() { @@ -182,6 +198,7 @@ public class BatchSeamPrimitiveTests ], sequence); } + /// <summary>Verifies that backoff, misconfigured ceiling below floor degrades to a fixed interval.</summary> [Fact] public void Backoff_MisconfiguredCeilingBelowFloorDegradesToAFixedInterval() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/FakeMxGatewayClient.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/FakeMxGatewayClient.cs index 995d9574..70f71281 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/FakeMxGatewayClient.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/FakeMxGatewayClient.cs @@ -26,16 +26,20 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact public Func<string?, (IReadOnlyList<MxBrowseChild>, bool)>? BrowseHandler; private int _nextHandle; + /// <inheritdoc /> public IMxGatewayClient Create() => this; + /// <inheritdoc /> public Task ConnectAsync(MxGatewayConnectionOptions o, CancellationToken ct = default) { ConnectedWith = o; return Task.CompletedTask; } + /// <inheritdoc /> public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<string> SubscribeAsync(string tag, CancellationToken ct = default) { var id = (++_nextHandle).ToString(); @@ -43,12 +47,14 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact return Task.FromResult(id); } + /// <inheritdoc /> public Task UnsubscribeAsync(string id, CancellationToken ct = default) { Unsubscribed.Add(id); return Task.CompletedTask; } + /// <inheritdoc /> public Task<IReadOnlyList<MxSubscribeOutcome>> SubscribeBulkAsync( IReadOnlyList<string> tagPaths, CancellationToken ct = default) { @@ -70,6 +76,7 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact return Task.FromResult<IReadOnlyList<MxSubscribeOutcome>>(outcomes); } + /// <inheritdoc /> public Task UnsubscribeBulkAsync(IReadOnlyList<string> subscriptionIds, CancellationToken ct = default) { BulkUnsubscribeCalls.Add(subscriptionIds.ToList()); @@ -77,15 +84,19 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact return Task.CompletedTask; } + /// <inheritdoc /> public Task<IReadOnlyList<MxReadOutcome>> ReadAsync(IReadOnlyList<string> tags, CancellationToken ct = default) => Task.FromResult(ReadHandler!(tags)); + /// <inheritdoc /> public Task<IReadOnlyList<MxWriteOutcome>> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> w, CancellationToken ct = default) => Task.FromResult(WriteHandler!(w)); + /// <inheritdoc /> public Task<(IReadOnlyList<MxBrowseChild> Children, bool Truncated)> BrowseChildrenAsync(string? p, CancellationToken ct = default) => Task.FromResult(BrowseHandler!(p)); + /// <inheritdoc /> public async Task RunEventLoopAsync(Action<MxValueUpdate> onUpdate, CancellationToken ct = default) { OnUpdate = onUpdate; @@ -94,6 +105,7 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact ct.ThrowIfCancellationRequested(); // …or FaultEventLoop() faults it to simulate a stream break } + /// <inheritdoc /> public Task RunAlarmStreamAsync( string? alarmFilterPrefix, Action<ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.NativeAlarmTransition> onTransition, @@ -106,6 +118,8 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact return Task.CompletedTask; } + /// <summary>Releases the resources used by this instance asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public ValueTask DisposeAsync() => ValueTask.CompletedTask; /// <summary>Simulate a stream break so the adapter raises Disconnected.</summary> diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayBatchSeamTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayBatchSeamTests.cs index 7b2268d4..089db6e0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayBatchSeamTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayBatchSeamTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; /// <summary> -/// WP2.1b — the MxGateway adapter's batch subscribe seam and the alarm-stream union +/// The MxGateway adapter's batch subscribe seam and the alarm-stream union /// filter. The plain-vs-supervisory advise choice itself lives inside /// <c>RealMxGatewayClient</c> (it needs a live MXAccess session); its bounded-pipeline /// mechanism is pinned separately by <see cref="BatchSeamPrimitiveTests"/>. @@ -26,6 +26,9 @@ public class MxGatewayBatchSeamTests ["ReadTimeoutMs"] = "2000", }; + /// <summary>Verifies that unsubscribing an old handle preserves routing for the new handle.</summary> + /// <param name="batch">Whether the batch subscribe path is used instead of the single-tag path.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] @@ -50,6 +53,8 @@ public class MxGatewayBatchSeamTests while (!condition()) await Task.Delay(10, timeout.Token); } + /// <summary>Verifies that a batch subscribe issues exactly one bulk call per batch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SubscribeBatch_IssuesExactlyOneBulkCallPerBatch() { @@ -69,6 +74,8 @@ public class MxGatewayBatchSeamTests Assert.All(results, r => Assert.NotNull(r.SubscriptionId)); } + /// <summary>Verifies that a batch subscribe reports per-tag failures without failing the batch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SubscribeBatch_ReportsPerTagFailuresWithoutFailingTheBatch() { @@ -86,6 +93,8 @@ public class MxGatewayBatchSeamTests Assert.NotNull(failed.ErrorMessage); } + /// <summary>Verifies that a batch unsubscribe releases every id in one bulk call.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UnsubscribeBatch_ReleasesEveryIdInOneBulkCall() { @@ -100,6 +109,8 @@ public class MxGatewayBatchSeamTests Assert.Equal(2, fake.BulkUnsubscribeCalls[0].Count); } + /// <summary>Verifies that the alarm stream uses the longest common prefix and refreshes for every new source.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AlarmStream_UsesLongestCommonPrefix_AndRefreshesEveryNewSource() { @@ -130,6 +141,8 @@ public class MxGatewayBatchSeamTests Assert.Null(fake.AlarmStreamPrefixes[3]); // "" → gateway-wide } + /// <summary>Verifies that unsubscribing never restarts the alarm stream.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AlarmStream_UnsubscribeNeverRestartsTheStream() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionAlarmReconnectTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionAlarmReconnectTests.cs index 13a9fa62..380a0c62 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionAlarmReconnectTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionAlarmReconnectTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; /// <summary> -/// P2-2 (arch-review 2026-08-19, High): before the fix, <c>ConnectAsync</c> reset the +/// Regression tests for alarm-stream state on reconnect. Before the fix, <c>ConnectAsync</c> reset the /// event loop and the client but left the alarm-stream state (<c>_alarmCts</c>, /// <c>_alarmSubCount</c>, <c>_alarmSubSources</c>, <c>_alarmStreamPrefix</c>) untouched. /// After a reconnect, the DataConnectionActor's transparent re-subscribe @@ -27,6 +27,8 @@ public class MxGatewayDataConnectionAlarmReconnectTests + /// <summary>Verifies that each transport refresh has its own connection-wide epoch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DC5_EachTransportRefreshHasItsOwnConnectionWideEpoch() { @@ -51,6 +53,8 @@ public class MxGatewayDataConnectionAlarmReconnectTests await adapter.DisconnectAsync(); } + /// <summary>Verifies that a newly covered source requests a fresh standing snapshot.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DC5_CoveredNewSourceRequestsFreshStandingSnapshot() { @@ -65,6 +69,8 @@ public class MxGatewayDataConnectionAlarmReconnectTests await adapter.DisconnectAsync(); } + /// <summary>Verifies that reconnecting and then subscribing to alarms opens a new stream on the new client.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconnect_ThenSubscribeAlarms_OpensNewStreamOnNewClient() { @@ -89,6 +95,8 @@ public class MxGatewayDataConnectionAlarmReconnectTests Assert.Single(clients[0].AlarmStreamOpens); } + /// <summary>Verifies that a reconnect cancels the old alarm stream's token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconnect_CancelsTheOldAlarmStreamToken() { @@ -109,6 +117,8 @@ public class MxGatewayDataConnectionAlarmReconnectTests Assert.True(oldStreamToken.IsCancellationRequested); } + /// <summary>Verifies that alarm events flow again on the new client after a reconnect.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconnect_AlarmEventsFlowAgainOnTheNewClient() { @@ -158,8 +168,11 @@ public class MxGatewayDataConnectionAlarmReconnectTests private sealed class RecordingFactory : IMxGatewayClientFactory { private readonly List<PerInstanceFakeMxGatewayClient> _clients; + /// <summary>Initializes a new instance of the <see cref="RecordingFactory"/> class.</summary> + /// <param name="clients">The list that collects every client the factory creates.</param> public RecordingFactory(List<PerInstanceFakeMxGatewayClient> clients) => _clients = clients; + /// <inheritdoc /> public IMxGatewayClient Create() { var c = new PerInstanceFakeMxGatewayClient(); @@ -178,22 +191,33 @@ public class MxGatewayDataConnectionAlarmReconnectTests private Action<NativeAlarmTransition>? _onTransition; private CancellationToken _streamToken; + /// <inheritdoc /> public Task ConnectAsync(MxGatewayConnectionOptions o, CancellationToken ct = default) => Task.CompletedTask; + /// <inheritdoc /> public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<string> SubscribeAsync(string tagPath, CancellationToken ct = default) => Task.FromResult(Guid.NewGuid().ToString()); + /// <inheritdoc /> public Task UnsubscribeAsync(string subscriptionId, CancellationToken ct = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<IReadOnlyList<MxSubscribeOutcome>> SubscribeBulkAsync(IReadOnlyList<string> tagPaths, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<MxSubscribeOutcome>>(Array.Empty<MxSubscribeOutcome>()); + /// <inheritdoc /> public Task UnsubscribeBulkAsync(IReadOnlyList<string> subscriptionIds, CancellationToken ct = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<IReadOnlyList<MxReadOutcome>> ReadAsync(IReadOnlyList<string> tagPaths, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<MxReadOutcome>>(Array.Empty<MxReadOutcome>()); + /// <inheritdoc /> public Task<IReadOnlyList<MxWriteOutcome>> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> writes, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<MxWriteOutcome>>(Array.Empty<MxWriteOutcome>()); + /// <inheritdoc /> public Task<(IReadOnlyList<MxBrowseChild> Children, bool Truncated)> BrowseChildrenAsync(string? parentNodeId, CancellationToken ct = default) => Task.FromResult<(IReadOnlyList<MxBrowseChild>, bool)>((Array.Empty<MxBrowseChild>(), false)); + /// <inheritdoc /> public Task RunEventLoopAsync(Action<MxValueUpdate> onUpdate, CancellationToken ct = default) => Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, TaskScheduler.Default); + /// <inheritdoc /> public Task RunAlarmStreamAsync(string? alarmFilterPrefix, Action<NativeAlarmTransition> onTransition, CancellationToken ct = default) { // Mirrors the real RunAlarmStreamAsync seam: records the open, then just @@ -210,12 +234,15 @@ public class MxGatewayDataConnectionAlarmReconnectTests return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, TaskScheduler.Default); } + /// <summary>Does nothing; the fake holds no resources.</summary> + /// <returns>A completed task.</returns> public ValueTask DisposeAsync() => ValueTask.CompletedTask; /// <summary>Simulates a transition arriving on this client's live stream. Gated on /// the stream token exactly like the real gateway stream would be — once cancelled, /// no more events are delivered — so a test can prove the OLD client's feed is /// genuinely dead post-reconnect rather than just unobserved.</summary> + /// <param name="transition">The alarm transition to deliver.</param> public void EmitAlarm(NativeAlarmTransition transition) { if (_streamToken.IsCancellationRequested) diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionReconnectTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionReconnectTests.cs index c125963c..1f6b8c9e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionReconnectTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionReconnectTests.cs @@ -18,6 +18,8 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; [Collection("DataConnectionManagerActor")] public class MxGatewayDataConnectionReconnectTests { + /// <summary>Verifies that a reconnect disposes the previous client and cancels the previous event loop.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconnect_DisposesPreviousClient_AndCancelsPreviousEventLoop() { @@ -50,6 +52,8 @@ public class MxGatewayDataConnectionReconnectTests Assert.False(loopTokens[1].IsCancellationRequested); // new loop live } + /// <summary>Verifies that an RPC fault from a stale event loop after a reconnect does not signal a disconnect.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaleEventLoopRpcFault_AfterReconnect_DoesNotSignalDisconnected() { @@ -61,7 +65,7 @@ public class MxGatewayDataConnectionReconnectTests await adapter.ConnectAsync(details); await WaitUntilAsync(() => loopTokens.Count == 1); - await adapter.ConnectAsync(details); // reconnect: cancels loop #0, resets _disconnectFired + await adapter.ConnectAsync(details); // reconnect: cancels the first loop, resets _disconnectFired await WaitUntilAsync(() => loopTokens.Count == 2); // The OLD loop observes its cancellation as a gRPC fault, not an OCE — @@ -75,6 +79,8 @@ public class MxGatewayDataConnectionReconnectTests await WaitUntilAsync(() => Volatile.Read(ref disconnectsRef.Value) == 1); } + /// <summary>Verifies that an object-disposed fault from a stale event loop after a reconnect does not signal a disconnect.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaleEventLoopObjectDisposedFault_AfterReconnect_DoesNotSignalDisconnected() { @@ -105,6 +111,7 @@ public class MxGatewayDataConnectionReconnectTests /// every write went to the dead session (<c>Session … was not found</c>) for an hour, until /// a process restart. A non-cancelled end of the event stream IS a disconnect. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EventStreamGracefulCompletion_WhileConnected_SignalsDisconnected() { @@ -121,6 +128,8 @@ public class MxGatewayDataConnectionReconnectTests Assert.Equal(ConnectionHealth.Disconnected, adapter.Status); } + /// <summary>Verifies that graceful completion of a superseded event stream does not signal a disconnect.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EventStreamGracefulCompletion_OfSupersededLoop_DoesNotSignalDisconnected() { @@ -131,7 +140,7 @@ public class MxGatewayDataConnectionReconnectTests await adapter.ConnectAsync(details); await WaitUntilAsync(() => loopTokens.Count == 1); - await adapter.ConnectAsync(details); // reconnect: loop #0 is cancelled + await adapter.ConnectAsync(details); // reconnect: the first loop is cancelled await WaitUntilAsync(() => loopTokens.Count == 2); loops[0].SetResult(); // the OLD loop winds down normally after its cancel @@ -139,6 +148,8 @@ public class MxGatewayDataConnectionReconnectTests Assert.Equal(0, Volatile.Read(ref disconnectsRef.Value)); // must not flap the new session } + /// <summary>Verifies that graceful completion of the event stream after an explicit disconnect does not signal a disconnect.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EventStreamGracefulCompletion_AfterDisconnectAsync_DoesNotSignalDisconnected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionSecretsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionSecretsTests.cs index b3ce8664..8d951ff9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionSecretsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionSecretsTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; -// G-3 / Task 9: the MxGateway per-endpoint ApiKey may hold a `secret:<name>` reference +// The MxGateway per-endpoint ApiKey may hold a `secret:<name>` reference // resolved via ISecretResolver at connect time. Literal keys pass through unchanged // (back-compat); an unresolvable / missing secret, or a secret ref with no resolver // available, fails closed (no connection established with an empty/null key). @@ -27,9 +27,16 @@ public class MxGatewayDataConnectionSecretsTests private sealed class FakeSecretResolver : ISecretResolver { private readonly string? _value; + /// <summary>Initializes the fake resolver with the value to return.</summary> + /// <param name="value">The secret value returned for every lookup, or null to simulate an unresolvable secret.</param> public FakeSecretResolver(string? value) => _value = value; + /// <summary>Gets the names of the secrets that were requested.</summary> public List<string> Requested { get; } = new(); + /// <summary>Looks up the secret by name.</summary> + /// <param name="name">The secret name.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>The secret, or null when none matches.</returns> public Task<string?> GetAsync(SecretName name, CancellationToken ct) { Requested.Add(name.Value); @@ -37,6 +44,8 @@ public class MxGatewayDataConnectionSecretsTests } } + /// <summary>Verifies that connecting resolves a secret reference used as the API key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConnectAsync_resolves_secret_ref_apikey() { @@ -52,13 +61,16 @@ public class MxGatewayDataConnectionSecretsTests Assert.Contains("mxgateway/testconn/api-key", resolver.Requested); } + /// <summary>Verifies that a padded or case-variant secret reference resolves to the name the scanner ships.</summary> + /// <param name="apiKey">The padded or case-variant API key reference.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("secret:mxgw-key ")] [InlineData(" SECRET: mxgw-key")] [InlineData("Secret:mxgw-key")] public async Task ConnectAsync_resolves_padded_or_case_variant_ref_to_the_name_the_scanner_ships(string apiKey) { - // Final review M3: the adapter and central's SecretReferenceScanner share one parser, + // The adapter and central's SecretReferenceScanner share one parser, // so the name looked up is exactly the name that was deployed. var shipped = Assert.Single(ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets.SecretReferenceScanner.Scan([ new ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts.DataConnectionArtifact( @@ -76,6 +88,8 @@ public class MxGatewayDataConnectionSecretsTests Assert.Equal("resolved-plaintext-key", fake.ConnectedWith!.ApiKey); } + /// <summary>Verifies that connecting fails closed on a secret reference with no name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConnectAsync_fails_closed_on_a_secret_ref_with_no_name() { @@ -89,6 +103,8 @@ public class MxGatewayDataConnectionSecretsTests Assert.Empty(resolver.Requested); } + /// <summary>Verifies that a literal API key passes through unchanged and the resolver is not called.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConnectAsync_passes_literal_apikey_unchanged_and_does_not_call_resolver() { @@ -102,6 +118,8 @@ public class MxGatewayDataConnectionSecretsTests Assert.Empty(resolver.Requested); } + /// <summary>Verifies that connecting fails closed when the secret cannot be resolved.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConnectAsync_fails_closed_when_secret_unresolvable() { @@ -117,6 +135,8 @@ public class MxGatewayDataConnectionSecretsTests Assert.NotEqual(ConnectionHealth.Connected, adapter.Status); } + /// <summary>Verifies that connecting fails closed on a secret reference when no resolver is available.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConnectAsync_fails_closed_on_secret_ref_with_no_resolver() { @@ -140,6 +160,8 @@ public class MxGatewayDataConnectionSecretsTests // 3-arg DataConnectionFactory ctor picked up the DI-registered resolver and threaded it in. // Had it been null, ConnectAsync would have thrown the "no ISecretResolver" fail-closed // path instead and the resolver would never have been called. + /// <summary>Verifies that the factory resolved from DI threads the registered secret resolver into the MxGateway adapter.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Factory_from_DI_threads_registered_ISecretResolver_into_MxGateway_adapter() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs index 981dbec4..7f6d0cb0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs @@ -25,8 +25,10 @@ public class MxGatewayDataConnectionTests ["ReadTimeoutMs"] = "2000", }; - // ── Task 6: connect / status / Disconnected ── + // ── connect / status / Disconnected ── + /// <summary>Verifies that connecting resolves the options and sets the status to connected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConnectAsync_resolves_options_and_sets_status_connected() { @@ -42,6 +44,8 @@ public class MxGatewayDataConnectionTests Assert.Equal(7, fake.ConnectedWith.WriteUserId); } + /// <summary>Verifies that connecting with a blank client name defaults it to scadabridge.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConnectAsync_blank_client_name_defaults_to_scadabridge() { @@ -55,6 +59,8 @@ public class MxGatewayDataConnectionTests Assert.Equal("scadabridge", fake.ConnectedWith!.ClientName); } + /// <summary>Verifies that the Disconnected event fires exactly once when the event loop faults.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Disconnected_fires_exactly_once_when_event_loop_faults() { @@ -74,8 +80,10 @@ public class MxGatewayDataConnectionTests Assert.Equal(ConnectionHealth.Disconnected, adapter.Status); } - // ── Task 7: subscribe / unsubscribe + event routing ── + // ── subscribe / unsubscribe + event routing ── + /// <summary>Verifies that an update to a subscribed tag invokes the callback with a mapped tag value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Subscribed_tag_update_invokes_callback_with_mapped_TagValue() { @@ -97,6 +105,8 @@ public class MxGatewayDataConnectionTests Assert.Equal(ts, received.Timestamp); } + /// <summary>Verifies that unsubscribing stops routing updates to the callback.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Unsubscribe_stops_routing_updates() { @@ -115,8 +125,10 @@ public class MxGatewayDataConnectionTests Assert.Contains(subId, fake.Unsubscribed); } - // ── Task 8: read / write + error classification ── + // ── read / write + error classification ── + /// <summary>Verifies that a read maps both success and failure results.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadAsync_maps_success_and_failure() { @@ -138,6 +150,8 @@ public class MxGatewayDataConnectionTests Assert.Equal("bad tag", bad.ErrorMessage); } + /// <summary>Verifies that a batch read returns a dictionary keyed by tag.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadBatchAsync_returns_dictionary_keyed_by_tag() { @@ -154,6 +168,8 @@ public class MxGatewayDataConnectionTests Assert.Equal(3, results["bbb"].Value!.Value); } + /// <summary>Verifies that a failed write is mapped to an unsuccessful write result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WriteAsync_maps_failure_to_unsuccessful_WriteResult() { @@ -170,8 +186,10 @@ public class MxGatewayDataConnectionTests Assert.Equal("rejected", r.ErrorMessage); } - // ── Task 9: WriteBatchAndWait ── + // ── WriteBatchAndWait ── + /// <summary>Verifies that a batch write and wait returns true when the response value matches.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WriteBatchAndWait_returns_true_when_response_matches() { @@ -200,6 +218,8 @@ public class MxGatewayDataConnectionTests Assert.Contains("Flag", writeCalls); } + /// <summary>Verifies that a batch write and wait returns false on timeout.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WriteBatchAndWait_returns_false_on_timeout() { @@ -219,8 +239,10 @@ public class MxGatewayDataConnectionTests Assert.False(ok); } - // ── Task 10: browse ── + // ── browse ── + /// <summary>Verifies that browsing children maps the children and the truncated flag.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BrowseChildrenAsync_maps_children_and_truncated() { @@ -245,6 +267,8 @@ public class MxGatewayDataConnectionTests Assert.Equal(BrowseNodeClass.Variable, result.Children[1].NodeClass); } + /// <summary>Verifies that browsing children surfaces the attribute data type.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BrowseChildrenAsync_surfaces_attribute_datatype() { @@ -263,6 +287,8 @@ public class MxGatewayDataConnectionTests Assert.Equal("Double", result.Children[0].DataType); } + /// <summary>Verifies that browsing children throws when the connection is not established.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BrowseChildrenAsync_throws_when_not_connected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs index 9f29d947..a9389156 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; /// <summary> -/// Task 9 (opcua-tag-browser): <see cref="OpcUaDataConnection"/> implements +/// <see cref="OpcUaDataConnection"/> implements /// <see cref="IBrowsableDataConnection"/> as a one-line forwarder onto the /// underlying <see cref="IOpcUaClient"/>. This guards that wiring — the /// adapter must not add its own browse semantics; all browse behaviour lives @@ -14,6 +14,8 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; /// </summary> public class OpcUaDataConnectionBrowseTests { + /// <summary>Verifies that browsing children is forwarded to the underlying OPC UA client.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BrowseChildrenAsync_ForwardsToUnderlyingClient() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaNodeReferenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaNodeReferenceTests.cs index ca0f264f..40a3af30 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaNodeReferenceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaNodeReferenceTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; /// <summary> -/// Gitea #14: stored bindings hard-code a namespace <i>index</i>, which is only +/// Stored bindings hard-code a namespace <i>index</i>, which is only /// meaningful against one server's namespace table. These tests pin the durable /// <c>nsu=<uri></c> form and the back-compat of the existing <c>ns=</c> form. /// </summary> @@ -32,6 +32,7 @@ public class OpcUaNodeReferenceTests return t; } + /// <summary>Verifies that the legacy namespace index form still resolves.</summary> [Fact] public void Resolve_IndexForm_StillWorks() { @@ -42,6 +43,7 @@ public class OpcUaNodeReferenceTests Assert.Equal("pymodbus/plc/HR200", nodeId.Identifier); } + /// <summary>Verifies that the namespace URI form resolves to the server's live namespace index.</summary> [Fact] public void Resolve_UriForm_MapsToLiveIndex() { @@ -51,6 +53,7 @@ public class OpcUaNodeReferenceTests Assert.Equal("pymodbus/plc/HR200", nodeId.Identifier); } + /// <summary>Verifies that the namespace URI form tracks the index across servers with different namespace tables.</summary> [Fact] public void Resolve_UriForm_TracksIndexAcrossServers() { @@ -68,10 +71,11 @@ public class OpcUaNodeReferenceTests Assert.Equal(3, moved.NamespaceIndex); // followed the URI, not the index } + /// <summary>Verifies that the index form silently binds to a different namespace after a server upgrade.</summary> [Fact] public void Resolve_IndexForm_SilentlyBindsToTheWrongNamespace_AcrossServerUpgrade() { - // This is the #14 hazard, pinned as a characterisation test rather than a wish: + // This is the namespace-index hazard, pinned as a characterisation test rather than a wish: // v2's sole custom namespace and v3's raw namespace BOTH sit at index 2, so a // v2-era ns=2 binding resolves happily against v3 while meaning something else // entirely. Nothing in the index form can detect that — which is the whole @@ -91,6 +95,7 @@ public class OpcUaNodeReferenceTests Assert.Contains("does not publish", ex.Message); } + /// <summary>Verifies that resolving an unknown namespace URI throws.</summary> [Fact] public void Resolve_UnknownNamespaceUri_Throws() { @@ -100,6 +105,7 @@ public class OpcUaNodeReferenceTests Assert.Contains("NamespaceArray", ex.Message); } + /// <summary>Verifies that resolving a reference to another server throws.</summary> [Fact] public void Resolve_ForeignServerReference_Throws() { @@ -109,6 +115,8 @@ public class OpcUaNodeReferenceTests Assert.Contains("another server", ex.Message); } + /// <summary>Verifies that an unparseable reference throws.</summary> + /// <param name="reference">The malformed node reference.</param> [Theory] [InlineData("")] [InlineData(" ;;; not a node id")] @@ -117,6 +125,7 @@ public class OpcUaNodeReferenceTests Assert.ThrowsAny<ArgumentException>(() => OpcUaNodeReference.Resolve(reference, V3Table())); } + /// <summary>Verifies that a server namespace is converted to the durable URI form.</summary> [Fact] public void ToDurable_ServerNamespace_EmitsUriForm() { @@ -127,6 +136,7 @@ public class OpcUaNodeReferenceTests Assert.Equal($"nsu={RawUri};s=pymodbus/plc/HR200", durable); } + /// <summary>Verifies that the standard namespace stays in the short form.</summary> [Fact] public void ToDurable_StandardNamespace_StaysShort() { @@ -136,6 +146,7 @@ public class OpcUaNodeReferenceTests Assert.Equal("i=85", durable); } + /// <summary>Verifies that a durable reference round-trips through resolve.</summary> [Fact] public void ToDurable_RoundTripsThroughResolve() { @@ -151,6 +162,7 @@ public class OpcUaNodeReferenceTests Assert.Equal("Area/Line/Equip/Speed", resolved.Identifier); } + /// <summary>Verifies that a URI missing from the namespace table falls back instead of throwing.</summary> [Fact] public void ToDurable_UriNotInTable_FallsBackRatherThanThrowing() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaSecurityDowngradeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaSecurityDowngradeTests.cs index 1dd72927..ff7e0009 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaSecurityDowngradeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaSecurityDowngradeTests.cs @@ -3,6 +3,8 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; public class OpcUaSecurityDowngradeTests { + /// <summary>Verifies that DC4: requested protection never falls back to none.</summary> + /// <param name="discoveryFails">The discovery fails.</param> [Theory] [InlineData(false)] [InlineData(true)] @@ -13,6 +15,7 @@ public class OpcUaSecurityDowngradeTests ? throw new Exception("discovery unavailable") : new EndpointDescriptionCollection { new("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.None } })); } + /// <summary>Verifies that DC4: exact requested mode is selected.</summary> [Fact] public void DC4_ExactRequestedModeIsSelected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs index efe80704..8b3d4633 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs @@ -5,13 +5,14 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; /// <summary> -/// M2.4 (#8): the OPC UA EventFilter gains a server-side <see cref="ContentFilter"/> +/// The OPC UA EventFilter gains a server-side <see cref="ContentFilter"/> /// WhereClause as a bandwidth optimisation when a condition-type filter is present. /// The client-side gate in DataConnectionActor remains authoritative; these tests /// only pin the filter-shaping. No live server required — pure SDK object building. /// </summary> public class RealOpcUaClientAlarmFilterTests { + /// <summary>Verifies that no condition-type filter produces no where clause.</summary> [Fact] public void BuildAlarmEventFilter_NoFilter_HasNoWhereClause() { @@ -20,6 +21,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Empty(filter.WhereClause.Elements); } + /// <summary>Verifies that known condition types produce a non-empty where clause.</summary> [Fact] public void BuildAlarmEventFilter_WithKnownTypes_BuildsNonEmptyWhereClause() { @@ -33,6 +35,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Contains(filter.WhereClause.Elements, e => e.FilterOperator == FilterOperator.Or); } + /// <summary>Verifies that a single known condition type produces a single of-type clause with no OR.</summary> [Fact] public void BuildAlarmEventFilter_SingleKnownType_BuildsSingleOfType_NoOr() { @@ -43,6 +46,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Equal(FilterOperator.OfType, filter.WhereClause.Elements[0].FilterOperator); } + /// <summary>Verifies that condition type matching is case-insensitive.</summary> [Fact] public void BuildAlarmEventFilter_TypeMatchingIsCaseInsensitive() { @@ -51,6 +55,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Single(filter.WhereClause.Elements, e => e.FilterOperator == FilterOperator.OfType); } + /// <summary>Verifies that a filter of only unknown types omits the where clause.</summary> [Fact] public void BuildAlarmEventFilter_AllUnknownTypes_OmitsWhereClause() { @@ -63,6 +68,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Empty(filter.WhereClause.Elements); } + /// <summary>Verifies that a mix of known and unknown types omits the where clause.</summary> [Fact] public void BuildAlarmEventFilter_MixedKnownAndUnknown_OmitsWhereClause() { @@ -74,10 +80,11 @@ public class RealOpcUaClientAlarmFilterTests Assert.Empty(filter.WhereClause.Elements); } - // ── SelectClause index alignment (M2.13 / #27) ─────────────────────────── + // ── SelectClause index alignment ─────────────────────────── // CRITICAL: HandleAlarmEvent reads fields[N] by position. Verify new clauses // are APPENDED at indices 13–18 so existing mappings (0–12) are undisturbed. + /// <summary>Verifies that the alarm event filter has exactly 19 select clauses.</summary> [Fact] public void BuildAlarmEventFilter_HasExactly19SelectClauses() { @@ -88,6 +95,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Equal(19, filter.SelectClauses.Count); } + /// <summary>Verifies that select clause 18 is the acknowledgeable-condition acked-state transition time.</summary> [Fact] public void BuildAlarmEventFilter_Index18_IsAcknowledgeableConditionType_AckedState_TransitionTime() { @@ -101,6 +109,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Equal("TransitionTime", clause.BrowsePath[1].Name); } + /// <summary>Verifies that select clause 13 is the alarm-condition active-state transition time.</summary> [Fact] public void BuildAlarmEventFilter_Index13_IsAlarmConditionType_ActiveState_TransitionTime() { @@ -113,6 +122,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Equal("TransitionTime", clause.BrowsePath[1].Name); } + /// <summary>Verifies that select clause 14 is the limit-alarm high-high limit.</summary> [Fact] public void BuildAlarmEventFilter_Index14_IsLimitAlarmType_HighHighLimit() { @@ -122,6 +132,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Equal("HighHighLimit", clause.BrowsePath[0].Name); } + /// <summary>Verifies that select clause 15 is the limit-alarm high limit.</summary> [Fact] public void BuildAlarmEventFilter_Index15_IsLimitAlarmType_HighLimit() { @@ -131,6 +142,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Equal("HighLimit", clause.BrowsePath[0].Name); } + /// <summary>Verifies that select clause 16 is the limit-alarm low limit.</summary> [Fact] public void BuildAlarmEventFilter_Index16_IsLimitAlarmType_LowLimit() { @@ -140,6 +152,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Equal("LowLimit", clause.BrowsePath[0].Name); } + /// <summary>Verifies that select clause 17 is the limit-alarm low-low limit.</summary> [Fact] public void BuildAlarmEventFilter_Index17_IsLimitAlarmType_LowLowLimit() { @@ -149,6 +162,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Equal("LowLowLimit", clause.BrowsePath[0].Name); } + /// <summary>Verifies that the existing select clause indices 0 to 12 are unchanged.</summary> [Fact] public void BuildAlarmEventFilter_ExistingIndices0To12_Unchanged() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs index ab888819..78cae50a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs @@ -24,6 +24,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; [Trait("Category", "Unit")] public class RealOpcUaClientArrayWriteTests { + /// <summary>Verifies that an integer list wraps into a variant array without throwing.</summary> [Fact] public void Variant_wraps_int_list_as_array_without_throwing() { @@ -39,6 +40,7 @@ public class RealOpcUaClientArrayWriteTests Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank); } + /// <summary>Verifies that an integer array wraps into a variant without throwing.</summary> [Fact] public void Variant_wraps_int_array_without_throwing() { @@ -52,6 +54,7 @@ public class RealOpcUaClientArrayWriteTests Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank); } + /// <summary>Verifies that a double array wraps into a variant without throwing.</summary> [Fact] public void Variant_wraps_double_array_without_throwing() { @@ -65,6 +68,7 @@ public class RealOpcUaClientArrayWriteTests Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank); } + /// <summary>Verifies that a string array wraps into a variant without throwing.</summary> [Fact] public void Variant_wraps_string_array_without_throwing() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBatchRollbackTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBatchRollbackTests.cs index 4d6ea521..f4bd0e5b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBatchRollbackTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBatchRollbackTests.cs @@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; /// <summary> -/// Arch-review 2026-08-19 <b>P2-4</b> (rollback half) — a batch subscribe whose +/// Rollback half of the batch-subscribe fix — a batch subscribe whose /// <c>ApplyChanges</c> throws must leave NO residue behind. /// /// <para> @@ -70,6 +70,8 @@ public class RealOpcUaClientBatchRollbackTests return (int)shard.GetType().GetProperty("ItemCount")!.GetValue(shard)!; } + /// <summary>Verifies that a throw from ApplyChanges rolls back every partially created monitored item.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyChangesThrow_RollsBackEveryPartiallyCreatedItem() { @@ -92,6 +94,8 @@ public class RealOpcUaClientBatchRollbackTests Assert.Empty(subscription.MonitoredItems); } + /// <summary>Verifies that repeated failed batches do not grow the adapter's bookkeeping.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RepeatedFailedBatches_DoNotGrowTheAdapterBookkeeping() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs index 1b6c66eb..e8125d5c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs @@ -28,6 +28,8 @@ public class RealOpcUaClientBrowseTests // existing docker/ and docker-env2/ topologies dial into. private const string EndpointUrl = "opc.tcp://localhost:50000"; + /// <summary>Verifies the case: browse Children, at, root, returns, objects Folder, with, server, node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task BrowseChildren_at_root_returns_ObjectsFolder_with_Server_node() { @@ -60,6 +62,8 @@ public class RealOpcUaClientBrowseTests Assert.Contains(result.Children, n => n.DisplayName == "Server"); } + /// <summary>Verifies the case: browse Children, throws, connection Not Connected, when, session, is, null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BrowseChildren_throws_ConnectionNotConnected_when_session_is_null() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientEndpointRewriteTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientEndpointRewriteTests.cs index ba7fb899..0a5000af 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientEndpointRewriteTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientEndpointRewriteTests.cs @@ -18,6 +18,7 @@ public class RealOpcUaClientEndpointRewriteTests return ep.EndpointUrl; } + /// <summary>Verifies that a 0.0.0.0 advertisement is rewritten to the reachable host while preserving the path.</summary> [Fact] public void Rewrites_0_0_0_0_advertisement_to_the_reachable_host_preserving_path() { @@ -26,6 +27,7 @@ public class RealOpcUaClientEndpointRewriteTests Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://otopcua-dev-site-a-1-1:4840/OtOpcUa")); } + /// <summary>Verifies that an internal NAT hostname is rewritten to the reachable host.</summary> [Fact] public void Rewrites_internal_nat_hostname_to_the_reachable_host() { @@ -34,6 +36,7 @@ public class RealOpcUaClientEndpointRewriteTests Rewrite("opc.tcp://be5667fab9da:4840/OtOpcUa", "opc.tcp://plc.example.com:4840/OtOpcUa")); } + /// <summary>Verifies that the port is also rewritten when the advertised port differs.</summary> [Fact] public void Rewrites_port_too_when_the_advertised_port_differs() { @@ -42,6 +45,7 @@ public class RealOpcUaClientEndpointRewriteTests Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://gateway:51210/OtOpcUa")); } + /// <summary>Verifies that an already reachable advertisement is left untouched.</summary> [Fact] public void Leaves_an_already_reachable_advertisement_untouched() { @@ -51,6 +55,7 @@ public class RealOpcUaClientEndpointRewriteTests Rewrite("opc.tcp://scadabridge-opcua:50000", "opc.tcp://scadabridge-opcua:50000")); } + /// <summary>Verifies that an authority-only advertisement is handled without appending a trailing slash.</summary> [Fact] public void Handles_authority_only_advertisement_without_appending_a_trailing_slash() { @@ -59,6 +64,7 @@ public class RealOpcUaClientEndpointRewriteTests Rewrite("opc.tcp://0.0.0.0:4840", "opc.tcp://host-b:4840")); } + /// <summary>Verifies that an IPv6 reachable host is re-wrapped in brackets.</summary> [Fact] public void Re_wraps_an_ipv6_reachable_host_in_brackets() { @@ -67,6 +73,7 @@ public class RealOpcUaClientEndpointRewriteTests Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://[::1]:4840/OtOpcUa")); } + /// <summary>Verifies that no port is emitted when neither URL carries one.</summary> [Fact] public void Emits_no_port_when_neither_url_carries_one() { @@ -76,6 +83,7 @@ public class RealOpcUaClientEndpointRewriteTests Rewrite("opc.tcp://0.0.0.0/UaServer", "opc.tcp://myhost/UaServer")); } + /// <summary>Verifies that the reachable port is used when the advertisement omits one.</summary> [Fact] public void Uses_the_reachable_port_when_the_advertisement_omits_one() { @@ -84,6 +92,8 @@ public class RealOpcUaClientEndpointRewriteTests Rewrite("opc.tcp://0.0.0.0/UaServer", "opc.tcp://myhost:4840/UaServer")); } + /// <summary>Verifies that the endpoint is left unchanged when the discovery URL is unusable.</summary> + /// <param name="discovery">The discovery.</param> [Theory] [InlineData("")] [InlineData(" ")] @@ -93,6 +103,7 @@ public class RealOpcUaClientEndpointRewriteTests Assert.Equal("opc.tcp://0.0.0.0:4840/OtOpcUa", Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", discovery)); } + /// <summary>Verifies that a null endpoint is a no-op.</summary> [Fact] public void Null_endpoint_is_a_no_op() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSnapshotScopeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSnapshotScopeTests.cs index 3eea2144..c69e7b19 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSnapshotScopeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSnapshotScopeTests.cs @@ -7,6 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; public class RealOpcUaSnapshotScopeTests { + /// <summary>Verifies that refresh boundaries, preserve source and have distinct epochs.</summary> [Fact] public void RefreshBoundaries_PreserveSourceAndHaveDistinctEpochs() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs index 4cd9122f..a9eced82 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// <summary> -/// M2.4 (#8): the alarm conditionFilter is a comma-separated, case-insensitive +/// The alarm conditionFilter is a comma-separated, case-insensitive /// list of condition type names. Blank = allow all. These tests pin the /// parse-once / IsAllowed predicate that the DataConnectionActor uses as the /// authoritative client-side gate. @@ -18,6 +18,8 @@ public class AlarmConditionFilterTests new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500), "cat", "desc", "msg", "", "", null, DateTimeOffset.UtcNow, "1", "0"); + /// <summary>Verifies that a null or blank filter is empty and allows every condition type.</summary> + /// <param name="filter">The condition filter text under test.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -32,6 +34,7 @@ public class AlarmConditionFilterTests Assert.True(f.IsAllowed(Tx("anything-at-all"))); } + /// <summary>Verifies that parsing splits a comma-separated list into individual names.</summary> [Fact] public void Parse_SplitsCommaSeparatedList() { @@ -43,6 +46,7 @@ public class AlarmConditionFilterTests Assert.False(f.IsAllowed(Tx("AnalogLimit.HiHi"))); } + /// <summary>Verifies that the allowed check ignores letter casing.</summary> [Fact] public void IsAllowed_IsCaseInsensitive() { @@ -52,6 +56,7 @@ public class AlarmConditionFilterTests Assert.False(f.IsAllowed(Tx("DiscreteAlarm"))); } + /// <summary>Verifies that parsing trims whitespace around each name.</summary> [Fact] public void Parse_TrimsWhitespaceAroundEachName() { @@ -60,6 +65,7 @@ public class AlarmConditionFilterTests Assert.True(f.IsAllowed(Tx("DiscreteAlarm"))); } + /// <summary>Verifies that parsing drops empty entries and keeps the non-empty ones.</summary> [Fact] public void Parse_DropsEmptyEntries_KeepsNonEmpty() { @@ -70,6 +76,7 @@ public class AlarmConditionFilterTests Assert.False(f.IsAllowed(Tx(""))); } + /// <summary>Verifies that the snapshot-complete framing sentinel is never filtered out.</summary> [Fact] public void IsAllowed_NeverDropsSnapshotCompleteFramingSentinel() { @@ -80,6 +87,7 @@ public class AlarmConditionFilterTests Assert.True(f.IsAllowed(Tx("", AlarmTransitionKind.SnapshotComplete))); } + /// <summary>Verifies that replayed snapshot conditions are filtered by condition type.</summary> [Fact] public void IsAllowed_FiltersReplayedSnapshotConditionsByType() { @@ -89,6 +97,7 @@ public class AlarmConditionFilterTests Assert.False(f.IsAllowed(Tx("DiscreteAlarm", AlarmTransitionKind.Snapshot))); } + /// <summary>Verifies that the names property exposes the normalized set for server-side optimization.</summary> [Fact] public void Names_ExposesNormalizedSet_ForServerSideOptimization() { @@ -97,10 +106,11 @@ public class AlarmConditionFilterTests Assert.Empty(AlarmConditionFilter.Parse(null).Names); } + /// <summary>Verifies that an OPC UA event whose type name resolves to a friendly name matches a filter on that friendly name.</summary> [Fact] public void IsAllowed_OpcUaResolvedFriendlyName_MatchesFriendlyNameFilter() { - // M2.4 (#8) regression: OPC UA delivers events whose AlarmTypeName, after + // Regression: OPC UA delivers events whose AlarmTypeName, after // RealOpcUaClient.ResolveAlarmTypeName, is a standard friendly type name // (e.g. "ExclusiveLevelAlarmType"). A friendly-name filter on that source // built a correct server WhereClause; the client gate must agree and deliver, diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmIndexTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmIndexTests.cs index 4b403ac9..bbce2b74 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmIndexTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmIndexTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// <summary> -/// WP2.1b — the alarm subscriber first-segment bucket index must make EXACTLY the routing +/// The alarm subscriber first-segment bucket index must make EXACTLY the routing /// decisions the previous linear scan made. The expectation in each case is computed with /// the original rule (plain <c>StartsWith</c> against every subscribed source), so the /// test pins equivalence rather than restating the new implementation. @@ -53,6 +53,7 @@ public class DataConnectionActorAlarmIndexTests : TestKit transitionSourceObjectRef.StartsWith(sourceRef, StringComparison.Ordinal) || transitionSourceRef.StartsWith(sourceRef, StringComparison.Ordinal); + /// <summary>Verifies that the bucket index routes exactly as the previous linear scan did.</summary> [Fact] public void BucketIndex_RoutesExactlyLikeTheLinearScan() { @@ -98,6 +99,7 @@ public class DataConnectionActorAlarmIndexTests : TestKit probes[source].ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } + /// <summary>Verifies that a snapshot-complete message is still broadcast to every subscriber, bypassing the index.</summary> [Fact] public void SnapshotComplete_StillBroadcastsToEverySubscriber_BypassingTheIndex() { @@ -137,6 +139,7 @@ public class DataConnectionActorAlarmIndexTests : TestKit } } + /// <summary>Verifies that an unsubscribed source is dropped from the index.</summary> [Fact] public void UnsubscribedSource_IsDroppedFromTheIndex() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs index 031ba22d..ca0bc5d7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; -/// <summary>Task-10: native alarm subscribe + source-ref routing + unavailable signal.</summary> +/// <summary>Native alarm subscribe + source-ref routing + unavailable signal.</summary> public class DataConnectionActorAlarmTests : TestKit { private readonly ISiteHealthCollector _health = Substitute.For<ISiteHealthCollector>(); @@ -44,6 +44,7 @@ public class DataConnectionActorAlarmTests : TestKit return (adapter, () => cb); } + /// <summary>Verifies that an alarm subscription routes a transition to the instance subscriber.</summary> [Fact] public void SubscribeAlarms_RoutesTransitionToInstanceSubscriber() { @@ -67,10 +68,11 @@ public class DataConnectionActorAlarmTests : TestKit ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.SourceObjectReference == "Tank01"); } + /// <summary>Verifies that an alarm bound by node id routes its transition to the subscriber.</summary> [Fact] public void SubscribeAlarms_NodeIdBinding_RoutesTransition() { - // Gitea #17 end-to-end: an instance binds a native alarm source by NodeId (the form + // End-to-end: an instance binds a native alarm source by NodeId (the form // the picker / CSV / config store). The adapter builds the transition's identity via // OpcUaAlarmMapper.BuildIdentity(binding, SourceName, ConditionName). With the old // "SourceObjectReference = SourceName" logic the event's name ("pymodbus/plc/HR200") @@ -105,6 +107,7 @@ public class DataConnectionActorAlarmTests : TestKit Assert.Equal("pymodbus/plc/HR200.HR200", update.Transition.SourceReference); } + /// <summary>Verifies that subscribing on an adapter that cannot raise alarms replies with a failure.</summary> [Fact] public void SubscribeAlarms_OnNonAlarmCapableAdapter_RepliesFailure() { @@ -119,6 +122,7 @@ public class DataConnectionActorAlarmTests : TestKit ExpectMsg<SubscribeAlarmsResponse>(m => !m.Success && m.ErrorMessage != null); } + /// <summary>Verifies that a second subscriber with a different filter logs an overwrite warning.</summary> [Fact] public void SecondAlarmSubscriber_WithDifferentFilter_LogsOverwriteWarning() { @@ -142,8 +146,9 @@ public class DataConnectionActorAlarmTests : TestKit }); } - // ── M2.4 (#8): conditionFilter is now applied client-side in the actor ── + // ── conditionFilter is now applied client-side in the actor ── + /// <summary>Verifies that a type filter delivers only matching alarm types.</summary> [Fact] public void SubscribeAlarms_WithTypeFilter_DeliversOnlyMatchingTypes() { @@ -166,6 +171,7 @@ public class DataConnectionActorAlarmTests : TestKit ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.AlarmTypeName == "AnalogLimit.Hi"); } + /// <summary>Verifies that a null filter delivers all alarm types.</summary> [Fact] public void SubscribeAlarms_WithNullFilter_DeliversAllTypes() { @@ -184,6 +190,7 @@ public class DataConnectionActorAlarmTests : TestKit ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.AlarmTypeName == "DiscreteAlarm"); } + /// <summary>Verifies that filter matching ignores letter case and surrounding whitespace.</summary> [Fact] public void SubscribeAlarms_FilterMatch_IgnoresCaseAndWhitespace() { @@ -205,6 +212,7 @@ public class DataConnectionActorAlarmTests : TestKit ExpectNoMsg(TimeSpan.FromMilliseconds(250)); } + /// <summary>Verifies that a gateway-wide alarm feed is filtered client-side.</summary> [Fact] public void SubscribeAlarms_GatewayWideFeed_IsFilteredClientSide() { @@ -234,6 +242,7 @@ public class DataConnectionActorAlarmTests : TestKit u.Transition.SourceObjectReference == "Reactor" && u.Transition.AlarmTypeName == "HighTemp"); } + /// <summary>Verifies that a filtered subscription still forwards the snapshot-complete sentinel.</summary> [Fact] public void SubscribeAlarms_WithFilter_StillForwardsSnapshotCompleteSentinel() { @@ -257,6 +266,7 @@ public class DataConnectionActorAlarmTests : TestKit ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.Kind == AlarmTransitionKind.SnapshotComplete); } + /// <summary>Verifies that a real snapshot-complete marker with an empty reference is broadcast to a specific source.</summary> [Fact] public void SubscribeAlarms_RealEmptyRefSnapshotComplete_IsBroadcastToSpecificSource() { @@ -293,13 +303,15 @@ public class DataConnectionActorAlarmTests : TestKit ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.Kind == AlarmTransitionKind.SnapshotComplete); } - // ── DataConnectionLayer-023: mid-flight alarm unsubscribe must release the adapter feed ── + // ── Mid-flight alarm unsubscribe must release the adapter feed ── + /// <summary>Verifies that unsubscribing during an in-flight alarm subscribe releases the adapter feed and leaves the actor state clean.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL023_UnsubscribeDuringInFlightAlarmSubscribe_ReleasesAdapterFeed_AndKeepsStateClean() { - // Regression test for DataConnectionLayer-023. Previously the native-alarm - // subscribe path never inherited the DCL-021 obsolete-completion guard: if the + // Regression test: previously the native-alarm + // subscribe path never inherited the obsolete-completion guard: if the // last subscriber unsubscribed while the adapter alarm subscribe was in flight, // HandleUnsubscribeAlarms could not tear down the feed (the subscription id was // not stored yet), and the late AlarmSubscribeCompleted unconditionally stored @@ -368,20 +380,22 @@ public class DataConnectionActorAlarmTests : TestKit "Tank01", Arg.Any<string?>(), Arg.Any<AlarmTransitionCallback>(), Arg.Any<CancellationToken>()); } - // ── DataConnectionLayer-029: a re-subscribe during an orphaned in-flight subscribe + // ── A re-subscribe during an orphaned in-flight subscribe // must not leak a duplicate adapter feed ── + /// <summary>Verifies that a re-subscribe during an orphaned in-flight subscribe releases the duplicate feed without leaking.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL029_ResubscribeDuringOrphanedInFlightSubscribe_ReleasesDuplicateFeed_NoLeak() { - // Regression test for DataConnectionLayer-029. The DCL-023 fix clears the in-flight + // Regression test: the mid-flight-unsubscribe fix clears the in-flight // marker on unsubscribe, which reopens a double-subscribe window: unsubscribe (last // subscriber, subId not stored yet) → a fresh subscribe for the SAME source sees // neither a stored id nor an in-flight marker, so it issues a SECOND adapter feed → - // both completions fire. The DCL-023 orphan guard does NOT trigger on either + // both completions fire. The orphan guard does NOT trigger on either // completion (the re-subscribe re-added the subscriber), so the alarm completion // handler used to OVERWRITE _alarmSubscriptionIds with the second id — leaking the - // first feed (never unsubscribed, kept streaming). After DCL-029 the handler mirrors + // first feed (never unsubscribed, kept streaming). With the fix the handler mirrors // the tag-path re-check: when a feed is already stored, the redundant completion // releases its just-created feed instead of overwriting + leaking. var sub1Started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -413,35 +427,35 @@ public class DataConnectionActorAlarmTests : TestKit var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor( "conn", adapter, _options, _health, _factory, "OpcUa"))); - // (1) Subscribe A — adapter subscribe #1 parks, in-flight={Tank01}. + // (1) Subscribe A — the first adapter subscribe parks, in-flight={Tank01}. actor.Tell(new SubscribeAlarmsRequest("c1", "instA", "conn", "Tank01", null, DateTimeOffset.UtcNow)); await sub1Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); - // (2) Last subscriber unsubscribes while subscribe #1 is in flight — clears the - // in-flight marker (DCL-023); subId#1 is not stored yet so no teardown happens. + // (2) Last subscriber unsubscribes while the first subscribe is in flight — clears the + // in-flight marker; the first subscription id is not stored yet so no teardown happens. actor.Tell(new UnsubscribeAlarmsRequest("unsub-c1", "instA", "conn", "Tank01", DateTimeOffset.UtcNow)); await Task.Delay(150); - // (3) Fresh subscribe for the SAME source before #1 completes — neither a stored id + // (3) Fresh subscribe for the SAME source before the first completes — neither a stored id // nor an in-flight marker exists, so the actor issues a SECOND adapter subscribe. actor.Tell(new SubscribeAlarmsRequest("c2", "instB", "conn", "Tank01", null, DateTimeOffset.UtcNow)); await sub2Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); - // (4) Complete subscribe #1 → a subscriber exists again (B re-added), so the orphan - // guard does NOT fire and subId#1 is stored as the live feed. + // (4) Complete the first subscribe → a subscriber exists again (B re-added), so the orphan + // guard does NOT fire and its subscription id is stored as the live feed. sub1Release.SetResult("alarm-sub-1"); await Task.Delay(150); - // (5) Complete subscribe #2 → a feed is already stored, so this redundant completion - // releases its just-created feed (#2) instead of overwriting + leaking subId#1. + // (5) Complete the second subscribe → a feed is already stored, so this redundant completion + // releases its just-created feed instead of overwriting + leaking the first subscription id. sub2Release.SetResult("alarm-sub-2"); await Task.Delay(300); - // The duplicate feed (#2) is released exactly once; the first feed (#1) is retained. + // The duplicate (second) feed is released exactly once; the first feed is retained. await alarmable.Received(1).UnsubscribeAlarmsAsync("alarm-sub-2", Arg.Any<CancellationToken>()); await alarmable.DidNotReceive().UnsubscribeAlarmsAsync("alarm-sub-1", Arg.Any<CancellationToken>()); - // The retained feed (#1) is what a later unsubscribe tears down — proving subId#1, + // The retained (first) feed is what a later unsubscribe tears down — proving the first subscription id, // not the duplicate, is the id _alarmSubscriptionIds actually tracks (no leak). actor.Tell(new UnsubscribeAlarmsRequest("unsub-c2", "instB", "conn", "Tank01", DateTimeOffset.UtcNow)); await Task.Delay(200); diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorTests.cs index cada495f..dd8b6f30 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorTests.cs @@ -11,14 +11,14 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// <summary> -/// WP-6: Tests for DataConnectionActor Become/Stash state machine. -/// WP-9: Auto-reconnect and bad quality tests. -/// WP-10: Transparent re-subscribe tests. -/// WP-11: Write-back support tests. -/// WP-12: Tag path resolution with retry tests. -/// WP-13: Health reporting tests. -/// WP-14: Subscription lifecycle tests. -/// Task-4: Failover state machine tests. +/// Tests for DataConnectionActor Become/Stash state machine. +/// Auto-reconnect and bad quality tests. +/// Transparent re-subscribe tests. +/// Write-back support tests. +/// Tag path resolution with retry tests. +/// Health reporting tests. +/// Subscription lifecycle tests. +/// Failover state machine tests. /// </summary> public class DataConnectionActorTests : TestKit { @@ -27,6 +27,7 @@ public class DataConnectionActorTests : TestKit private readonly ISiteHealthCollector _mockHealthCollector; private readonly IDataConnectionFactory _mockFactory; + /// <summary>Initializes a new instance of <c>DataConnectionActorTests</c>.</summary> public DataConnectionActorTests() : base(@"akka.loglevel = DEBUG") { @@ -39,7 +40,7 @@ public class DataConnectionActorTests : TestKit TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200), WriteTimeout = TimeSpan.FromSeconds(5), // Default to a single seed-read attempt so existing tests behave exactly as - // before the DCL-027 retry was added; the retry-specific tests opt in. + // before the seed-read retry was added; the retry-specific tests opt in. SeedReadMaxAttempts = 1, SeedReadRetryDelay = TimeSpan.FromMilliseconds(10) }; @@ -93,6 +94,7 @@ public class DataConnectionActorTests : TestKit c.GetArguments() is [string name, ConnectionHealth.Connected, ..] && name == connectionName); + /// <summary>Verifies that starts in connecting state attempts connect.</summary> [Fact] public void WP6_StartsInConnectingState_AttemptsConnect() { @@ -107,6 +109,7 @@ public class DataConnectionActorTests : TestKit TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that connecting state stashes subscribe requests.</summary> [Fact] public void WP6_ConnectingState_StashesSubscribeRequests() { @@ -134,6 +137,8 @@ public class DataConnectionActorTests : TestKit ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that connected state write returns result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WP11_ConnectedState_Write_ReturnsResult() { @@ -158,6 +163,8 @@ public class DataConnectionActorTests : TestKit Assert.True(response.Success); } + /// <summary>Verifies that write failure returned synchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WP11_Write_Failure_ReturnedSynchronously() { @@ -177,6 +184,8 @@ public class DataConnectionActorTests : TestKit Assert.Equal("Device offline", response.ErrorMessage); } + /// <summary>Verifies that health report returns connection status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WP13_HealthReport_ReturnsConnectionStatus() { @@ -195,8 +204,10 @@ public class DataConnectionActorTests : TestKit Assert.Equal(ConnectionHealth.Connected, report.Status); } - // ── Task-4: Failover state machine tests ── + // ── Failover state machine tests ── + /// <summary>Verifies that failover after n retries switches to backup.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Task4_FailoverAfterNRetries_SwitchesToBackup() { @@ -242,6 +253,8 @@ public class DataConnectionActorTests : TestKit TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that single endpoint retries indefinitely no failover.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Task4_SingleEndpoint_RetriesIndefinitely_NoFailover() { @@ -275,6 +288,8 @@ public class DataConnectionActorTests : TestKit _mockFactory.DidNotReceive().Create(Arg.Any<string>(), Arg.Any<IDictionary<string, string>>()); } + /// <summary>Verifies that round robin back to primary after backup fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Task4_RoundRobin_BackToPrimary_AfterBackupFails() { @@ -352,6 +367,8 @@ public class DataConnectionActorTests : TestKit TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that successful reconnect resets failure counter.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Task4_SuccessfulReconnect_ResetsFailureCounter() { @@ -406,6 +423,8 @@ public class DataConnectionActorTests : TestKit _mockFactory.DidNotReceive().Create(Arg.Any<string>(), Arg.Any<IDictionary<string, string>>()); } + /// <summary>Verifies that re subscribe all called after failover reconnect.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Task4_ReSubscribeAll_CalledAfterFailoverReconnect() { @@ -485,8 +504,10 @@ public class DataConnectionActorTests : TestKit "sensor/temp", Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>()); } - // ── PLAN-03 Task 16 (S7): background tasks must use the adapter that started them ── + // ── Background tasks must use the adapter that started them ── + /// <summary>Verifies that in flight subscribe continues on original adapter after failover.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Task16_InFlightSubscribe_ContinuesOnOriginalAdapter_AfterFailover() { @@ -545,7 +566,7 @@ public class DataConnectionActorTests : TestKit backupAdapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ConnectAsync"), TimeSpan.FromSeconds(5)); - // Demand is registered before I/O (DC-1), so failover independently recovers + // Demand is registered before I/O, so failover independently recovers // both tags on backup even while the original primary operation is parked. AwaitCondition(() => backupAdapter.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "SubscribeAsync") == 2, TimeSpan.FromSeconds(5)); @@ -562,7 +583,7 @@ public class DataConnectionActorTests : TestKit Assert.Equal(2, backupAdapter.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "SubscribeAsync")); } - // ── PLAN-03 Task 17 (P3): tag→instances reverse index for the value fan-out hot path ── + // ── Tag→instances reverse index for the value fan-out hot path ── private IActorRef CreateInstantSubscribeActor(string name) { @@ -578,6 +599,8 @@ public class DataConnectionActorTests : TestKit return CreateConnectionActor(name); } + /// <summary>Verifies that tag fan out after one instance unsubscribes only remaining subscriber receives.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Task17_TagFanOut_AfterOneInstanceUnsubscribes_OnlyRemainingSubscriberReceives() { @@ -608,6 +631,8 @@ public class DataConnectionActorTests : TestKit probeA.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } + /// <summary>Verifies that tag fan out two instances disjoint tags each receives only its own.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Task17_TagFanOut_TwoInstancesDisjointTags_EachReceivesOnlyItsOwn() { @@ -631,7 +656,7 @@ public class DataConnectionActorTests : TestKit probeB.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } - // ── DataConnectionLayer-001: subscribe must not mutate actor state off-thread ── + // ── Subscribe must not mutate actor state off-thread ── private static async Task<string> DelayedSubscribeAsync() { @@ -641,10 +666,12 @@ public class DataConnectionActorTests : TestKit return "sub-" + Guid.NewGuid().ToString("N"); } + /// <summary>Verifies that concurrent subscribes do not corrupt subscription counters.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL001_ConcurrentSubscribes_DoNotCorruptSubscriptionCounters() { - // Regression test for DataConnectionLayer-001. HandleSubscribe used to mutate + // Regression test. HandleSubscribe used to mutate // actor state (_subscriptionIds, _totalSubscribed, _resolvedTags, the per-instance // HashSet) from a Task.Run background thread. Many concurrent subscribes then race // on non-thread-safe Dictionary/HashSet and on non-atomic int++ — losing increments @@ -684,12 +711,14 @@ public class DataConnectionActorTests : TestKit Assert.Equal(instances * tagsPerInstance, report.ResolvedTags); } - // ── DataConnectionLayer-004: subscribe-time failure classification ── + // ── Subscribe-time failure classification ── + /// <summary>Verifies that genuine tag resolution failure pushes bad quality to subscriber.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL004_GenuineTagResolutionFailure_PushesBadQualityToSubscriber() { - // Regression test for DataConnectionLayer-004. When a tag genuinely fails to + // Regression test. When a tag genuinely fails to // resolve at subscribe time, the design doc (Tag Path Resolution, step 2) // requires the attribute to be marked quality `bad`. The pre-fix code only // logged and added the tag to _unresolvedTags — the Instance Actor never got @@ -718,10 +747,12 @@ public class DataConnectionActorTests : TestKit Assert.True(ack.Success); } + /// <summary>Verifies that static tag seed value is delivered after registration.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL026_StaticTagSeedValue_IsDeliveredAfterRegistration() { - // Regression test for DataConnectionLayer-026. The initial-read seed value used to + // Regression test. The initial-read seed value used to // be emitted (TagValueReceived) from HandleSubscribe's background task BEFORE // HandleSubscribeCompleted registered the instance's tags in // _subscriptionsByInstance. HandleTagValueReceived's fan-out then found no @@ -755,8 +786,10 @@ public class DataConnectionActorTests : TestKit Assert.Equal("Left54321", update.Value); } - // ── DataConnectionLayer-027: robust seeding for STATIC tags ── + // ── Robust seeding for STATIC tags ── + /// <summary>Verifies that seed read empty then good seeds tag within retry budget.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL027_SeedRead_EmptyThenGood_SeedsTagWithinRetryBudget() { @@ -792,6 +825,8 @@ public class DataConnectionActorTests : TestKit Assert.Equal("HY12333", update.Value); } + /// <summary>Verifies that seed read never returns value logs warning and still acks.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL027_SeedRead_NeverReturnsValue_LogsWarningAndStillAcks() { @@ -823,10 +858,12 @@ public class DataConnectionActorTests : TestKit Assert.True(ack.Success); } + /// <summary>Verifies that reconnect re seeds static tag.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL027_Reconnect_ReSeedsStaticTag() { - // Mechanism #3: ReSubscribeAll re-advises after a reconnect but pre-fix did NO + // Mechanism three: ReSubscribeAll re-advises after a reconnect but pre-fix did NO // seed read, so a STATIC tag (no further OnDataChange) could not self-heal across // a reconnect/failover even though the source reads Good. After the fix the // reconnect re-seeds the re-advised tags, so the post-reconnect value reaches the @@ -873,10 +910,12 @@ public class DataConnectionActorTests : TestKit Assert.Equal(99.0, reseeded.Value); } + /// <summary>Verifies that connection level subscribe failure triggers reconnect not tag retry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL004_ConnectionLevelSubscribeFailure_TriggersReconnect_NotTagRetry() { - // Regression test for DataConnectionLayer-004. A subscribe failing because the + // Regression test. A subscribe failing because the // adapter is not connected (InvalidOperationException from EnsureConnected) is // a connection problem, not a bad tag path. The pre-fix code misclassified it // as an unresolved tag and retried it on the 10s tag-resolution timer. After @@ -904,12 +943,14 @@ public class DataConnectionActorTests : TestKit TimeSpan.FromSeconds(5)); } - // ── DataConnectionLayer-005: WriteTimeout must bound a hung write ── + // ── WriteTimeout must bound a hung write ── + /// <summary>Verifies that write that hangs times out and returns failure synchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL005_Write_ThatHangs_TimesOutAndReturnsFailureSynchronously() { - // Regression test for DataConnectionLayer-005. HandleWrite called WriteAsync + // Regression test. HandleWrite called WriteAsync // with no CancellationToken and no timeout, so a hung device write never // produced a WriteTagResponse. The calling script would block until its own // Ask-timeout with no DCL-level error. After the fix, _options.WriteTimeout @@ -939,12 +980,14 @@ public class DataConnectionActorTests : TestKit Assert.Contains("timeout", response.ErrorMessage, StringComparison.OrdinalIgnoreCase); } - // ── DataConnectionLayer-006: quality counters must not drift after unsubscribe/reconnect ── + // ── Quality counters must not drift after unsubscribe/reconnect ── + /// <summary>Verifies that disconnect after unsubscribe bad quality count matches remaining tags.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL006_DisconnectAfterUnsubscribe_BadQualityCountMatchesRemainingTags() { - // Regression test for DataConnectionLayer-006. _lastTagQuality and the three + // Regression test. _lastTagQuality and the three // quality counters were never cleaned up on unsubscribe, so a tag removed via // HandleUnsubscribe lingered in _lastTagQuality. PushBadQualityForAllTags then // set _tagsBadQuality = _lastTagQuality.Count, counting the dropped tag and @@ -1008,12 +1051,14 @@ public class DataConnectionActorTests : TestKit reconnectGate.SetCanceled(); } - // ── DataConnectionLayer-010: tag-resolution retry must not double-dispatch ── + // ── Tag-resolution retry must not double-dispatch ── + /// <summary>Verifies that tag resolution retry does not issue duplicate concurrent subscribes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL010_TagResolutionRetry_DoesNotIssueDuplicateConcurrentSubscribes() { - // Regression test for DataConnectionLayer-010. HandleRetryTagResolution fired a + // Regression test. HandleRetryTagResolution fired a // SubscribeAsync for every unresolved tag without removing it from _unresolvedTags // first. A slow SubscribeAsync overlapping the next retry tick produced duplicate // concurrent subscribe attempts for the same tag, leaking the first monitored @@ -1057,12 +1102,14 @@ public class DataConnectionActorTests : TestKit subscribeGate.SetCanceled(); } - // ── DataConnectionLayer-018: concurrent same-tag subscribes must not orphan adapter handles ── + // ── Concurrent same-tag subscribes must not orphan adapter handles ── + /// <summary>Verifies that concurrent subscribes same tag different instances issue one adapter subscribe.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL018_ConcurrentSubscribes_SameTag_DifferentInstances_IssueOneAdapterSubscribe() { - // Regression test for DataConnectionLayer-018. Before the fix, HandleSubscribe + // Regression test. Before the fix, HandleSubscribe // snapshotted _subscriptionIds.Keys on the actor thread BEFORE the Task.Run // I/O. Two SubscribeTagsRequest messages for different instances sharing a tag // would both observe "not subscribed" (the first request's SubscribeCompleted @@ -1130,17 +1177,19 @@ public class DataConnectionActorTests : TestKit actor.Tell(new DataConnectionActor.GetHealthReport()); var report = ExpectMsg<DataConnectionHealthReport>(TimeSpan.FromSeconds(5)); // One tag shared across two instances must count as exactly one subscription. - // DCL-020 (#28) tracks the related "previously-unresolved tag promoted via + // A separate scenario covers the related "previously-unresolved tag promoted via // another instance" double-count case; here the tag was never unresolved. Assert.Equal(1, report.ResolvedTags); } - // ── DataConnectionLayer-020: previously-unresolved tag, resolved via different instance, must not double-count ── + // ── Previously-unresolved tag, resolved via different instance, must not double-count ── + /// <summary>Verifies that unresolved tag promoted by different instance does not double count total subscribed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL020_UnresolvedTagPromoted_ByDifferentInstance_DoesNotDoubleCountTotalSubscribed() { - // Regression test for DataConnectionLayer-020. The first SubscribeTagsRequest + // Regression test. The first SubscribeTagsRequest // (instance A, tag "promote/tag") fails at the resolution layer — the tag is // added to _unresolvedTags AND _totalSubscribed is bumped to 1. The second // SubscribeTagsRequest (instance B, same tag) succeeds the adapter call. Before @@ -1185,10 +1234,12 @@ public class DataConnectionActorTests : TestKit Assert.Equal(1, report.ResolvedTags); } + /// <summary>Verifies that two instances failing same tag only counts tag once in total.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL020_TwoInstancesFailingSameTag_OnlyCountsTagOnceInTotal() { - // Regression test for DataConnectionLayer-020's symmetric failure branch. + // Regression test for the symmetric failure branch of the same scenario. // Two instances both fail to resolve the same tag — _unresolvedTags must hold // a single entry and _totalSubscribed must be 1, not 2. Pre-fix the failure // branch always ran _totalSubscribed++, double-counting on the second @@ -1221,12 +1272,14 @@ public class DataConnectionActorTests : TestKit Assert.Equal(0, report.ResolvedTags); } - // ── DataConnectionLayer-021: mid-flight unsubscribe must release adapter handle and drop state ── + // ── Mid-flight unsubscribe must release adapter handle and drop state ── + /// <summary>Verifies that unsubscribe during in flight subscribe releases adapter handle and keeps state clean.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL021_UnsubscribeDuringInFlightSubscribe_ReleasesAdapterHandle_AndKeepsStateClean() { - // Regression test for DataConnectionLayer-021. Previously HandleSubscribeCompleted + // Regression test. Previously HandleSubscribeCompleted // re-created _subscriptionsByInstance[instanceName] when the instance had been // unsubscribed while the subscribe I/O was in flight, and then ran the same // counter/handle mutations as the happy path. The leak permanently inflated @@ -1280,12 +1333,14 @@ public class DataConnectionActorTests : TestKit Assert.Equal(0, report.ResolvedTags); } - // ── DataConnectionLayer-022: tag-resolution retry timer must not reset on every failure ── + // ── Tag-resolution retry timer must not reset on every failure ── + /// <summary>Verifies that bursted failed subscribes do not reset retry timer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL022_BurstedFailedSubscribes_DoNotResetRetryTimer() { - // Regression test for DataConnectionLayer-022. Both HandleSubscribeCompleted + // Regression test. Both HandleSubscribeCompleted // and HandleTagResolutionFailed previously called Timers.StartPeriodicTimer // unconditionally — StartPeriodicTimer with an existing key cancels and // replaces the prior timer, so a burst of SubscribeTagsRequests arriving @@ -1357,12 +1412,14 @@ public class DataConnectionActorTests : TestKit $"This is the DCL-022 reset-on-every-call starvation regression."); } - // ── DataConnectionLayer-011: stale callbacks from a disposed adapter must be dropped ── + // ── Stale callbacks from a disposed adapter must be dropped ── + /// <summary>Verifies that stale tag value from old adapter is not forwarded after failover.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL011_StaleTagValueFromOldAdapter_IsNotForwardedAfterFailover() { - // Regression test for DataConnectionLayer-011. On failover the old adapter is + // Regression test. On failover the old adapter is // disposed and a fresh one created, but the old adapter's subscription callbacks // captured Self and keep Telling TagValueReceived. With no per-adapter generation // tag, a value from the disposed adapter delivered after the actor is Connected @@ -1430,12 +1487,14 @@ public class DataConnectionActorTests : TestKit ExpectNoMsg(TimeSpan.FromSeconds(1)); } - // ── DataConnectionLayer-015: initial-connect failures must trigger failover ── + // ── Initial-connect failures must trigger failover ── + /// <summary>Verifies that primary down at startup fails over to backup.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL015_PrimaryDownAtStartup_FailsOverToBackup() { - // Regression test for DataConnectionLayer-015. HandleConnectResult — the handler + // Regression test. HandleConnectResult — the handler // for the INITIAL connection attempt in the Connecting state — only logged and // re-armed the reconnect timer. It never incremented _consecutiveFailures and // never switched endpoint, so a primary that is unreachable when the actor first @@ -1470,6 +1529,8 @@ public class DataConnectionActorTests : TestKit TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that single endpoint down at startup retries indefinitely no failover.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL015_SingleEndpointDownAtStartup_RetriesIndefinitely_NoFailover() { @@ -1495,12 +1556,14 @@ public class DataConnectionActorTests : TestKit _mockFactory.DidNotReceive().Create(Arg.Any<string>(), Arg.Any<IDictionary<string, string>>()); } - // ── DataConnectionLayer-016: subscribe response must reflect a connection-level failure ── + // ── Subscribe response must reflect a connection-level failure ── + /// <summary>Verifies that connection level subscribe failure replies with unsuccessful response.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL016_ConnectionLevelSubscribeFailure_RepliesWithUnsuccessfulResponse() { - // Regression test for DataConnectionLayer-016. When a subscribe arrives while the + // Regression test. When a subscribe arrives while the // adapter is silently down, HandleSubscribeCompleted drove the actor into // Reconnecting (a connection-level failure) but still replied to the caller with // SubscribeTagsResponse(Success: true, Error: null). The Instance Actor was told @@ -1530,6 +1593,8 @@ public class DataConnectionActorTests : TestKit Assert.NotNull(response.ErrorMessage); } + /// <summary>Verifies that genuine resolution failure still replies success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL016_GenuineResolutionFailure_StillRepliesSuccess() { @@ -1556,6 +1621,8 @@ public class DataConnectionActorTests : TestKit Assert.Null(ack.ErrorMessage); } + /// <summary>Verifies that subscribe with failed tags counts resolved and unresolved separately.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL001_SubscribeWithFailedTags_CountsResolvedAndUnresolvedSeparately() { @@ -1579,7 +1646,7 @@ public class DataConnectionActorTests : TestKit ["good/a", "good/b", "good/c", "bad/x", "bad/y"], DateTimeOffset.UtcNow)); // Two genuine resolution failures now also push a bad-quality TagValueUpdate - // to the subscriber (DataConnectionLayer-004); skip past those to the ack. + // to the subscriber; skip past those to the ack. var ack = FishForMessage<SubscribeTagsResponse>(_ => true, TimeSpan.FromSeconds(5)); Assert.True(ack.Success); @@ -1590,12 +1657,14 @@ public class DataConnectionActorTests : TestKit Assert.Equal(3, report.ResolvedTags); // only the 3 good ones resolved } - // ── DataConnectionLayer-008: HandleUnsubscribe shared-tag reference counting ── + // ── HandleUnsubscribe shared-tag reference counting ── + /// <summary>Verifies that unsubscribe only releases tag when last subscriber leaves.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL008_Unsubscribe_OnlyReleasesTagWhenLastSubscriberLeaves() { - // Regression test for DataConnectionLayer-008. HandleUnsubscribe must release a + // Regression test. HandleUnsubscribe must release a // tag at the adapter only when no other instance still subscribes to it. The // O(n) per-tag scan over every instance was replaced with an O(1) reference // count; this guards that the reference count tracks shared subscriptions @@ -1649,14 +1718,16 @@ public class DataConnectionActorTests : TestKit Assert.Equal(0, report2.ResolvedTags); } - // ── DataConnectionLayer-027: a reconnect re-seed read that lands after a SECOND + // ── A reconnect re-seed read that lands after a SECOND // failover (generation bumped twice) must be DROPPED, not applied ── + /// <summary>Verifies that stale generation reseed after second failover is dropped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL027_StaleGenerationReseed_AfterSecondFailover_IsDropped() { - // Regression/invariant pin for DataConnectionLayer-027 (the reconnect re-seed in - // ReSubscribeAll). The re-seed captures the adapter generation at kickoff and + // Regression/invariant pin for the reconnect re-seed in + // ReSubscribeAll. The re-seed captures the adapter generation at kickoff and // delivers the seed value through the generation-guarded TagValueReceived path: // // var generation = _adapterGeneration; // captured at reseed kickoff @@ -1712,7 +1783,7 @@ public class DataConnectionActorTests : TestKit backupAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()) .Returns(_ => { - // Signal the test that the generation-1 re-seed read has been entered + // Signal the test that the generation 1 re-seed read has been entered // (so generation 1 is definitely captured), then hand back the gated task. backupReadEntered.TrySetResult(); return staleSeedReadGate.Task; @@ -1720,7 +1791,7 @@ public class DataConnectionActorTests : TestKit // ── Primary-again (generation 2): the round-robin target of the SECOND failover. // Connects cleanly; its re-seed read returns a DISTINCT fresh value (77.0) so we - // can prove the FRESH generation-2 value IS accepted while the stale gen-1 is not. ── + // can prove the FRESH generation 2 value IS accepted while the stale gen 1 is not. ── primaryAgainAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>()) .Returns(Task.CompletedTask); primaryAgainAdapter.Status.Returns(ConnectionHealth.Connected); @@ -1755,12 +1826,12 @@ public class DataConnectionActorTests : TestKit RaiseDisconnected(primaryAdapter); ExpectMsg<ConnectionQualityChanged>(TimeSpan.FromSeconds(3)); // bad-quality push on disconnect - // Wait until the generation-1 re-seed read has been ENTERED (generation 1 captured, + // Wait until the generation 1 re-seed read has been ENTERED (generation 1 captured, // read pending on the gate). This anchors "generation N captured at reseed kickoff". // WaitAsync throws TimeoutException (failing the test) if the read is never entered. await backupReadEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); - // ── SECOND failover (while the gen-1 re-seed read is still blocked): disconnect + // ── SECOND failover (while the gen 1 re-seed read is still blocked): disconnect // backup → unstable → failover round-robin back to primary config (generation // bumps 1 → 2). After this the actor's current generation is 2. ── RaiseDisconnected(backupAdapter); @@ -1774,16 +1845,16 @@ public class DataConnectionActorTests : TestKit TimeSpan.FromSeconds(5)); await Task.Delay(200); // actor Connected on primary-again (generation 2) - // The FRESH generation-2 re-seed (read 77.0 on primary-again) is the only value + // The FRESH generation 2 re-seed (read 77.0 on primary-again) is the only value // that should be applied. It may arrive before or after we release the stale seed; // fish for it specifically and assert the stale value is never among the updates. - // ── Now release the gated generation-1 backup re-seed read. The continuation + // ── Now release the gated generation 1 backup re-seed read. The continuation // Tells TagValueReceived(generation = 1); the actor's generation is now 2, so // HandleTagValueReceived's guard MUST drop it. ── staleSeedReadGate.SetResult( new ReadResult(true, new TagValue(staleValue, QualityCode.Good, DateTimeOffset.UtcNow), null)); - // The fresh generation-2 value (77.0) is accepted and reaches the subscriber; + // The fresh generation 2 value (77.0) is accepted and reaches the subscriber; // FishForMessage will fail the test outright if it ever sees the stale 11.0. var fresh = FishForMessage<TagValueUpdate>( m => @@ -1801,6 +1872,8 @@ public class DataConnectionActorTests : TestKit // ── Deferred register row 55: first-subscribe resolution counts never reached the collector ── + /// <summary>Verifies that first subscribe all tags resolve pushes resolution counts to health collector.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FirstSubscribe_AllTagsResolve_PushesResolutionCountsToHealthCollector() { @@ -1839,7 +1912,7 @@ public class DataConnectionActorTests : TestKit _mockHealthCollector.Received().UpdateTagResolution("row55-first-subscribe", 0, 0); // One instance subscribes to two tags; both resolve on the first attempt, so the - // batch/probe path never runs — exactly the rig's rig-probe-1 shape. + // batch/probe path never runs — exactly the rig's rig probe 1 shape. actor.Tell(new SubscribeTagsRequest( "c1", "inst1", "row55-first-subscribe", ["sensor/temp", "sensor/pressure"], DateTimeOffset.UtcNow)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs index 97d31170..a619a1a7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs @@ -4,10 +4,11 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// <summary> -/// WP-34: Tests for protocol extensibility via DataConnectionFactory. +/// Tests for protocol extensibility via DataConnectionFactory. /// </summary> public class DataConnectionFactoryTests { + /// <summary>Verifies that creating an OPC UA connection returns the OPC UA adapter.</summary> [Fact] public void Create_OpcUa_ReturnsOpcUaAdapter() { @@ -18,6 +19,7 @@ public class DataConnectionFactoryTests Assert.IsType<OpcUaDataConnection>(connection); } + /// <summary>Verifies that creating an MxGateway connection returns the MxGateway adapter.</summary> [Fact] public void Create_MxGateway_ReturnsMxGatewayAdapter() { @@ -28,6 +30,7 @@ public class DataConnectionFactoryTests Assert.IsType<MxGatewayDataConnection>(connection); } + /// <summary>Verifies that protocol names are matched case-insensitively.</summary> [Fact] public void Create_CaseInsensitive() { @@ -38,6 +41,7 @@ public class DataConnectionFactoryTests Assert.IsType<OpcUaDataConnection>(connection); } + /// <summary>Verifies that an unknown protocol throws an exception.</summary> [Fact] public void Create_UnknownProtocol_Throws() { @@ -50,12 +54,13 @@ public class DataConnectionFactoryTests Assert.Contains("OpcUa", ex.Message); } + /// <summary>Verifies that a registered adapter extends the set of protocols the factory can create.</summary> [Fact] public void RegisterAdapter_ExtendsFactory() { var factory = new DataConnectionFactory(NullLoggerFactory.Instance); - // WP-34: Adding new protocol = register adapter + // Adding a new protocol = register an adapter factory.RegisterAdapter("Custom", _ => new OpcUaDataConnection( new DefaultOpcUaClientFactory(), NullLogger<OpcUaDataConnection>.Instance)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionManagerActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionManagerActorTests.cs index 039f72aa..a04f3e18 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionManagerActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionManagerActorTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// <summary> -/// WP-34: Tests for DataConnectionManagerActor routing and lifecycle. +/// Tests for DataConnectionManagerActor routing and lifecycle. /// </summary> [Collection("DataConnectionManagerActor")] public class DataConnectionManagerActorTests : TestKit @@ -19,6 +19,7 @@ public class DataConnectionManagerActorTests : TestKit private readonly DataConnectionOptions _options; private readonly ISiteHealthCollector _mockHealthCollector; + /// <summary>Initializes the test class with mock collaborators and the actor-system configuration.</summary> public DataConnectionManagerActorTests() : base(@"akka.loglevel = DEBUG") { @@ -31,6 +32,7 @@ public class DataConnectionManagerActorTests : TestKit }; } + /// <summary>Verifies that writing to an unknown connection returns an error.</summary> [Fact] public void WriteToUnknownConnection_ReturnsError() { @@ -45,6 +47,7 @@ public class DataConnectionManagerActorTests : TestKit Assert.Contains("Unknown connection", response.ErrorMessage); } + /// <summary>Verifies that subscribing to an unknown connection returns an error.</summary> [Fact] public void SubscribeToUnknownConnection_ReturnsError() { @@ -59,6 +62,7 @@ public class DataConnectionManagerActorTests : TestKit Assert.Contains("Unknown connection", response.ErrorMessage); } + /// <summary>Verifies that subscribing to alarms on an unknown connection returns an error.</summary> [Fact] public void SubscribeAlarmsToUnknownConnection_ReturnsError() { @@ -76,10 +80,11 @@ public class DataConnectionManagerActorTests : TestKit Assert.Contains("Unknown connection", response.ErrorMessage); } + /// <summary>Verifies that a connection actor crash preserves the subscription state.</summary> [Fact] public void DCL002_ConnectionActorCrash_PreservesSubscriptionState() { - // Regression test for DataConnectionLayer-002. The supervisor used + // Regression test: the supervisor used // Directive.Restart, which discards the connection actor's in-memory // subscription registry — breaking the design doc's "transparent // re-subscribe" guarantee (subscribers are never re-subscribed and sit at @@ -133,6 +138,7 @@ public class DataConnectionManagerActorTests : TestKit Assert.Equal(1, report.ResolvedTags); } + /// <summary>Verifies that creating a connection uses the connection factory.</summary> [Fact] public void CreateConnection_UsesFactory() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsBindingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsBindingTests.cs index 59ec2a02..f302c667 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsBindingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsBindingTests.cs @@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// </summary> public class DataConnectionOptionsBindingTests { + /// <summary>Verifies that add data connection layer binds the scada bridge data connection section.</summary> [Fact] public void AddDataConnectionLayer_BindsTheScadaBridgeDataConnectionSection() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs index 2fbe3c1a..3d2450a6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs @@ -14,6 +14,7 @@ public class DataConnectionOptionsValidatorTests private static ValidateOptionsResult Validate(DataConnectionOptions options) => new DataConnectionOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the default options are valid.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -21,6 +22,7 @@ public class DataConnectionOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero reconnect interval is rejected.</summary> [Fact] public void ZeroReconnectInterval_IsRejected() { @@ -29,6 +31,7 @@ public class DataConnectionOptionsValidatorTests Assert.Contains("ReconnectInterval", result.FailureMessage); } + /// <summary>Verifies that a non-positive seed-read max attempts value is rejected.</summary> [Fact] public void NonPositiveSeedReadMaxAttempts_IsRejected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/GlobalOptionsValidationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/GlobalOptionsValidationTests.cs index ffa1e8d6..6b455baf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/GlobalOptionsValidationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/GlobalOptionsValidationTests.cs @@ -6,6 +6,8 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; public class GlobalOptionsValidationTests { + /// <summary>Verifies that startup rejects blank identity.</summary> + /// <param name="key">Test case input.</param> [Theory] [InlineData("ScadaBridge:OpcUa:ApplicationName")] [InlineData("ScadaBridge:MxGateway:ClientNamePrefix")] @@ -20,6 +22,7 @@ public class GlobalOptionsValidationTests Assert.Contains(key, exception.Message); } + /// <summary>Verifies that defaults validate at startup.</summary> [Fact] public void DefaultsValidateAtStartup() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs index dc7ab88a..8f962c6a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs @@ -8,9 +8,10 @@ using ProtoTransitionKind = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionK namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; -/// <summary>Task-12: pure MxGateway alarm-feed proto → NativeAlarmTransition mapping.</summary> +/// <summary>Pure MxGateway alarm-feed proto → NativeAlarmTransition mapping.</summary> public class MxGatewayAlarmMapperTests { + /// <summary>Verifies that an acknowledge transition is mapped as acknowledged with the operator.</summary> [Fact] public void MapTransition_AckTransition_IsAcknowledgedWithOperator() { @@ -37,6 +38,7 @@ public class MxGatewayAlarmMapperTests Assert.Equal("Tank01", t.SourceObjectReference); } + /// <summary>Verifies that an active, acknowledged condition state maps to active true and acknowledged true.</summary> [Fact] public void MapConditionState_ActiveAcked_To_ActiveTrue_AckTrue() { @@ -46,6 +48,7 @@ public class MxGatewayAlarmMapperTests Assert.Equal(600, c.Severity); } + /// <summary>Verifies that an active, unacknowledged snapshot maps to the snapshot kind.</summary> [Fact] public void MapSnapshot_ActiveUnacked_IsSnapshotKind() { @@ -66,6 +69,7 @@ public class MxGatewayAlarmMapperTests Assert.Equal(1000, t.Condition.Severity); } + /// <summary>Verifies that the source reference is object-relative rather than the full provider reference.</summary> [Fact] public void SourceReference_IsObjectRelative_NotFullProviderReference() { @@ -98,6 +102,7 @@ public class MxGatewayAlarmMapperTests Assert.Equal("CVDAisle_1", snapT.Category); } + /// <summary>Verifies that the source reference falls back to the full reference when the object reference is empty.</summary> [Fact] public void SourceReference_FallsBackToFullReference_WhenObjectReferenceEmpty() { @@ -114,8 +119,9 @@ public class MxGatewayAlarmMapperTests Assert.Equal("Galaxy!Area.Obj.Alarm", t.SourceReference); } - // ── CurrentValue / LimitValue (M2.13 / #27) ────────────────────────────── + // ── CurrentValue / LimitValue ────────────────────────────── + /// <summary>Verifies that the current and limit values of a transition are populated from the proto.</summary> [Fact] public void MapTransition_CurrentAndLimitValue_PopulatedFromProto() { @@ -139,6 +145,7 @@ public class MxGatewayAlarmMapperTests Assert.Equal("90", t.LimitValue); } + /// <summary>Verifies that a transition with no current or limit value yields empty values.</summary> [Fact] public void MapTransition_AbsentCurrentAndLimitValue_YieldsEmpty() { @@ -160,6 +167,7 @@ public class MxGatewayAlarmMapperTests Assert.Equal("", t.LimitValue); } + /// <summary>Verifies that the current and limit values of a snapshot are populated from the proto.</summary> [Fact] public void MapSnapshot_CurrentAndLimitValue_PopulatedFromProto() { @@ -181,6 +189,7 @@ public class MxGatewayAlarmMapperTests Assert.Equal("10", t.LimitValue); } + /// <summary>Verifies that a string MX value produces a string current value.</summary> [Fact] public void MapSnapshot_StringMxValue_ProducesStringCurrentValue() { @@ -206,6 +215,7 @@ public class MxGatewayAlarmMapperTests private static readonly DateTimeOffset GatewayTime = new(2026, 8, 1, 9, 30, 0, TimeSpan.Zero); + /// <summary>Verifies that an acknowledge transition stamps the observed transition time as the ack time.</summary> [Fact] public void MapTransition_Acknowledge_StampsTheObservedTransitionTimeAsAckTime() { @@ -227,6 +237,7 @@ public class MxGatewayAlarmMapperTests Assert.Equal(t.TransitionTime, t.AckTime); } + /// <summary>Verifies that a raise transition has no ack time.</summary> [Fact] public void MapTransition_Raise_HasNoAckTime() { @@ -243,6 +254,7 @@ public class MxGatewayAlarmMapperTests Assert.Null(MxGatewayAlarmMapper.MapTransition(ev).AckTime); } + /// <summary>Verifies that a retrigger transition clears the ack time.</summary> [Fact] public void MapTransition_Retrigger_ClearsTheAckTime() { @@ -260,6 +272,7 @@ public class MxGatewayAlarmMapperTests Assert.Null(MxGatewayAlarmMapper.MapTransition(ev).AckTime); } + /// <summary>Verifies that a clear transition has no ack time despite the inactive state mapping to acknowledged.</summary> [Fact] public void MapTransition_Clear_HasNoAckTime_DespiteInactiveMappingToAcked() { @@ -280,6 +293,7 @@ public class MxGatewayAlarmMapperTests Assert.Null(t.AckTime); // …but no ack was observed } + /// <summary>Verifies that an active, acknowledged snapshot restores an ack time from the last transition.</summary> [Fact] public void MapSnapshot_ActiveAcked_RestoresAnAckTimeFromTheLastTransition() { @@ -297,6 +311,7 @@ public class MxGatewayAlarmMapperTests Assert.Equal(GatewayTime, MxGatewayAlarmMapper.MapSnapshot(snap).AckTime); } + /// <summary>Verifies that an active, unacknowledged snapshot has no ack time.</summary> [Fact] public void MapSnapshot_ActiveUnacked_HasNoAckTime() { @@ -312,6 +327,7 @@ public class MxGatewayAlarmMapperTests Assert.Null(MxGatewayAlarmMapper.MapSnapshot(snap).AckTime); } + /// <summary>Verifies that the snapshot-complete sentinel carries no ack time.</summary> [Fact] public void SnapshotComplete_Sentinel_CarriesNoAckTime() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmLiveSmokeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmLiveSmokeTests.cs index 0f3f0c23..0da2ee2e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmLiveSmokeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmLiveSmokeTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// <summary> -/// Task 28: live smoke test for the OPC UA Alarms & Conditions adapter +/// Live smoke test for the OPC UA Alarms & Conditions adapter /// (<see cref="OpcUaDataConnection"/> as <c>IAlarmSubscribableConnection</c>). /// /// Round-trips against an alarm-capable OPC UA endpoint @@ -28,6 +28,8 @@ public class OpcUaAlarmLiveSmokeTests { private const string EndpointUrl = "opc.tcp://localhost:50000"; + /// <summary>Verifies that subscribing to alarms delivers a condition refresh snapshot.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SubscribeAlarms_DeliversConditionRefreshSnapshot() { @@ -80,6 +82,8 @@ public class OpcUaAlarmLiveSmokeTests } } + /// <summary>Verifies that two boiler alarm sources keep distinct refresh scopes and epochs.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task TwoBoilerSources_KeepDistinctRefreshScopesAndEpochs() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs index b3bfb42b..e0321c13 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs @@ -3,15 +3,16 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; -/// <summary>Task-11: pure OPC UA A&C field → AlarmConditionState/transition mapping.</summary> +/// <summary>Pure OPC UA A&C field → AlarmConditionState/transition mapping.</summary> public class OpcUaAlarmMapperTests { - // ── Gitea #17: routing identity must be the binding, not the event name ── + // ── Routing identity must be the binding, not the event name ── + /// <summary>Verifies that a NodeId binding routes on the binding rather than the event name.</summary> [Fact] public void BuildIdentity_NodeIdBinding_RoutesOnBindingNotName() { - // OtOpcUa v3 post-#473: SourceName is the RawPath, ConditionName the leaf. The + // OtOpcUa v3 post-migration: SourceName is the RawPath, ConditionName the leaf. The // binding is a durable node reference. The bug was SourceObjectReference = SourceName, // which never prefix-matches a NodeId binding, so every transition was dropped. var (sourceRef, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity( @@ -25,6 +26,7 @@ public class OpcUaAlarmMapperTests Assert.Equal("pymodbus/plc/HR200.HR200", sourceRef); } + /// <summary>Verifies that a legacy index binding routes verbatim.</summary> [Fact] public void BuildIdentity_LegacyIndexBinding_RoutesVerbatim() { @@ -38,6 +40,7 @@ public class OpcUaAlarmMapperTests Assert.Equal("ns=2;s=pymodbus/plc/HR200", sourceObjectRef); } + /// <summary>Verifies that an aggregate feed keeps an empty identity.</summary> [Fact] public void BuildIdentity_AggregateFeed_StaysEmpty() { @@ -53,6 +56,7 @@ public class OpcUaAlarmMapperTests Assert.Equal("pymodbus/plc/HR200.HR200", sourceRef); } + /// <summary>Verifies that a missing condition name uses the source name alone.</summary> [Fact] public void BuildIdentity_NoConditionName_UsesSourceNameAlone() { @@ -64,6 +68,7 @@ public class OpcUaAlarmMapperTests Assert.Equal("Tank01", sourceRef); } + /// <summary>Verifies that a missing source name falls back to the binding for the key.</summary> [Fact] public void BuildIdentity_NoSourceName_FallsBackToBindingForKey() { @@ -77,6 +82,7 @@ public class OpcUaAlarmMapperTests Assert.Equal("ns=2;s=X", sourceObjectRef); } + /// <summary>Verifies that severity normalization clamps to the range 0 to 1000.</summary> [Fact] public void NormalizeSeverity_ClampsTo0_1000() { @@ -85,6 +91,7 @@ public class OpcUaAlarmMapperTests Assert.Equal(500, OpcUaAlarmMapper.NormalizeSeverity(500)); } + /// <summary>Verifies that an active, unacknowledged event builds the expected condition state.</summary> [Fact] public void BuildCondition_ActiveUnacked() { @@ -96,6 +103,7 @@ public class OpcUaAlarmMapperTests Assert.Equal(AlarmShelveState.Unshelved, c.Shelve); } + /// <summary>Verifies that an acknowledge edge yields an Acknowledge transition.</summary> [Fact] public void DeriveKind_AckEdge_YieldsAcknowledge() { @@ -103,6 +111,7 @@ public class OpcUaAlarmMapperTests OpcUaAlarmMapper.DeriveKind(prevAcked: false, nowAcked: true, prevActive: true, nowActive: true)); } + /// <summary>Verifies that an active edge yields a Raise transition.</summary> [Fact] public void DeriveKind_ActiveEdge_YieldsRaise() { @@ -110,6 +119,7 @@ public class OpcUaAlarmMapperTests OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: false, nowActive: true)); } + /// <summary>Verifies that an inactive edge yields a Clear transition.</summary> [Fact] public void DeriveKind_InactiveEdge_YieldsClear() { @@ -117,6 +127,9 @@ public class OpcUaAlarmMapperTests OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: true, nowActive: false)); } + /// <summary>Verifies that the current shelving state name maps to the expected shelve state.</summary> + /// <param name="name">The shelving state name reported by the source, or null.</param> + /// <param name="expected">The expected shelve state.</param> [Theory] [InlineData("OneShotShelved", AlarmShelveState.OneShotShelved)] [InlineData("TimedShelved", AlarmShelveState.TimedShelved)] @@ -127,8 +140,9 @@ public class OpcUaAlarmMapperTests Assert.Equal(expected, OpcUaAlarmMapper.MapShelve(name)); } - // ── PickLimitValue (M2.13 / #27) ───────────────────────────────────────── + // ── PickLimitValue ───────────────────────────────────────── + /// <summary>Verifies that a set of all-null limits returns an empty value.</summary> [Fact] public void PickLimitValue_AllNull_ReturnsEmpty() { @@ -136,6 +150,7 @@ public class OpcUaAlarmMapperTests Assert.Equal("", OpcUaAlarmMapper.PickLimitValue(null, null, null, null)); } + /// <summary>Verifies that a present high-high limit is returned.</summary> [Fact] public void PickLimitValue_HighHighLimitPresent_ReturnsIt() { @@ -144,6 +159,7 @@ public class OpcUaAlarmMapperTests Assert.Equal("100.5", result); } + /// <summary>Verifies that when only the high limit is present it is returned.</summary> [Fact] public void PickLimitValue_OnlyHighLimit_ReturnsHighLimit() { @@ -152,6 +168,7 @@ public class OpcUaAlarmMapperTests Assert.Equal("80", result); } + /// <summary>Verifies that the high-high limit wins over the high limit when both are present.</summary> [Fact] public void PickLimitValue_PriorityOrder_HighHighWinsOverHigh() { @@ -160,6 +177,7 @@ public class OpcUaAlarmMapperTests Assert.Equal("95", result); } + /// <summary>Verifies that when only the low-low limit is present it is returned.</summary> [Fact] public void PickLimitValue_OnlyLowLow_ReturnsLowLow() { @@ -168,6 +186,7 @@ public class OpcUaAlarmMapperTests Assert.Equal("-10.5", result); } + /// <summary>Verifies that limit values are formatted using the invariant culture.</summary> [Fact] public void PickLimitValue_UsesInvariantCulture() { @@ -184,6 +203,7 @@ public class OpcUaAlarmMapperTests private static readonly DateTimeOffset Observed = new(2026, 8, 1, 10, 5, 0, TimeSpan.Zero); + /// <summary>Verifies that an active, acknowledged alarm prefers the source acknowledgement time.</summary> [Fact] public void DeriveAckTime_ActiveAcked_PrefersTheSourcesOwnAckInstant() { @@ -195,6 +215,7 @@ public class OpcUaAlarmMapperTests OpcUaAlarmMapper.DeriveAckTime(active: true, acked: true, SourceAck, Observed)); } + /// <summary>Verifies that an active, acknowledged alarm without a source time falls back to the observation time.</summary> [Fact] public void DeriveAckTime_ActiveAcked_WithoutSourceTime_FallsBackToObservationTime() { @@ -206,6 +227,7 @@ public class OpcUaAlarmMapperTests OpcUaAlarmMapper.DeriveAckTime(active: true, acked: true, sourceAckTime: null, Observed)); } + /// <summary>Verifies that an unacknowledged alarm has no acknowledgement time even when the source reports an acknowledgement transition.</summary> [Fact] public void DeriveAckTime_Unacked_IsNull_EvenWhenSourceReportsAnAckTransition() { @@ -216,6 +238,7 @@ public class OpcUaAlarmMapperTests active: true, acked: false, sourceAckTime: SourceAck, Observed)); } + /// <summary>Verifies that an inactive alarm has no acknowledgement time.</summary> [Fact] public void DeriveAckTime_Inactive_IsNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataConnectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataConnectionTests.cs index af02990c..bb950a9b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataConnectionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataConnectionTests.cs @@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// <summary> -/// DataConnectionLayer-003: structural regression guard. RealOpcUaClient's +/// Structural regression guard. RealOpcUaClient's /// monitored-item / callback maps are read from the OPC UA SDK's publish threads /// concurrently with subscribe/disconnect mutations on other threads. They must be /// concurrent collections, not plain Dictionary. This is verified structurally @@ -18,6 +18,10 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// </summary> public class RealOpcUaClientThreadSafetyTests { + /// <summary> + /// Verifies that the shared dictionary fields of the OPC UA client are concurrent collections. + /// </summary> + /// <param name="fieldName">The name of the field that is inspected.</param> [Theory] [InlineData("_callbacks")] [InlineData("_monitoredItems")] @@ -40,7 +44,7 @@ public class RealOpcUaClientThreadSafetyTests } /// <summary> -/// DataConnectionLayer-019: <see cref="OpcUaDataConnection"/> previously kept a +/// <see cref="OpcUaDataConnection"/> previously kept a /// dead <c>Dictionary<string,string> _subscriptionHandles</c> field that was /// written and removed across thread-pool continuations but never read. Plain /// Dictionary writes from concurrent post-await continuations are racy; the @@ -53,6 +57,7 @@ public class RealOpcUaClientThreadSafetyTests /// </summary> public class OpcUaDataConnectionThreadSafetyTests { + /// <summary>Verifies that the OPC UA data connection has no non-concurrent shared dictionary field.</summary> [Fact] public void DCL019_OpcUaDataConnection_HasNoNonConcurrentSharedDictionary() { @@ -76,14 +81,15 @@ public class OpcUaDataConnectionThreadSafetyTests } /// <summary> -/// DataConnectionLayer-012: secure-by-default certificate handling. +/// Secure-by-default certificate handling. /// </summary> public class OpcUaCertificateDefaultTests { + /// <summary>Verifies that automatic acceptance of untrusted certificates defaults to false.</summary> [Fact] public void DCL012_OpcUaConnectionOptions_AutoAcceptUntrustedCerts_DefaultsToFalse() { - // Regression test for DataConnectionLayer-012. AutoAcceptUntrustedCerts defaulted + // Regression test: AutoAcceptUntrustedCerts defaulted // to true, accepting every server certificate unconditionally and defeating the // Sign / SignAndEncrypt security modes against an active man-in-the-middle. A // secure-by-default posture rejects untrusted certs unless explicitly opted in. @@ -93,7 +99,7 @@ public class OpcUaCertificateDefaultTests } /// <summary> -/// DataConnectionLayer-014: the DCL-012 auto-accept-certificate security warning is +/// The auto-accept-certificate security warning is /// only effective if RealOpcUaClient is built with a real logger. The only production /// path that constructs one is RealOpcUaClientFactory.Create(); that factory must /// thread a logger through, otherwise the warning sinks into NullLogger and an @@ -109,12 +115,13 @@ public class RealOpcUaClientFactoryLoggerTests return (ILogger<RealOpcUaClient>)field!.GetValue(client)!; } + /// <summary>Verifies that the real OPC UA client factory creates a client with a real logger.</summary> [Fact] public void DCL014_RealOpcUaClientFactory_CreatesClientWithRealLogger() { - // Regression test for DataConnectionLayer-014. RealOpcUaClientFactory.Create() + // Regression test: RealOpcUaClientFactory.Create() // constructed `new RealOpcUaClient(_globalOptions)` with no logger, so the - // DCL-012 man-in-the-middle warning was always discarded by NullLogger in + // auto-accept man-in-the-middle warning was always discarded by NullLogger in // production. After the fix the factory accepts an ILoggerFactory and passes a // real ILogger<RealOpcUaClient> into every client it creates. using var loggerFactory = LoggerFactory.Create(b => { }); @@ -126,6 +133,7 @@ public class RealOpcUaClientFactoryLoggerTests Assert.NotSame(NullLogger<RealOpcUaClient>.Instance, logger); } + /// <summary>Verifies that the data connection factory passes its logger through to the real OPC UA client.</summary> [Fact] public void DCL014_DataConnectionFactory_ThreadsLoggerToRealOpcUaClient() { @@ -150,14 +158,15 @@ public class RealOpcUaClientFactoryLoggerTests } /// <summary> -/// DataConnectionLayer-009: failover-stability tunables must be configurable. +/// Failover-stability tunables must be configurable. /// </summary> public class DataConnectionOptionsStabilityTests { + /// <summary>Verifies that the stable connection threshold is configurable and defaults to sixty seconds.</summary> [Fact] public void DCL009_StableConnectionThreshold_IsConfigurable_WithSixtySecondDefault() { - // Regression test for DataConnectionLayer-009. The unstable-disconnect failover + // Regression test: the unstable-disconnect failover // path used a hard-coded 60s StableConnectionThreshold constant inside // DataConnectionActor. It must live on DataConnectionOptions like the other // tunables (ReconnectInterval, TagResolutionRetryInterval, WriteTimeout) so it @@ -171,7 +180,7 @@ public class DataConnectionOptionsStabilityTests } /// <summary> -/// WP-7: Tests for OPC UA adapter. +/// Tests for the OPC UA adapter. /// </summary> public class OpcUaDataConnectionTests { @@ -179,6 +188,7 @@ public class OpcUaDataConnectionTests private readonly IOpcUaClientFactory _mockFactory; private readonly OpcUaDataConnection _adapter; + /// <summary>Initializes the mocked OPC UA client and the adapter under test.</summary> public OpcUaDataConnectionTests() { _mockClient = Substitute.For<IOpcUaClient>(); @@ -187,6 +197,8 @@ public class OpcUaDataConnectionTests _adapter = new OpcUaDataConnection(_mockFactory, NullLogger<OpcUaDataConnection>.Instance); } + /// <summary>Verifies that connecting sets the status to connected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Connect_SetsStatusToConnected() { @@ -201,6 +213,8 @@ public class OpcUaDataConnectionTests await _mockClient.Received(1).ConnectAsync("opc.tcp://localhost:4840", Arg.Any<OpcUaConnectionOptions?>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that disconnecting sets the status to disconnected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Disconnect_SetsStatusToDisconnected() { @@ -211,10 +225,12 @@ public class OpcUaDataConnectionTests Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status); } + /// <summary>Verifies that concurrent connection-lost notifications raise the disconnected event exactly once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL013_ConcurrentConnectionLost_RaisesDisconnectedExactlyOnce() { - // Regression test for DataConnectionLayer-013. RaiseDisconnected used a + // Regression test: RaiseDisconnected used a // non-atomic check-then-set on a volatile bool: two threads racing through it // (e.g. the keep-alive thread and a ReadAsync failure path, both routed via // OnClientConnectionLost) could both observe _disconnectFired == false and both @@ -250,6 +266,8 @@ public class OpcUaDataConnectionTests } } + /// <summary>Verifies that subscribing delegates to the client and returns the subscription id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Subscribe_DelegatesAndReturnsId() { @@ -263,6 +281,8 @@ public class OpcUaDataConnectionTests Assert.Equal("sub-001", subId); } + /// <summary>Verifies that a successful write returns a good result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Write_Success_ReturnsGoodResult() { @@ -277,6 +297,8 @@ public class OpcUaDataConnectionTests Assert.Null(result.ErrorMessage); } + /// <summary>Verifies that a failed write returns an error result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Write_Failure_ReturnsError() { @@ -291,6 +313,8 @@ public class OpcUaDataConnectionTests Assert.Contains("0x80000000", result.ErrorMessage); } + /// <summary>Verifies that reading a value with a bad status returns a bad result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Read_BadStatus_ReturnsBadResult() { @@ -304,6 +328,8 @@ public class OpcUaDataConnectionTests Assert.False(result.Success); } + /// <summary>Verifies that reading a value with a good status returns the value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Read_GoodStatus_ReturnsValue() { @@ -320,10 +346,12 @@ public class OpcUaDataConnectionTests Assert.Equal(QualityCode.Good, result.Value.Quality); } + /// <summary>Verifies that a batch read returns a value for every requested tag.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadBatch_ReadsAllTags() { - // WP2.1b: ReadBatchAsync is TRUE bulk — ONE client ReadValuesAsync call carrying + // ReadBatchAsync is TRUE bulk — ONE client ReadValuesAsync call carrying // every requested node, not a per-tag loop. _mockClient.IsConnected.Returns(true); _mockClient.ReadValuesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>()) @@ -340,10 +368,12 @@ public class OpcUaDataConnectionTests Assert.Single(_mockClient.ReceivedCalls(), c => c.GetMethodInfo().Name == "ReadValuesAsync"); } + /// <summary>Verifies that a batch read returns per-tag results when one tag fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL007_ReadBatch_ReturnsPerTagResults_WhenOneTagFails() { - // Regression test for DataConnectionLayer-007. ReadBatchAsync originally looped + // Regression test: ReadBatchAsync originally looped // calling ReadAsync per tag; ReadAsync re-throws any non-cancellation exception, so // a single failing tag aborted the whole batch and the caller got NO results for // the tags that did read successfully — even though ReadResult already carries a @@ -372,13 +402,15 @@ public class OpcUaDataConnectionTests Assert.NotNull(results["bad"].ErrorMessage); } + /// <summary>Verifies that a batch write returns per-tag results when some tags fail.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL017_WriteBatch_ReturnsPerTagResults_WhenSomeTagsFail() { - // Regression test for DataConnectionLayer-017. WriteBatchAsync originally looped + // Regression test: WriteBatchAsync originally looped // calling WriteAsync per tag; a mid-batch fault made the whole call throw and the // caller lost the per-tag outcomes for the tags that already wrote. The batch is - // now ONE bulk service call (WP2.1b), and the invariant is unchanged: per-node + // now ONE bulk service call, and the invariant is unchanged: per-node // failures are reported as failed WriteResult rows and every requested tag is // present in the returned map. _mockClient.IsConnected.Returns(true); @@ -411,6 +443,8 @@ public class OpcUaDataConnectionTests Assert.Single(_mockClient.ReceivedCalls(), c => c.GetMethodInfo().Name == "WriteValuesAsync"); } + /// <summary>Verifies that cancellation aborts the whole batch write.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DCL017_WriteBatch_CancellationAbortsWholeBatch() { @@ -429,6 +463,8 @@ public class OpcUaDataConnectionTests _adapter.WriteBatchAsync(new Dictionary<string, object?> { ["tag1"] = 1 }, cts.Token)); } + /// <summary>Verifies that operations throw when the adapter is not connected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NotConnected_ThrowsOnOperations() { @@ -438,6 +474,8 @@ public class OpcUaDataConnectionTests _adapter.ReadAsync("tag1")); } + /// <summary>Verifies that disposing the adapter cleans up its resources.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DisposeAsync_CleansUp() { @@ -450,6 +488,8 @@ public class OpcUaDataConnectionTests // --- Configuration Parsing --- + /// <summary>Verifies that connecting parses every supported configuration key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Connect_ParsesAllConfigurationKeys() { @@ -487,6 +527,8 @@ public class OpcUaDataConnectionTests Arg.Any<CancellationToken>()); } + /// <summary>Verifies that connecting uses default values when the configuration keys are not provided.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Connect_UsesDefaults_WhenKeysNotProvided() { @@ -511,6 +553,8 @@ public class OpcUaDataConnectionTests Arg.Any<CancellationToken>()); } + /// <summary>Verifies that connecting ignores invalid numeric configuration values.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Connect_IgnoresInvalidNumericValues() { @@ -535,6 +579,8 @@ public class OpcUaDataConnectionTests Arg.Any<CancellationToken>()); } + /// <summary>Verifies that connecting parses the security mode configuration value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Connect_ParsesSecurityMode() { @@ -551,6 +597,8 @@ public class OpcUaDataConnectionTests Arg.Any<CancellationToken>()); } + /// <summary>Verifies that connecting parses the automatic certificate acceptance setting.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Connect_ParsesAutoAcceptCerts() { @@ -573,6 +621,8 @@ public class OpcUaDataConnectionTests // one, leaking its session/keep-alive/socket per flap AND leaving its stale // ConnectionLost handler wired — so a late keep-alive death on the OLD client // flapped the NEW healthy session (after _disconnectFired was reset). + /// <summary>Verifies that reconnecting disposes the previous client and detaches its handlers.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconnect_DisposesAndDetachesPreviousClient() { @@ -593,6 +643,8 @@ public class OpcUaDataConnectionTests await clients[1].DidNotReceive().DisposeAsync(); } + /// <summary>Verifies that a connection-lost event from a stale client does not raise a disconnect after a reconnect.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaleClientConnectionLost_DoesNotRaiseDisconnected_AfterReconnect() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataTypeNameMapTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataTypeNameMapTests.cs index ddea1814..6923dbae 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataTypeNameMapTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataTypeNameMapTests.cs @@ -4,13 +4,15 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// <summary> -/// M7-B1 (T16 type-info): pure NodeId → friendly built-in DataType name mapping. +/// Type-info: pure NodeId → friendly built-in DataType name mapping. /// Known OPC UA built-in DataType NodeIds (ns=0;i=…) resolve to their CLR-ish /// friendly names; unknown/custom DataType NodeIds fall back to the NodeId string. /// No live server is required — this is a pure lookup helper. /// </summary> public class OpcUaDataTypeNameMapTests { + /// <summary>Verifies that resolve, known built in types, return friendly names.</summary> + /// <param name="typeName">Test case input.</param> [Theory] [InlineData("Boolean")] [InlineData("SByte")] @@ -38,30 +40,35 @@ public class OpcUaDataTypeNameMapTests Assert.Equal(typeName, OpcUaBuiltInTypeNames.Resolve(nodeId)); } + /// <summary>Verifies that resolve, double, returns double.</summary> [Fact] public void Resolve_Double_ReturnsDouble() { Assert.Equal("Double", OpcUaBuiltInTypeNames.Resolve(DataTypeIds.Double)); } + /// <summary>Verifies that resolve, int32, returns int32.</summary> [Fact] public void Resolve_Int32_ReturnsInt32() { Assert.Equal("Int32", OpcUaBuiltInTypeNames.Resolve(DataTypeIds.Int32)); } + /// <summary>Verifies that resolve, boolean, returns boolean.</summary> [Fact] public void Resolve_Boolean_ReturnsBoolean() { Assert.Equal("Boolean", OpcUaBuiltInTypeNames.Resolve(DataTypeIds.Boolean)); } + /// <summary>Verifies that resolve, string, returns string.</summary> [Fact] public void Resolve_String_ReturnsString() { Assert.Equal("String", OpcUaBuiltInTypeNames.Resolve(DataTypeIds.String)); } + /// <summary>Verifies that resolve, unknown custom node id, returns node id string.</summary> [Fact] public void Resolve_UnknownCustomNodeId_ReturnsNodeIdString() { @@ -73,6 +80,7 @@ public class OpcUaDataTypeNameMapTests Assert.Equal(custom.ToString(), OpcUaBuiltInTypeNames.Resolve(custom)); } + /// <summary>Verifies that resolve, null, returns empty string.</summary> [Fact] public void Resolve_Null_ReturnsEmptyString() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/RealOpcUaClientAlarmFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/RealOpcUaClientAlarmFilterTests.cs index 852a5608..7ba4366d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/RealOpcUaClientAlarmFilterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/RealOpcUaClientAlarmFilterTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// <summary> -/// M2.4 (#8) regression: standard OPC UA A&C events carry an event-type +/// Regression: standard OPC UA A&C events carry an event-type /// <see cref="NodeId"/> (e.g. <c>i=9341</c> for ExclusiveLevelAlarmType), but the /// client-side conditionFilter gate — and the server-side WhereClause — both key off /// the friendly type names in <see cref="RealOpcUaClient.KnownConditionTypeIds"/>. @@ -14,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// </summary> public class RealOpcUaClientAlarmFilterTests { + /// <summary>Verifies that a known standard node id resolves to its friendly alarm type name.</summary> [Fact] public void ResolveAlarmTypeName_KnownStandardNodeId_ReturnsFriendlyName() { @@ -23,6 +24,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Equal("ExclusiveLevelAlarmType", resolved); } + /// <summary>Verifies that a discrete alarm node id resolves to its friendly alarm type name.</summary> [Fact] public void ResolveAlarmTypeName_DiscreteAlarmNodeId_ReturnsFriendlyName() { @@ -30,6 +32,7 @@ public class RealOpcUaClientAlarmFilterTests Assert.Equal("DiscreteAlarmType", resolved); } + /// <summary>Verifies that an unknown custom node id resolves to its node id string.</summary> [Fact] public void ResolveAlarmTypeName_UnknownCustomNodeId_ReturnsNodeIdString() { @@ -42,12 +45,14 @@ public class RealOpcUaClientAlarmFilterTests Assert.Equal(custom.ToString(), resolved); } + /// <summary>Verifies that a null node id resolves to an empty string.</summary> [Fact] public void ResolveAlarmTypeName_Null_ReturnsEmptyString() { Assert.Equal("", RealOpcUaClient.ResolveAlarmTypeName(null)); } + /// <summary>Verifies that the inverse map round-trips every known condition type.</summary> [Fact] public void InverseMap_RoundTrips_EveryKnownConditionType() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientBrowseTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientBrowseTests.cs index 986d2bee..c0ce69d4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientBrowseTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientBrowseTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// <summary> -/// M7-B2 (T15): <see cref="StubOpcUaClient"/> serves a small canned address-space +/// Verifies that <see cref="StubOpcUaClient"/> serves a small canned address-space /// tree so DCL browse/paging flows can be exercised without a live OPC UA server. /// The stub also fakes BrowseNext continuation paging on <c>Folder1</c> so the /// continuation-token round-trip is testable end-to-end against the contract. @@ -21,6 +21,8 @@ public class StubOpcUaClientBrowseTests return client; } + /// <summary>Verifies that browsing the root returns two folders with no continuation token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BrowseRoot_ReturnsTwoFolders_NoContinuation() { @@ -46,6 +48,8 @@ public class StubOpcUaClientBrowseTests }); } + /// <summary>Verifies that browsing Folder1 without a token returns the first tag and a continuation token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BrowseFolder1_NoToken_ReturnsTag1_AndContinuationToken() { @@ -61,6 +65,8 @@ public class StubOpcUaClientBrowseTests Assert.False(only.HasChildren); } + /// <summary>Verifies that browsing Folder1 with the continuation token returns the second tag and no further continuation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BrowseFolder1_WithContinuationToken_ReturnsTag2_AndNoFurtherContinuation() { @@ -78,6 +84,8 @@ public class StubOpcUaClientBrowseTests Assert.False(only.HasChildren); } + /// <summary>Verifies that browsing a leaf node returns no children and no continuation token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BrowseLeaf_ReturnsEmpty_NoContinuation() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientSearchTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientSearchTests.cs index 51e8f263..31f3c6aa 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientSearchTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientSearchTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// <summary> -/// M7-B4 (T15): <see cref="StubOpcUaClient"/> searches its canned address-space +/// <see cref="StubOpcUaClient"/> searches its canned address-space /// tree (root → Folder1/Folder2; Folder1 → Tag1/Tag2) so DCL address-space /// search flows can be exercised without a live OPC UA server. The stub fakes /// BrowseNext continuation paging on <c>Folder1</c>, so finding both Tag1 and @@ -21,6 +21,8 @@ public class StubOpcUaClientSearchTests return client; } + /// <summary>Verifies that search tag finds both tags with root relative paths.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Search_Tag_FindsBothTags_WithRootRelativePaths() { @@ -36,6 +38,8 @@ public class StubOpcUaClientSearchTests Assert.All(result.Matches, m => Assert.Contains("Tag", m.Node.DisplayName)); } + /// <summary>Verifies that search folder matches on display name and path.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Search_Folder_MatchesOnDisplayNameAndPath() { @@ -51,6 +55,8 @@ public class StubOpcUaClientSearchTests Assert.Equal(new[] { "/Folder1", "/Folder1/Tag1", "/Folder1/Tag2", "/Folder2" }, paths); } + /// <summary>Verifies that search folder 2 matches only the folder by display name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Search_Folder2_MatchesOnlyTheFolderByDisplayName() { @@ -66,6 +72,8 @@ public class StubOpcUaClientSearchTests Assert.Equal("Folder2", only.Node.DisplayName); } + /// <summary>Verifies that search is case insensitive.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Search_IsCaseInsensitive() { @@ -76,6 +84,8 @@ public class StubOpcUaClientSearchTests Assert.Equal(2, result.Matches.Count); } + /// <summary>Verifies that search max results one stops early with cap reached.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Search_MaxResultsOne_StopsEarly_WithCapReached() { @@ -88,6 +98,8 @@ public class StubOpcUaClientSearchTests Assert.Contains("Tag", only.Node.DisplayName); } + /// <summary>Verifies that search empty query returns no matches.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Search_EmptyQuery_ReturnsNoMatches() { @@ -99,6 +111,8 @@ public class StubOpcUaClientSearchTests Assert.False(result.CapReached); } + /// <summary>Verifies that search whitespace query returns no matches.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Search_WhitespaceQuery_ReturnsNoMatches() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs index 0e8ac256..00196a8f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs @@ -31,6 +31,7 @@ public class VerifyEndpointResultMappingTests return request.CreateSelfSigned(notBefore, notAfter); } + /// <summary>Verifies that a captured certificate with a validation exception maps to an untrusted-certificate result.</summary> [Fact] public void CapturedCert_WithValidationException_MapsToUntrustedCertificate() { @@ -55,6 +56,7 @@ public class VerifyEndpointResultMappingTests Assert.NotNull(result.Error); } + /// <summary>Verifies that a captured certificate without an exception still maps to an untrusted-certificate result.</summary> [Fact] public void CapturedCert_WithoutException_MapsToUntrustedCertificate() { @@ -70,6 +72,7 @@ public class VerifyEndpointResultMappingTests Assert.Equal(cert.Thumbprint, result.Cert!.Thumbprint); } + /// <summary>Verifies that a timeout exception maps to a timeout result.</summary> [Fact] public void TimeoutException_MapsToTimeout() { @@ -80,6 +83,7 @@ public class VerifyEndpointResultMappingTests Assert.Null(result.Cert); } + /// <summary>Verifies that an operation-canceled exception maps to a timeout result.</summary> [Fact] public void OperationCanceledException_MapsToTimeout() { @@ -90,6 +94,7 @@ public class VerifyEndpointResultMappingTests Assert.Null(result.Cert); } + /// <summary>Verifies that a ServiceResultException with a bad-request timeout code maps to a timeout result.</summary> [Fact] public void ServiceResultException_BadRequestTimeout_MapsToTimeout() { @@ -101,6 +106,8 @@ public class VerifyEndpointResultMappingTests Assert.Equal(VerifyFailureKind.Timeout, result.FailureKind); } + /// <summary>Verifies that ServiceResultException authentication status codes map to an auth-failed result.</summary> + /// <param name="statusCode">The status code.</param> [Theory] [InlineData(StatusCodes.BadUserAccessDenied)] [InlineData(StatusCodes.BadIdentityTokenRejected)] @@ -116,6 +123,8 @@ public class VerifyEndpointResultMappingTests Assert.Null(result.Cert); } + /// <summary>Verifies that ServiceResultException connection status codes map to an unreachable result.</summary> + /// <param name="statusCode">The status code.</param> [Theory] [InlineData(StatusCodes.BadConnectionRejected)] [InlineData(StatusCodes.BadNotConnected)] @@ -129,6 +138,7 @@ public class VerifyEndpointResultMappingTests Assert.Equal(VerifyFailureKind.Unreachable, result.FailureKind); } + /// <summary>Verifies that a socket exception maps to an unreachable result.</summary> [Fact] public void SocketException_MapsToUnreachable() { @@ -141,6 +151,7 @@ public class VerifyEndpointResultMappingTests Assert.Equal(VerifyFailureKind.Unreachable, result.FailureKind); } + /// <summary>Verifies that a socket exception wrapped in a ServiceResultException maps to an unreachable result.</summary> [Fact] public void SocketExceptionWrappedInServiceResult_MapsToUnreachable() { @@ -156,6 +167,7 @@ public class VerifyEndpointResultMappingTests Assert.Equal(VerifyFailureKind.Unreachable, result.FailureKind); } + /// <summary>Verifies that a generic exception maps to a server-error result.</summary> [Fact] public void GenericException_MapsToServerError() { @@ -167,6 +179,7 @@ public class VerifyEndpointResultMappingTests Assert.NotNull(result.Error); } + /// <summary>Verifies that a null exception with no captured certificate maps to a success result.</summary> [Fact] public void NullException_NullCert_MapsToSuccess() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ArgParserTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ArgParserTests.cs index b0c45be8..aabd473f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ArgParserTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ArgParserTests.cs @@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests; public class ArgParserTests { + /// <summary>Verifies that all short flags are parsed into the payload.</summary> [Fact] public void Parses_all_flags() { @@ -17,6 +18,7 @@ public class ArgParserTests Assert.Equal("op", r.Payload.Username); } + /// <summary>Verifies that long-form flags are parsed into the payload.</summary> [Fact] public void Parses_long_flags() { @@ -26,6 +28,7 @@ public class ArgParserTests Assert.Equal("x", r.Payload.DownloadPath); } + /// <summary>Verifies that omitting a required flag returns an error.</summary> [Fact] public void Missing_required_returns_error() { @@ -34,6 +37,7 @@ public class ArgParserTests Assert.Contains("partnumber", r.Error, System.StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that optional flags may be omitted without error.</summary> [Fact] public void Optional_flags_may_be_omitted() { @@ -43,6 +47,7 @@ public class ArgParserTests Assert.Null(r.Payload.JobStepNumber); } + /// <summary>Verifies that an unknown flag returns an error.</summary> [Fact] public void Unknown_flag_returns_error() { @@ -50,6 +55,7 @@ public class ArgParserTests Assert.False(r.Ok); } + /// <summary>Verifies that a flag without a value returns an error.</summary> [Fact] public void Flag_without_value_returns_error() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ConfigLoaderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ConfigLoaderTests.cs index 858d09b4..dc47c66e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ConfigLoaderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ConfigLoaderTests.cs @@ -4,6 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests; public class ConfigLoaderTests { + /// <summary>Verifies that invalid endpoint lists are rejected when the configuration loads.</summary> + /// <param name="urls">The base URL list to load.</param> [Theory] [InlineData("relative")] [InlineData("ftp://example.com")] @@ -17,6 +19,7 @@ public class ConfigLoaderTests Assert.Contains("HTTP", error.Message); } + /// <summary>Verifies that splitting base URLs trims entries and drops empty ones.</summary> [Fact] public void SplitBaseUrls_trims_and_drops_empties() { @@ -24,6 +27,7 @@ public class ConfigLoaderTests Assert.Equal(new[] { "a", "b", "c" }, urls); } + /// <summary>Verifies that a null or whitespace base URL list yields no URLs.</summary> [Fact] public void SplitBaseUrls_null_or_whitespace_returns_empty() { @@ -31,6 +35,7 @@ public class ConfigLoaderTests Assert.Empty(ConfigLoader.SplitBaseUrls(" ")); } + /// <summary>Verifies that the timeout defaults to 30 seconds when it is omitted.</summary> [Fact] public void Load_defaults_timeout_to_30_when_omitted() { @@ -39,6 +44,7 @@ public class ConfigLoaderTests Assert.Equal("http://x", cfg.ScadaBridge.BaseUrls); } + /// <summary>Verifies that loading reads every configured field.</summary> [Fact] public void Load_reads_all_fields() { @@ -48,6 +54,7 @@ public class ConfigLoaderTests Assert.Equal("l.log", cfg.ScadaBridge.LogPath); } + /// <summary>Verifies that an unset or whitespace environment key resolves to null.</summary> [Fact] public void ResolveApiKey_returns_null_when_unset_or_whitespace() { @@ -55,12 +62,14 @@ public class ConfigLoaderTests Assert.Null(ConfigLoader.ResolveApiKey(_ => " ", new NotifierConfig())); } + /// <summary>Verifies that a key present in the environment is returned.</summary> [Fact] public void ResolveApiKey_returns_value_when_present() { Assert.Equal("sbk_x", ConfigLoader.ResolveApiKey(n => n == "SCADABRIDGE_API_KEY" ? "sbk_x" : null, new NotifierConfig())); } + /// <summary>Verifies that the API key is read from the ScadaBridge configuration section.</summary> [Fact] public void Load_reads_ApiKey_from_ScadaBridge_section() { @@ -68,6 +77,7 @@ public class ConfigLoaderTests Assert.Equal("sbk_file", cfg.ScadaBridge.ApiKey); } + /// <summary>Verifies that the environment key takes precedence over the configured key.</summary> [Fact] public void ResolveApiKey_prefers_env_over_config() { @@ -75,6 +85,7 @@ public class ConfigLoaderTests Assert.Equal("sbk_env", ConfigLoader.ResolveApiKey(n => n == "SCADABRIDGE_API_KEY" ? "sbk_env" : null, cfg)); } + /// <summary>Verifies that the configured key is used when the environment key is unset or blank.</summary> [Fact] public void ResolveApiKey_falls_back_to_config_when_env_unset_or_blank() { @@ -83,12 +94,14 @@ public class ConfigLoaderTests Assert.Equal("sbk_file", ConfigLoader.ResolveApiKey(_ => " ", cfg)); } + /// <summary>Verifies that the environment key is trimmed the same way as the configured key.</summary> [Fact] public void ResolveApiKey_trims_env_value_like_the_file_value() { Assert.Equal("sbk_env", ConfigLoader.ResolveApiKey(_ => " sbk_env \t", new NotifierConfig())); } + /// <summary>Verifies that key resolution reports whether the environment or the file supplied the key.</summary> [Fact] public void ResolveApiKey_reports_which_source_supplied_the_key() { @@ -101,6 +114,7 @@ public class ConfigLoaderTests Assert.Null(none); } + /// <summary>Verifies that the API key stays null when the configuration omits it.</summary> [Fact] public void Load_leaves_ApiKey_null_when_absent() { @@ -108,6 +122,7 @@ public class ConfigLoaderTests Assert.Null(cfg.ScadaBridge.ApiKey); } + /// <summary>Verifies that key resolution returns null when neither source supplies a key.</summary> [Fact] public void ResolveApiKey_returns_null_when_neither_source_set() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs index b8cc19f9..f8abfa64 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs @@ -6,6 +6,8 @@ namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests; public class HttpRecipeSenderTests { + /// <summary>Verifies the case: DC7, explicit Standby Refusal, tries Second Url, but Generic503 Does Not.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DC7_ExplicitStandbyRefusal_TriesSecondUrl_ButGeneric503DoesNot() { @@ -27,6 +29,8 @@ public class HttpRecipeSenderTests } } + /// <summary>Verifies the case: DC7, applied Then Response Lost, does Not Replay.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DC7_AppliedThenResponseLost_DoesNotReplay() { @@ -39,8 +43,10 @@ public class HttpRecipeSenderTests private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler { + /// <summary>Gets the last HTTP request seen by this handler.</summary> public HttpRequestMessage? LastRequest { get; private set; } + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; @@ -51,6 +57,8 @@ public class HttpRecipeSenderTests private static readonly RecipeDownload Payload = new() { MachineCode = "Z", DownloadPath = "x", WorkOrderNumber = "W", PartNumber = "P" }; + /// <summary>Verifies the case: connect Failure, maps, to, connect Failed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConnectFailure_maps_to_ConnectFailed() { @@ -62,6 +70,8 @@ public class HttpRecipeSenderTests Assert.Equal(0, outcome.StatusCode); } + /// <summary>Verifies the case: success, 200, parses, body, and, sends, header, and, path.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Success_200_parses_body_and_sends_header_and_path() { @@ -80,6 +90,8 @@ public class HttpRecipeSenderTests Assert.Equal("http://a/api/DelmiaRecipeDownload", handler.LastRequest.RequestUri!.ToString()); } + /// <summary>Verifies the case: non2xx, is, connected, with, status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Non2xx_is_Connected_with_status() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/JsonContractTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/JsonContractTests.cs index c6074f04..bb0dc5c8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/JsonContractTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/JsonContractTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests; public class JsonContractTests { + /// <summary>Verifies that recipe download serializes pascalcase.</summary> [Fact] public void RecipeDownload_serializes_pascalcase() { @@ -16,6 +17,7 @@ public class JsonContractTests Assert.Contains("\"DownloadPath\"", json); } + /// <summary>Verifies that recipe download result deserializes pascalcase.</summary> [Fact] public void RecipeDownloadResult_deserializes_pascalcase() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/NotifierTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/NotifierTests.cs index 95bc488e..6718f1d3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/NotifierTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/NotifierTests.cs @@ -7,8 +7,10 @@ public class NotifierTests private sealed class FakeSender(params AttemptOutcome[] outcomes) : IRecipeSender { private readonly Queue<AttemptOutcome> _outcomes = new(outcomes); + /// <summary>Gets the calls recorded by this fake.</summary> public int Calls { get; private set; } + /// <inheritdoc /> public Task<AttemptOutcome> SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct) { Calls++; @@ -20,6 +22,8 @@ public class NotifierTests private static readonly RecipeDownload Payload = new() { MachineCode = "Z", DownloadPath = "x", WorkOrderNumber = "W", PartNumber = "P" }; + /// <summary>Verifies that a connection failure advances to the next URL.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConnectFailure_advances_to_next_url() { @@ -31,6 +35,8 @@ public class NotifierTests Assert.Equal(2, sender.Calls); } + /// <summary>Verifies that a connected response with a false result is final and does not fail over.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Connected_result_false_is_final_no_failover() { @@ -43,6 +49,8 @@ public class NotifierTests Assert.Equal(1, sender.Calls); } + /// <summary>Verifies that a connected response with a 5xx status is final and does not fail over.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Connected_5xx_is_final_no_failover() { @@ -55,6 +63,8 @@ public class NotifierTests Assert.Equal(1, sender.Calls); } + /// <summary>Verifies that when every URL fails to connect the notifier reports unreachable with the last error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task All_connect_failures_report_unreachable_with_last_error() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs index 88c65ce7..bb339544 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs @@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests; public class ReporterTests { + /// <summary>Verifies that success writes YES and returns 0.</summary> [Fact] public void Success_writes_YES_and_returns_0() { @@ -13,6 +14,7 @@ public class ReporterTests Assert.Equal(0, code); } + /// <summary>Verifies that failure writes NO then reason and returns minus 1.</summary> [Fact] public void Failure_writes_NO_then_reason_and_returns_minus1() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs index 20f7e822..999612aa 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs @@ -18,7 +18,7 @@ using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// <summary> -/// WP-7: Tests for system-wide artifact deployment. +/// Tests for system-wide artifact deployment. /// </summary> public class ArtifactDeploymentServiceTests : TestKit { @@ -31,13 +31,14 @@ public class ArtifactDeploymentServiceTests : TestKit private readonly ArtifactRevisionHashService _hashService = new(); private readonly ISecretStore _secretStore; + /// <summary>Initializes the test class with substitute repositories and an audit service.</summary> public ArtifactDeploymentServiceTests() { _siteRepo = Substitute.For<ISiteRepository>(); _deploymentRepo = Substitute.For<IDeploymentManagerRepository>(); _templateRepo = Substitute.For<ITemplateEngineRepository>(); _externalSystemRepo = Substitute.For<IExternalSystemRepository>(); - // DeploymentManager-025/-027: the notification repo is retained only so the + // The notification repo is retained only so the // tests can assert the artifact path NEVER touches it (it is no longer a // constructor dependency of ArtifactDeploymentService). _notificationRepo = Substitute.For<INotificationRepository>(); @@ -64,6 +65,8 @@ public class ArtifactDeploymentServiceTests : TestKit UpdatedUtc = DateTimeOffset.Parse("2026-01-02T00:00:00Z"), }; + /// <summary>Verifies that deploying to all sites when none are configured returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployToAllSitesAsync_NoSites_ReturnsFailure() { @@ -77,6 +80,7 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.Contains("No sites", result.Error); } + /// <summary>Verifies that a per-site artifact result carries the site's identifying information.</summary> [Fact] public void SiteArtifactResult_ContainsSiteInfo() { @@ -87,6 +91,7 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.Null(result.ErrorMessage); } + /// <summary>Verifies that the artifact deployment summary counts successes and failures correctly.</summary> [Fact] public void ArtifactDeploymentSummary_CountsCorrectly() { @@ -103,12 +108,14 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.Equal(3, summary.SiteResults.Count); } - // ── DeploymentManager-010: one logical deployment id across all per-site commands ── + // ── One logical deployment id across all per-site commands ── + /// <summary>Verifies that every per-site command carries the same deployment id as the summary.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployToAllSitesAsync_AllPerSiteCommandsShareTheSummaryDeploymentId() { - // DeploymentManager-010: previously each per-site DeployArtifactsCommand + // Previously each per-site DeployArtifactsCommand // minted its own GUID, so one logical deployment produced N+1 unrelated // ids. Every per-site command must now carry the SAME id, equal to the // id reported in the summary and audit log. @@ -144,8 +151,10 @@ public class ArtifactDeploymentServiceTests : TestKit Arg.Any<CancellationToken>()); } - // ── DeploymentManager-014: real per-site success/failure coverage ── + // ── Real per-site success/failure coverage ── + /// <summary>Verifies that a partial failure is reported as a per-site success and failure matrix.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployToAllSitesAsync_PartialFailure_ReportsPerSiteMatrix() { @@ -170,12 +179,14 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.Contains(result.Value.SiteResults, r => r.SiteId == "fail-site" && !r.Success); } - // ── DeploymentManager-023: global artifact queries hoisted out of the per-site loop ── + // ── Global artifact queries hoisted out of the per-site loop ── + /// <summary>Verifies that the global artifact queries run once in total rather than once per site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployToAllSitesAsync_HoistsGlobalArtifactQueriesOutOfPerSiteLoop() { - // DeploymentManager-023: previously each per-site iteration of the deploy-many + // Previously each per-site iteration of the deploy-many // loop re-issued the global artifact queries (shared scripts, external systems, // DB connections) — a textbook N+1 over the global sets. With three sites the // queries must now be issued ONCE in total, regardless of site count. @@ -197,7 +208,7 @@ public class ArtifactDeploymentServiceTests : TestKit await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>()); await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>()); await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>()); - // DeploymentManager-025/-027: notification lists and SMTP configuration are + // Notification lists and SMTP configuration are // central-only — the artifact path must NEVER fetch them, and the per-site // command must NEVER carry them. await _notificationRepo.DidNotReceive().GetAllNotificationListsAsync(Arg.Any<CancellationToken>()); @@ -221,6 +232,7 @@ public class ArtifactDeploymentServiceTests : TestKit /// silently used the engine default (50 x 30 s) — while the sibling /// <c>DatabaseConnectionArtifact</c> had always carried its equivalents. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployedExternalSystemArtifact_CarriesRetryPolicy() { @@ -246,10 +258,12 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.Equal(TimeSpan.FromSeconds(120), artifact.RetryDelay); } + /// <summary>Verifies that retrying a single site still runs the global artifact queries exactly once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryForSiteAsync_SingleSitePath_StillRunsTheGlobalQueriesOnce() { - // DeploymentManager-023: the single-site convenience overload still owns its + // The single-site convenience overload still owns its // own global-fetch (it cannot inherit from a sweep), so for one site every // global query is issued exactly once. Pin this so a future refactor cannot // accidentally route RetryForSiteAsync through the multi-site loop and lose @@ -264,7 +278,7 @@ public class ArtifactDeploymentServiceTests : TestKit await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>()); await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>()); await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>()); - // DeploymentManager-025/-027: central-only — never fetched, never shipped. + // Central-only — never fetched, never shipped. await _notificationRepo.DidNotReceive().GetAllNotificationListsAsync(Arg.Any<CancellationToken>()); await _notificationRepo.DidNotReceive().GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>()); Assert.All(recorder.Received, cmd => @@ -274,6 +288,8 @@ public class ArtifactDeploymentServiceTests : TestKit }); } + /// <summary>Verifies that a successful retry for a site returns success and writes an audit entry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryForSiteAsync_SiteSucceeds_ReturnsSuccessAndAudits() { @@ -290,8 +306,10 @@ public class ArtifactDeploymentServiceTests : TestKit Arg.Any<string>(), "retry-site", Arg.Any<object>(), Arg.Any<CancellationToken>()); } - // ── PLAN-07 Task 4: single-site artifact deployment ── + // ── Single-site artifact deployment ── + /// <summary>Verifies that a single-site deploy sends artifacts only to the target site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployToSiteAsync_DeploysOnlyToTargetSite() { @@ -313,6 +331,8 @@ public class ArtifactDeploymentServiceTests : TestKit await _siteRepo.DidNotReceive().GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a single-site deploy to an unknown site returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployToSiteAsync_UnknownSite_ReturnsFailure() { @@ -327,6 +347,8 @@ public class ArtifactDeploymentServiceTests : TestKit // ── Site artifact revision stamp (pending-updates §3.1) ── + /// <summary>Verifies that a successful deploy stamps the site's artifact hash and timestamp and saves.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployToAllSitesAsync_SuccessfulSite_StampsHashAndTimestampAndSaves() { @@ -348,6 +370,8 @@ public class ArtifactDeploymentServiceTests : TestKit await _siteRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a failed site keeps its previous artifact stamp.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployToAllSitesAsync_FailedSite_KeepsItsPreviousStamp() { @@ -376,6 +400,8 @@ public class ArtifactDeploymentServiceTests : TestKit await _siteRepo.Received(1).UpdateSiteAsync(ok, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the deployment record is still persisted and success reported when saving the stamp throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployToAllSitesAsync_StampSaveThrows_DeploymentRecordStillPersistedAndSuccessReported() { @@ -406,6 +432,8 @@ public class ArtifactDeploymentServiceTests : TestKit }); } + /// <summary>Verifies that a successful retry stamps the site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryForSiteAsync_Success_StampsTheSite() { @@ -426,6 +454,8 @@ public class ArtifactDeploymentServiceTests : TestKit await _siteRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a failed retry does not stamp the site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryForSiteAsync_Failure_DoesNotStampTheSite() { @@ -443,6 +473,8 @@ public class ArtifactDeploymentServiceTests : TestKit await _siteRepo.DidNotReceive().UpdateSiteAsync(Arg.Any<Site>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the computed current artifact hashes match the hash a deploy stamps.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputeCurrentArtifactHashesAsync_MatchesTheHashADeployStamps() { @@ -470,10 +502,12 @@ public class ArtifactDeploymentServiceTests : TestKit } } + /// <summary>Verifies that the computed current artifact hashes match the stamped hash when data connections reference secrets.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputeCurrentArtifactHashesAsync_MatchesTheHashADeployStamps_WithSecrets() { - // Final review M7: the parity above runs with no data connections. Pin it for a site + // The parity above runs with no data connections. Pin it for a site // whose connections reference secrets, so the stamped hash (built from a command that // carries a non-empty Secrets list) equals the "what would it be now" hash — a // successful secrets deploy must read Current, not Out of date. @@ -510,6 +544,8 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.Equal(site.ArtifactRevisionHash, current[site.Id]); } + /// <summary>Verifies that computing the current artifact hashes runs the global queries once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputeCurrentArtifactHashesAsync_HoistsTheGlobalQueriesOnce() { @@ -531,6 +567,8 @@ public class ArtifactDeploymentServiceTests : TestKit await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(3, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that computing the current hashes with no sites returns an empty result without querying.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputeCurrentArtifactHashesAsync_NoSites_ReturnsEmptyWithoutQuerying() { @@ -544,6 +582,8 @@ public class ArtifactDeploymentServiceTests : TestKit // ── Secrets ride the artifact deploy (design §3.1/§3.3) ── + /// <summary>Verifies that a site with a missing secret fails alone and nothing is sent to it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployToAllSites_SiteWithMissingSecret_FailsThatSiteOnly_AndSendsNothingToIt() { @@ -589,6 +629,8 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.Null(badSite.ArtifactRevisionHash); } + /// <summary>Verifies that a missing secret is represented by the missing marker in the computed hash.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputeCurrentHashes_MissingSecret_UsesMissingMarker() { @@ -612,6 +654,8 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.NotEqual(beforeCreated[1], afterCreated[1]); } + /// <summary>Verifies that deploy logs never contain secret material.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployLogs_NeverContainSecretMaterial() { @@ -640,7 +684,7 @@ public class ArtifactDeploymentServiceTests : TestKit .Returns(Task.FromResult<StoredSecret?>(resolvedRow)); var recorder = new ArtifactProbeRecorder(); - // Task 7: ok-site's command ships one secret, so the probe must ack it (SecretsApplied + // ok-site's command ships one secret, so the probe must ack it (SecretsApplied // = shipped count) for the site to reconcile as a success — an unset SecretsApplied // now reads as an old site (see the dedicated reconciliation tests below). var probe = Sys.ActorOf(Props.Create(() => @@ -665,8 +709,10 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.DoesNotContain(capturingLogger.Messages, m => m.Contains(ciphertextHex, StringComparison.OrdinalIgnoreCase)); } - // ── Task 7: stamp gating on secret application + DeploySiteSecrets audit row ── + // ── Stamp gating on secret application + DeploySiteSecrets audit row ── + /// <summary>Verifies that an old site that reports no applied secrets fails and is not stamped when secrets were shipped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OldSite_NullSecretsApplied_WithSecrets_FailsAndDoesNotStamp() { @@ -703,6 +749,8 @@ public class ArtifactDeploymentServiceTests : TestKit await _siteRepo.DidNotReceive().UpdateSiteAsync(site, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a partial secrets apply fails the deploy and does not stamp the site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PartialApply_FailsAndDoesNotStamp() { @@ -740,6 +788,8 @@ public class ArtifactDeploymentServiceTests : TestKit await _siteRepo.DidNotReceive().UpdateSiteAsync(site, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that an old site is still stamped when no secrets were shipped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OldSite_NoSecrets_StillStamps() { @@ -766,6 +816,8 @@ public class ArtifactDeploymentServiceTests : TestKit await _siteRepo.Received(1).UpdateSiteAsync(site, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a site with no secret references receives an empty secrets list so it removes previously deployed ones.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoSecretReferences_ShipsEmptySecretsList_SoTheSiteRemovesPreviouslyDeployedOnes() { @@ -773,7 +825,7 @@ public class ArtifactDeploymentServiceTests : TestKit // are marked removed and the mirror deletes them, while a NULL Secrets means "central // shipped no secrets" and touches nothing. Dropping a site's LAST secret reference must // therefore ship an empty list, not null — otherwise the previously deployed secret stays - // live on both site nodes forever (Task 13 live gate, check 5). + // live on both site nodes forever. var site = new Site("Site One", "site-1") { Id = 1 }; _siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site }); _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>()) @@ -804,6 +856,8 @@ public class ArtifactDeploymentServiceTests : TestKit Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a secret deploy writes one audit row per site without any secret material.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SecretDeploy_WritesOneAuditRowPerSite_WithoutMaterial() { @@ -879,9 +933,11 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.DoesNotContain("\"tag\"", json, StringComparison.OrdinalIgnoreCase); } - // ── Task 7 fix round 1: the same gate + audit row on the RetryForSiteAsync path ── + // ── The same gate + audit row on the RetryForSiteAsync path ── // (review gap: only the DeployToAllSitesAsync/sweep path had coverage above) + /// <summary>Verifies that a retry for an old site that reports no applied secrets fails and is not stamped when secrets were shipped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryForSiteAsync_OldSite_NullSecretsApplied_WithSecrets_FailsAndDoesNotStamp() { @@ -910,6 +966,8 @@ public class ArtifactDeploymentServiceTests : TestKit await _siteRepo.DidNotReceive().UpdateSiteAsync(Arg.Any<Site>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a partial secrets apply on retry fails and does not stamp the site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryForSiteAsync_PartialApply_FailsAndDoesNotStamp() { @@ -940,6 +998,8 @@ public class ArtifactDeploymentServiceTests : TestKit await _siteRepo.DidNotReceive().UpdateSiteAsync(Arg.Any<Site>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a retry with a missing secret fails closed, sends nothing and writes one audit row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryForSiteAsync_MissingSecret_FailsClosed_SendsNothing_WritesOneAuditRow() { @@ -988,6 +1048,8 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.Contains("\"secrets\":[]", json); } + /// <summary>Verifies that a successful retry with secrets stamps the site and writes one audit row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryForSiteAsync_HappyPath_WithSecrets_StampsAndWritesOneAuditRow() { @@ -1088,12 +1150,27 @@ public class ArtifactDeploymentServiceTests : TestKit /// </summary> private sealed class CapturingLogger<T> : ILogger<T> { + /// <summary>Gets the formatted log messages captured so far.</summary> public List<string> Messages { get; } = []; + /// <summary>Returns no scope because the fake does not track scopes.</summary> + /// <typeparam name="TState">The type of the state object.</typeparam> + /// <param name="state">The scope or log state.</param> + /// <returns>A no-op scope.</returns> public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance; + /// <summary>Reports whether logging is enabled for the given level.</summary> + /// <param name="logLevel">The log level.</param> + /// <returns>True if the level is enabled; otherwise false.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The type of the state object.</typeparam> + /// <param name="logLevel">The log level.</param> + /// <param name="eventId">The event id.</param> + /// <param name="state">The scope or log state.</param> + /// <param name="exception">The exception associated with the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message text.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) @@ -1108,13 +1185,14 @@ public class ArtifactDeploymentServiceTests : TestKit private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); + /// <summary>Does nothing; there is nothing to release.</summary> public void Dispose() { } } } /// <summary> - /// Per-test recorder for <see cref="ArtifactProbeActor"/>. DeploymentManager-024: - /// each test owns its own instance, passed into the actor's constructor, so + /// Per-test recorder for <see cref="ArtifactProbeActor"/>. + /// Each test owns its own instance, passed into the actor's constructor, so /// the received-command list is no longer shared static state that races /// under parallel test execution. /// </summary> @@ -1131,6 +1209,9 @@ public class ArtifactDeploymentServiceTests : TestKit /// </summary> private class ArtifactProbeActor : ReceiveActor { + /// <summary>Initializes the actor with the per-test recorder and the site ids that should fail.</summary> + /// <param name="recorder">The recorder that receives every deploy command.</param> + /// <param name="failingSites">The site ids for which the actor replies with a failure.</param> public ArtifactProbeActor(ArtifactProbeRecorder recorder, params string[] failingSites) { var failSet = new HashSet<string>(failingSites); @@ -1149,16 +1230,10 @@ public class ArtifactDeploymentServiceTests : TestKit } } - /// <summary> - /// Task 7: stand-in CentralCommunicationActor that always reports the deploy itself as - /// successful but lets a test control exactly what <see cref="ArtifactDeploymentResponse.SecretsApplied"/> - /// comes back for a given command — an old site (<c>null</c>), a partial apply (a count - /// short of what was shipped), or a correct ack (the shipped count) — so the secrets- - /// reconciliation gate can be exercised independently of the wire-level success/failure - /// the shared <see cref="ArtifactProbeActor"/> models. - /// </summary> - // ── Final review M5: a central secret-store outage is contained to the sites that need it ── + // ── A central secret-store outage is contained to the sites that need it ── + /// <summary>Verifies that a central secret-store outage fails only the secret-referencing site and audits it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreOutage_FailsOnlyTheSecretReferencingSite_AndAuditsIt() { @@ -1186,7 +1261,7 @@ public class ArtifactDeploymentServiceTests : TestKit Arg.Any<object>(), Arg.Any<CancellationToken>())) .Do(ci => audited = ci[5]); - // Before M5 the store exception escaped the build loop and the whole sweep threw. + // Previously the store exception escaped the build loop and the whole sweep threw. var result = await service.DeployToAllSitesAsync("admin"); Assert.True(result.IsSuccess); @@ -1210,6 +1285,8 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.Contains("Central secret store could not be read", json); } + /// <summary>Verifies that a secret-store outage omits only the secret-referencing site from the computed hashes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ComputeCurrentHashes_StoreOutage_OmitsOnlyTheSecretReferencingSite() { @@ -1234,6 +1311,8 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.True(hashes.ContainsKey(2)); } + /// <summary>Verifies that a secret-store outage on retry fails the site without sending anything.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryForSite_StoreOutage_FailsWithoutSending() { @@ -1261,6 +1340,8 @@ public class ArtifactDeploymentServiceTests : TestKit // ── Removal-only deploys are audited (the site reports the names it retired) ── + /// <summary>Verifies that a removal-only deploy writes an audit row naming the removed secrets.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RemovalOnlyDeploy_WritesAuditRowNamingTheRemovedSecrets() { @@ -1294,6 +1375,8 @@ public class ArtifactDeploymentServiceTests : TestKit Assert.Contains("\"success\":true", json); } + /// <summary>Verifies that a removal-only retry writes an audit row naming the removed secrets.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryForSite_RemovalOnly_WritesAuditRowNamingTheRemovedSecrets() { @@ -1320,6 +1403,9 @@ public class ArtifactDeploymentServiceTests : TestKit /// <summary>Acks every command as applied (0 shipped secrets) and reports the given names as retired.</summary> private class RemovingProbeActor : ReceiveActor { + /// <summary>Initializes the actor with the recorder and the secret names to report as retired.</summary> + /// <param name="recorder">The recorder that receives every deploy command.</param> + /// <param name="removed">The secret names reported as removed by the site.</param> public RemovingProbeActor(ArtifactProbeRecorder recorder, IReadOnlyList<string> removed) { Receive<SiteEnvelope>(env => @@ -1335,8 +1421,12 @@ public class ArtifactDeploymentServiceTests : TestKit } } + /// <summary>Stand-in CentralCommunicationActor that always reports the deploy itself as successful but lets a test control exactly what <see cref="ArtifactDeploymentResponse.SecretsApplied"/> comes back for a given command: an old site (<c>null</c>), a partial apply, or a correct ack, so the secrets-reconciliation gate can be exercised independently of the wire-level success or failure that <see cref="ArtifactProbeActor"/> models.</summary> private class SecretsAckProbeActor : ReceiveActor { + /// <summary>Initializes the actor with the recorder and the function that decides the applied-secrets count.</summary> + /// <param name="recorder">The recorder that receives every deploy command.</param> + /// <param name="secretsApplied">Computes the applied-secrets count to acknowledge for a command.</param> public SecretsAckProbeActor(ArtifactProbeRecorder recorder, Func<DeployArtifactsCommand, int?> secretsApplied) { Receive<SiteEnvelope>(env => diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs index 0e21140d..504dabc0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs @@ -64,6 +64,7 @@ public class ArtifactRevisionHashServiceTests IReadOnlyList<string>? missing = null) => Command() with { Secrets = secrets, MissingSecrets = missing }; + /// <summary>Verifies that changing a secret changes the hash while reordering secrets does not.</summary> [Fact] public void SecretChanges_MoveTheHash_ButOrderDoesNot() { @@ -78,6 +79,7 @@ public class ArtifactRevisionHashServiceTests Assert.NotEqual(baseline, svc.ComputeHash(Cmd(secrets: [a with { KekId = "kek-B" }, b]))); } + /// <summary>Verifies that a missing secret hashes differently from a present one.</summary> [Fact] public void MissingSecret_HashesDifferentlyFromPresent() { @@ -88,13 +90,14 @@ public class ArtifactRevisionHashServiceTests svc.ComputeHash(Cmd(missing: ["A"]))); } + /// <summary>Verifies that the hash of a command without secrets still matches the golden value captured before secrets were added.</summary> [Fact] public void ComputeHash_PreSecretsGoldenValue_IsUnchanged() { - // Captured on HEAD 738e4587 — BEFORE Task 4 wired Secrets/MissingSecrets into the + // Captured on HEAD 738e4587 — BEFORE Secrets/MissingSecrets were wired into the // projection — using the fixed default baseline (one of each existing artifact // kind) with a pinned deploymentId/timestamp. A command with Secrets == null and - // MissingSecrets == null must keep hashing to this exact value after Task 4, or a + // MissingSecrets == null must keep hashing to this exact value after that change, or a // site with no secret references would spuriously flip to "out of date". var golden = Command( deploymentId: "golden", @@ -105,6 +108,7 @@ public class ArtifactRevisionHashServiceTests _sut.ComputeHash(golden)); } + /// <summary>Verifies that hashing the same input twice produces the same hash.</summary> [Fact] public void ComputeHash_SameInputTwice_ProducesSameHash() { @@ -114,6 +118,7 @@ public class ArtifactRevisionHashServiceTests _sut.ComputeHash(Command(timestamp: timestamp))); } + /// <summary>Verifies that the hash is a SHA-256 value encoded as hexadecimal.</summary> [Fact] public void ComputeHash_IsSha256HexEncoded() { @@ -126,6 +131,7 @@ public class ArtifactRevisionHashServiceTests Assert.All(digest, c => Assert.Contains(c, "0123456789abcdef")); } + /// <summary>Verifies that reordering the artifact lists produces the same hash.</summary> [Fact] public void ComputeHash_ReorderedArtifactLists_ProducesSameHash() { @@ -143,6 +149,7 @@ public class ArtifactRevisionHashServiceTests Assert.Equal(_sut.ComputeHash(baseline), _sut.ComputeHash(reordered)); } + /// <summary>Verifies that reordering the methods of an external system produces the same hash.</summary> [Fact] public void ComputeHash_ReorderedExternalSystemMethods_ProducesSameHash() { @@ -162,6 +169,7 @@ public class ArtifactRevisionHashServiceTests Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b)); } + /// <summary>Verifies that reordering camelCase method names produces the same hash.</summary> [Fact] public void ComputeHash_ReorderedCamelCaseMethodNames_ProducesSameHash() { @@ -182,6 +190,7 @@ public class ArtifactRevisionHashServiceTests Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b)); } + /// <summary>Verifies that unparseable method definitions still participate in the hash.</summary> [Fact] public void ComputeHash_UnparseableMethodDefinitions_StillParticipatesInTheHash() { @@ -195,6 +204,7 @@ public class ArtifactRevisionHashServiceTests Assert.NotEqual(_sut.ComputeHash(a), _sut.ComputeHash(b)); } + /// <summary>Verifies that a different deployment id and timestamp produce the same hash.</summary> [Fact] public void ComputeHash_DifferentDeploymentIdAndTimestamp_ProducesSameHash() { @@ -206,6 +216,7 @@ public class ArtifactRevisionHashServiceTests Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b)); } + /// <summary>Verifies that empty and null artifact lists hash identically.</summary> [Fact] public void ComputeHash_EmptyAndNullArtifactLists_HashIdentically() { @@ -217,6 +228,7 @@ public class ArtifactRevisionHashServiceTests Assert.Equal(_sut.ComputeHash(withNulls), _sut.ComputeHash(withEmpties)); } + /// <summary>Verifies that notification and SMTP artifacts do not affect the hash.</summary> [Fact] public void ComputeHash_NotificationAndSmtpArtifacts_DoNotAffectTheHash() { @@ -233,6 +245,8 @@ public class ArtifactRevisionHashServiceTests Assert.Equal(_sut.ComputeHash(baseline), _sut.ComputeHash(withCentralOnlyPayload)); } + /// <summary>Provides one deploy command per artifact field, each differing from its baseline in exactly that field.</summary> + /// <returns>Theory data of the changed field name and the changed command.</returns> public static TheoryData<string, DeployArtifactsCommand> ChangedFieldCases() { var data = new TheoryData<string, DeployArtifactsCommand>(); @@ -288,6 +302,9 @@ public class ArtifactRevisionHashServiceTests return data; } + /// <summary>Verifies that changing any single artifact field changes the hash.</summary> + /// <param name="changedField">The name of the artifact field that differs from the baseline.</param> + /// <param name="changed">The command whose single field differs from the baseline.</param> [Theory] [MemberData(nameof(ChangedFieldCases))] public void ComputeHash_EveryArtifactField_ParticipatesInTheHash( @@ -312,6 +329,7 @@ public class ArtifactRevisionHashServiceTests Assert.NotEqual(_sut.ComputeHash(baseline), _sut.ComputeHash(changed)); } + /// <summary>Verifies that the hashable records declare their properties in alphabetical order.</summary> [Fact] public void HashableRecords_PropertiesDeclaredAlphabetically() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs index 7b7a8898..ecb75f45 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs @@ -22,7 +22,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// <summary> -/// WP2.5: bulk site deployment. Pins the properties that make the fan-out safe — +/// Bulk site deployment. Pins the properties that make the fan-out safe — /// concurrency is BOUNDED by the configured degree, one slow instance does not stop /// the others completing, a per-instance timeout contains a wedged instance, and /// every instance still gets its own deployment id and its own operation lock. @@ -38,6 +38,7 @@ public class DeploySiteAsyncTests : TestKit private readonly IStaleInstanceProbe _staleProbe = Substitute.For<IStaleInstanceProbe>(); private readonly OperationLockManager _lockManager = new(); + /// <summary>Initializes the test class with substituted repositories, a flattening pipeline and a stale-instance probe.</summary> public DeploySiteAsyncTests() { _siteRepo.GetSiteByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()) @@ -111,6 +112,8 @@ public class DeploySiteAsyncTests : TestKit NullLogger<DeploymentService>.Instance); } + /// <summary>Verifies that one slow instance does not stop the others completing and that concurrency stays bounded.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploySiteAsync_SlowInstance_OthersStillComplete_AndConcurrencyIsBounded() { @@ -149,6 +152,8 @@ public class DeploySiteAsyncTests : TestKit Assert.DoesNotContain(deploymentIds, id => string.IsNullOrEmpty(id)); } + /// <summary>Verifies that a wedged instance times out on its own while the rest of the batch succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploySiteAsync_WedgedInstance_TimesOutAlone_RestSucceed() { @@ -175,6 +180,8 @@ public class DeploySiteAsyncTests : TestKit Assert.Equal("Inst-02", wedged.UniqueName); } + /// <summary>Verifies that a bulk site deploy releases every per-instance operation lock.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploySiteAsync_ReleasesEveryOperationLock() { @@ -193,6 +200,8 @@ public class DeploySiteAsyncTests : TestKit Assert.Equal(0, _lockManager.TrackedLockCount); } + /// <summary>Verifies that deploying a site with no instances succeeds with an empty summary.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploySiteAsync_SiteWithNoInstances_SucceedsWithEmptySummary() { @@ -207,6 +216,8 @@ public class DeploySiteAsyncTests : TestKit Assert.Empty(result.Value.InstanceResults); } + /// <summary>Verifies that deploying an unknown site returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploySiteAsync_UnknownSite_ReturnsFailure() { @@ -237,6 +248,7 @@ public class DeploySiteAsyncTests : TestKit /// under the old shape the last instance's token would already be ~2 s old. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploySiteAsync_StagesEachTokenImmediatelyBeforeItsOwnSend() { @@ -280,6 +292,7 @@ public class DeploySiteAsyncTests : TestKit /// the process. Their records must also be finalised as Failed rather than left /// InProgress. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploySiteAsync_CancelledMidPrepare_ReleasesEveryLock_AndFailsPreparedRecords() { @@ -320,7 +333,7 @@ public class DeploySiteAsyncTests : TestKit } /// <summary> - /// P7-2: a bulk site deploy must not restart deliberately-stopped equipment. + /// A bulk site deploy must not restart deliberately-stopped equipment. /// /// <para> /// Deploy is a legal transition out of <c>Disabled</c> and the deploy path @@ -332,6 +345,7 @@ public class DeploySiteAsyncTests : TestKit /// this one" action. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploySiteAsync_SkipsDisabledInstances_WithoutDeployingOrReEnablingThem() { @@ -376,7 +390,7 @@ public class DeploySiteAsyncTests : TestKit } /// <summary> - /// P7-3: phase 1 is serial, so an unreachable site used to cost the batch one + /// Phase 1 is serial, so an unreachable site used to cost the batch one /// full 30-second <c>QueryTimeout</c> PER INSTANCE before a single deploy was /// sent — hours for a large site, so the CLI's 5-minute bulk deadline cancelled /// and the next attempt re-armed the same cost. Two bounds close it: a short @@ -389,6 +403,7 @@ public class DeploySiteAsyncTests : TestKit /// whole finding: without the breaker there would be six. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploySiteAsync_UnreachableSite_ReconcilesOnceThenSkipsTheRestOfTheBatch() { @@ -457,7 +472,7 @@ public class DeploySiteAsyncTests : TestKit } /// <summary> - /// P7-6: a bulk site deploy must not redeploy instances whose configuration has + /// A bulk site deploy must not redeploy instances whose configuration has /// not changed. /// /// <para> @@ -476,6 +491,7 @@ public class DeploySiteAsyncTests : TestKit /// record, no staged config, no command — for the unchanged pair. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploySiteAsync_SkipsUpToDateInstances_SoTheirSiteStateIsNeverReset() { @@ -519,10 +535,11 @@ public class DeploySiteAsyncTests : TestKit } /// <summary> - /// P7-6: <c>force</c> restores the unconditional behaviour for operators whose + /// <c>force</c> restores the unconditional behaviour for operators whose /// site state central cannot see — a restored data volume or a rebuilt node /// looks perfectly up to date from central and still needs the push. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploySiteAsync_Force_RedeploysUpToDateInstances() { @@ -542,12 +559,14 @@ public class DeploySiteAsyncTests : TestKit } /// <summary> - /// P7-6 composed with P7-2: <c>force</c> suppresses the up-to-date filter and + /// Combining <c>force</c> with the disabled-instance skip: <c>force</c> suppresses the up-to-date filter and /// ONLY that filter. A Disabled instance is never deployed at bulk scope, /// because forcing a bulk push is an operator saying "the site's config may be /// wrong", not "restart the equipment I stopped" — the single-instance deploy /// remains the explicit re-enable action. /// </summary> + /// <param name="force">Whether the bulk deploy is forced.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] @@ -576,6 +595,7 @@ public class DeploySiteAsyncTests : TestKit /// NOT sufficient — each one would be a silently skipped instance that actually /// needed the deploy. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploySiteAsync_MatchingHashIsNotEnough_WhenTheInstanceStillNeedsDeploying() { @@ -614,6 +634,8 @@ public class DeploySiteAsyncTests : TestKit /// <summary>Answers every deploy with Success, recording which instances the site was asked to apply.</summary> private sealed class RecordingSiteActor : ReceiveActor { + /// <summary>Initializes the actor with the collection that records applied instance names.</summary> + /// <param name="applied">The collection that receives each applied instance's unique name.</param> public RecordingSiteActor(ConcurrentBag<string> applied) { Receive<SiteEnvelope>(env => @@ -637,6 +659,8 @@ public class DeploySiteAsyncTests : TestKit /// </summary> private sealed class SilentQuerySiteActor : ReceiveActor { + /// <summary>Initializes the actor with the callback invoked for each unanswered query.</summary> + /// <param name="onQuery">The callback invoked each time a state query arrives.</param> public SilentQuerySiteActor(Action onQuery) { Receive<SiteEnvelope>(env => @@ -663,6 +687,9 @@ public class DeploySiteAsyncTests : TestKit /// </summary> private sealed class TokenAgeRecordingSiteActor : ReceiveActor { + /// <summary>Initializes the actor with the collection that records token ages and the reply delay.</summary> + /// <param name="ages">The collection that receives each command's token age on arrival.</param> + /// <param name="delay">The delay before each command is answered.</param> public TokenAgeRecordingSiteActor(ConcurrentBag<TimeSpan> ages, TimeSpan delay) { Receive<SiteEnvelope>(env => @@ -719,6 +746,10 @@ public class DeploySiteAsyncTests : TestKit /// </summary> private sealed class ThrottledSiteActor : ReceiveActor { + /// <summary>Initializes the actor with the concurrency tracker and the optional slow instance.</summary> + /// <param name="tracker">The tracker that records the observed concurrency.</param> + /// <param name="slowInstanceName">The name of the instance whose reply is delayed, or null for none.</param> + /// <param name="slowDelay">How long the named instance's reply is delayed.</param> public ThrottledSiteActor(ConcurrencyTracker tracker, string? slowInstanceName, TimeSpan slowDelay) { Receive<SiteEnvelope>(env => diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentComparisonTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentComparisonTests.cs index 23a14d57..941caaa6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentComparisonTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentComparisonTests.cs @@ -11,9 +11,9 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// <summary> -/// WP-8: Tests for deployed vs template-derived state comparison. +/// Tests for deployed vs template-derived state comparison. /// -/// PLAN-05 Task 16: adds behavioral coverage proving the read-only +/// Adds behavioral coverage proving the read-only /// staleness/comparison path can flatten (and thus hash) an instance whose /// script does NOT compile — because <c>validateScripts: false</c> skips the /// expensive Roslyn <see cref="ValidationService.ValidateScriptCompilation"/> @@ -22,6 +22,7 @@ namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// </summary> public class DeploymentComparisonTests { + /// <summary>Verifies that a comparison with matching hashes is not stale.</summary> [Fact] public void DeploymentComparisonResult_MatchingHashes_NotStale() { @@ -33,6 +34,7 @@ public class DeploymentComparisonTests Assert.Equal("sha256:abc", result.CurrentRevisionHash); } + /// <summary>Verifies that a comparison with different hashes is stale.</summary> [Fact] public void DeploymentComparisonResult_DifferentHashes_IsStale() { @@ -43,6 +45,7 @@ public class DeploymentComparisonTests Assert.NotEqual(result.DeployedRevisionHash, result.CurrentRevisionHash); } + /// <summary>Verifies that a comparison result carries the deployed timestamp.</summary> [Fact] public void DeploymentComparisonResult_ContainsDeployedTimestamp() { @@ -52,7 +55,7 @@ public class DeploymentComparisonTests Assert.Equal(deployedAt, result.DeployedAt); } - // ── PLAN-05 Task 16: read-only paths skip Roslyn script compilation ── + // ── read-only paths skip Roslyn script compilation ── private const int InstanceId = 1; private const int TemplateId = 10; @@ -100,6 +103,8 @@ public class DeploymentComparisonTests return (templateRepo, siteRepo, sharedSchemaRepo); } + /// <summary>Verifies that flattening with script validation off succeeds with a hash and no compilation error for a non-compiling script.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FlattenAndValidate_ValidateScriptsFalse_NonCompilingScript_SucceedsWithHashAndNoCompilationError() { @@ -116,6 +121,8 @@ public class DeploymentComparisonTests e => e.Category == ValidationCategory.ScriptCompilation); } + /// <summary>Verifies that flattening with the default script validation reports a compilation error for a non-compiling script.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FlattenAndValidate_DefaultValidateScripts_NonCompilingScript_ReportsCompilationError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentManagerOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentManagerOptionsValidatorTests.cs index 430a86ab..d1038b8a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentManagerOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentManagerOptionsValidatorTests.cs @@ -15,6 +15,7 @@ public class DeploymentManagerOptionsValidatorTests private static ValidateOptionsResult Validate(DeploymentManagerOptions options) => new DeploymentManagerOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the default options pass validation.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -22,6 +23,7 @@ public class DeploymentManagerOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero lifecycle command timeout is rejected.</summary> [Fact] public void ZeroLifecycleCommandTimeout_IsRejected() { @@ -30,6 +32,7 @@ public class DeploymentManagerOptionsValidatorTests Assert.Contains("LifecycleCommandTimeout", result.FailureMessage); } + /// <summary>Verifies that a negative lifecycle command timeout is rejected.</summary> [Fact] public void NegativeLifecycleCommandTimeout_IsRejected() { @@ -38,6 +41,7 @@ public class DeploymentManagerOptionsValidatorTests Assert.Contains("LifecycleCommandTimeout", result.FailureMessage); } + /// <summary>Verifies that a zero per-site artifact deployment timeout is rejected.</summary> [Fact] public void ZeroArtifactDeploymentTimeoutPerSite_IsRejected() { @@ -46,6 +50,7 @@ public class DeploymentManagerOptionsValidatorTests Assert.Contains("ArtifactDeploymentTimeoutPerSite", result.FailureMessage); } + /// <summary>Verifies that a zero operation lock timeout is rejected.</summary> [Fact] public void ZeroOperationLockTimeout_IsRejected() { @@ -54,6 +59,7 @@ public class DeploymentManagerOptionsValidatorTests Assert.Contains("OperationLockTimeout", result.FailureMessage); } + /// <summary>Verifies that a zero site deployment reconcile timeout is rejected.</summary> [Fact] public void ZeroSiteDeploymentReconcileTimeout_IsRejected() { @@ -62,6 +68,7 @@ public class DeploymentManagerOptionsValidatorTests Assert.Contains("SiteDeploymentReconcileTimeout", result.FailureMessage); } + /// <summary>Verifies that a negative site deployment reconcile timeout is rejected.</summary> [Fact] public void NegativeSiteDeploymentReconcileTimeout_IsRejected() { @@ -73,6 +80,7 @@ public class DeploymentManagerOptionsValidatorTests Assert.Contains("SiteDeploymentReconcileTimeout", result.FailureMessage); } + /// <summary>Verifies that a zero pending-updates cache TTL is rejected.</summary> [Fact] public void ZeroPendingUpdatesCacheTtl_IsRejected() { @@ -84,6 +92,7 @@ public class DeploymentManagerOptionsValidatorTests Assert.Contains("PendingUpdatesCacheTtl", result.FailureMessage); } + /// <summary>Verifies that a negative pending-updates cache TTL is rejected.</summary> [Fact] public void NegativePendingUpdatesCacheTtl_IsRejected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs index 47f0b6b1..7a97d80a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs @@ -89,13 +89,24 @@ public class DeploymentServiceStalenessTests /// <summary>Every entry logged, in order.</summary> public List<(LogLevel Level, string Message)> Entries { get; } = []; - /// <inheritdoc /> + /// <summary>Begins a logical scope; this capturing logger does not track scopes.</summary> + /// <typeparam name="TState">The type of the scope state.</typeparam> + /// <param name="state">The scope state.</param> + /// <returns>Always <see langword="null"/>.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; - /// <inheritdoc /> + /// <summary>Reports whether a log level is enabled; every level is.</summary> + /// <param name="logLevel">The log level to check.</param> + /// <returns>Always <see langword="true"/>.</returns> public bool IsEnabled(LogLevel logLevel) => true; - /// <inheritdoc /> + /// <summary>Records the level and formatted message in <see cref="Entries"/>.</summary> + /// <typeparam name="TState">The type of the log state.</typeparam> + /// <param name="logLevel">The severity of the entry.</param> + /// <param name="eventId">The event id of the entry.</param> + /// <param name="state">The log state.</param> + /// <param name="exception">The exception associated with the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, @@ -135,6 +146,8 @@ public class DeploymentServiceStalenessTests _repo.GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>()) .Returns(rows.ToDictionary(r => r.InstanceId, r => r.Hash)); + /// <summary>Verifies that the staleness check shares one flatten session across the whole batch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceStalenessAsync_SharesOneSessionAcrossTheBatch() { @@ -156,6 +169,8 @@ public class DeploymentServiceStalenessTests await _pipeline.Received(1).FlattenAndValidateAsync(3, Arg.Any<CancellationToken>(), false, _session); } + /// <summary>Verifies that a matching hash is reported as not stale and no snapshot is read.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceStalenessAsync_HashMatches_NotStaleAndNoSnapshotRead() { @@ -172,6 +187,8 @@ public class DeploymentServiceStalenessTests await _repo.DidNotReceive().GetDeployedSnapshotByInstanceIdAsync(1, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that an old-form list snapshot is normalised to not stale.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceStalenessAsync_OldFormListSnapshot_NormalisesToNotStale() { @@ -214,6 +231,8 @@ public class DeploymentServiceStalenessTests await _repo.Received(1).GetDeployedSnapshotByInstanceIdAsync(50, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a genuine hash mismatch is reported as stale.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceStalenessAsync_GenuineMismatch_IsStale() { @@ -231,6 +250,8 @@ public class DeploymentServiceStalenessTests Assert.True(result[1]); } + /// <summary>Verifies that a failing flatten is reported with unknown staleness.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceStalenessAsync_FlattenFails_ReportsUnknown() { @@ -246,6 +267,8 @@ public class DeploymentServiceStalenessTests Assert.Null(result[1]); } + /// <summary>Verifies that many flatten failures log exactly one warning.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceStalenessAsync_ManyFlattenFailures_LogsExactlyOneWarning() { @@ -274,6 +297,8 @@ public class DeploymentServiceStalenessTests Assert.Equal(2, _logger.Entries.Count(e => e.Level == LogLevel.Debug)); } + /// <summary>Verifies that no flatten failures log no warning.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceStalenessAsync_NoFailures_LogsNoWarning() { @@ -287,6 +312,8 @@ public class DeploymentServiceStalenessTests Assert.DoesNotContain(_logger.Entries, e => e.Level == LogLevel.Warning); } + /// <summary>Verifies that an instance without a snapshot is omitted from the result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceStalenessAsync_InstanceWithoutSnapshot_IsOmitted() { @@ -304,6 +331,8 @@ public class DeploymentServiceStalenessTests 2, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>()); } + /// <summary>Verifies that an instance whose snapshot vanishes between reads is omitted from the result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceStalenessAsync_SnapshotVanishedBetweenReads_IsOmitted() { @@ -319,6 +348,8 @@ public class DeploymentServiceStalenessTests Assert.False(result.ContainsKey(1)); } + /// <summary>Verifies that duplicate instance ids are flattened only once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceStalenessAsync_DuplicateIds_FlattenedOnce() { @@ -334,6 +365,8 @@ public class DeploymentServiceStalenessTests 1, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>()); } + /// <summary>Verifies that empty input returns an empty result without any reads.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetInstanceStalenessAsync_EmptyInput_ReturnsEmptyWithoutReads() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs index 8a7e3836..2d956bf3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs @@ -23,7 +23,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// <summary> -/// WP-1/2/4/5/6/8/16: Tests for central-side DeploymentService. +/// Tests for central-side DeploymentService. /// </summary> public class DeploymentServiceTests : TestKit { @@ -34,6 +34,7 @@ public class DeploymentServiceTests : TestKit private readonly IAuditService _audit; private readonly DeploymentService _service; + /// <summary>Initializes a new instance of the <c>DeploymentServiceTests</c> class.</summary> public DeploymentServiceTests() { _repo = Substitute.For<IDeploymentManagerRepository>(); @@ -62,7 +63,7 @@ public class DeploymentServiceTests : TestKit } /// <summary> - /// Notify-and-fetch (Task 6): the central HTTP base URL carried in the + /// Notify-and-fetch: the central HTTP base URL carried in the /// <c>RefreshDeploymentCommand</c>. Must be non-empty for the deploy path to /// clear the fail-fast configuration guard. /// </summary> @@ -86,8 +87,10 @@ public class DeploymentServiceTests : TestKit PendingDeploymentTtl = TestPendingTtl }); - // ── WP-1: Deployment flow ── + // ── Deployment flow ── + /// <summary>Verifies that deploy instance async instance not found returns failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_InstanceNotFound_ReturnsFailure() { @@ -99,6 +102,8 @@ public class DeploymentServiceTests : TestKit Assert.Contains("not found", result.Error); } + /// <summary>Verifies that deploy instance async validation fails returns failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_ValidationFails_ReturnsFailure() { @@ -119,6 +124,8 @@ public class DeploymentServiceTests : TestKit Assert.Contains("validation failed", result.Error); } + /// <summary>Verifies that deploy instance async flattening fails returns failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_FlatteningFails_ReturnsFailure() { @@ -134,12 +141,14 @@ public class DeploymentServiceTests : TestKit Assert.Contains("Validation failed", result.Error); } - // ── DeploymentManager-021: missing Site row -> hard failure, no silent fabrication ── + // ── Missing Site row -> hard failure, no silent fabrication ── + /// <summary>Verifies that deploy instance async site row missing fails loudly instead of silently substituting.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_SiteRowMissing_FailsLoudlyInsteadOfSilentlySubstituting() { - // DeploymentManager-021 regression: previously ResolveSiteIdentifierAsync + // Regression: Previously ResolveSiteIdentifierAsync // silently returned the numeric siteId rendered as a string when the // site row was missing (FK was deleted, race with admin delete, DB // inconsistency). That bogus identifier then surfaced downstream as a @@ -181,12 +190,14 @@ public class DeploymentServiceTests : TestKit Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); } - // ── DeploymentManager-022: no transient Pending -> single InProgress insert ── + // ── No transient Pending -> single InProgress insert ── + /// <summary>Verifies that deploy instance async no transient pending write record created directly in progress.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_NoTransientPendingWrite_RecordCreatedDirectlyInProgress() { - // DeploymentManager-022 regression: previously the deploy path wrote + // Regression: Previously the deploy path wrote // the record as Pending, then immediately updated it to InProgress // with no work in between — an extra SaveChangesAsync round-trip, an // extra notifier push, and a Pending->InProgress flicker in the @@ -222,8 +233,10 @@ public class DeploymentServiceTests : TestKit Arg.Any<CancellationToken>()); } - // ── WP-2: Deployment identity ── + // ── Deployment identity ── + /// <summary>Verifies that deploy instance async creates unique deployment id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_CreatesUniqueDeploymentId() { @@ -257,8 +270,10 @@ public class DeploymentServiceTests : TestKit Assert.Equal("sha256:abc", captured.RevisionHash); } - // ── WP-4: State transition validation ── + // ── State transition validation ── + /// <summary>Verifies that deploy instance async enabled instance allows deploy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_EnabledInstance_AllowsDeploy() { @@ -277,8 +292,10 @@ public class DeploymentServiceTests : TestKit await _repo.Received().AddDeploymentRecordAsync(Arg.Any<DeploymentRecord>(), Arg.Any<CancellationToken>()); } - // ── DeploymentManager-001: unexpected exception must not leave record InProgress ── + // ── Unexpected exception must not leave record InProgress ── + /// <summary>Verifies that deploy instance async communication throws unexpected exception record marked failed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_CommunicationThrowsUnexpectedException_RecordMarkedFailed() { @@ -310,8 +327,10 @@ public class DeploymentServiceTests : TestKit Assert.NotNull(captured.CompletedAt); } - // ── DeploymentManager-002: failure write must not use a cancelled token ── + // ── Failure write must not use a cancelled token ── + /// <summary>Verifies that deploy instance async failure write uses non cancellable token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_FailureWrite_UsesNonCancellableToken() { @@ -355,6 +374,8 @@ public class DeploymentServiceTests : TestKit // ── AskTimeoutException: Akka's AskTimeoutException must be classified as a deploy timeout ── + /// <summary>Verifies that deploy instance async ask timeout exception classified as timeout.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_AskTimeoutException_ClassifiedAsTimeout() { @@ -363,7 +384,7 @@ public class DeploymentServiceTests : TestKit // AskTimeoutException (which does NOT derive from System.TimeoutException). // Prior to the fix this fell through to the generic "Deployment error:" // branch instead of the timeout "Communication failure:" branch, which - // broke the DeploymentManager-006 reconciliation that keys off that prefix. + // broke the query-the-site-before-redeploy reconciliation that keys off that prefix. var instance = new Instance("AskTimeoutInst") { Id = 99, SiteId = 1, State = InstanceState.NotDeployed }; _repo.GetInstanceByIdAsync(99, Arg.Any<CancellationToken>()).Returns(instance); SetupValidPipeline(99, "AskTimeoutInst", "sha256:target"); @@ -403,7 +424,7 @@ public class DeploymentServiceTests : TestKit Assert.DoesNotContain("Deployment failed:", result.Error); // The deployment record must be Failed with the timeout-failure prefix - // (which the DeploymentManager-006 reconciliation check keys off). + // (which the query-the-site-before-redeploy reconciliation check keys off). Assert.NotNull(captured); Assert.Equal(DeploymentStatus.Failed, captured!.Status); Assert.NotNull(captured.ErrorMessage); @@ -411,8 +432,10 @@ public class DeploymentServiceTests : TestKit Assert.DoesNotContain("Deployment error:", captured.ErrorMessage); } - // ── WP-6: Lifecycle commands ── + // ── Lifecycle commands ── + /// <summary>Verifies that disable instance async instance not found returns failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DisableInstanceAsync_InstanceNotFound_ReturnsFailure() { @@ -424,6 +447,8 @@ public class DeploymentServiceTests : TestKit Assert.Contains("not found", result.Error); } + /// <summary>Verifies that disable instance async when disabled returns transition error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DisableInstanceAsync_WhenDisabled_ReturnsTransitionError() { @@ -436,6 +461,8 @@ public class DeploymentServiceTests : TestKit Assert.Contains("not allowed", result.Error); } + /// <summary>Verifies that enable instance async when enabled returns transition error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnableInstanceAsync_WhenEnabled_ReturnsTransitionError() { @@ -448,6 +475,8 @@ public class DeploymentServiceTests : TestKit Assert.Contains("not allowed", result.Error); } + /// <summary>Verifies that delete instance async instance not found returns failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteInstanceAsync_InstanceNotFound_ReturnsFailure() { @@ -459,10 +488,12 @@ public class DeploymentServiceTests : TestKit Assert.Contains("not found", result.Error); } + /// <summary>Verifies that delete instance async not deployed removes record without site round trip.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteInstanceAsync_NotDeployed_RemovesRecordWithoutSiteRoundTrip() { - // #05-T18: a NotDeployed instance has no live actor at any site (Transport- + // A NotDeployed instance has no live actor at any site (Transport- // imported instances always land NotDeployed, often against an uncommissioned // or unreachable site). Delete must succeed as a central-side record cleanup // WITHOUT contacting the site. The default _service's CommunicationService has @@ -478,8 +509,10 @@ public class DeploymentServiceTests : TestKit await _repo.Received(1).DeleteInstanceAsync(70, Arg.Any<CancellationToken>()); } - // ── DeploymentManager-004: site-success but central-delete-failure must not escape uncaught ── + // ── Site-success but central-delete-failure must not escape uncaught ── + /// <summary>Verifies that delete instance async site succeeds central delete fails returns distinct failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteInstanceAsync_SiteSucceeds_CentralDeleteFails_ReturnsDistinctFailure() { @@ -507,8 +540,10 @@ public class DeploymentServiceTests : TestKit Assert.Contains("central", result.Error, StringComparison.OrdinalIgnoreCase); } - // ── WP-8: Deployment comparison ── + // ── Deployment comparison ── + /// <summary>Verifies that get deployment comparison async no snapshot returns failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeploymentComparisonAsync_NoSnapshot_ReturnsFailure() { @@ -520,6 +555,8 @@ public class DeploymentServiceTests : TestKit Assert.Contains("No deployed snapshot", result.Error); } + /// <summary>Verifies that get deployment comparison async same hash not stale.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeploymentComparisonAsync_SameHash_NotStale() { @@ -554,6 +591,8 @@ public class DeploymentServiceTests : TestKit Assert.False(result.Value.IsStale); } + /// <summary>Verifies that get deployment comparison async different hash is stale.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeploymentComparisonAsync_DifferentHash_IsStale() { @@ -591,8 +630,10 @@ public class DeploymentServiceTests : TestKit Assert.True(result.Value.IsStale); } - // ── PLAN-05 Task 16: read-only paths skip Roslyn script compilation ── + // ── Read-only paths skip Roslyn script compilation ── + /// <summary>Verifies that get deployment comparison async requests validate scripts false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeploymentComparisonAsync_RequestsValidateScriptsFalse() { @@ -622,6 +663,8 @@ public class DeploymentServiceTests : TestKit await _pipeline.DidNotReceive().FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), true); } + /// <summary>Verifies that deploy instance async requests validate scripts true.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_RequestsValidateScriptsTrue() { @@ -642,6 +685,8 @@ public class DeploymentServiceTests : TestKit await _pipeline.Received().FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), true); } + /// <summary>Verifies that stale instance probe requests validate scripts false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaleInstanceProbe_RequestsValidateScriptsFalse() { @@ -663,8 +708,10 @@ public class DeploymentServiceTests : TestKit await _pipeline.Received(1).FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), false, Arg.Any<FlattenSession?>()); } - // ── DeploymentManager-007: comparison must produce a structured diff ── + // ── Comparison must produce a structured diff ── + /// <summary>Verifies that get deployment comparison async produces structured diff.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeploymentComparisonAsync_ProducesStructuredDiff() { @@ -710,6 +757,8 @@ public class DeploymentServiceTests : TestKit // ── I-1 (latent): old-form List snapshot must not show a spurious stale/diff ── + /// <summary>Verifies that get deployment comparison async old form list value normalized to native not stale no diff.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeploymentComparisonAsync_OldFormListValue_NormalizedToNative_NotStaleNoDiff() { @@ -785,6 +834,8 @@ public class DeploymentServiceTests : TestKit Assert.Equal(nativeHash, result.Value.DeployedRevisionHash); } + /// <summary>Verifies that get deployment comparison async genuinely changed list value still stale and diffs.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeploymentComparisonAsync_GenuinelyChangedListValue_StillStaleAndDiffs() { @@ -845,8 +896,10 @@ public class DeploymentServiceTests : TestKit c => c.CanonicalName == "Thresholds" && c.ChangeType == DiffChangeType.Changed); } - // ── WP-2: GetDeploymentStatusAsync ── + // ── GetDeploymentStatusAsync ── + /// <summary>Verifies that get deployment status async returns record by deployment id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeploymentStatusAsync_ReturnsRecordByDeploymentId() { @@ -865,10 +918,12 @@ public class DeploymentServiceTests : TestKit // ── Audit logging ── + /// <summary>Verifies that deploy instance async flattening fails does not reach audit.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_FlatteningFails_DoesNotReachAudit() { - // DeploymentManager-011: this test previously asserted nothing. A + // This test previously asserted nothing. A // flatten failure returns before any site communication, so no audit // entry is written. var instance = new Instance("TestInst") { Id = 1, SiteId = 1, State = InstanceState.NotDeployed }; @@ -884,10 +939,12 @@ public class DeploymentServiceTests : TestKit Arg.Any<string>(), Arg.Any<object>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that deploy instance async site succeeds writes deploy audit entry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_SiteSucceeds_WritesDeployAuditEntry() { - // DeploymentManager-011: a successful deployment must write a "Deploy" + // A successful deployment must write a "Deploy" // audit entry referencing the deployed instance. var instance = new Instance("AuditInst") { Id = 50, SiteId = 1, State = InstanceState.NotDeployed }; _repo.GetInstanceByIdAsync(50, Arg.Any<CancellationToken>()).Returns(instance); @@ -908,10 +965,12 @@ public class DeploymentServiceTests : TestKit Arg.Any<object>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that deploy instance async deploy audit write throws keeps committed success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_DeployAuditWriteThrows_KeepsCommittedSuccess() { - // #05-T17: the post-success "Deploy" audit write is best-effort. If it + // The post-success "Deploy" audit write is best-effort. If it // throws, the already-committed Success record must NOT be flipped to // Failed by the outer catch — "audit-write failure NEVER aborts the // user-facing action". @@ -940,6 +999,8 @@ public class DeploymentServiceTests : TestKit // ── Telemetry follow-on: scadabridge.deployments.applied on deploy success ── + /// <summary>Verifies that deploy instance async site succeeds emits deployments applied counter once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_SiteSucceeds_EmitsDeploymentsAppliedCounterOnce() { @@ -983,6 +1044,8 @@ public class DeploymentServiceTests : TestKit Assert.Equal(1, Interlocked.Read(ref applied)); } + /// <summary>Verifies that deploy instance async reconciled does not emit deployments applied counter.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_Reconciled_DoesNotEmitDeploymentsAppliedCounter() { @@ -1038,8 +1101,10 @@ public class DeploymentServiceTests : TestKit Assert.Equal(0, Interlocked.Read(ref applied)); } - // ── DeploymentManager-011: lifecycle success paths ── + // ── Lifecycle success paths ── + /// <summary>Verifies that disable instance async site succeeds sets disabled state and audits.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DisableInstanceAsync_SiteSucceeds_SetsDisabledStateAndAudits() { @@ -1061,6 +1126,8 @@ public class DeploymentServiceTests : TestKit Arg.Any<object>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that enable instance async site succeeds sets enabled state and audits.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnableInstanceAsync_SiteSucceeds_SetsEnabledStateAndAudits() { @@ -1082,6 +1149,8 @@ public class DeploymentServiceTests : TestKit Arg.Any<object>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that delete instance async site succeeds removes record and audits.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteInstanceAsync_SiteSucceeds_RemovesRecordAndAudits() { @@ -1102,10 +1171,12 @@ public class DeploymentServiceTests : TestKit Arg.Any<object>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that deploy instance async same instance operation lock serializes concurrent deploys.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_SameInstance_OperationLockSerializesConcurrentDeploys() { - // DeploymentManager-011: two concurrent deploys of the SAME instance + // Two concurrent deploys of the SAME instance // must be serialized by the per-instance operation lock — the site sees // them one at a time, never overlapping. var instance = new Instance("LockInst") { Id = 54, SiteId = 1, State = InstanceState.Enabled }; @@ -1130,7 +1201,7 @@ public class DeploymentServiceTests : TestKit Assert.Equal(1, serializationCounters.MaxConcurrent); } - // ── DeploymentManager-006: query-the-site-before-redeploy idempotency ── + // ── Query-the-site-before-redeploy idempotency ── /// <summary> /// Builds a DeploymentService whose CommunicationService is backed by the @@ -1172,11 +1243,11 @@ public class DeploymentServiceTests : TestKit } /// <summary> - /// DeploymentManager-021 test helper: returns an <see cref="ISiteRepository"/> + /// Test helper: Returns an <see cref="ISiteRepository"/> /// substitute that resolves <see cref="ISiteRepository.GetSiteByIdAsync"/> /// for ANY integer id to a stub <see cref="Site"/> whose /// <c>SiteIdentifier</c> is <c>"site-{id}"</c>. Prior to the - /// DeploymentManager-021 fix the production `ResolveSiteIdentifierAsync` + /// missing-site-row fix the production `ResolveSiteIdentifierAsync` /// silently substituted the numeric id when the site row was missing, so /// these tests passed without seeding any Sites. After the fix a missing /// site throws — every test that drives a deploy/lifecycle path needs a @@ -1195,6 +1266,8 @@ public class DeploymentServiceTests : TestKit return siteRepo; } + /// <summary>Verifies that deploy instance async prior in progress record site has target hash marks success without redeploy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_PriorInProgressRecord_SiteHasTargetHash_MarksSuccessWithoutRedeploy() { @@ -1230,6 +1303,8 @@ public class DeploymentServiceTests : TestKit Arg.Any<DeploymentRecord>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that deploy instance async prior in progress record site has different hash proceeds with deploy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_PriorInProgressRecord_SiteHasDifferentHash_ProceedsWithDeploy() { @@ -1262,6 +1337,8 @@ public class DeploymentServiceTests : TestKit Arg.Any<DeploymentRecord>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that deploy instance async prior failed timeout record queries site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_PriorFailedTimeoutRecord_QueriesSite() { @@ -1292,6 +1369,8 @@ public class DeploymentServiceTests : TestKit Assert.Equal(DeploymentStatus.Success, prior.Status); } + /// <summary>Verifies that deploy instance async prior success record skips site query.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_PriorSuccessRecord_SkipsSiteQuery() { @@ -1321,6 +1400,8 @@ public class DeploymentServiceTests : TestKit Assert.Equal(1, counters.DeployCount); } + /// <summary>Verifies that deploy instance async fresh first time deploy skips site query.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_FreshFirstTimeDeploy_SkipsSiteQuery() { @@ -1343,6 +1424,8 @@ public class DeploymentServiceTests : TestKit Assert.Equal(1, counters.DeployCount); } + /// <summary>Verifies that deploy instance async prior in progress record query fails falls through to deploy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_PriorInProgressRecord_QueryFails_FallsThroughToDeploy() { @@ -1375,8 +1458,10 @@ public class DeploymentServiceTests : TestKit Assert.Equal(1, counters.DeployCount); } - // ── Task 6: notify-and-fetch send path (stage PendingDeployment + send RefreshDeploymentCommand) ── + // ── Notify-and-fetch send path (stage PendingDeployment + send RefreshDeploymentCommand) ── + /// <summary>Verifies that deploy instance async success stages pending config then sends refresh notify.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_Success_StagesPendingConfigThenSendsRefreshNotify() { @@ -1433,6 +1518,8 @@ public class DeploymentServiceTests : TestKit Arg.Any<PendingDeployment>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that deploy instance async success does not delete pending row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_Success_DoesNotDeletePendingRow() { @@ -1461,6 +1548,8 @@ public class DeploymentServiceTests : TestKit Arg.Any<string>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that deploy instance async central fetch base url empty fails fast without notify or staging.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_CentralFetchBaseUrlEmpty_FailsFastWithoutNotifyOrStaging() { @@ -1496,8 +1585,10 @@ public class DeploymentServiceTests : TestKit Arg.Any<DeploymentRecord>(), Arg.Any<CancellationToken>()); } - // ── DeploymentManager-015: reconciliation must perform the normal success side effects ── + // ── Reconciliation must perform the normal success side effects ── + /// <summary>Verifies that deploy instance async reconciled sets instance enabled and stores snapshot.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_Reconciled_SetsInstanceEnabledAndStoresSnapshot() { @@ -1539,24 +1630,26 @@ public class DeploymentServiceTests : TestKit Assert.Equal(1, counters.QueryCount); Assert.Equal(0, counters.DeployCount); - // DeploymentManager-015: the instance State must reflect the deployed + // The instance State must reflect the deployed // config the site is actually running. Assert.Equal(InstanceState.Enabled, instance.State); await _repo.Received().UpdateInstanceAsync(instance, Arg.Any<CancellationToken>()); - // DeploymentManager-015: a deployed-config snapshot must be stored so + // A deployed-config snapshot must be stored so // GetDeploymentComparisonAsync has something to compare against. Assert.NotNull(storedSnapshot); Assert.Equal(70, storedSnapshot!.InstanceId); Assert.Equal("sha256:target", storedSnapshot.RevisionHash); } - // ── DeploymentManager-018: reconciliation must preserve an intentional Disabled state ── + // ── Reconciliation must preserve an intentional Disabled state ── + /// <summary>Verifies that deploy instance async reconciled disabled instance preserves disabled state.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_Reconciled_DisabledInstance_PreservesDisabledState() { - // DeploymentManager-018: after a central failover, the in-memory + // After a central failover, the in-memory // OperationLockManager is lost (by design — in-progress treated as // failed). The prior deployment record remains InProgress in the DB. // The operator can legitimately invoke Disable on the instance between @@ -1596,19 +1689,20 @@ public class DeploymentServiceTests : TestKit Assert.Equal(1, counters.QueryCount); Assert.Equal(0, counters.DeployCount); - // DeploymentManager-018: the operator's explicit Disable must survive + // The operator's explicit Disable must survive // the reconciliation — Instance.State stays Disabled, not silently // flipped to Enabled. Assert.Equal(InstanceState.Disabled, instance.State); } /// <summary> - /// The counterpart to the bulk path's Disabled skip (arch-review P7-2): a + /// The counterpart to the bulk path's Disabled skip: a /// SINGLE-instance deploy of a Disabled instance still deploys AND enables it. /// That is the documented "Deploy on a Disabled instance also enables it" /// transition and the explicit operator action bulk deploy now refuses to /// perform implicitly, so it must keep working exactly as before. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_DisabledInstance_StillDeploysAndEnables() { @@ -1632,8 +1726,10 @@ public class DeploymentServiceTests : TestKit Assert.Equal(InstanceState.Enabled, instance.State); } - // ── DeploymentManager-016: reconciled record must carry the target revision hash ── + // ── Reconciled record must carry the target revision hash ── + /// <summary>Verifies that deploy instance async reconciled prior record revision hash updated to target.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_Reconciled_PriorRecordRevisionHashUpdatedToTarget() { @@ -1667,17 +1763,19 @@ public class DeploymentServiceTests : TestKit Assert.True(result.IsSuccess); Assert.Equal(DeploymentStatus.Success, prior.Status); - // DeploymentManager-016: the persisted record's RevisionHash must agree + // The persisted record's RevisionHash must agree // with the site's applied revision, not keep the stale R1. Assert.Equal("sha256:target", prior.RevisionHash); } - // ── DeploymentManager-020: reconciliation audit attributes to the CURRENT user, not the prior deployer ── + // ── Reconciliation audit attributes to the CURRENT user, not the prior deployer ── + /// <summary>Verifies that deploy instance async reconciled audit attributes current user not prior deployer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_Reconciled_AuditAttributesCurrentUserNotPriorDeployer() { - // DeploymentManager-020: a redeploy that reconciles a timed-out prior + // A redeploy that reconciles a timed-out prior // record must be audited as the action of the user driving THIS // redeploy — not the user who originally issued the now-reconciled // deployment. The prior deployer is preserved in the detail object so @@ -1707,7 +1805,7 @@ public class DeploymentServiceTests : TestKit var result = await service.DeployInstanceAsync(73, "currentUser"); Assert.True(result.IsSuccess); - // DeploymentManager-020: audit row's actor is the current user. + // Audit row's actor is the current user. await _audit.Received().LogAsync( "currentUser", "DeployReconciled", "Instance", "73", "ReconcileAuditUser", Arg.Any<object>(), Arg.Any<CancellationToken>()); @@ -1717,8 +1815,10 @@ public class DeploymentServiceTests : TestKit Arg.Any<object>(), Arg.Any<CancellationToken>()); } - // ── DeploymentManager-012: LifecycleCommandTimeout must actually bound lifecycle commands ── + // ── LifecycleCommandTimeout must actually bound lifecycle commands ── + /// <summary>Verifies that disable instance async site unresponsive lifecycle command timeout bounds the wait.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DisableInstanceAsync_SiteUnresponsive_LifecycleCommandTimeoutBoundsTheWait() { @@ -1771,17 +1871,19 @@ public class DeploymentServiceTests : TestKit /// <summary>Stand-in actor that never replies to anything.</summary> private class SilentProbeActor : ReceiveActor { + /// <summary>Initializes a new instance of the <c>SilentProbeActor</c> class.</summary> public SilentProbeActor() => ReceiveAny(_ => { }); } - // ── DeploymentManager-019: lifecycle timeouts must write an audit entry ── + // ── Lifecycle timeouts must write an audit entry ── /// <summary> - /// DeploymentManager-019: when a Disable times out at the site, the + /// When a Disable times out at the site, the /// operator's attempted action must still be recorded in the audit log /// with the documented <c>DisableTimedOut</c> action — pre-fix nothing /// was written and the audit trail was silent about the attempt. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DisableInstanceAsync_LifecycleTimeout_WritesDisableTimedOutAuditEntry() { @@ -1833,8 +1935,9 @@ public class DeploymentServiceTests : TestKit } /// <summary> - /// DeploymentManager-019: same audit guarantee for the Enable path. + /// Same audit guarantee for the Enable path. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnableInstanceAsync_LifecycleTimeout_WritesEnableTimedOutAuditEntry() { @@ -1881,8 +1984,9 @@ public class DeploymentServiceTests : TestKit } /// <summary> - /// DeploymentManager-019: same audit guarantee for the Delete path. + /// Same audit guarantee for the Delete path. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteInstanceAsync_LifecycleTimeout_WritesDeleteTimedOutAuditEntry() { @@ -1928,8 +2032,10 @@ public class DeploymentServiceTests : TestKit Arg.Any<CancellationToken>()); } - // ── DeploymentManager-003: post-success persistence must commit the Success status ── + // ── Post-success persistence must commit the Success status ── + /// <summary>Verifies that deploy instance async site succeeds snapshot write fails record still committed success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_SiteSucceeds_SnapshotWriteFails_RecordStillCommittedSuccess() { @@ -1974,8 +2080,8 @@ public class DeploymentServiceTests : TestKit } /// <summary> - /// Per-test counters for <see cref="SerializationProbeActor"/>. DeploymentManager-024: - /// each test owns its own instance, passed into the actor's constructor, so + /// Per-test counters for <see cref="SerializationProbeActor"/>. + /// Each test owns its own instance, passed into the actor's constructor, so /// counters are no longer shared static state that races under parallel /// test execution. /// </summary> @@ -1992,13 +2098,18 @@ public class DeploymentServiceTests : TestKit /// same instance were NOT serialized by the operation lock their windows /// would overlap and <c>MaxConcurrent</c> would exceed 1. /// - /// Notify-and-fetch (Task 6): the deploy path now sends a + /// Notify-and-fetch: the deploy path now sends a /// <see cref="RefreshDeploymentCommand"/> instead of <c>DeployInstanceCommand</c>. /// </summary> private class SerializationProbeActor : ReceiveActor, IWithTimers { + /// <summary>Gets or sets the actor timer scheduler supplied by Akka.</summary> public ITimerScheduler Timers { get; set; } = null!; + /// <summary> + /// Initializes a new instance of the <c>SerializationProbeActor</c> class. + /// </summary> + /// <param name="counters">The counters.</param> public SerializationProbeActor(SerializationProbeCounters counters) { Receive<SiteEnvelope>(env => @@ -2043,8 +2154,8 @@ public class DeploymentServiceTests : TestKit } /// <summary> - /// Per-test counters for <see cref="ReconcileProbeActor"/>. DeploymentManager-024: - /// each test owns its own instance so counter assertions cannot race across + /// Per-test counters for <see cref="ReconcileProbeActor"/>. + /// Each test owns its own instance so counter assertions cannot race across /// tests running in parallel. /// </summary> private sealed class ReconcileProbeCounters @@ -2053,7 +2164,7 @@ public class DeploymentServiceTests : TestKit public int DeployCount; /// <summary> - /// Notify-and-fetch (Task 6): the most recent + /// Notify-and-fetch: the most recent /// <see cref="RefreshDeploymentCommand"/> the probe received, so a test /// can assert the deploy path notified the site with the staged token, /// fetch base URL, and deployment id. @@ -2068,13 +2179,19 @@ public class DeploymentServiceTests : TestKit /// configurable applied revision hash, and (optionally) drops the query to /// simulate an unreachable site so the central Ask times out. /// - /// Notify-and-fetch (Task 6): the deploy path now sends a + /// Notify-and-fetch: the deploy path now sends a /// <see cref="RefreshDeploymentCommand"/> (not the fat /// <c>DeployInstanceCommand</c>), so the probe counts that message as a /// "deploy" and records it for assertion. /// </summary> private class ReconcileProbeActor : ReceiveActor { + /// <summary> + /// Initializes a new instance of the <c>ReconcileProbeActor</c> class. + /// </summary> + /// <param name="counters">The counters.</param> + /// <param name="siteHash">The site hash.</param> + /// <param name="failQuery">The fail query.</param> public ReconcileProbeActor(ReconcileProbeCounters counters, string siteHash, bool failQuery) { Receive<SiteEnvelope>(env => diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs index cc362059..b3048846 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs @@ -17,7 +17,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// <summary> -/// CentralUI-006: regression tests proving <see cref="DeploymentService"/> +/// Regression tests proving <see cref="DeploymentService"/> /// raises <see cref="IDeploymentStatusNotifier.StatusChanged"/> whenever it /// writes a deployment record's status. This is the event source the Central /// UI deployment-status page subscribes to instead of polling. @@ -32,6 +32,7 @@ public class DeploymentStatusNotifierTests : TestKit private readonly DeploymentStatusNotifier _notifier; private readonly DeploymentService _service; + /// <summary>Initializes the test class with a notifier substitute and a deployment service wired to stubbed repositories.</summary> public DeploymentStatusNotifierTests() { _repo = Substitute.For<IDeploymentManagerRepository>(); @@ -48,7 +49,7 @@ public class DeploymentStatusNotifierTests : TestKit OperationLockTimeout = TimeSpan.FromSeconds(5) }); - // DeploymentManager-021: the resolver now throws when the site row + // The resolver now throws when the site row // is missing, so seed the substitute to return a real-shaped Site for // any id these tests touch. var siteRepo = Substitute.For<ISiteRepository>(); @@ -63,13 +64,15 @@ public class DeploymentStatusNotifierTests : TestKit _repo, siteRepo, _pipeline, _comms, _lockManager, _audit, new DiffService(), new RevisionHashService(), _notifier, Substitute.For<IStaleInstanceProbe>(), options, - // Notify-and-fetch (Task 6): a non-empty CentralFetchBaseUrl lets the + // Notify-and-fetch: a non-empty CentralFetchBaseUrl lets the // deploy path reach the send (and, here, the catch block) rather than // tripping the fail-fast configuration guard. Options.Create(new CommunicationOptions { CentralFetchBaseUrl = "https://central.test:9000" }), NullLogger<DeploymentService>.Instance); } + /// <summary>Verifies that deploying an instance raises a status change for every deployment record status write.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployInstanceAsync_RaisesStatusChange_ForEveryRecordStatusWrite() { @@ -88,7 +91,7 @@ public class DeploymentStatusNotifierTests : TestKit // the record ends Failed. The notifier must fire for the InProgress // and Failed writes — not be silent (the pre-fix behaviour). // - // DeploymentManager-022: the transient Pending write was dropped from + // The transient Pending write was dropped from // the deploy path (the record is now created directly in InProgress), // so there is no Pending notification any more. The remaining two // writes — the initial InProgress insert and the catch-block Failed @@ -108,6 +111,7 @@ public class DeploymentStatusNotifierTests : TestKit Assert.All(changes, c => Assert.Equal(deploymentId, c.DeploymentId)); } + /// <summary>Verifies that notifying a status change with no subscribers does not throw.</summary> [Fact] public void NotifyStatusChanged_WithNoSubscribers_DoesNotThrow() { @@ -119,6 +123,7 @@ public class DeploymentStatusNotifierTests : TestKit Assert.Null(ex); } + /// <summary>Verifies that a throwing subscriber does not prevent other subscribers from being notified.</summary> [Fact] public void NotifyStatusChanged_ThrowingSubscriber_DoesNotBreakOtherSubscribers() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlattenSessionCacheTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlattenSessionCacheTests.cs index 248c11e9..f94af317 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlattenSessionCacheTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlattenSessionCacheTests.cs @@ -11,7 +11,7 @@ using TemplateAttribute = ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Templ namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// <summary> -/// WP2.5: the flatten-session cache. Pins the two properties the bulk deploy path +/// The flatten-session cache. Pins the two properties the bulk deploy path /// depends on — that N instances sharing a template chain load that chain ONCE per /// session, and that a template mutation (observed through the watermark) /// invalidates the memo so a session can never serve stale template state. @@ -28,6 +28,7 @@ public class FlattenSessionCacheTests private readonly TemplateGraphWatermark _watermark = new(); private readonly FlatteningPipeline _sut; + /// <summary>Initializes the test with an in-memory repository and a counting flattening pipeline.</summary> public FlattenSessionCacheTests() { _sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>()).Returns([]); @@ -67,6 +68,8 @@ public class FlattenSessionCacheTests } } + /// <summary>Verifies that shared session, ten instances one template, loads each template once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SharedSession_TenInstancesOneTemplate_LoadsEachTemplateOnce() { @@ -95,6 +98,8 @@ public class FlattenSessionCacheTests await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that no shared session, every instance reloads the chain.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoSharedSession_EveryInstanceReloadsTheChain() { @@ -102,7 +107,7 @@ public class FlattenSessionCacheTests ArrangeSharedChain(instanceCount); // Baseline: without a shared session each flatten gets its own private - // one, which is the pre-WP2.5 behaviour the batch path improves on. + // one, which is the earlier per-instance behaviour the batch path improves on. for (var i = 1; i <= instanceCount; i++) { var result = await _sut.FlattenAndValidateAsync(i, CancellationToken.None, validateScripts: false); @@ -113,6 +118,8 @@ public class FlattenSessionCacheTests .GetTemplateWithChildrenAsync(TemplateId, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that template mutation, invalidates memo, so session never serves stale state.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TemplateMutation_InvalidatesMemo_SoSessionNeverServesStaleState() { @@ -133,6 +140,8 @@ public class FlattenSessionCacheTests await _templateRepo.Received(1).GetTemplateWithChildrenAsync(ParentTemplateId, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that structural mutation, invalidates cached chain membership.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StructuralMutation_InvalidatesCachedChainMembership() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineConnectionBindingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineConnectionBindingTests.cs index fcd1b404..3105bea3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineConnectionBindingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineConnectionBindingTests.cs @@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// <summary> -/// M2.8 (#23): proves the deploy path (FlatteningPipeline.FlattenAndValidateAsync) +/// Proves the deploy path (FlatteningPipeline.FlattenAndValidateAsync) /// opts into connection-binding enforcement, so a data-sourced attribute with no /// binding gates the deployment as an ERROR (not just a warning), and that a binding /// resolving to a connection that actually exists at the target site passes. @@ -30,6 +30,7 @@ public class FlatteningPipelineConnectionBindingTests private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>(); private readonly FlatteningPipeline _sut; + /// <summary>Initializes a new instance of the <c>FlatteningPipelineConnectionBindingTests</c> class.</summary> public FlatteningPipelineConnectionBindingTests() { _sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>()) @@ -79,6 +80,8 @@ public class FlatteningPipelineConnectionBindingTests .Returns([connection]); } + /// <summary>Verifies that flatten and validate, when data sourced attribute with no binding, reports binding error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FlattenAndValidate_DataSourcedAttributeWithNoBinding_ReportsBindingError() { @@ -92,6 +95,8 @@ public class FlatteningPipelineConnectionBindingTests e => e.Category == ValidationCategory.ConnectionBinding); } + /// <summary>Verifies that flatten and validate, when binding to existing site connection, no binding error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FlattenAndValidate_BindingToExistingSiteConnection_NoBindingError() { @@ -104,10 +109,12 @@ public class FlatteningPipelineConnectionBindingTests e => e.Category == ValidationCategory.ConnectionBinding); } + /// <summary>Verifies that flatten and validate, when binding to stale deleted connection, reports binding error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FlattenAndValidate_BindingToStaleDeletedConnection_ReportsBindingError() { - // M2.8 (#23): FlatteningService.ApplyConnectionBindings silently drops a + // FlatteningService.ApplyConnectionBindings silently drops a // binding whose DataConnectionId doesn't resolve to any loaded site // DataConnection (stale / deleted connection). The flattener leaves // BoundDataConnectionId == null, so the validator treats the attribute as diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs index bab04be9..46519228 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs @@ -12,7 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// <summary> -/// M2.1 (#22): proves the FlatteningPipeline actually computes the alarm-capable +/// Proves the FlatteningPipeline actually computes the alarm-capable /// connection set from the loaded site data connections and threads it through /// ValidationService → SemanticValidator. Before the fix the pipeline loaded the /// connections but never passed the capable set, so the native-alarm-source @@ -30,6 +30,7 @@ public class FlatteningPipelineNativeAlarmCapabilityTests private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>(); private readonly FlatteningPipeline _sut; + /// <summary>Initializes a new instance of the <see cref="FlatteningPipelineNativeAlarmCapabilityTests"/> class.</summary> public FlatteningPipelineNativeAlarmCapabilityTests() { _sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>()) @@ -73,6 +74,8 @@ public class FlatteningPipelineNativeAlarmCapabilityTests .Returns([connection]); } + /// <summary>Verifies that a native alarm source on a connection that is not alarm-capable reports a capability error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FlattenAndValidate_NativeAlarmSourceOnNonAlarmCapableConnection_ReportsCapabilityError() { @@ -87,6 +90,9 @@ public class FlatteningPipelineNativeAlarmCapabilityTests && e.Message.Contains("alarm-capable")); } + /// <summary>Verifies that a native alarm source on an alarm-capable connection reports no capability error.</summary> + /// <param name="protocol">The data connection protocol under test.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("OpcUa")] [InlineData("MxGateway")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs index f74962fe..ba7fcfcd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs @@ -24,6 +24,7 @@ public class FlatteningPipelinePreviewTests private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>(); private readonly FlatteningPipeline _sut; + /// <summary>Initializes the test class with in-memory repositories and the pipeline under test.</summary> public FlatteningPipelinePreviewTests() { _sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>()).Returns([]); @@ -60,6 +61,8 @@ public class FlatteningPipelinePreviewTests .Returns([new DataConnection("MxGateway", "MxGateway", SiteId) { Id = 1 }]); } + /// <summary>Verifies that previewing matches flattening a freshly created instance.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Preview_matches_flatten_of_fresh_instance() { @@ -81,6 +84,8 @@ public class FlatteningPipelinePreviewTests Assert.Contains(actual, a => a.Item1 == "Left.MoveInFlag"); } + /// <summary>Verifies that previewing an unknown template fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Preview_unknown_template_fails() { @@ -88,6 +93,8 @@ public class FlatteningPipelinePreviewTests Assert.True(r.IsFailure); } + /// <summary>Verifies that previewing never touches the instance repository.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Preview_never_touches_instance_repository() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/OperationLockManagerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/OperationLockManagerTests.cs index 6f909843..1e27731f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/OperationLockManagerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/OperationLockManagerTests.cs @@ -1,12 +1,14 @@ namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// <summary> -/// WP-3: Tests for per-instance operation lock. +/// Tests for per-instance operation lock. /// </summary> public class OperationLockManagerTests { private readonly OperationLockManager _lockManager = new(); + /// <summary>Verifies that acquiring the lock returns a disposable handle.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AcquireAsync_ReturnsDisposable() { @@ -14,6 +16,8 @@ public class OperationLockManagerTests Assert.NotNull(lockHandle); } + /// <summary>Verifies that a second caller for the same instance is blocked until the lock is released.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AcquireAsync_SameInstance_BlocksSecondCaller() { @@ -24,6 +28,8 @@ public class OperationLockManagerTests _lockManager.AcquireAsync("inst1", TimeSpan.FromMilliseconds(50))); } + /// <summary>Verifies that locks on different instances can both be acquired.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AcquireAsync_DifferentInstances_BothSucceed() { @@ -34,6 +40,8 @@ public class OperationLockManagerTests Assert.NotNull(lock2); } + /// <summary>Verifies that the lock can be re-acquired after it is released.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AcquireAsync_AfterRelease_CanReacquire() { @@ -45,6 +53,8 @@ public class OperationLockManagerTests Assert.NotNull(secondLock); } + /// <summary>Verifies that an instance reports as locked while the lock is held.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task IsLocked_ReturnsTrueWhileLocked() { @@ -54,6 +64,8 @@ public class OperationLockManagerTests Assert.True(_lockManager.IsLocked("inst1")); } + /// <summary>Verifies that an instance reports as not locked after the lock is released.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task IsLocked_ReturnsFalseAfterRelease() { @@ -63,6 +75,8 @@ public class OperationLockManagerTests Assert.False(_lockManager.IsLocked("inst1")); } + /// <summary>Verifies that disposing the lock handle twice does not throw.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AcquireAsync_DoubleDispose_DoesNotThrow() { @@ -71,6 +85,8 @@ public class OperationLockManagerTests lockHandle.Dispose(); // Should not throw } + /// <summary>Verifies that acquiring the lock respects the cancellation token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AcquireAsync_CancellationToken_Respected() { @@ -81,6 +97,8 @@ public class OperationLockManagerTests _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(30), cts.Token)); } + /// <summary>Verifies that concurrent acquisitions on different instances all succeed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AcquireAsync_ConcurrentDifferentInstances_AllSucceed() { @@ -93,8 +111,10 @@ public class OperationLockManagerTests await Task.WhenAll(tasks); } - // ── DeploymentManager-005: semaphore must not leak ── + // ── Semaphore must not leak ── + /// <summary>Verifies that releasing a lock removes its semaphore entry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AcquireAsync_ReleasedLock_RemovesSemaphoreEntry() { @@ -110,6 +130,8 @@ public class OperationLockManagerTests Assert.Equal(0, _lockManager.TrackedLockCount); } + /// <summary>Verifies that many distinct instances do not accumulate semaphores.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AcquireAsync_ManyDistinctInstances_DoesNotAccumulateSemaphores() { @@ -123,6 +145,8 @@ public class OperationLockManagerTests Assert.Equal(0, _lockManager.TrackedLockCount); } + /// <summary>Verifies that a contended lock keeps its semaphore until the last release and then reclaims it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AcquireAsync_ContendedLock_KeepsSemaphoreUntilLastReleaseThenReclaims() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs index a76d9d02..caa4e62e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs @@ -149,6 +149,8 @@ public class PendingUpdatesEvaluatorTests // ── artifact state ─────────────────────────────────────────────────────── + /// <summary>Verifies that a site with a null stored hash is reported as never deployed and not as a pending update.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvaluateAsync_NullStoredHash_IsNeverDeployedAndNotAPendingUpdate() { @@ -167,6 +169,8 @@ public class PendingUpdatesEvaluatorTests Assert.False(snapshot.HasPendingUpdates); } + /// <summary>Verifies that a stored hash that matches the current definitions is reported as current.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvaluateAsync_StoredHashMatchesCurrentDefinitions_IsCurrent() { @@ -186,6 +190,8 @@ public class PendingUpdatesEvaluatorTests Assert.False(snapshot.HasPendingUpdates); } + /// <summary>Verifies that a stored hash that differs from the current definitions is reported as out of date and counted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvaluateAsync_StoredHashDiffersFromCurrentDefinitions_IsOutOfDateAndCounted() { @@ -203,6 +209,8 @@ public class PendingUpdatesEvaluatorTests Assert.True(snapshot.HasPendingUpdates); } + /// <summary>Verifies that the evaluation carries the site identity onto each result row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvaluateAsync_CarriesSiteIdentityOntoTheRow() { @@ -220,6 +228,8 @@ public class PendingUpdatesEvaluatorTests // ── instance counts ────────────────────────────────────────────────────── + /// <summary>Verifies that deployed, stale and unknown instances are counted per site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvaluateAsync_CountsDeployedStaleAndUnknownInstancesPerSite() { @@ -248,6 +258,8 @@ public class PendingUpdatesEvaluatorTests Assert.True(snapshot.HasPendingUpdates); } + /// <summary>Verifies that not-deployed instances are never handed to the staleness batch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvaluateAsync_NotDeployedInstancesAreNeverHandedToTheStalenessBatch() { @@ -278,6 +290,8 @@ public class PendingUpdatesEvaluatorTests Assert.Equal(1, snapshot.Sites.Single().DeployedInstanceCount); } + /// <summary>Verifies that instance counts are attributed to their own site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvaluateAsync_AttributesInstanceCountsToTheirOwnSite() { @@ -302,6 +316,8 @@ public class PendingUpdatesEvaluatorTests Assert.Equal(1, snapshot.StaleInstanceCount); } + /// <summary>Verifies that a fleet with nothing stale has no pending updates.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvaluateAsync_NothingStale_HasNoPendingUpdates() { @@ -325,6 +341,8 @@ public class PendingUpdatesEvaluatorTests // ── ordering, ComputedAt, ForSites ─────────────────────────────────────── + /// <summary>Verifies that sites are ordered by name, ignoring case.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvaluateAsync_OrdersSitesByNameIgnoringCase() { @@ -339,6 +357,8 @@ public class PendingUpdatesEvaluatorTests Assert.Equal(["Alpha", "bravo", "charlie"], snapshot.Sites.Select(s => s.SiteName)); } + /// <summary>Verifies that the evaluation stamps the computed-at time.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvaluateAsync_StampsComputedAt() { @@ -351,6 +371,8 @@ public class PendingUpdatesEvaluatorTests Assert.InRange(snapshot.ComputedAt, before, DateTimeOffset.UtcNow); } + /// <summary>Verifies that exceptions thrown during evaluation propagate to the caller.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvaluateAsync_ExceptionsPropagate() { @@ -364,6 +386,7 @@ public class PendingUpdatesEvaluatorTests Assert.Equal("db down", ex.Message); } + /// <summary>Verifies that scoping to sites keeps only the named sites and the same computed-at time.</summary> [Fact] public void ForSites_KeepsOnlyTheNamedSitesAndTheSameComputedAt() { @@ -387,6 +410,7 @@ public class PendingUpdatesEvaluatorTests Assert.True(scoped.HasPendingUpdates); } + /// <summary>Verifies that scoping to an empty site set yields no pending updates.</summary> [Fact] public void ForSites_EmptyScope_YieldsNoPendingUpdates() { @@ -401,6 +425,7 @@ public class PendingUpdatesEvaluatorTests Assert.False(scoped.HasPendingUpdates); } + /// <summary>Verifies that the snapshot totals sum across all sites.</summary> [Fact] public void Snapshot_Totals_SumAcrossSites() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ServiceCollectionExtensionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ServiceCollectionExtensionsTests.cs index d9b06959..cd6bcf36 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ServiceCollectionExtensionsTests.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// <summary> -/// DeploymentManager-008: DeploymentManagerOptions must be resolvable via the +/// DeploymentManagerOptions must be resolvable via the /// Options pattern and bindable to the "ScadaBridge:DeploymentManager" /// configuration section. The component itself does not depend on /// IConfiguration (enforced by Host's OptionsTests) — the Host binds the @@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// </summary> public class ServiceCollectionExtensionsTests { + /// <summary>Verifies that AddDeploymentManager registers resolvable options with default values.</summary> [Fact] public void AddDeploymentManager_RegistersResolvableOptions_WithDefaults() { @@ -26,6 +27,7 @@ public class ServiceCollectionExtensionsTests Assert.Equal(TimeSpan.FromSeconds(5), options.OperationLockTimeout); } + /// <summary>Verifies that the options bind to the configuration section the way the host wires them.</summary> [Fact] public void AddDeploymentManager_OptionsBindToConfigurationSection_AsTheHostWires() { @@ -51,16 +53,18 @@ public class ServiceCollectionExtensionsTests Assert.Equal(TimeSpan.FromMinutes(3), options.ArtifactDeploymentTimeoutPerSite); } + /// <summary>Verifies that the options section name matches the conventional component section path.</summary> [Fact] public void OptionsSection_MatchesTheConventionalComponentSectionPath() { Assert.Equal("ScadaBridge:DeploymentManager", ServiceCollectionExtensions.OptionsSection); } - // CentralUI-006: the deployment-status notifier must be a singleton so the + // The deployment-status notifier must be a singleton so the // scoped DeploymentService and the Central UI's scoped Blazor page share // one instance — without that, a push notification raised by the service // would never reach the page's subscription. + /// <summary>Verifies that AddDeploymentManager registers the deployment status notifier as a singleton.</summary> [Fact] public void AddDeploymentManager_RegistersDeploymentStatusNotifier_AsSingleton() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/SiteSecretsCollectorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/SiteSecretsCollectorTests.cs index a41ffcbf..564c9ec3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/SiteSecretsCollectorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/SiteSecretsCollectorTests.cs @@ -32,6 +32,8 @@ public class SiteSecretsCollectorTests UpdatedUtc = DateTimeOffset.Parse("2026-01-02T00:00:00Z"), }; + /// <summary>Verifies that collect returns only referenced rows and names missing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Collect_ReturnsOnlyReferencedRows_AndNamesMissing() { @@ -48,6 +50,8 @@ public class SiteSecretsCollectorTests Assert.Equal(["GW2"], result.Missing[0].ReferencedBy); } + /// <summary>Verifies that collect resolved row maps every field from the stored secret verbatim.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Collect_ResolvedRow_MapsEveryFieldFromTheStoredSecretVerbatim() { @@ -74,6 +78,8 @@ public class SiteSecretsCollectorTests Assert.Equal(row.Description, artifact.Description); } + /// <summary>Verifies that collect absent name is reported missing not thrown.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Collect_AbsentName_IsReportedMissing_NotThrown() { @@ -88,6 +94,8 @@ public class SiteSecretsCollectorTests Assert.Equal(["GW1"], missing.ReferencedBy); } + /// <summary>Verifies that collect no secret references returns empty without querying the store.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Collect_NoSecretReferences_ReturnsEmptyWithoutQueryingTheStore() { @@ -100,10 +108,12 @@ public class SiteSecretsCollectorTests Assert.Empty(result.Missing); } + /// <summary>Verifies that collect store throws returns store error instead of throwing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Collect_StoreThrows_ReturnsStoreError_InsteadOfThrowing() { - // Final review M5: one site's store read failing must not abort a whole sweep. + // One site's store read failing must not abort a whole sweep. var store = new FakeSecretStore(Row("a", 1)) { Fault = new InvalidOperationException("db down") }; var result = await new SiteSecretsCollector(store).CollectAsync( @@ -114,6 +124,8 @@ public class SiteSecretsCollectorTests Assert.Contains("InvalidOperationException: db down", result.StoreError); } + /// <summary>Verifies that collect caller cancellation still propagates.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Collect_CallerCancellation_StillPropagates() { @@ -125,6 +137,8 @@ public class SiteSecretsCollectorTests [Dc("GW1", """{"ApiKey":"secret:a"}""")], cts.Token)); } + /// <summary>Verifies that collect success has no store error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Collect_Success_HasNoStoreError() { @@ -144,30 +158,61 @@ public class SiteSecretsCollectorTests { private readonly Dictionary<SecretName, StoredSecret> _rows; + /// <summary>Initializes a new instance of <c>FakeSecretStore</c>.</summary> + /// <param name="rows">The rows.</param> public FakeSecretStore(params StoredSecret[] rows) => _rows = rows.ToDictionary(r => r.Name); /// <summary>When set, <see cref="GetAsync"/> throws this instead of reading.</summary> public Exception? Fault { get; init; } + /// <summary>Returns the canned secret row, or faults when a fault is configured.</summary> + /// <param name="name">The secret name to look up.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>A task that yields the stored secret, or null.</returns> public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) => Fault is not null ? Task.FromException<StoredSecret?>(Fault) : Task.FromResult(_rows.TryGetValue(name, out var row) ? row : null); + /// <summary>Not supported by this fake.</summary> + /// <param name="row">The secret row, unused.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException(); + /// <summary>Not supported by this fake.</summary> + /// <param name="name">The secret name, unused.</param> + /// <param name="actor">The acting user, unused.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>A task that yields whether a row was deleted.</returns> public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) => throw new NotSupportedException(); + /// <summary>Not supported by this fake.</summary> + /// <param name="includeDeleted">Whether tombstoned secrets are included.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>A task that yields the secret metadata.</returns> public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) => throw new NotSupportedException(); + /// <summary>Not supported by this fake.</summary> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>A task that yields the secret manifest.</returns> public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) => throw new NotSupportedException(); + /// <summary>Not supported by this fake.</summary> + /// <param name="row">The replicated secret row, unused.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException(); + /// <summary>Not supported by this fake.</summary> + /// <param name="rewrappedRow">The rewrapped secret row, unused.</param> + /// <param name="expectedCurrentWrappedDek">The wrapped data key expected to be current, unused.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>A task that yields whether the rewrap was applied.</returns> public Task<bool> ApplyRewrapAsync( StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => throw new NotSupportedException(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/StaleInstanceProbeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/StaleInstanceProbeTests.cs index eff3fb61..cd93eb51 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/StaleInstanceProbeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/StaleInstanceProbeTests.cs @@ -32,6 +32,7 @@ public class StaleInstanceProbeTests private string _currentHash = "sha256:first"; private int _flattenCount; + /// <summary>Initializes a new instance of the <c>StaleInstanceProbeTests</c> class.</summary> public StaleInstanceProbeTests() { // Process-static memo: start every test from a clean slate. @@ -51,6 +52,8 @@ public class StaleInstanceProbeTests private StaleInstanceProbe CreateProbe() => new(_pipeline, _watermark); + /// <summary>Verifies that peer edit with independent watermark is observed by warm active probe.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PeerEdit_WithIndependentWatermark_IsObservedByWarmActiveProbe() { @@ -62,6 +65,8 @@ public class StaleInstanceProbeTests Assert.Equal("sha256:peer-edit", await active.GetCurrentRevisionHashAsync(InstanceId)); } + /// <summary>Verifies that unchanged watermark still reflattens authoritatively.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UnchangedWatermark_StillReflattensAuthoritatively() { @@ -79,6 +84,7 @@ public class StaleInstanceProbeTests /// flattening input without an owning template id — data-connection saves and /// bundle imports both rely on it. It must invalidate the memo. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BumpAll_InvalidatesTheMemo() { @@ -97,6 +103,8 @@ public class StaleInstanceProbeTests Assert.Equal(3, _flattenCount); } + /// <summary>Verifies that bump instance invalidates the memo for that instance.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BumpInstance_InvalidatesTheMemoForThatInstance() { @@ -110,7 +118,7 @@ public class StaleInstanceProbeTests } /// <summary> - /// P7-4: a non-structural template edit landing DURING the flatten must not be + /// A non-structural template edit landing DURING the flatten must not be /// captured as if it were already included. /// /// <para> @@ -130,6 +138,7 @@ public class StaleInstanceProbeTests /// which is correct, since that is the graph the flatten actually saw. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TemplateEditedDuringFlatten_InvalidatesTheMemo_RatherThanPinningTheStaleHash() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/StateTransitionValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/StateTransitionValidatorTests.cs index b99bda2b..ca91d966 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/StateTransitionValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/StateTransitionValidatorTests.cs @@ -3,12 +3,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// <summary> -/// WP-4: Tests for instance state transition matrix. +/// Tests for the instance state transition matrix. /// </summary> public class StateTransitionValidatorTests { // ── Deploy transitions ── + /// <summary>Verifies that deploying is allowed from every instance state.</summary> + /// <param name="state">The current instance state.</param> [Theory] [InlineData(InstanceState.NotDeployed)] [InlineData(InstanceState.Enabled)] @@ -20,18 +22,21 @@ public class StateTransitionValidatorTests // ── Disable transitions ── + /// <summary>Verifies that an enabled instance can be disabled.</summary> [Fact] public void CanDisable_WhenEnabled_ReturnsTrue() { Assert.True(StateTransitionValidator.CanDisable(InstanceState.Enabled)); } + /// <summary>Verifies that a disabled instance cannot be disabled again.</summary> [Fact] public void CanDisable_WhenDisabled_ReturnsFalse() { Assert.False(StateTransitionValidator.CanDisable(InstanceState.Disabled)); } + /// <summary>Verifies that a not-deployed instance cannot be disabled.</summary> [Fact] public void CanDisable_WhenNotDeployed_ReturnsFalse() { @@ -40,18 +45,21 @@ public class StateTransitionValidatorTests // ── Enable transitions ── + /// <summary>Verifies that a disabled instance can be enabled.</summary> [Fact] public void CanEnable_WhenDisabled_ReturnsTrue() { Assert.True(StateTransitionValidator.CanEnable(InstanceState.Disabled)); } + /// <summary>Verifies that an enabled instance cannot be enabled again.</summary> [Fact] public void CanEnable_WhenEnabled_ReturnsFalse() { Assert.False(StateTransitionValidator.CanEnable(InstanceState.Enabled)); } + /// <summary>Verifies that a not-deployed instance cannot be enabled.</summary> [Fact] public void CanEnable_WhenNotDeployed_ReturnsFalse() { @@ -60,18 +68,21 @@ public class StateTransitionValidatorTests // ── Delete transitions ── + /// <summary>Verifies that an enabled instance can be deleted.</summary> [Fact] public void CanDelete_WhenEnabled_ReturnsTrue() { Assert.True(StateTransitionValidator.CanDelete(InstanceState.Enabled)); } + /// <summary>Verifies that a disabled instance can be deleted.</summary> [Fact] public void CanDelete_WhenDisabled_ReturnsTrue() { Assert.True(StateTransitionValidator.CanDelete(InstanceState.Disabled)); } + /// <summary>Verifies that a not-deployed instance can be deleted.</summary> [Fact] public void CanDelete_WhenNotDeployed_ReturnsTrue() { @@ -80,6 +91,7 @@ public class StateTransitionValidatorTests // ── ValidateTransition ── + /// <summary>Verifies that a valid deploy transition returns no error.</summary> [Fact] public void ValidateTransition_ValidDeploy_ReturnsNull() { @@ -87,6 +99,7 @@ public class StateTransitionValidatorTests Assert.Null(error); } + /// <summary>Verifies that an invalid enable transition returns an error.</summary> [Fact] public void ValidateTransition_InvalidEnable_ReturnsError() { @@ -95,6 +108,7 @@ public class StateTransitionValidatorTests Assert.Contains("not allowed", error); } + /// <summary>Verifies that an invalid disable transition returns an error.</summary> [Fact] public void ValidateTransition_InvalidDisable_ReturnsError() { @@ -102,6 +116,7 @@ public class StateTransitionValidatorTests Assert.NotNull(error); } + /// <summary>Verifies that deleting a not-deployed instance returns no error.</summary> [Fact] public void ValidateTransition_ValidDeleteOnNotDeployed_ReturnsNull() { @@ -109,6 +124,7 @@ public class StateTransitionValidatorTests Assert.Null(error); } + /// <summary>Verifies that an unknown operation returns an error.</summary> [Fact] public void ValidateTransition_UnknownOperation_ReturnsError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs index 0971d130..38c802f3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests; /// <summary> -/// WP-9: Tests for Database access — connection resolution, cached writes. +/// Tests for Database access — connection resolution, cached writes. /// </summary> public class DatabaseGatewayTests : IDisposable { @@ -55,7 +55,7 @@ public class DatabaseGatewayTests : IDisposable /// <summary> /// Configures the repository substitute for the name-keyed connection-resolution - /// path used by <c>DatabaseGateway</c> (ExternalSystemGateway-011). A <c>null</c> + /// path used by <c>DatabaseGateway</c>. A <c>null</c> /// connection models a "not found" — the substitute returns <c>null</c> by default, /// so no stub is needed for the absent entity. /// </summary> @@ -68,6 +68,8 @@ public class DatabaseGatewayTests : IDisposable } } + /// <summary>Verifies that GetConnection throws when the connection is not found.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetConnection_NotFound_Throws() { @@ -81,6 +83,8 @@ public class DatabaseGatewayTests : IDisposable () => gateway.GetConnectionAsync("nonexistent")); } + /// <summary>Verifies that CachedWrite throws when no store-and-forward service is configured.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_NoStoreAndForward_Throws() { @@ -96,6 +100,8 @@ public class DatabaseGatewayTests : IDisposable () => gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)")); } + /// <summary>Verifies that CachedWrite throws when the connection is not found.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_ConnectionNotFound_Throws() { @@ -109,8 +115,10 @@ public class DatabaseGatewayTests : IDisposable () => gateway.CachedWriteAsync("nonexistent", "INSERT INTO t VALUES (1)")); } - // ── ExternalSystemGateway-014: CachedWrite happy-path buffering ── + // ── CachedWrite happy-path buffering ── + /// <summary>Verifies that CachedWrite buffers the write using the connection's retry settings.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_BuffersTheWriteWithConnectionRetrySettings() { @@ -135,7 +143,7 @@ public class DatabaseGatewayTests : IDisposable var sf = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService( storage, sfOptions, NullLogger<ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService>.Instance); - // M2.3 (#7): CachedWriteAsync now attempts the write immediately and + // CachedWriteAsync now attempts the write immediately and // only buffers on a TRANSIENT failure. The stub forces a transient // outcome so this test exercises the buffering path deterministically // without a real SQL Server. @@ -144,7 +152,7 @@ public class DatabaseGatewayTests : IDisposable sf, onExecute: () => throw new TransientDatabaseException("deadlock", errorNumber: 1205)); - // Audit Log #23 (ExecutionId Task 4): a known execution id / source + // ExecutionId provenance: a known execution id / source // script so the gateway -> EnqueueAsync hop can be asserted below. var executionId = Guid.NewGuid(); const string sourceScript = "ScriptActor:WriteAudit"; @@ -160,17 +168,19 @@ public class DatabaseGatewayTests : IDisposable Assert.Equal(5, buffered.MaxRetries); Assert.Equal((long)TimeSpan.FromSeconds(12).TotalMilliseconds, buffered.RetryIntervalMs); - // ExecutionId Task 4: the gateway must forward executionId / sourceScript + // ExecutionId provenance: the gateway must forward executionId / sourceScript // into EnqueueAsync, and the S&F layer must persist them on the // sf_messages row so the retry loop can stamp the right provenance. Assert.Equal(executionId, buffered.ExecutionId); Assert.Equal(sourceScript, buffered.SourceScript); } + /// <summary>Verifies that a zero MaxRetries is treated as unset rather than as retry-forever.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_ZeroMaxRetriesIsTreatedAsUnsetNotRetryForever() { - // ExternalSystemGateway-015: a stored MaxRetries of 0 is interpreted by the + // A stored MaxRetries of 0 is interpreted by the // Store-and-Forward retry sweep as "no limit" (retry forever). The entity's // non-nullable int default is also 0, so the gateway must treat the // connection's MaxRetries == 0 as "unset" and pass null — the bounded S&F @@ -196,7 +206,7 @@ public class DatabaseGatewayTests : IDisposable var sf = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService( storage, sfOptions, NullLogger<ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService>.Instance); - // M2.3 (#7): force a transient outcome so the write reaches S&F. + // Force a transient outcome so the write reaches S&F. var gateway = new ExecuteStubGateway( _repository, sf, @@ -210,7 +220,7 @@ public class DatabaseGatewayTests : IDisposable Assert.NotEqual(0, maxRetries); } - // ── M2.3 (#7): transient-vs-permanent SQL classification on the immediate + // ── Transient-vs-permanent SQL classification on the immediate // cached-write attempt + the buffered retry path ── /// <summary> @@ -235,6 +245,8 @@ public class DatabaseGatewayTests : IDisposable return (sf, localDb.Db); } + /// <summary>Verifies that a permanent SQL error returns a failure synchronously and is not buffered.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_PermanentSqlError_ReturnsFailedSynchronously_NotBuffered() { @@ -263,6 +275,8 @@ public class DatabaseGatewayTests : IDisposable Assert.Equal(0, ReadBufferDepth(sfDb)); } + /// <summary>Verifies that a transient SQL error is buffered to store-and-forward.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_TransientSqlError_BuffersToStoreAndForward() { @@ -294,6 +308,8 @@ public class DatabaseGatewayTests : IDisposable Assert.Equal(1, ReadBufferDepth(sfDb)); } + /// <summary>Verifies that an immediate success is not buffered and returns Delivered.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_ImmediateSuccess_NotBuffered_ReturnsDelivered() { @@ -316,6 +332,8 @@ public class DatabaseGatewayTests : IDisposable Assert.Equal(0, ReadBufferDepth(sfDb)); } + /// <summary>Verifies that a transient SQL error during buffered delivery is rethrown so the engine retries.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliverBuffered_TransientSqlError_RethrowsSoEngineRetries() { @@ -343,6 +361,8 @@ public class DatabaseGatewayTests : IDisposable () => gateway.DeliverBufferedAsync(message)); } + /// <summary>Verifies that a permanent SQL error during buffered delivery returns false so the message parks.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliverBuffered_PermanentSqlError_ReturnsFalseSoMessageParks() { @@ -372,7 +392,7 @@ public class DatabaseGatewayTests : IDisposable Assert.False(delivered); // permanent — the S&F engine parks the message } - // ── M2.3 (#7) code-review fix: ExecuteWriteAsync must classify NON-SqlException + // ── ExecuteWriteAsync must classify NON-SqlException // DB outages as transient (buffer+retry) and propagate cancellation — // mirroring the HTTP path's ordered catches in InvokeHttpAsync. The pre-fix // code only caught SqlException, so a live outage surfacing as @@ -381,6 +401,8 @@ public class DatabaseGatewayTests : IDisposable // buffering. These tests drive the RAW execution seam (RunSqlAsync) so the // PRODUCTION classification in ExecuteWriteAsync runs end-to-end. ── + /// <summary>Provides the non-SQL outage exceptions that must be classified as transient.</summary> + /// <returns>The outage exceptions, one per test case.</returns> public static IEnumerable<object[]> TransientNonSqlOutages() { // A live DB outage that surfaces as a non-SqlException: connection-state, @@ -391,6 +413,9 @@ public class DatabaseGatewayTests : IDisposable yield return new object[] { new TimeoutException("The operation has timed out.") }; } + /// <summary>Verifies that a non-SQL outage is classified as transient and buffered rather than crashing.</summary> + /// <param name="outage">The outage exception raised by the write.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [MemberData(nameof(TransientNonSqlOutages))] public async Task CachedWrite_NonSqlOutage_ClassifiedTransient_BuffersNotCrash(Exception outage) @@ -422,6 +447,8 @@ public class DatabaseGatewayTests : IDisposable Assert.Equal(1, ReadBufferDepth(sfDb)); } + /// <summary>Verifies that a requested cancellation propagates OperationCanceledException and is not reclassified.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_CancellationRequested_PropagatesOperationCanceled_NotReclassified() { @@ -447,6 +474,8 @@ public class DatabaseGatewayTests : IDisposable Assert.Equal(0, ReadBufferDepth(sfDb)); } + /// <summary>Verifies that an unexpected exception propagates and is not classified as transient.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_UnexpectedException_Propagates_NotClassifiedTransient() { @@ -468,6 +497,8 @@ public class DatabaseGatewayTests : IDisposable Assert.Equal(0, ReadBufferDepth(sfDb)); } + /// <summary>Verifies that a non-SQL outage during buffered delivery is rethrown as transient so the engine retries.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliverBuffered_NonSqlOutage_RethrowsAsTransient_SoEngineRetries() { @@ -495,13 +526,15 @@ public class DatabaseGatewayTests : IDisposable () => gateway.DeliverBufferedAsync(message)); } - // ── ExternalSystemGateway-025: a caller-token cancel that surfaces from the SQL + // ── A caller-token cancel that surfaces from the SQL // driver as a SqlException (mid-flight cancel) must propagate as // OperationCanceledException — never reclassified as a permanent DB error. // The fix re-checks the caller's token at the TOP of `catch (SqlException)` // via cancellationToken.ThrowIfCancellationRequested(), so the cancel wins // regardless of the driver's exception shape (version-independent). ── + /// <summary>Verifies that a cancellation surfacing as a SqlException propagates as canceled and is not reclassified as permanent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_CancellationSurfacingAsSqlException_PropagatesCanceled_NotReclassifiedPermanent() { @@ -584,6 +617,10 @@ public class DatabaseGatewayTests : IDisposable { private readonly Action _onExecute; + /// <summary>Initializes the stub gateway.</summary> + /// <param name="repository">The external system repository.</param> + /// <param name="storeAndForward">The store-and-forward service, or null.</param> + /// <param name="onExecute">The callback invoked when a write executes.</param> public ExecuteStubGateway( IExternalSystemRepository repository, ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService? storeAndForward, @@ -591,6 +628,7 @@ public class DatabaseGatewayTests : IDisposable : base(repository, NullLogger<DatabaseGateway>.Instance, storeAndForward) => _onExecute = onExecute; + /// <inheritdoc /> internal override Task ExecuteWriteAsync( string connectionName, string connectionString, @@ -617,6 +655,10 @@ public class DatabaseGatewayTests : IDisposable { private readonly Action _onRunSql; + /// <summary>Initializes the stub gateway.</summary> + /// <param name="repository">The external system repository.</param> + /// <param name="storeAndForward">The store-and-forward service, or null.</param> + /// <param name="onRunSql">The callback invoked when SQL is run.</param> public RawExecuteStubGateway( IExternalSystemRepository repository, ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService? storeAndForward, @@ -624,6 +666,7 @@ public class DatabaseGatewayTests : IDisposable : base(repository, NullLogger<DatabaseGateway>.Instance, storeAndForward) => _onRunSql = onRunSql; + /// <inheritdoc /> internal override Task RunSqlAsync( string connectionString, string sql, @@ -654,8 +697,10 @@ public class DatabaseGatewayTests : IDisposable return result; } - // ── ExternalSystemGateway-001: buffered CachedDbWrite delivery handler ── + // ── Buffered CachedDbWrite delivery handler ── + /// <summary>Verifies that buffered delivery returns false so the message parks when the connection no longer exists.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliverBuffered_ConnectionNoLongerExists_ReturnsFalseSoMessageParks() { @@ -676,14 +721,16 @@ public class DatabaseGatewayTests : IDisposable Assert.False(delivered); // permanent — the S&F engine parks the message } - // ── ExternalSystemGateway-018: malformed JSON payload must park, not retry-forever ── + // ── Malformed JSON payload must park, not retry-forever ── + /// <summary>Verifies that a malformed JSON payload returns false so the message parks.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliverBuffered_MalformedJsonPayload_ReturnsFalseSoMessageParks() { // No connection stub needed — deserialization fails before any // resolution or SQL execution. If the JsonException were to escape (the - // pre-018 behaviour) the S&F engine would treat it as transient and + // previous behaviour) the S&F engine would treat it as transient and // retry the same poison row forever. var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance); @@ -701,8 +748,9 @@ public class DatabaseGatewayTests : IDisposable Assert.False(delivered); // permanent — the S&F engine parks the message } - // ── ExternalSystemGateway-020: decimal SQL parameter precision survives JsonElement round-trip ── + // ── Decimal SQL parameter precision survives JsonElement round-trip ── + /// <summary>Verifies that a decimal-shaped JSON number preserves its precision by being converted to a decimal.</summary> [Fact] public void JsonElementToParameterValue_DecimalShapedNumber_PreservesPrecisionViaDecimal() { @@ -710,7 +758,7 @@ public class DatabaseGatewayTests : IDisposable // (System.Text.Json has no decimal type tag), then on the cached-write // retry path the buffered payload is re-deserialised into a // JsonElement and the gateway must materialise a CLR value for the - // parameter. Pre-020 it called GetDouble() for any non-Int64 number, + // parameter. Previously it called GetDouble() for any non-Int64 number, // which silently downcast every decimal to a binary float and lost // precision (1234567890.1234567890 -> 1234567890.1234567 as double). // The 020 fix prefers decimal — round-tripping must preserve every @@ -724,13 +772,14 @@ public class DatabaseGatewayTests : IDisposable var materialised = DatabaseGateway.JsonElementToParameterValue(element); // The materialised value must be the original decimal, not a double. - // Asserting on the type alone is enough to fail pre-020 (which + // Asserting on the type alone is enough to fail the previous implementation (which // produced a System.Double); the value assertion locks in the // precision invariant. var asDecimal = Assert.IsType<decimal>(materialised); Assert.Equal(1234567890.1234567890m, asDecimal); } + /// <summary>Verifies that a whole JSON number takes the fast path and returns a long.</summary> [Fact] public void JsonElementToParameterValue_WholeNumber_FastPathReturnsLong() { @@ -745,6 +794,7 @@ public class DatabaseGatewayTests : IDisposable Assert.Equal(42L, materialised); } + /// <summary>Verifies that a number outside the decimal range falls through to a double.</summary> [Fact] public void JsonElementToParameterValue_OutOfDecimalRangeNumber_FallsThroughToDouble() { @@ -761,8 +811,10 @@ public class DatabaseGatewayTests : IDisposable Assert.Equal(1e40, (double)materialised); } - // ── ExternalSystemGateway-010: SqlConnection must not leak when OpenAsync fails ── + // ── SqlConnection must not leak when OpenAsync fails ── + /// <summary>Verifies that the connection is disposed before the exception is rethrown when opening fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetConnection_OpenFails_DisposesConnectionBeforeRethrowing() { @@ -783,27 +835,36 @@ public class DatabaseGatewayTests : IDisposable { private readonly DbConnection _connection; + /// <summary>Initializes the stub gateway with the connection it will hand out.</summary> + /// <param name="repository">The external system repository.</param> + /// <param name="connection">The connection returned by the factory method.</param> public ConnectionFactoryStubGateway(IExternalSystemRepository repository, DbConnection connection) : base(repository, NullLogger<DatabaseGateway>.Instance) => _connection = connection; + /// <inheritdoc /> internal override DbConnection CreateConnection(string connectionString) => _connection; } /// <summary>A DbConnection whose OpenAsync always fails, tracking whether it was disposed.</summary> private sealed class ThrowingDbConnection : DbConnection { + /// <summary>Gets a value indicating whether the connection was disposed.</summary> public bool WasDisposed { get; private set; } + /// <inheritdoc /> public override Task OpenAsync(CancellationToken cancellationToken) => throw new InvalidOperationException("simulated open failure"); + /// <inheritdoc /> public override void Open() => throw new InvalidOperationException("simulated open failure"); + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) WasDisposed = true; base.Dispose(disposing); } + /// <inheritdoc /> public override ValueTask DisposeAsync() { WasDisposed = true; @@ -811,15 +872,24 @@ public class DatabaseGatewayTests : IDisposable } // Unused abstract members. + /// <inheritdoc /> [System.Diagnostics.CodeAnalysis.AllowNull] public override string ConnectionString { get; set; } = string.Empty; + /// <inheritdoc /> public override string Database => string.Empty; + /// <inheritdoc /> public override string DataSource => string.Empty; + /// <inheritdoc /> public override string ServerVersion => string.Empty; + /// <inheritdoc /> public override ConnectionState State => ConnectionState.Closed; + /// <inheritdoc /> public override void ChangeDatabase(string databaseName) => throw new NotSupportedException(); + /// <inheritdoc /> public override void Close() { } + /// <inheritdoc /> protected override DbTransaction BeginDbTransaction(IsolationLevel il) => throw new NotSupportedException(); + /// <inheritdoc /> protected override DbCommand CreateDbCommand() => throw new NotSupportedException(); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ErrorClassifierTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ErrorClassifierTests.cs index 7a548c6e..db149172 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ErrorClassifierTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ErrorClassifierTests.cs @@ -3,12 +3,15 @@ using System.Net; namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests; /// <summary> -/// WP-8: Tests for HTTP error classification. +/// Tests for HTTP error classification. /// Transient: connection refused, timeout, HTTP 408/429/5xx. /// Permanent: HTTP 4xx (except 408/429). /// </summary> public class ErrorClassifierTests { + /// <summary>Verifies that transient HTTP status codes are classified as transient.</summary> + /// <param name="statusCode">The test input.</param> + /// <param name="expectedTransient">The test input.</param> [Theory] [InlineData(HttpStatusCode.InternalServerError, true)] [InlineData(HttpStatusCode.BadGateway, true)] @@ -21,6 +24,9 @@ public class ErrorClassifierTests Assert.Equal(expectedTransient, ErrorClassifier.IsTransient(statusCode)); } + /// <summary>Verifies that permanent HTTP status codes are classified as permanent.</summary> + /// <param name="statusCode">The test input.</param> + /// <param name="expectedTransient">The test input.</param> [Theory] [InlineData(HttpStatusCode.BadRequest, false)] [InlineData(HttpStatusCode.Unauthorized, false)] @@ -33,30 +39,35 @@ public class ErrorClassifierTests Assert.Equal(expectedTransient, ErrorClassifier.IsTransient(statusCode)); } + /// <summary>Verifies that an HttpRequestException is classified as transient.</summary> [Fact] public void HttpRequestException_IsTransient() { Assert.True(ErrorClassifier.IsTransient(new HttpRequestException("Connection refused"))); } + /// <summary>Verifies that a TaskCanceledException is classified as transient.</summary> [Fact] public void TaskCanceledException_IsTransient() { Assert.True(ErrorClassifier.IsTransient(new TaskCanceledException("Timeout"))); } + /// <summary>Verifies that a TimeoutException is classified as transient.</summary> [Fact] public void TimeoutException_IsTransient() { Assert.True(ErrorClassifier.IsTransient(new TimeoutException())); } + /// <summary>Verifies that a generic exception is not classified as transient.</summary> [Fact] public void GenericException_IsNotTransient() { Assert.False(ErrorClassifier.IsTransient(new InvalidOperationException("bad input"))); } + /// <summary>Verifies that AsTransient creates the expected exception.</summary> [Fact] public void AsTransient_CreatesCorrectException() { @@ -65,6 +76,7 @@ public class ErrorClassifierTests Assert.Equal("test message", ex.Message); } + /// <summary>Verifies that an OperationCanceledException caused by the caller's cancellation is not classified as transient.</summary> [Fact] public void IsTransient_CallerCancelledOce_IsNotTransient() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs index ebea6eca..f01c93fb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs @@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests; /// <summary> -/// WP-6/7: Tests for ExternalSystemClient — HTTP client, call modes, error handling. +/// Tests for ExternalSystemClient — HTTP client, call modes, error handling. /// </summary> public class ExternalSystemClientTests : IDisposable { @@ -57,7 +57,7 @@ public class ExternalSystemClientTests : IDisposable /// <summary> /// Configures the repository substitute for the name-keyed resolution path used by - /// <c>ExternalSystemClient</c> (ExternalSystemGateway-011). A <c>null</c> system or + /// <c>ExternalSystemClient</c>. A <c>null</c> system or /// method models a "not found" — the substitute returns <c>null</c> by default, so /// no stub is needed for the absent entity. /// </summary> @@ -76,6 +76,8 @@ public class ExternalSystemClientTests : IDisposable } } + /// <summary>Verifies that calling an unknown system returns an error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_SystemNotFound_ReturnsError() { @@ -91,6 +93,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("not found", result.ErrorMessage); } + /// <summary>Verifies that calling an unknown method returns an error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_MethodNotFound_ReturnsError() { @@ -107,6 +111,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("not found", result.ErrorMessage); } + /// <summary>Verifies that a successful HTTP call returns the response.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_SuccessfulHttp_ReturnsResponse() { @@ -129,6 +135,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("42", result.ResponseJson!); } + /// <summary>Verifies that an HTTP 500 response is returned as a transient error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_Transient500_ReturnsTransientError() { @@ -151,6 +159,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("Transient error", result.ErrorMessage); } + /// <summary>Verifies that an HTTP 400 response is returned as a permanent error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_Permanent400_ReturnsPermanentError() { @@ -173,6 +183,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("Permanent error", result.ErrorMessage); } + /// <summary>Verifies that a cached call to an unknown system returns an error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_SystemNotFound_ReturnsError() { @@ -188,6 +200,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("not found", result.ErrorMessage); } + /// <summary>Verifies that a successful cached call returns its response directly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_Success_ReturnsDirectly() { @@ -210,7 +224,7 @@ public class ExternalSystemClientTests : IDisposable Assert.False(result.WasBuffered); } - // ── ExternalSystemGateway-001: buffered-call delivery handler ── + // ── buffered-call delivery handler ── private static ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardMessage BufferedCall( string systemName, string methodName) => @@ -223,6 +237,8 @@ public class ExternalSystemClientTests : IDisposable $$"""{"SystemName":"{{systemName}}","MethodName":"{{methodName}}","Parameters":null}""", }; + /// <summary>Verifies that delivering a buffered message over a successful HTTP call returns true.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliverBuffered_SuccessfulHttp_ReturnsTrue() { @@ -241,6 +257,8 @@ public class ExternalSystemClientTests : IDisposable Assert.True(delivered); } + /// <summary>Verifies that delivering a buffered message returns false when its system no longer exists, so the message parks.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliverBuffered_SystemNoLongerExists_ReturnsFalseSoMessageParks() { @@ -254,6 +272,8 @@ public class ExternalSystemClientTests : IDisposable Assert.False(delivered); // permanent — the S&F engine parks the message } + /// <summary>Verifies that delivering a buffered message that gets an HTTP 500 throws so the engine retries.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliverBuffered_Transient500_ThrowsSoEngineRetries() { @@ -271,14 +291,16 @@ public class ExternalSystemClientTests : IDisposable () => client.DeliverBufferedAsync(BufferedCall("TestAPI", "failMethod"))); } - // ── ExternalSystemGateway-018: malformed JSON payload must park, not retry-forever ── + // ── malformed JSON payload must park, not retry-forever ── + /// <summary>Verifies that delivering a buffered message with a malformed JSON payload returns false, so the message parks.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliverBuffered_MalformedJsonPayload_ReturnsFalseSoMessageParks() { // No repository / HTTP stubs needed — deserialization fails before any // resolution or HTTP call. If the JsonException were to escape (the - // pre-018 behaviour) the S&F engine would treat it as transient and + // previous behaviour) the S&F engine would treat it as transient and // retry the same poison row forever. var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance); @@ -297,8 +319,10 @@ public class ExternalSystemClientTests : IDisposable Assert.False(delivered); // permanent — the S&F engine parks the message } - // ── ExternalSystemGateway-003: CachedCall must not double-dispatch ── + // ── CachedCall must not double-dispatch ── + /// <summary>Verifies that a cached call transient failure is not immediately redispatched through the registered handler.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_TransientFailure_DoesNotImmediatelyRedispatchViaRegisteredHandler() { @@ -339,8 +363,10 @@ public class ExternalSystemClientTests : IDisposable Assert.Equal(0, handlerInvocations); } - // ── ExternalSystemGateway-002: per-system call timeout ── + // ── per-system call timeout ── + /// <summary>Verifies that a call to a slow system times out as a transient error within the configured window.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_SlowSystem_TimesOutAsTransientErrorWithinConfiguredWindow() { @@ -372,7 +398,7 @@ public class ExternalSystemClientTests : IDisposable } /// <summary> - /// ExternalSystemGateway-019 regression: <see cref="HttpClient.Timeout"/> defaults + /// Regression: <see cref="HttpClient.Timeout"/> defaults /// to 100 seconds and is enforced internally by <c>SendAsync</c> via its own /// private CTS — a <see cref="TaskCanceledException"/> raised by that internal /// CTS does not trip either the caller's token or the gateway's timeout CTS, @@ -383,6 +409,7 @@ public class ExternalSystemClientTests : IDisposable /// <c>InvokeHttpAsync</c> is the sole timeout source. This test verifies the /// property is in fact set before any request is dispatched. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_DisablesHttpClientFrameworkTimeoutSoLongTimeoutsArentClipped() { @@ -414,6 +441,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Equal(Timeout.InfiniteTimeSpan, httpClient.Timeout); } + /// <summary>Verifies that caller cancellation of a call is not misreported as a timeout.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_CallerCancellation_IsNotMisreportedAsTimeout() { @@ -438,8 +467,10 @@ public class ExternalSystemClientTests : IDisposable () => client.CallAsync("TestAPI", "getData", cancellationToken: cts.Token)); } - // ── ExternalSystemGateway-004: per-system retry settings honoured for cached calls ── + // ── per-system retry settings honoured for cached calls ── + /// <summary>Verifies that a cached call transient failure is buffered with the system's retry settings.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_TransientFailure_BuffersWithSystemRetrySettings() { @@ -471,7 +502,7 @@ public class ExternalSystemClientTests : IDisposable _httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance, storeAndForward: sf); - // Audit Log #23 (ExecutionId Task 4): a known execution id / source + // ExecutionId: a known execution id / source // script so the gateway -> EnqueueAsync hop can be asserted below. var executionId = Guid.NewGuid(); const string sourceScript = "ScriptActor:CheckPressure"; @@ -488,7 +519,7 @@ public class ExternalSystemClientTests : IDisposable Assert.Equal(7, buffered.MaxRetries); Assert.Equal((long)TimeSpan.FromSeconds(42).TotalMilliseconds, buffered.RetryIntervalMs); - // ExecutionId Task 4: the gateway must forward executionId / sourceScript + // ExecutionId: the gateway must forward executionId / sourceScript // into EnqueueAsync, and the S&F layer must persist them on the // sf_messages row so the retry loop can stamp the right provenance. Assert.Equal(executionId, buffered.ExecutionId); @@ -514,10 +545,12 @@ public class ExternalSystemClientTests : IDisposable return result; } + /// <summary>Verifies that a zero max-retries value on a cached call transient failure is treated as unset rather than as retry forever.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_TransientFailure_ZeroMaxRetriesIsTreatedAsUnsetNotRetryForever() { - // ExternalSystemGateway-015: the Store-and-Forward engine interprets a stored + // the Store-and-Forward engine interprets a stored // MaxRetries of 0 as "no limit" (retry forever) — see StoreAndForwardMessage.cs // and the retry-sweep guard `MaxRetries > 0 && ...`. The entity's non-nullable // int default is also 0, so passing 0 verbatim would buffer every cached call @@ -558,8 +591,10 @@ public class ExternalSystemClientTests : IDisposable Assert.NotEqual(0, maxRetries); } - // ── ExternalSystemGateway-005: HttpRequestMessage / HttpResponseMessage disposal ── + // ── HttpRequestMessage / HttpResponseMessage disposal ── + /// <summary>Verifies that a successful HTTP call disposes the request and the response.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_SuccessfulHttp_DisposesRequestAndResponse() { @@ -580,6 +615,8 @@ public class ExternalSystemClientTests : IDisposable Assert.True(handler.ResponseContentDisposed, "HttpResponseMessage content was not disposed"); } + /// <summary>Verifies that a permanent failure still disposes the request and the response.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_PermanentFailure_StillDisposesRequestAndResponse() { @@ -600,8 +637,10 @@ public class ExternalSystemClientTests : IDisposable Assert.True(handler.ResponseContentDisposed, "HttpResponseMessage content was not disposed on the error path"); } - // ── ExternalSystemGateway-006: BuildUrl — empty path must not append a trailing slash ── + // ── BuildUrl — empty path must not append a trailing slash ── + /// <summary>Verifies that a method with an empty path does not append a trailing slash to the URL.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_MethodWithEmptyPath_DoesNotAppendTrailingSlash() { @@ -621,6 +660,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Equal("https://api.example.com/api", handler.LastUri!.ToString()); } + /// <summary>Verifies that a method with a path builds the expected URL.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_MethodWithPath_BuildsExpectedUrl() { @@ -640,8 +681,10 @@ public class ExternalSystemClientTests : IDisposable Assert.Equal("https://api.example.com/api/data", handler.LastUri!.ToString()); } - // ── ExternalSystemGateway-007: external error body must be truncated, not echoed verbatim ── + // ── external error body must be truncated, not echoed verbatim ── + /// <summary>Verifies that a permanent failure with a huge error body truncates the error message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_PermanentFailureWithHugeErrorBody_TruncatesErrorMessage() { @@ -666,8 +709,10 @@ public class ExternalSystemClientTests : IDisposable $"Error message was {result.ErrorMessage.Length} chars — expected it to be truncated"); } - // ── ExternalSystemGateway-008: cancellation of a CachedCall must not be buffered ── + // ── cancellation of a CachedCall must not be buffered ── + /// <summary>Verifies that caller cancellation of a cached call is not buffered as a transient failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_CallerCancellation_IsNotBufferedAsTransient() { @@ -706,8 +751,10 @@ public class ExternalSystemClientTests : IDisposable "A caller-cancelled CachedCall must not be buffered for retry"); } - // ── ExternalSystemGateway-014: BuildUrl query-string, ApplyAuth, connection errors ── + // ── BuildUrl query-string, ApplyAuth, connection errors ── + /// <summary>Verifies that a GET call with parameters appends an escaped query string.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_GetWithParameters_AppendsEscapedQueryString() { @@ -735,10 +782,12 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("page=2", uri); } + /// <summary>Verifies that a GET call whose parameters are all null does not append a trailing question mark.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_GetWithAllNullParameters_DoesNotAppendTrailingQuestionMark() { - // ExternalSystemGateway-017: a GET method invoked with a non-empty parameter + // a GET method invoked with a non-empty parameter // dictionary whose values are all null has an effectively empty query string. // The URL must be identical to the no-parameters case — no bare trailing '?'. var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; @@ -762,6 +811,8 @@ public class ExternalSystemClientTests : IDisposable Assert.DoesNotContain("?", uri); } + /// <summary>Verifies that a POST call with parameters sends a JSON body.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_PostWithParameters_SendsJsonBody() { @@ -781,6 +832,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("\"name\":\"widget\"", handler.LastBody); } + /// <summary>Verifies that API key auth with the default header sends the X-Api-Key header.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_ApiKeyAuthWithDefaultHeader_SendsXApiKeyHeader() { @@ -804,6 +857,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Equal("secret-key-123", values!.Single()); } + /// <summary>Verifies that API key auth with a custom header name sends that named header.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_ApiKeyAuthWithCustomHeader_SendsNamedHeader() { @@ -827,6 +882,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Equal("abc", values!.Single()); } + /// <summary>Verifies that basic auth sends a Base64-encoded Authorization header.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_BasicAuth_SendsBase64AuthorizationHeader() { @@ -853,6 +910,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Equal("alice:s3cret", decoded); } + /// <summary>Verifies that a JSON API key auth config sets the configured header and preserves colons in the key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApiKeyAuth_JsonConfig_SetsConfiguredHeaderAndKeepsColons() { @@ -876,11 +935,13 @@ public class ExternalSystemClientTests : IDisposable Assert.Equal("ab:cd:ef", values!.Single()); } + /// <summary>Verifies that a JSON API key auth config without a header name uses the default header.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApiKeyAuth_JsonConfig_DefaultHeader() { // The UI-placeholder shape ({"key":"xyz"}) — silently misparsed by the - // legacy colon-split before Task 15 (it produced header name '{"key'). + // legacy colon-split (it produced header name '{"key'). var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey") { Id = 1, @@ -901,6 +962,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Equal("xyz", values!.Single()); } + /// <summary>Verifies that a JSON basic auth config encodes the username and password.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BasicAuth_JsonConfig_EncodesUsernamePassword() { @@ -927,6 +990,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Equal("u:p:w", decoded); } + /// <summary>Verifies that the legacy colon-delimited API key auth config still works.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApiKeyAuth_LegacyColonConfig_StillWorks() { @@ -952,6 +1017,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Equal("secret", values!.Single()); } + /// <summary>Verifies that a connection error is classified as transient.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_ConnectionError_IsClassifiedAsTransient() { @@ -972,8 +1039,10 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("Transient error", result.ErrorMessage); } - // ── ExternalSystemGateway-012: permanent failures must be logged ── + // ── permanent failures must be logged ── + /// <summary>Verifies that a permanent failure logs a warning.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_PermanentFailure_LogsAWarning() { @@ -996,6 +1065,8 @@ public class ExternalSystemClientTests : IDisposable e.Level >= LogLevel.Warning && e.Message.Contains("TestAPI")); } + /// <summary>Verifies that a per-system timeout overrides the gateway default.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PerSystemTimeout_OverridesGatewayDefault() { @@ -1023,6 +1094,8 @@ public class ExternalSystemClientTests : IDisposable "per-system 1s timeout must apply, not the 30s default"); } + /// <summary>Verifies that a transient failure does not log at warning level or above.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_TransientFailure_DoesNotLogAtWarningOrAbove() { @@ -1047,11 +1120,26 @@ public class ExternalSystemClientTests : IDisposable /// <summary>Test helper: an ILogger that records every entry for assertions.</summary> private sealed class CapturingLogger<T> : ILogger<T> { + /// <summary>Gets the entries value.</summary> public List<(LogLevel Level, string Message)> Entries { get; } = new(); + /// <summary>Starts no scope; the fake does not track scopes.</summary> + /// <typeparam name="TState">The type of the scope state.</typeparam> + /// <param name="state">The scope state, ignored.</param> + /// <returns>No scope token; the fake does not track scopes.</returns> public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level being queried.</param> + /// <returns>Always <see langword="true"/>.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The type of the log state.</typeparam> + /// <param name="logLevel">The severity of the entry.</param> + /// <param name="eventId">The event id of the entry.</param> + /// <param name="state">The state object being logged.</param> + /// <param name="exception">The exception associated with the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message text.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) @@ -1062,12 +1150,15 @@ public class ExternalSystemClientTests : IDisposable private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); + /// <summary>Does nothing; the scope holds no resources.</summary> public void Dispose() { } } } // ── ESG: bound outbound response buffering (MaxResponseBodyBytes) ── + /// <summary>Verifies that an oversized response body fails permanently without buffering the whole body.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OversizedResponseBody_FailsPermanently_WithoutBufferingWholeBody() { @@ -1124,12 +1215,16 @@ public class ExternalSystemClientTests : IDisposable private readonly HttpStatusCode _statusCode; private readonly string _body; + /// <summary>Initializes a new instance of the <c>MockHttpMessageHandler</c> class.</summary> + /// <param name="statusCode">The status code.</param> + /// <param name="body">The body.</param> public MockHttpMessageHandler(HttpStatusCode statusCode, string body) { _statusCode = statusCode; _body = body; } + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { return Task.FromResult(new HttpResponseMessage(_statusCode) @@ -1147,15 +1242,21 @@ public class ExternalSystemClientTests : IDisposable private readonly HttpStatusCode _statusCode; private readonly string _body; + /// <summary>Initializes a new instance of the <c>DisposalTrackingHandler</c> class.</summary> + /// <param name="statusCode">The status code.</param> + /// <param name="body">The body.</param> public DisposalTrackingHandler(HttpStatusCode statusCode, string body) { _statusCode = statusCode; _body = body; } + /// <summary>Gets the request disposed value.</summary> public bool RequestDisposed { get; private set; } + /// <summary>Gets the response content disposed value.</summary> public bool ResponseContentDisposed { get; private set; } + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { request.Content = new TrackingContent(string.Empty, () => RequestDisposed = true); @@ -1169,8 +1270,12 @@ public class ExternalSystemClientTests : IDisposable private sealed class TrackingContent : StringContent { private readonly Action _onDispose; + /// <summary>Initializes a new instance of the <c>TrackingContent</c> class.</summary> + /// <param name="content">The content.</param> + /// <param name="onDispose">The on dispose.</param> public TrackingContent(string content, Action onDispose) : base(content) => _onDispose = onDispose; + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) _onDispose(); @@ -1187,16 +1292,23 @@ public class ExternalSystemClientTests : IDisposable private readonly HttpStatusCode _statusCode; private readonly string _body; + /// <summary>Initializes a new instance of the <c>RequestCapturingHandler</c> class.</summary> + /// <param name="statusCode">The status code.</param> + /// <param name="body">The body.</param> public RequestCapturingHandler(HttpStatusCode statusCode, string body) { _statusCode = statusCode; _body = body; } + /// <summary>Gets the last uri value.</summary> public Uri? LastUri { get; private set; } + /// <summary>Gets the last headers value.</summary> public HttpRequestHeaders? LastHeaders { get; private set; } + /// <summary>Gets the last body value.</summary> public string? LastBody { get; private set; } + /// <inheritdoc /> protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { LastUri = request.RequestUri; @@ -1213,8 +1325,11 @@ public class ExternalSystemClientTests : IDisposable private class ThrowingHttpMessageHandler : HttpMessageHandler { private readonly Exception _exception; + /// <summary>Initializes a new instance of the <c>ThrowingHttpMessageHandler</c> class.</summary> + /// <param name="exception">The exception.</param> public ThrowingHttpMessageHandler(Exception exception) => _exception = exception; + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => throw _exception; } @@ -1226,8 +1341,11 @@ public class ExternalSystemClientTests : IDisposable { private readonly TimeSpan _delay; + /// <summary>Initializes a new instance of the <c>HangingHttpMessageHandler</c> class.</summary> + /// <param name="delay">The delay.</param> public HangingHttpMessageHandler(TimeSpan delay) => _delay = delay; + /// <inheritdoc /> protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { await Task.Delay(_delay, cancellationToken); @@ -1236,11 +1354,13 @@ public class ExternalSystemClientTests : IDisposable } /// <summary> - /// ExternalSystemGateway-022: an HTTP method outside the documented allowlist + /// an HTTP method outside the documented allowlist /// (GET/POST/PUT/PATCH/DELETE) must be rejected at the gateway entry with a /// clear <see cref="ArgumentException"/> — not silently dispatched as a /// non-standard verb whose parameters land in neither body nor query string. /// </summary> + /// <param name="httpMethod">The unsupported HTTP method name.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("FOO")] [InlineData("DLETE")] // common typo for DELETE @@ -1265,6 +1385,9 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("GET", ex.Message); } + /// <summary>Verifies that a call using a documented HTTP method is accepted.</summary> + /// <param name="httpMethod">The http method.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("GET")] [InlineData("get")] // case-insensitive — operator-authored strings @@ -1291,6 +1414,8 @@ public class ExternalSystemClientTests : IDisposable // ── ExternalSystemGateway: {param} path-template substitution ── + /// <summary>Verifies that a path template substitutes the parameter and consumes it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PathTemplate_SubstitutesAndConsumesParameter() { @@ -1319,6 +1444,8 @@ public class ExternalSystemClientTests : IDisposable Assert.DoesNotContain("id=", uri); } + /// <summary>Verifies that in a POST with a path template, the consumed parameter is excluded from the body.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PathTemplate_Post_ConsumedParamExcludedFromBody() { @@ -1345,6 +1472,8 @@ public class ExternalSystemClientTests : IDisposable Assert.DoesNotContain("orderId", handler.LastBody); } + /// <summary>Verifies that a path template with a missing parameter throws a clear error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PathTemplate_MissingParameter_ThrowsClearError() { @@ -1364,6 +1493,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("id", ex.Message); } + /// <summary>Verifies that delivering a buffered message whose path template is now unresolvable returns false, so the message parks.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliverBuffered_PathTemplateNowUnresolvable_ReturnsFalseSoMessageParks() { @@ -1385,6 +1516,8 @@ public class ExternalSystemClientTests : IDisposable Assert.False(delivered); } + /// <summary>Verifies that delivering a buffered message whose stored method has an unsupported verb returns false, so the message parks.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliverBuffered_UnsupportedVerbOnStoredMethod_ReturnsFalseSoMessageParks() { @@ -1400,6 +1533,8 @@ public class ExternalSystemClientTests : IDisposable Assert.False(delivered); } + /// <summary>Verifies that a response declaring the Latin1 charset is decoded using that charset.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_Latin1Charset_DecodesUsingDeclaredCharset() { @@ -1422,6 +1557,8 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("température 25°C", result.ResponseJson); } + /// <summary>Verifies that a response with an unknown charset falls back to UTF-8 decoding.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_UnknownCharset_FallsBackToUtf8() { @@ -1444,6 +1581,7 @@ public class ExternalSystemClientTests : IDisposable private sealed class ByteBodyHandler( HttpStatusCode statusCode, byte[] body, string contentType) : HttpMessageHandler { + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemGatewayOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemGatewayOptionsValidatorTests.cs index b5fc1af4..7f59701e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemGatewayOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemGatewayOptionsValidatorTests.cs @@ -13,6 +13,7 @@ public class ExternalSystemGatewayOptionsValidatorTests private static ValidateOptionsResult Validate(ExternalSystemGatewayOptions options) => new ExternalSystemGatewayOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the default options are valid.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -20,6 +21,7 @@ public class ExternalSystemGatewayOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero default HTTP timeout is rejected.</summary> [Fact] public void ZeroDefaultHttpTimeout_IsRejected() { @@ -29,6 +31,7 @@ public class ExternalSystemGatewayOptionsValidatorTests Assert.Contains("DefaultHttpTimeout", result.FailureMessage); } + /// <summary>Verifies that a negative default HTTP timeout is rejected.</summary> [Fact] public void NegativeDefaultHttpTimeout_IsRejected() { @@ -38,6 +41,7 @@ public class ExternalSystemGatewayOptionsValidatorTests Assert.Contains("DefaultHttpTimeout", result.FailureMessage); } + /// <summary>Verifies that a zero maximum of concurrent connections per system is rejected.</summary> [Fact] public void ZeroMaxConcurrentConnectionsPerSystem_IsRejected() { @@ -47,6 +51,7 @@ public class ExternalSystemGatewayOptionsValidatorTests Assert.Contains("MaxConcurrentConnectionsPerSystem", result.FailureMessage); } + /// <summary>Verifies that a negative maximum of concurrent connections per system is rejected.</summary> [Fact] public void NegativeMaxConcurrentConnectionsPerSystem_IsRejected() { @@ -56,6 +61,7 @@ public class ExternalSystemGatewayOptionsValidatorTests Assert.Contains("MaxConcurrentConnectionsPerSystem", result.FailureMessage); } + /// <summary>Verifies that a zero maximum response body size is rejected.</summary> [Fact] public void ZeroMaxResponseBodyBytes_IsRejected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ServiceWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ServiceWiringTests.cs index 7d68c923..76e1acfc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ServiceWiringTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ServiceWiringTests.cs @@ -7,11 +7,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests; /// <summary> -/// ExternalSystemGateway-013: configuration options must actually influence the +/// Configuration options must actually influence the /// registered HTTP client — an operator setting them must not be silently ignored. /// </summary> public class ServiceWiringTests { + /// <summary>Verifies that the per-system connection cap is applied to the primary handler of the named HTTP client.</summary> [Fact] public void MaxConcurrentConnectionsPerSystem_IsAppliedToTheNamedHttpClientPrimaryHandler() { @@ -40,10 +41,11 @@ public class ServiceWiringTests Assert.Equal(4, sockets.MaxConnectionsPerServer); } + /// <summary>Verifies that the per-system connection cap is not applied to HTTP clients that do not belong to the gateway.</summary> [Fact] public void MaxConcurrentConnectionsPerSystem_IsNotAppliedToNonGatewayHttpClients() { - // ExternalSystemGateway-016: the gateway's connection cap must be scoped to + // The gateway's connection cap must be scoped to // its own per-system clients ("ExternalSystem_{name}"). It must NOT leak onto // unrelated HttpClient consumers in the same host process (e.g. the // Notification Service's OAuth2 token client) — that would silently throttle diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/SqlErrorClassifierTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/SqlErrorClassifierTests.cs index 4664661e..a38d1a81 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/SqlErrorClassifierTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/SqlErrorClassifierTests.cs @@ -3,7 +3,7 @@ using System.Data.Common; namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests; /// <summary> -/// M2.3 (#7): unit tests for the transient-vs-permanent SQL error-number +/// Unit tests for the transient-vs-permanent SQL error-number /// classifier that <c>DatabaseGateway</c> uses to decide whether a failed /// cached write should be buffered (transient) or returned to the script /// synchronously / parked (permanent). @@ -13,6 +13,8 @@ public class SqlErrorClassifierTests // The full transient set documented on SqlErrorClassifier — connection, // timeout, deadlock, and Azure throttle error numbers. A retry can plausibly // succeed for any of these, so they are buffered to store-and-forward. + /// <summary>Verifies that a known transient error number is classified as transient.</summary> + /// <param name="errorNumber">The SQL error number.</param> [Theory] [InlineData(-2)] // timeout expired [InlineData(-1)] // connection error @@ -37,6 +39,8 @@ public class SqlErrorClassifierTests // Constraint, syntax, and permission errors are permanent — retrying the // identical statement cannot succeed and may cause duplicate side effects. + /// <summary>Verifies that a known permanent error number is not classified as transient.</summary> + /// <param name="errorNumber">The SQL error number.</param> [Theory] [InlineData(547)] // constraint violation (FK/CHECK) [InlineData(2627)] // primary-key / unique constraint violation @@ -53,6 +57,8 @@ public class SqlErrorClassifierTests Assert.False(SqlErrorClassifier.IsTransient(errorNumber)); } + /// <summary>Verifies that an unknown error number defaults to permanent.</summary> + /// <param name="errorNumber">The SQL error number.</param> [Theory] [InlineData(0)] // no error number captured [InlineData(99999)] // unknown / undocumented number @@ -65,11 +71,13 @@ public class SqlErrorClassifierTests Assert.False(SqlErrorClassifier.IsTransient(errorNumber)); } - // ── M2.3 (#7) code-review fix: IsTransient(Exception) — a live DB outage does + // ── IsTransient(Exception) — a live DB outage does // not always surface as a SqlException. Transport/connection/timeout/driver // exception types are transient (buffer+retry), mirroring the HTTP path's // ErrorClassifier.IsTransient(Exception). ── + /// <summary>Provides exception instances of the transport types that count as transient.</summary> + /// <returns>Theory data with one exception instance per row.</returns> public static IEnumerable<object[]> TransientExceptionTypes() { yield return new object[] { new InvalidOperationException("connection not open") }; @@ -81,6 +89,8 @@ public class SqlErrorClassifierTests yield return new object[] { new NonSqlDbException("provider transport error") }; } + /// <summary>Verifies that transport exception types are classified as transient.</summary> + /// <param name="ex">The exception to classify.</param> [Theory] [MemberData(nameof(TransientExceptionTypes))] public void IsTransient_Exception_TrueForTransportTypes(Exception ex) @@ -88,6 +98,7 @@ public class SqlErrorClassifierTests Assert.True(SqlErrorClassifier.IsTransient(ex)); } + /// <summary>Verifies that an unexpected exception type is not classified as transient.</summary> [Fact] public void IsTransient_Exception_FalseForUnexpectedType() { @@ -100,6 +111,8 @@ public class SqlErrorClassifierTests /// <summary>A concrete <see cref="DbException"/> that is not a SqlException, for the classifier unit test.</summary> private sealed class NonSqlDbException : DbException { + /// <summary>Initializes a database exception that is not a SQL exception.</summary> + /// <param name="message">The exception message.</param> public NonSqlDbException(string message) : base(message) { } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/AddSiteEventLogHealthMetricsBridgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/AddSiteEventLogHealthMetricsBridgeTests.cs index b52d3020..6c5d3043 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/AddSiteEventLogHealthMetricsBridgeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/AddSiteEventLogHealthMetricsBridgeTests.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests; /// <summary> -/// M2.16 (#30) idempotency regression — code-review finding on commit d81f747. +/// Idempotency regression — code-review finding on commit d81f747. /// <para> /// <see cref="ServiceCollectionExtensions.AddSiteEventLogHealthMetricsBridge"/> uses a /// factory-lambda overload of <c>AddHostedService</c>, which sets only @@ -18,10 +18,11 @@ namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests; /// </summary> public class AddSiteEventLogHealthMetricsBridgeTests { + /// <summary>Verifies that adding the site event log health metrics bridge twice registers the hosted service only once.</summary> [Fact] public void AddSiteEventLogHealthMetricsBridge_IsIdempotent_DoesNotDoubleRegister_HostedService() { - // M2.16 (#30): calling the bridge method twice must register exactly one + // Calling the bridge method twice must register exactly one // SiteEventLogFailureCountReporter. Without the marker-type guard the // ImplementationType == check was a no-op for factory-lambda registrations, // so the second call would have added a second hosted service (two timers). diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/AuditRedactionFailureMetricTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/AuditRedactionFailureMetricTests.cs index 84dc104f..fbe57891 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/AuditRedactionFailureMetricTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/AuditRedactionFailureMetricTests.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests; /// <summary> -/// Bundle C (M5-T7) regression coverage. The canonical audit redactor +/// Regression coverage: the canonical audit redactor /// (<see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>) increments /// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter"/> every time /// a header/body/SQL-param redactor stage throws and the redactor has to @@ -17,6 +17,7 @@ public class AuditRedactionFailureMetricTests { private readonly SiteHealthCollector _collector = new(); + /// <summary>Verifies that incrementing the counter three times makes it report three.</summary> [Fact] public void Increment_Three_Times_Counter_Reports_3() { @@ -29,6 +30,7 @@ public class AuditRedactionFailureMetricTests Assert.Equal(3, report.AuditRedactionFailure); } + /// <summary>Verifies that the report payload includes the audit redaction failure count as zero by default.</summary> [Fact] public void Report_Payload_Includes_AuditRedactionFailure_AsZeroByDefault() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs index 25c3f8fb..f1f2387b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs @@ -12,13 +12,18 @@ internal sealed class TestTimeProvider : TimeProvider { private DateTimeOffset _utcNow; + /// <summary>Initializes the provider at the given start time.</summary> + /// <param name="startTime">The initial UTC time reported by the provider.</param> public TestTimeProvider(DateTimeOffset startTime) { _utcNow = startTime; } + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => _utcNow; + /// <summary>Advances the fake clock by the given duration.</summary> + /// <param name="duration">The amount of time to advance.</param> public void Advance(TimeSpan duration) => _utcNow += duration; } @@ -27,6 +32,7 @@ public class CentralHealthAggregatorTests private readonly TestTimeProvider _timeProvider; private readonly CentralHealthAggregator _aggregator; + /// <summary>Initializes the tests with a controllable clock and a fresh aggregator.</summary> public CentralHealthAggregatorTests() { _timeProvider = new TestTimeProvider(DateTimeOffset.UtcNow); @@ -72,6 +78,7 @@ public class CentralHealthAggregatorTests EnabledInstanceCount: 0, DisabledInstanceCount: 0); + /// <summary>Verifies that processing a report stores the state for a new site.</summary> [Fact] public void ProcessReport_StoresState_ForNewSite() { @@ -83,6 +90,7 @@ public class CentralHealthAggregatorTests Assert.Equal(1, state.LastSequenceNumber); } + /// <summary>Verifies that processing a report updates the state when the sequence number increases.</summary> [Fact] public void ProcessReport_UpdatesState_WhenSequenceIncreases() { @@ -93,6 +101,7 @@ public class CentralHealthAggregatorTests Assert.Equal(2, state!.LastSequenceNumber); } + /// <summary>Verifies that a report whose sequence number is not greater than the stored one is rejected as stale.</summary> [Fact] public void ProcessReport_RejectsStaleReport_WhenSequenceNotGreater() { @@ -103,6 +112,7 @@ public class CentralHealthAggregatorTests Assert.Equal(5, state!.LastSequenceNumber); } + /// <summary>Verifies that a report with a sequence number equal to the stored one is rejected.</summary> [Fact] public void ProcessReport_RejectsEqualSequence() { @@ -113,6 +123,7 @@ public class CentralHealthAggregatorTests Assert.Equal(5, state!.LastSequenceNumber); } + /// <summary>Verifies that a site goes offline when no report arrives within the timeout.</summary> [Fact] public void OfflineDetection_SiteGoesOffline_WhenNoReportWithinTimeout() { @@ -126,6 +137,7 @@ public class CentralHealthAggregatorTests Assert.False(_aggregator.GetSiteState("site-1")!.IsOnline); } + /// <summary>Verifies that an offline site comes back online when a report is received.</summary> [Fact] public void OnlineRecovery_SiteComesBackOnline_WhenReportReceived() { @@ -141,6 +153,7 @@ public class CentralHealthAggregatorTests Assert.True(_aggregator.GetSiteState("site-1")!.IsOnline); } + /// <summary>Verifies that a site remains online when a report arrives within the timeout.</summary> [Fact] public void OfflineDetection_SiteRemainsOnline_WhenReportWithinTimeout() { @@ -152,6 +165,7 @@ public class CentralHealthAggregatorTests Assert.True(_aggregator.GetSiteState("site-1")!.IsOnline); } + /// <summary>Verifies that all known sites are returned by the all-states query.</summary> [Fact] public void GetAllSiteStates_ReturnsAllKnownSites() { @@ -164,6 +178,7 @@ public class CentralHealthAggregatorTests Assert.Contains("site-2", states.Keys); } + /// <summary>Verifies that the state lookup returns null for an unknown site.</summary> [Fact] public void GetSiteState_ReturnsNull_ForUnknownSite() { @@ -171,6 +186,7 @@ public class CentralHealthAggregatorTests Assert.Null(state); } + /// <summary>Verifies that processing a report stores the latest report for the site.</summary> [Fact] public void ProcessReport_StoresLatestReport() { @@ -182,13 +198,14 @@ public class CentralHealthAggregatorTests } /// <summary> - /// HealthMonitoring-002 regression: SiteHealthState is mutated from multiple + /// Regression test: SiteHealthState is mutated from multiple /// threads (ProcessReport, MarkHeartbeat, CheckForOfflineSites). With a mutable /// class and unsynchronized field writes, a snapshot read could observe a torn /// or half-applied state. The state must be immutable and every transition an /// atomic reference swap, so a snapshot is always internally consistent and the /// monotonic sequence-number guard is never subverted by a lost update. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ProcessReport_ConcurrentUpdates_NeverLoseSequenceOrTearState() { @@ -237,7 +254,7 @@ public class CentralHealthAggregatorTests } /// <summary> - /// HealthMonitoring-007 regression: a heartbeat for a site that has not yet + /// Regression test: a heartbeat for a site that has not yet /// sent a full report (e.g. immediately after a central restart/failover, when /// the aggregator's in-memory state is empty) must register the site as online /// rather than being silently discarded. Otherwise reachable sites show as @@ -258,9 +275,9 @@ public class CentralHealthAggregatorTests } /// <summary> - /// Regression test for HealthMonitoring-015. A heartbeat-only registered site + /// Regression test: a heartbeat-only registered site /// has never processed a full report, so <see cref="SiteHealthState.LastReportReceivedAt"/> - /// must be <c>null</c> — not the <c>DateTimeOffset.MinValue</c> (year-0001) + /// must be <c>null</c> — not the <c>DateTimeOffset.MinValue</c> (year 0001) /// sentinel that the UI would otherwise render as a ~2000-year-stale timestamp. /// The "no report yet" signal must be an explicit nullable state, consistent /// with <see cref="SiteHealthState.LatestReport"/>. @@ -276,7 +293,7 @@ public class CentralHealthAggregatorTests } /// <summary> - /// Regression test for HealthMonitoring-015. Once a full report is processed + /// Regression test: once a full report is processed /// for a heartbeat-registered site, <see cref="SiteHealthState.LastReportReceivedAt"/> /// becomes a real (non-null) instant. /// </summary> @@ -294,6 +311,7 @@ public class CentralHealthAggregatorTests Assert.Equal(reportTime, state.LastReportReceivedAt); } + /// <summary>Verifies that heartbeats keep a site online between full reports.</summary> [Fact] public void MarkHeartbeat_KeepsSiteOnline_BetweenReports() { @@ -310,6 +328,7 @@ public class CentralHealthAggregatorTests Assert.True(_aggregator.GetSiteState("site-1")!.IsOnline); } + /// <summary>Verifies that a heartbeat brings an offline site back online.</summary> [Fact] public void MarkHeartbeat_BringsOfflineSiteBackOnline() { @@ -324,7 +343,7 @@ public class CentralHealthAggregatorTests } /// <summary> - /// HealthMonitoring-020 regression: an offline-to-online transition must + /// Regression test: an offline-to-online transition must /// be backed by a fresh LastHeartbeatAt. Previously MarkHeartbeat used /// <c>max(receivedAt, existing.LastHeartbeatAt)</c>, so an out-of-order /// heartbeat carrying an older timestamp would bring the site online with @@ -357,7 +376,7 @@ public class CentralHealthAggregatorTests } /// <summary> - /// HealthMonitoring-005 regression: the synthetic "central" site has no + /// Regression test: the synthetic "central" site has no /// heartbeat source — its LastHeartbeatAt is only bumped by the 30s /// CentralHealthReportLoop self-report. A single skipped/late self-report /// (leader GC pause, brief stall, mid-failover) would leave it with no signal @@ -382,6 +401,7 @@ public class CentralHealthAggregatorTests "central must survive a single missed self-report"); } + /// <summary>Verifies that the synthetic central site still goes offline on a genuine loss of self-reports.</summary> [Fact] public void OfflineDetection_CentralSite_StillGoesOfflineOnGenuineLoss() { @@ -395,7 +415,7 @@ public class CentralHealthAggregatorTests } /// <summary> - /// HealthMonitoring-013 regression: the offline-check cadence must be derived + /// Regression test: the offline-check cadence must be derived /// from the *shorter* of <see cref="HealthMonitoringOptions.OfflineTimeout"/> /// and <see cref="HealthMonitoringOptions.CentralOfflineTimeout"/>, so that if /// an operator configures <c>CentralOfflineTimeout</c> smaller than @@ -424,6 +444,7 @@ public class CentralHealthAggregatorTests })); } + /// <summary>Verifies that a sequence number reset is rejected until it exceeds the previous maximum.</summary> [Fact] public void SequenceNumberReset_RejectedUntilExceedsPrevMax() { @@ -495,7 +516,7 @@ public class CentralHealthAggregatorTests } /// <summary> - /// Review 01 underdeveloped #7: "when did the site drop" was unanswerable. + /// Previously "when did the site drop" was unanswerable. /// Every online↔offline flip stamps <see cref="SiteHealthState.LastStatusChangeAt"/>. /// </summary> [Fact] @@ -515,7 +536,7 @@ public class CentralHealthAggregatorTests } /// <summary> - /// Round-2 N3: a site known only via heartbeats (LastReportReceivedAt == null) + /// A site known only via heartbeats (LastReportReceivedAt == null) /// previously skipped the staleness check forever — a report pipeline dead /// from FIRST BOOT showed "online with no metrics" indefinitely. FirstSeenAt /// anchors the window instead (LastHeartbeatAt cannot: heartbeats advance it). diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthReportLoopTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthReportLoopTests.cs index 38ac5966..5bc17cf3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthReportLoopTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthReportLoopTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests; /// <summary> -/// HealthMonitoring-009 regression: the central self-report loop had no test +/// Regression tests for the central self-report loop, which had no test /// coverage at all. These tests exercise leader-only gating (SelfIsPrimary), /// self-report generation for siteId="central", and monotonic sequence /// assignment. @@ -14,24 +14,33 @@ public class CentralHealthReportLoopTests { private sealed class FakeClusterNodeProvider : IClusterNodeProvider { + /// <inheritdoc /> public bool SelfIsPrimary { get; set; } + /// <summary>Gets or sets the nodes returned by <c>GetClusterNodes</c>.</summary> public IReadOnlyList<NodeStatus> Nodes { get; set; } = []; + /// <inheritdoc /> public IReadOnlyList<NodeStatus> GetClusterNodes() => Nodes; } private sealed class RecordingAggregator : ICentralHealthAggregator { + /// <summary>Gets the reports passed to <see cref="ProcessReport"/>.</summary> public List<SiteHealthReport> Processed { get; } = []; + /// <inheritdoc /> public void ProcessReport(SiteHealthReport report) => Processed.Add(report); + /// <inheritdoc /> public void MarkHeartbeat(string siteId, DateTimeOffset receivedAt) { } + /// <inheritdoc /> public IReadOnlyDictionary<string, SiteHealthState> GetAllSiteStates() => new Dictionary<string, SiteHealthState>(); + /// <inheritdoc /> public SiteHealthState? GetSiteState(string siteId) => null; + /// <inheritdoc /> public void PruneUnknownSites(IReadOnlyCollection<string> knownSiteIds) { } } /// <summary> - /// HealthMonitoring-022 de-flake: <see cref="CentralHealthReportLoop"/>'s + /// De-flake note: <see cref="CentralHealthReportLoop"/>'s /// internal cadence is a real <see cref="PeriodicTimer"/>, so the loop is /// timing-sensitive. We can't drive a virtual clock (PeriodicTimer doesn't /// consume <see cref="TimeProvider"/>) without refactoring the production @@ -82,6 +91,8 @@ public class CentralHealthReportLoopTests catch (OperationCanceledException) { } } + /// <summary>Verifies that the loop generates central reports when this node is the primary.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GeneratesCentralReports_WhenSelfIsPrimary() { @@ -97,7 +108,7 @@ public class CentralHealthReportLoopTests collector, aggregator, clusterNodes, options, NullLogger<CentralHealthReportLoop>.Instance); - // HealthMonitoring-022: wait up to 5 s for at least one report to fire + // Wait up to 5 s for at least one report to fire // rather than fixed-budget Task.Delay; tolerates slow CI runners. await RunLoopUntil(loop, () => aggregator.Processed.Count >= 1); @@ -106,6 +117,8 @@ public class CentralHealthReportLoopTests r => Assert.Equal(CentralHealthReportLoop.CentralSiteId, r.SiteId)); } + /// <summary>Verifies that the loop generates no reports when this node is not the primary.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GeneratesNoReports_WhenNotPrimary() { @@ -126,6 +139,8 @@ public class CentralHealthReportLoopTests Assert.Empty(aggregator.Processed); } + /// <summary>Verifies that the loop assigns monotonically increasing sequence numbers.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AssignsMonotonicSequenceNumbers() { @@ -141,7 +156,7 @@ public class CentralHealthReportLoopTests collector, aggregator, clusterNodes, options, NullLogger<CentralHealthReportLoop>.Instance); - // HealthMonitoring-022: wait up to 5 s for at least 2 reports rather + // Wait up to 5 s for at least 2 reports rather // than a fixed 300 ms window that could miss the second tick on a // slow CI runner; the assertion below proves the sequence is monotonic. await RunLoopUntil(loop, () => aggregator.Processed.Count >= 2); @@ -157,7 +172,7 @@ public class CentralHealthReportLoopTests } /// <summary> - /// HealthMonitoring-006 regression: the central loop's sequence-number seed + /// Regression: the central loop's sequence-number seed /// must be derived from the injected <see cref="TimeProvider"/> (Unix-ms), /// not from <c>DateTimeOffset.UtcNow</c> read at field initialization, so the /// seeding strategy is deterministically testable. @@ -180,13 +195,14 @@ public class CentralHealthReportLoopTests } /// <summary> - /// HealthMonitoring-018 regression: when <see cref="ICentralHealthAggregator.ProcessReport"/> + /// Regression: when <see cref="ICentralHealthAggregator.ProcessReport"/> /// throws, the per-interval counters that /// <see cref="SiteHealthCollector.CollectReport"/> just drained must be /// restored back into the shared collector so they roll forward into the /// next interval rather than being silently lost. Same shape as the - /// HealthMonitoring-017 fix in <see cref="HealthReportSender"/>. + /// restore-on-failure fix in <see cref="HealthReportSender"/>. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ProcessReportFailure_PreservesIntervalCountersForNextReport() { @@ -216,7 +232,7 @@ public class CentralHealthReportLoopTests collector, aggregator, clusterNodes, options, NullLogger<CentralHealthReportLoop>.Instance); - // HealthMonitoring-022: the first ProcessReport call throws (counters + // The first ProcessReport call throws (counters // get restored), the second succeeds. Wait up to 5 s for that second // (successful) call rather than a fixed 450 ms budget. await RunLoopUntil(loop, () => aggregator.Processed.Count >= 1); @@ -236,13 +252,15 @@ public class CentralHealthReportLoopTests /// <see cref="ICentralHealthAggregator"/> whose first <c>ProcessReport</c> /// call throws (only the first), then subsequent calls succeed. Used by /// <see cref="ProcessReportFailure_PreservesIntervalCountersForNextReport"/> - /// to verify the HealthMonitoring-018 restore-on-failure path. + /// to verify the restore-on-failure path. /// </summary> private sealed class FailingThenSucceedingAggregator : ICentralHealthAggregator { private int _callCount; + /// <summary>Gets the reports that were processed successfully.</summary> public List<SiteHealthReport> Processed { get; } = []; + /// <inheritdoc /> public void ProcessReport(SiteHealthReport report) { var n = Interlocked.Increment(ref _callCount); @@ -253,13 +271,19 @@ public class CentralHealthReportLoopTests Processed.Add(report); } + /// <inheritdoc /> public void MarkHeartbeat(string siteId, DateTimeOffset receivedAt) { } + /// <inheritdoc /> public IReadOnlyDictionary<string, SiteHealthState> GetAllSiteStates() => new Dictionary<string, SiteHealthState>(); + /// <inheritdoc /> public SiteHealthState? GetSiteState(string siteId) => null; + /// <inheritdoc /> public void PruneUnknownSites(IReadOnlyCollection<string> knownSiteIds) { } } + /// <summary>Verifies that the active-node flag is set on the report even when this node is not the primary.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetsActiveNodeFlag_EvenWhenNotPrimary() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthMonitoringOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthMonitoringOptionsValidatorTests.cs index 91406d83..0129efda 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthMonitoringOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthMonitoringOptionsValidatorTests.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests; /// <summary> -/// HealthMonitoring-014 regression: <see cref="HealthMonitoringOptions"/> intervals +/// Regression tests: <see cref="HealthMonitoringOptions"/> intervals /// are fed straight into <c>new PeriodicTimer(...)</c>, which throws /// <see cref="ArgumentOutOfRangeException"/> for a zero/negative period. A /// misconfigured <c>appsettings.json</c> must be rejected by an @@ -15,6 +15,7 @@ public class HealthMonitoringOptionsValidatorTests private static ValidateOptionsResult Validate(HealthMonitoringOptions options) => new HealthMonitoringOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the default options pass validation.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -22,6 +23,7 @@ public class HealthMonitoringOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero report interval is rejected.</summary> [Fact] public void ZeroReportInterval_IsRejected() { @@ -31,6 +33,7 @@ public class HealthMonitoringOptionsValidatorTests Assert.Contains("ReportInterval", result.FailureMessage); } + /// <summary>Verifies that a negative report interval is rejected.</summary> [Fact] public void NegativeReportInterval_IsRejected() { @@ -40,6 +43,7 @@ public class HealthMonitoringOptionsValidatorTests Assert.Contains("ReportInterval", result.FailureMessage); } + /// <summary>Verifies that a zero offline timeout is rejected.</summary> [Fact] public void ZeroOfflineTimeout_IsRejected() { @@ -49,6 +53,7 @@ public class HealthMonitoringOptionsValidatorTests Assert.Contains("OfflineTimeout", result.FailureMessage); } + /// <summary>Verifies that a zero central offline timeout is rejected.</summary> [Fact] public void ZeroCentralOfflineTimeout_IsRejected() { @@ -58,6 +63,7 @@ public class HealthMonitoringOptionsValidatorTests Assert.Contains("CentralOfflineTimeout", result.FailureMessage); } + /// <summary>Verifies that a central offline timeout shorter than the offline timeout is rejected.</summary> [Fact] public void CentralOfflineTimeout_ShorterThanOfflineTimeout_IsRejected() { @@ -71,6 +77,7 @@ public class HealthMonitoringOptionsValidatorTests Assert.Contains("CentralOfflineTimeout", result.FailureMessage); } + /// <summary>Verifies that a zero metrics stale timeout is rejected.</summary> [Fact] public void ZeroMetricsStaleTimeout_IsRejected() { @@ -80,6 +87,7 @@ public class HealthMonitoringOptionsValidatorTests Assert.Contains("MetricsStaleTimeout", result.FailureMessage); } + /// <summary>Verifies that a metrics stale timeout shorter than the report interval is rejected.</summary> [Fact] public void MetricsStaleTimeout_ShorterThanReportInterval_IsRejected() { @@ -98,6 +106,8 @@ public class HealthMonitoringOptionsValidatorTests // LocalDb sustained-backlog badge (register row 33) // ------------------------------------------------------------------ + /// <summary>Verifies that a non-positive LocalDb backlog threshold is rejected.</summary> + /// <param name="threshold">The invalid backlog threshold under test.</param> [Theory] [InlineData(0)] [InlineData(-1)] @@ -111,6 +121,7 @@ public class HealthMonitoringOptionsValidatorTests Assert.Contains("LocalDbBacklogThreshold", result.FailureMessage); } + /// <summary>Verifies that a zero LocalDb backlog sustained-for window is rejected.</summary> [Fact] public void ZeroLocalDbBacklogSustainedFor_IsRejected() { @@ -123,6 +134,7 @@ public class HealthMonitoringOptionsValidatorTests Assert.Contains("LocalDbBacklogSustainedFor", result.FailureMessage); } + /// <summary>Verifies that a LocalDb backlog sustained-for window shorter than the report interval is rejected.</summary> [Fact] public void LocalDbBacklogSustainedFor_ShorterThanReportInterval_IsRejected() { @@ -139,6 +151,7 @@ public class HealthMonitoringOptionsValidatorTests Assert.Contains("LocalDbBacklogSustainedFor", result.FailureMessage); } + /// <summary>Verifies that the default sustained window comfortably outlasts a purge drain.</summary> [Fact] public void DefaultSustainedWindow_ComfortablyOutlastsAPurgeDrain() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthReportSenderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthReportSenderTests.cs index ed76052e..fae7155d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthReportSenderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthReportSenderTests.cs @@ -12,7 +12,9 @@ public class HealthReportSenderTests { private class FakeTransport : IHealthReportTransport { + /// <summary>Gets the reports captured by <c>SendAsync</c>, in send order.</summary> public List<SiteHealthReport> SentReports { get; } = []; + /// <inheritdoc /> public Task SendAsync(SiteHealthReport report, CancellationToken cancellationToken) { SentReports.Add(report); @@ -22,22 +24,38 @@ public class HealthReportSenderTests private class FakeSiteIdentityProvider : ISiteIdentityProvider { + /// <inheritdoc /> public string SiteId { get; set; } = "test-site"; } /// <summary> /// Captures emitted log entries so tests can assert that non-fatal failures - /// are surfaced (HealthMonitoring-010) rather than silently swallowed. + /// are surfaced rather than silently swallowed. /// </summary> private sealed class CapturingLogger<T> : ILogger<T> { public sealed record Entry(LogLevel Level, string Message, Exception? Exception); + /// <summary>Gets the log entries captured so far.</summary> public List<Entry> Entries { get; } = []; + /// <summary>Returns no scope; the fake logger does not track scopes.</summary> + /// <typeparam name="TState">The type of the scope state.</typeparam> + /// <param name="state">The scope state, ignored.</param> + /// <returns>Always null or a no-op scope.</returns> public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level being queried.</param> + /// <returns>Always true.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The type of the log state.</typeparam> + /// <param name="logLevel">The severity of the entry.</param> + /// <param name="eventId">The event identifier.</param> + /// <param name="state">The log state.</param> + /// <param name="exception">The associated exception, if any.</param> + /// <param name="formatter">Formats the state and exception into the message.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) @@ -51,6 +69,7 @@ public class HealthReportSenderTests private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { } } } @@ -58,11 +77,15 @@ public class HealthReportSenderTests /// <summary>An <see cref="IClusterNodeProvider"/> whose query always throws.</summary> private sealed class ThrowingClusterNodeProvider : IClusterNodeProvider { + /// <inheritdoc /> public bool SelfIsPrimary => true; + /// <inheritdoc /> public IReadOnlyList<NodeStatus> GetClusterNodes() => throw new InvalidOperationException("cluster query failed"); } + /// <summary>Verifies that successive reports are sent with monotonically increasing sequence numbers.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendsReportsWithMonotonicSequenceNumbers() { @@ -107,6 +130,8 @@ public class HealthReportSenderTests } } + /// <summary>Verifies that the first report sequence number exceeds the startup Unix time in milliseconds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FirstReportSequenceExceedsStartupUnixMs() { @@ -144,6 +169,8 @@ public class HealthReportSenderTests $"First sequence {transport.SentReports[0].SequenceNumber} should be >= startup epoch {beforeCtor}"); } + /// <summary>Verifies that each report carries a UTC timestamp.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReportsIncludeUtcTimestamp() { @@ -182,12 +209,13 @@ public class HealthReportSenderTests } /// <summary> - /// HealthMonitoring-001 regression: the documented "store-and-forward buffer + /// Regression: the documented "store-and-forward buffer /// depth" metric (pending messages by category) must actually be populated in /// the emitted report. Previously SetStoreAndForwardDepths had no callers, so /// StoreAndForwardBufferDepths was always empty. The sender must query the S&F /// engine's per-category depth API and include it alongside the parked count. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReportsIncludeStoreAndForwardBufferDepthsFromStorage() { @@ -256,6 +284,7 @@ public class HealthReportSenderTests Status = StoreAndForwardMessageStatus.Pending }; + /// <summary>Verifies that the initial sequence number is seeded from the current Unix time in milliseconds.</summary> [Fact] public void InitialSequenceNumberSeededWithUnixMs() { @@ -276,11 +305,12 @@ public class HealthReportSenderTests } /// <summary> - /// HealthMonitoring-010 regression: a failure refreshing cluster nodes is + /// Regression: a failure refreshing cluster nodes is /// non-fatal (the report still ships) but must no longer be swallowed by a /// bare <c>catch {}</c> — it must be logged as a warning with the exception so /// persistent degradation is diagnosable. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ClusterNodeRefreshFailure_IsLoggedNotSwallowed() { @@ -325,7 +355,7 @@ public class HealthReportSenderTests } /// <summary> - /// HealthMonitoring-017 regression: when the transport's <c>Send</c> throws, + /// Regression: when the transport's <c>Send</c> throws, /// the per-interval counters that <see cref="SiteHealthCollector.CollectReport"/> /// just drained via <c>Interlocked.Exchange</c> must be restored back into the /// collector so they roll forward into the next interval rather than being @@ -334,8 +364,8 @@ public class HealthReportSenderTests /// counters at zero. /// </summary> /// <remarks> - /// Extended for arch-review 2026-08-19 P8-1: <c>ScriptRunShedCount</c> is drained - /// by <c>CollectReport</c> the exact same way as the five counters above, but + /// Also covers <c>ScriptRunShedCount</c>, which is drained + /// by <c>CollectReport</c> in the exact same way as the five counters above, but /// <c>AddIntervalCounters</c> used to take only those five — a failed send /// destructively lost the shed count forever. <c>DetachedScriptThreads</c> and /// <c>SiteStreamAlarmDropCount</c> are deliberately NOT exercised here: they are @@ -343,6 +373,7 @@ public class HealthReportSenderTests /// never zeroed by <c>CollectReport</c>), so a failed send loses nothing for them /// to restore. /// </remarks> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendFailure_PreservesIntervalCountersForNextReport() { @@ -404,13 +435,15 @@ public class HealthReportSenderTests /// <see cref="IHealthReportTransport"/> that throws on the first /// <c>Send</c> call (and only the first), then succeeds. Used by /// <see cref="SendFailure_PreservesIntervalCountersForNextReport"/> to - /// verify the HealthMonitoring-017 restore-on-failure path. + /// verify the restore-on-failure path. /// </summary> private sealed class FailingThenSucceedingTransport : IHealthReportTransport { private int _callCount; + /// <summary>Gets the reports captured by <c>SendAsync</c>, in send order.</summary> public List<SiteHealthReport> SentReports { get; } = []; + /// <inheritdoc /> public Task SendAsync(SiteHealthReport report, CancellationToken cancellationToken) { var n = Interlocked.Increment(ref _callCount); @@ -424,7 +457,7 @@ public class HealthReportSenderTests } /// <summary> - /// HealthMonitoring-006 regression: the sequence-number seed must be derived + /// Regression: the sequence-number seed must be derived /// from the injected <see cref="TimeProvider"/> so the Unix-ms seeding strategy /// is deterministically testable and the clock dependency is explicit, rather /// than reading <c>DateTimeOffset.UtcNow</c> directly at field initialization. diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/Kpi/CentralHealthAuditBacklogProviderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/Kpi/CentralHealthAuditBacklogProviderTests.cs index 0c6e4061..5c44f665 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/Kpi/CentralHealthAuditBacklogProviderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/Kpi/CentralHealthAuditBacklogProviderTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring.Kpi; namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.Kpi; /// <summary> -/// Task 10 (arch-review 04) coverage for <see cref="CentralHealthAuditBacklogProvider"/>. +/// Coverage for <see cref="CentralHealthAuditBacklogProvider"/>. /// The provider sums the latest per-site <c>SiteAuditBacklog.PendingCount</c> across the /// in-memory <see cref="ICentralHealthAggregator"/> — the same aggregate the live Audit /// backlog tile shows — so the <c>backlogTotal</c> KPI history stops recording a hardwired @@ -16,6 +16,8 @@ public class CentralHealthAuditBacklogProviderTests { private static readonly DateTime At = new(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc); + /// <summary>Verifies that the pending backlog total sums the pending count across all sites.</summary> + /// <returns>The result.</returns> [Fact] public void GetPendingBacklogTotal_Sums_PendingCount_Across_Sites() { @@ -35,6 +37,8 @@ public class CentralHealthAuditBacklogProviderTests Assert.Equal(13, provider.GetPendingBacklogTotal()); } + /// <summary>Verifies that the pending backlog total is zero when no site has reported a backlog.</summary> + /// <returns>The result.</returns> [Fact] public void GetPendingBacklogTotal_Returns_Zero_When_No_Backlog_Reported() { @@ -84,17 +88,23 @@ public class CentralHealthAuditBacklogProviderTests /// </summary> private sealed class StubAggregator : ICentralHealthAggregator { + /// <summary>Gets the site health states served by the fake.</summary> public Dictionary<string, SiteHealthState> States { get; } = new(); + /// <inheritdoc /> public IReadOnlyDictionary<string, SiteHealthState> GetAllSiteStates() => States; + /// <inheritdoc /> public SiteHealthState? GetSiteState(string siteId) => States.TryGetValue(siteId, out var state) ? state : null; + /// <inheritdoc /> public void ProcessReport(SiteHealthReport report) => throw new NotSupportedException(); + /// <inheritdoc /> public void MarkHeartbeat(string siteId, DateTimeOffset receivedAt) => throw new NotSupportedException(); + /// <inheritdoc /> public void PruneUnknownSites(IReadOnlyCollection<string> knownSiteIds) => throw new NotSupportedException(); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/Kpi/SiteHealthKpiSampleSourceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/Kpi/SiteHealthKpiSampleSourceTests.cs index 8bba11bb..69252c05 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/Kpi/SiteHealthKpiSampleSourceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/Kpi/SiteHealthKpiSampleSourceTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring.Kpi; namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.Kpi; /// <summary> -/// M6 "KPI History & Trends" (K9) coverage for +/// "KPI History & Trends" coverage for /// <see cref="SiteHealthKpiSampleSource"/>. The source reads the in-memory /// <see cref="ICentralHealthAggregator"/> and emits per-site /// (<see cref="KpiScopes.Site"/>) <see cref="KpiSample"/> rows for each site that @@ -20,6 +20,7 @@ public class SiteHealthKpiSampleSourceTests private static readonly DateTime CapturedAt = new(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc); + /// <summary>Verifies that the source identifier is the site health source.</summary> [Fact] public void Source_Is_SiteHealth() { @@ -27,6 +28,8 @@ public class SiteHealthKpiSampleSourceTests Assert.Equal(KpiSources.SiteHealth, source.Source); } + /// <summary>Verifies that collecting emits every metric for a populated site and nothing for a site with a null report.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_PopulatedSite_EmitsAllMetrics_NullReportSite_EmitsNothing() { @@ -110,6 +113,8 @@ public class SiteHealthKpiSampleSourceTests // LocalDb replication metrics (register row 33) // ------------------------------------------------------------------ + /// <summary>Verifies that collecting emits neither LocalDb metric when the LocalDb fields are null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_NullLocalDbFields_EmitsNeitherLocalDbMetric() { @@ -134,6 +139,8 @@ public class SiteHealthKpiSampleSourceTests Assert.Equal(12, samples.Count); } + /// <summary>Verifies that collecting emits both LocalDb metrics for a replicated site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_ReplicatedSite_EmitsBothLocalDbMetrics() { @@ -154,6 +161,8 @@ public class SiteHealthKpiSampleSourceTests Assert.Equal(14, samples.Count); } + /// <summary>Verifies that collecting still emits a zero backlog value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_ZeroBacklog_IsStillEmitted() { @@ -171,6 +180,8 @@ public class SiteHealthKpiSampleSourceTests Assert.Equal(0, samples.Single(s => s.Metric == "localDbOplogBacklog").Value); } + /// <summary>Verifies that collecting emits zero rather than omitting the metric when replication is disconnected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_DisconnectedReplication_EmitsZeroNotAbsent() { @@ -188,6 +199,8 @@ public class SiteHealthKpiSampleSourceTests Assert.Equal(0, samples.Single(s => s.Metric == "localDbReplicationConnected").Value); } + /// <summary>Verifies that collecting emits zero pending audit backlog when the audit backlog is null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_NullAuditBacklog_EmitsZeroAuditBacklogPending() { @@ -202,6 +215,8 @@ public class SiteHealthKpiSampleSourceTests Assert.Equal(0, samples.Single(s => s.Metric == "auditBacklogPending").Value); } + /// <summary>Verifies that collecting returns an empty list when no site has a report.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_NoSitesWithReports_ReturnsEmptyList() { @@ -238,19 +253,25 @@ public class SiteHealthKpiSampleSourceTests /// </summary> private sealed class StubAggregator : ICentralHealthAggregator { + /// <summary>Gets the canned per-site health states returned by the fake.</summary> public Dictionary<string, SiteHealthState> States { get; } = new(); + /// <inheritdoc /> public IReadOnlyDictionary<string, SiteHealthState> GetAllSiteStates() => States; + /// <inheritdoc /> public SiteHealthState? GetSiteState(string siteId) => States.TryGetValue(siteId, out var state) ? state : null; + /// <inheritdoc /> public void ProcessReport(SiteHealthReport report) => throw new NotSupportedException(); + /// <inheritdoc /> public void MarkHeartbeat(string siteId, DateTimeOffset receivedAt) => throw new NotSupportedException(); + /// <inheritdoc /> public void PruneUnknownSites(IReadOnlyCollection<string> knownSiteIds) => throw new NotSupportedException(); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/LocalDbBacklogSustainedTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/LocalDbBacklogSustainedTests.cs index 014eb3c0..74a5791d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/LocalDbBacklogSustainedTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/LocalDbBacklogSustainedTests.cs @@ -21,6 +21,7 @@ public sealed class LocalDbBacklogSustainedTests { private const long Threshold = 1000; + /// <summary>Verifies that a backlog above the threshold starts the above-threshold clock.</summary> [Fact] public void BacklogAboveThreshold_StartsTheClock() { @@ -32,6 +33,7 @@ public sealed class LocalDbBacklogSustainedTests Assert.Equal(start, aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince); } + /// <summary>Verifies that a backlog that stays above the threshold keeps the original start instant.</summary> [Fact] public void BacklogStayingAbove_KeepsTheOriginalStartInstant() { @@ -47,6 +49,7 @@ public sealed class LocalDbBacklogSustainedTests Assert.Equal(start, aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince); } + /// <summary>Verifies that a backlog at or below the threshold clears the clock.</summary> [Fact] public void BacklogAtOrBelowThreshold_ClearsTheClock() { @@ -61,6 +64,7 @@ public sealed class LocalDbBacklogSustainedTests Assert.Null(aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince); } + /// <summary>Verifies that a backlog exactly at the threshold does not start the clock.</summary> [Fact] public void BacklogExactlyAtThreshold_DoesNotStartTheClock() { @@ -73,6 +77,7 @@ public sealed class LocalDbBacklogSustainedTests Assert.Null(aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince); } + /// <summary>Verifies that an unreplicated site reporting a null backlog never accumulates an anchor.</summary> [Fact] public void UnreplicatedSite_NeverAccumulatesAnAnchor() { @@ -88,6 +93,7 @@ public sealed class LocalDbBacklogSustainedTests Assert.Null(aggregator.GetSiteState("site-b")!.LocalDbBacklogAboveSince); } + /// <summary>Verifies that a null backlog in the middle of a climb leaves the anchor untouched.</summary> [Fact] public void NullBacklogMidClimb_LeavesTheAnchorUntouched() { @@ -104,6 +110,7 @@ public sealed class LocalDbBacklogSustainedTests Assert.Equal(start, aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince); } + /// <summary>Verifies that the very first report for a new site can start the clock.</summary> [Fact] public void FirstReportForANewSite_CanStartTheClock() { @@ -117,6 +124,7 @@ public sealed class LocalDbBacklogSustainedTests Assert.Equal(start, aggregator.GetSiteState("brand-new")!.LocalDbBacklogAboveSince); } + /// <summary>Verifies that the backlog threshold is configurable.</summary> [Fact] public void ThresholdIsConfigurable() { @@ -127,6 +135,7 @@ public sealed class LocalDbBacklogSustainedTests Assert.NotNull(aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince); } + /// <summary>Verifies that a stale out-of-order report does not disturb the anchor.</summary> [Fact] public void StaleReport_DoesNotDisturbTheAnchor() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/LocalDbReplicationStatusReporterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/LocalDbReplicationStatusReporterTests.cs index c350de37..a33d89ae 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/LocalDbReplicationStatusReporterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/LocalDbReplicationStatusReporterTests.cs @@ -32,10 +32,11 @@ public class LocalDbReplicationStatusReporterTests logger: NullLogger<LocalDbReplicationStatusReporter>.Instance, refreshInterval: TimeSpan.FromHours(1)); + /// <summary>Verifies that probing an unconfigured replication node publishes null for both fields.</summary> [Fact] public void Probe_ReplicationUnconfigured_PublishesNullForBothFields() { - // The row-80 regression. The inert engine's false/0 must NOT reach the report. + // The inert-engine regression. The inert engine's false/0 must NOT reach the report. var collector = new SiteHealthCollector(); using var reporter = Build(collector, replicationConfigured: false); @@ -46,6 +47,7 @@ public class LocalDbReplicationStatusReporterTests Assert.Null(report.LocalDbOplogBacklog); } + /// <summary>Verifies that probing an unconfigured replication node does not read the sync status providers.</summary> [Fact] public void Probe_ReplicationUnconfigured_DoesNotReadTheSyncStatusProviders() { @@ -69,6 +71,7 @@ public class LocalDbReplicationStatusReporterTests Assert.Equal(0, backlogReads); } + /// <summary>Verifies that a configured but disconnected node publishes false rather than null.</summary> [Fact] public void Probe_ReplicationConfiguredButDisconnected_PublishesFalse_NotNull() { @@ -86,6 +89,7 @@ public class LocalDbReplicationStatusReporterTests Assert.Equal(4200L, report.LocalDbOplogBacklog); } + /// <summary>Verifies that a configured and connected node publishes true.</summary> [Fact] public void Probe_ReplicationConfiguredAndConnected_PublishesTrue() { @@ -100,6 +104,7 @@ public class LocalDbReplicationStatusReporterTests Assert.Equal(0L, report.LocalDbOplogBacklog); } + /// <summary>Verifies that a configured node preserves a null backlog as unknown.</summary> [Fact] public void Probe_ReplicationConfigured_PreservesANullBacklogAsUnknown() { @@ -117,6 +122,7 @@ public class LocalDbReplicationStatusReporterTests Assert.Null(report.LocalDbOplogBacklog); } + /// <summary>Verifies that a throwing provider leaves the previous snapshot intact.</summary> [Fact] public void Probe_ProviderThrows_LeavesThePreviousSnapshotIntact() { @@ -143,6 +149,7 @@ public class LocalDbReplicationStatusReporterTests Assert.Equal(7L, report.LocalDbOplogBacklog); } + /// <summary>Verifies that flipping the replication gate on stops the null reporting.</summary> [Fact] public void Probe_GateFlippedOn_StopsReportingNull() { @@ -169,6 +176,7 @@ public class LocalDbReplicationStatusReporterTests Assert.Equal(12L, report.LocalDbOplogBacklog); } + /// <summary>Verifies that a null replication-configured provider causes the constructor to throw.</summary> [Fact] public void Constructor_NullReplicationConfiguredProvider_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SecretMirrorStatusHealthFieldTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SecretMirrorStatusHealthFieldTests.cs index 0f7ac2ea..12440112 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SecretMirrorStatusHealthFieldTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SecretMirrorStatusHealthFieldTests.cs @@ -3,17 +3,19 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests; /// <summary> -/// Review finding M1 — <c>SiteHealthReport.SecretMirrorStatuses</c> carries every site node's +/// <c>SiteHealthReport.SecretMirrorStatuses</c> carries every site node's /// deployed-secrets mirror failures (the standby's included) on the active node's report. /// </summary> public class SecretMirrorStatusHealthFieldTests { + /// <summary>Verifies that a report collected before any status was supplied leaves the field null.</summary> [Fact] public void CollectReport_NeverTold_LeavesTheFieldNull() { Assert.Null(new SiteHealthCollector().CollectReport("TestSite").SecretMirrorStatuses); } + /// <summary>Verifies that a collected report carries the latest snapshot without draining it.</summary> [Fact] public void CollectReport_CarriesTheLatestSnapshot_AndDoesNotDrainIt() { @@ -33,6 +35,7 @@ public class SecretMirrorStatusHealthFieldTests Assert.Equal("S1", Assert.Single(first[1].Failures).SecretName); } + /// <summary>Verifies that setting the status to null resets the field to no data.</summary> [Fact] public void SetNull_ResetsToNoData() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteAuditBacklogMetricTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteAuditBacklogMetricTests.cs index e448eea3..468eb552 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteAuditBacklogMetricTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteAuditBacklogMetricTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests; /// <summary> -/// Bundle E (M6-T6) regression coverage. The site-side audit-log SQLite writer +/// Regression coverage. The site-side audit-log SQLite writer /// exposes a backlog snapshot (<c>SiteAuditBacklogSnapshot</c>) via the /// <c>ISiteAuditQueue.GetBacklogStatsAsync</c> surface. A periodic /// <c>SiteAuditBacklogReporter</c> hosted service polls that snapshot and @@ -18,6 +18,7 @@ public class SiteAuditBacklogMetricTests { private readonly SiteHealthCollector _collector = new(); + /// <summary>Verifies that a report collected after an update includes the backlog.</summary> [Fact] public void Update_Then_CollectReport_IncludesBacklog() { @@ -33,6 +34,7 @@ public class SiteAuditBacklogMetricTests Assert.Equal(snapshot, report.SiteAuditBacklog); } + /// <summary>Verifies that the report payload includes the site audit backlog as null by default.</summary> [Fact] public void Report_Payload_Includes_SiteAuditBacklog_AsNullByDefault() { @@ -43,6 +45,7 @@ public class SiteAuditBacklogMetricTests Assert.Null(report.SiteAuditBacklog); } + /// <summary>Verifies that collecting a report does not reset the site audit backlog.</summary> [Fact] public void CollectReport_DoesNotReset_SiteAuditBacklog() { @@ -64,6 +67,7 @@ public class SiteAuditBacklogMetricTests Assert.Equal(snapshot, second.SiteAuditBacklog); } + /// <summary>Verifies that updating with a null snapshot throws an argument-null exception.</summary> [Fact] public void Update_With_Null_Throws_ArgumentNullException() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteAuditWriteFailuresMetricTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteAuditWriteFailuresMetricTests.cs index f2ec8b2f..a2187f41 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteAuditWriteFailuresMetricTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteAuditWriteFailuresMetricTests.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests; /// <summary> -/// Bundle G (M2-T11) regression coverage. The site-side Audit Log writer chain +/// Regression coverage for the site audit write failure metric. The site-side Audit Log writer chain /// (FallbackAuditWriter) increments <see cref="IAuditWriteFailureCounter"/> /// every time the primary SQLite writer throws. Bundle G bridges that counter /// into the Site Health Monitoring report payload as <c>SiteAuditWriteFailures</c> @@ -12,6 +12,7 @@ public class SiteAuditWriteFailuresMetricTests { private readonly SiteHealthCollector _collector = new(); + /// <summary>Verifies that incrementing the counter three times makes it report 3.</summary> [Fact] public void Increment_Three_Times_Counter_Reports_3() { @@ -24,6 +25,7 @@ public class SiteAuditWriteFailuresMetricTests Assert.Equal(3, report.SiteAuditWriteFailures); } + /// <summary>Verifies that the report payload includes the site audit write failure count as zero by default.</summary> [Fact] public void Report_Payload_Includes_SiteAuditWriteFailures_AsZeroByDefault() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteEventLogFailureCountReporterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteEventLogFailureCountReporterTests.cs index c2ac2a9c..3a1d346d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteEventLogFailureCountReporterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteEventLogFailureCountReporterTests.cs @@ -3,17 +3,19 @@ using Microsoft.Extensions.Logging.Abstractions; namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests; /// <summary> -/// M2.16 (#30) — unit tests for <see cref="SiteEventLogFailureCountReporter"/>. +/// Unit tests for <see cref="SiteEventLogFailureCountReporter"/>. /// Verifies that the poller reads the count provided by the /// <see cref="Func{TResult}"/> delegate and pushes it into /// <see cref="ISiteHealthCollector.SetSiteEventLogWriteFailures"/>. /// </summary> public class SiteEventLogFailureCountReporterTests { + /// <summary>Verifies that stopping the reporter after it has been disposed does not throw.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StopAsync_AfterDispose_DoesNotThrow() { - // Regression (Gitea #15 follow-up): Dispose tears down the CTS StopAsync + // Regression: Dispose tears down the CTS StopAsync // cancels, and the host does not guarantee StopAsync is driven before the DI // container is disposed. Cancel() on a disposed CTS throws, and letting that // escape an IHostedService aborts the host's whole shutdown sequence. @@ -31,6 +33,8 @@ public class SiteEventLogFailureCountReporterTests reporter.Dispose(); } + /// <summary>Verifies that starting the reporter immediately probes the failed-write count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_ImmediatelyProbes_FailedWriteCount() { @@ -61,6 +65,8 @@ public class SiteEventLogFailureCountReporterTests await reporter.StopAsync(CancellationToken.None); } + /// <summary>Verifies that the reporter pushes the latest failed-write count on each tick.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_PushesLatestCount_OnEachTick() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteEventLogWriteFailuresMetricTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteEventLogWriteFailuresMetricTests.cs index 1d3132dd..4debd7d5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteEventLogWriteFailuresMetricTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteEventLogWriteFailuresMetricTests.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests; /// <summary> -/// M2.16 (#30) regression coverage. <see cref="ISiteEventLogger.FailedWriteCount"/> +/// Regression coverage. <see cref="ISiteEventLogger.FailedWriteCount"/> /// is a cumulative (point-in-time) counter. A periodic /// <c>SiteEventLogFailureCountReporter</c> hosted service polls the count and /// pushes it into the collector via @@ -15,6 +15,7 @@ public class SiteEventLogWriteFailuresMetricTests { private readonly SiteHealthCollector _collector = new(); + /// <summary>Verifies that a count that was set appears in the collected report.</summary> [Fact] public void Set_Then_CollectReport_IncludesCount() { @@ -25,6 +26,7 @@ public class SiteEventLogWriteFailuresMetricTests Assert.Equal(17L, report.SiteEventLogWriteFailures); } + /// <summary>Verifies that the report payload includes the site event log write failure count, defaulting to zero.</summary> [Fact] public void Report_Payload_Includes_SiteEventLogWriteFailures_AsZeroByDefault() { @@ -33,6 +35,7 @@ public class SiteEventLogWriteFailuresMetricTests Assert.Equal(0L, report.SiteEventLogWriteFailures); } + /// <summary>Verifies that collecting a report does not reset the site event log write failure count.</summary> [Fact] public void CollectReport_DoesNotReset_SiteEventLogWriteFailures() { @@ -49,6 +52,7 @@ public class SiteEventLogWriteFailuresMetricTests Assert.Equal(42L, second.SiteEventLogWriteFailures); } + /// <summary>Verifies that setting the count overwrites the previous value.</summary> [Fact] public void Set_Overwrites_Previous_Value() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteHealthCollectorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteHealthCollectorTests.cs index 67e248bc..4296dc3e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteHealthCollectorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteHealthCollectorTests.cs @@ -6,6 +6,7 @@ public class SiteHealthCollectorTests { private readonly SiteHealthCollector _collector = new(); + /// <summary>Verifies that a report has zero counters when no errors have been recorded.</summary> [Fact] public void CollectReport_ReturnsZeroCounters_WhenNoErrorsRecorded() { @@ -17,6 +18,7 @@ public class SiteHealthCollectorTests Assert.Equal(0, report.DeadLetterCount); } + /// <summary>Verifies that script errors accumulate between reports.</summary> [Fact] public void IncrementScriptError_AccumulatesBetweenReports() { @@ -28,6 +30,7 @@ public class SiteHealthCollectorTests Assert.Equal(3, report.ScriptErrorCount); } + /// <summary>Verifies that alarm errors accumulate between reports.</summary> [Fact] public void IncrementAlarmError_AccumulatesBetweenReports() { @@ -38,6 +41,7 @@ public class SiteHealthCollectorTests Assert.Equal(2, report.AlarmEvaluationErrorCount); } + /// <summary>Verifies that a report carries the script scheduler statistics.</summary> [Fact] public void CollectReport_CarriesScriptSchedulerStats() { @@ -48,6 +52,7 @@ public class SiteHealthCollectorTests Assert.Equal(42.5, report.ScriptOldestBusyAgeSeconds); } + /// <summary>Verifies that script scheduler statistics default to zero and null.</summary> [Fact] public void CollectReport_ScriptSchedulerStats_DefaultToZeroAndNull() { @@ -57,6 +62,7 @@ public class SiteHealthCollectorTests Assert.Null(report.ScriptOldestBusyAgeSeconds); } + /// <summary>Verifies that dead letters accumulate between reports.</summary> [Fact] public void IncrementDeadLetter_AccumulatesBetweenReports() { @@ -66,6 +72,7 @@ public class SiteHealthCollectorTests Assert.Equal(1, report.DeadLetterCount); } + /// <summary>Verifies that collecting a report resets the counters.</summary> [Fact] public void CollectReport_ResetsCounters_AfterCollection() { @@ -84,6 +91,7 @@ public class SiteHealthCollectorTests Assert.Equal(0, second.DeadLetterCount); } + /// <summary>Verifies that a connection health update is reflected in the report.</summary> [Fact] public void UpdateConnectionHealth_ReflectedInReport() { @@ -97,6 +105,7 @@ public class SiteHealthCollectorTests Assert.Equal(ConnectionHealth.Disconnected, report.DataConnectionStatuses["opc-2"]); } + /// <summary>Verifies that connection health is not reset after a report is collected.</summary> [Fact] public void ConnectionHealth_NotResetAfterCollect() { @@ -109,6 +118,7 @@ public class SiteHealthCollectorTests Assert.Equal(ConnectionHealth.Connected, second.DataConnectionStatuses["opc-1"]); } + /// <summary>Verifies that removing a connection removes it from the report.</summary> [Fact] public void RemoveConnection_RemovesFromReport() { @@ -121,6 +131,7 @@ public class SiteHealthCollectorTests Assert.Empty(report.TagResolutionCounts); } + /// <summary>Verifies that a tag resolution update is reflected in the report.</summary> [Fact] public void UpdateTagResolution_ReflectedInReport() { @@ -133,6 +144,7 @@ public class SiteHealthCollectorTests Assert.Equal(45, report.TagResolutionCounts["opc-1"].SuccessfullyResolved); } + /// <summary>Verifies that store-and-forward buffer depths default to empty when the setter was never called.</summary> [Fact] public void StoreAndForwardBufferDepths_DefaultsToEmpty_WhenSetterNotCalled() { @@ -140,6 +152,7 @@ public class SiteHealthCollectorTests Assert.Empty(report.StoreAndForwardBufferDepths); } + /// <summary>Verifies that a report includes a UTC timestamp.</summary> [Fact] public void CollectReport_IncludesUtcTimestamp() { @@ -151,7 +164,7 @@ public class SiteHealthCollectorTests } /// <summary> - /// HealthMonitoring-016 regression: <see cref="SiteHealthCollector.CollectReport"/> + /// Regression: <see cref="SiteHealthCollector.CollectReport"/> /// must stamp <c>ReportTimestamp</c> from an injected <see cref="TimeProvider"/> /// (consistent with the rest of the module), not directly from /// <c>DateTimeOffset.UtcNow</c>, so the report timestamp is deterministically @@ -168,6 +181,7 @@ public class SiteHealthCollectorTests Assert.Equal(fixedInstant, report.ReportTimestamp); } + /// <summary>Verifies that a report sequence number is zero because the caller assigns it.</summary> [Fact] public void CollectReport_SequenceNumberIsZero_CallerAssignsIt() { @@ -175,10 +189,11 @@ public class SiteHealthCollectorTests Assert.Equal(0, report.SequenceNumber); } - // HealthMonitoring-009 regression: the remaining collector setters had no + // Regression coverage: the remaining collector setters had no // "reflected in report" coverage. The following tests verify each setter's // value reaches CollectReport output. + /// <summary>Verifies that the cluster nodes are reflected in the report.</summary> [Fact] public void SetClusterNodes_ReflectedInReport() { @@ -196,6 +211,7 @@ public class SiteHealthCollectorTests Assert.Equal("node-a", report.ClusterNodes[0].Hostname); } + /// <summary>Verifies that the instance counts are reflected in the report.</summary> [Fact] public void SetInstanceCounts_ReflectedInReport() { @@ -208,6 +224,7 @@ public class SiteHealthCollectorTests Assert.Equal(3, report.DisabledInstanceCount); } + /// <summary>Verifies that the parked message count is reflected in the report.</summary> [Fact] public void SetParkedMessageCount_ReflectedInReport() { @@ -218,6 +235,7 @@ public class SiteHealthCollectorTests Assert.Equal(42, report.ParkedMessageCount); } + /// <summary>Verifies that the node hostname is reflected in the report.</summary> [Fact] public void SetNodeHostname_ReflectedInReport() { @@ -228,6 +246,7 @@ public class SiteHealthCollectorTests Assert.Equal("site-host-1", report.NodeHostname); } + /// <summary>Verifies that the active-node setting is reflected in the node role.</summary> [Fact] public void SetActiveNode_ReflectedInNodeRole() { @@ -240,6 +259,7 @@ public class SiteHealthCollectorTests Assert.False(_collector.IsActiveNode); } + /// <summary>Verifies that a tag quality update is reflected in the report.</summary> [Fact] public void UpdateTagQuality_ReflectedInReport() { @@ -254,6 +274,7 @@ public class SiteHealthCollectorTests Assert.Equal(5, quality.Uncertain); } + /// <summary>Verifies that a connection endpoint update is reflected in the report.</summary> [Fact] public void UpdateConnectionEndpoint_ReflectedInReport() { @@ -265,6 +286,7 @@ public class SiteHealthCollectorTests Assert.Equal("opc.tcp://plc-1:4840", report.DataConnectionEndpoints!["opc-1"]); } + /// <summary>Verifies that the store-and-forward depths are reflected in the report.</summary> [Fact] public void SetStoreAndForwardDepths_ReflectedInReport() { @@ -280,6 +302,8 @@ public class SiteHealthCollectorTests Assert.Equal(2, report.StoreAndForwardBufferDepths["Notification"]); } + /// <summary>Verifies that concurrent increments are counted without loss.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ThreadSafety_ConcurrentIncrements() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/StartupReconcilerHealthFieldTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/StartupReconcilerHealthFieldTests.cs index e17db263..6d49dd53 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/StartupReconcilerHealthFieldTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/StartupReconcilerHealthFieldTests.cs @@ -7,13 +7,14 @@ namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests; /// <remarks> /// The skip used to be a single boot Warning and nothing else, so a node that lost its whole /// self-heal path went on reporting itself perfectly healthy. The field is tri-state and the -/// three cases must stay distinct: null = not reported (central self-report, or a pre-row-73 +/// three cases must stay distinct: null = not reported (central self-report, or a older /// site build), true = running, false = skipped because no <c>IDeploymentConfigFetcher</c> /// was available. Collapsing null into false would badge every site the moment central is /// upgraded ahead of them. /// </remarks> public class StartupReconcilerHealthFieldTests { + /// <summary>Verifies the case: collect Report, never Told, leaves The Field Null.</summary> [Fact] public void CollectReport_NeverTold_LeavesTheFieldNull() { @@ -26,6 +27,7 @@ public class StartupReconcilerHealthFieldTests Assert.Null(report.StartupReconcilerActive); } + /// <summary>Verifies the case: collect Report, reconciler Spawned, reports True.</summary> [Fact] public void CollectReport_ReconcilerSpawned_ReportsTrue() { @@ -36,6 +38,7 @@ public class StartupReconcilerHealthFieldTests Assert.True(collector.CollectReport("TestSite").StartupReconcilerActive); } + /// <summary>Verifies the case: collect Report, reconciler Skipped, reports False, not Null.</summary> [Fact] public void CollectReport_ReconcilerSkipped_ReportsFalse_NotNull() { @@ -50,6 +53,7 @@ public class StartupReconcilerHealthFieldTests Assert.False(report.StartupReconcilerActive); } + /// <summary>Verifies the case: collect Report, is Point In Time, not Drained On Collect.</summary> [Fact] public void CollectReport_IsPointInTime_NotDrainedOnCollect() { @@ -64,6 +68,7 @@ public class StartupReconcilerHealthFieldTests Assert.False(second.StartupReconcilerActive); } + /// <summary>Verifies the case: set Startup Reconciler Active, is Not Write Once.</summary> [Fact] public void SetStartupReconcilerActive_IsNotWriteOnce() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs index ff348f7b..4df89c7a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs @@ -22,6 +22,8 @@ public class CentralActorPathTests : IAsyncLifetime private string? _previousEnv; private CentralDbTestEnvironment? _dbEnv; + /// <summary>Initializes the fixture before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task InitializeAsync() { _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); @@ -49,7 +51,7 @@ public class CentralActorPathTests : IAsyncLifetime ["ScadaBridge:Cluster:MinNrOfMembers"] = "1", ["ScadaBridge:Database:SkipMigrations"] = "true", ["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!", - // Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions). + // LDAP settings nest under Security:Ldap (shared LdapOptions). // ServiceAccountDn is now required by the library's LdapOptionsValidator // (ValidateOnStart), so it must be present for the host to start. ["ScadaBridge:Security:Ldap:Server"] = "localhost", @@ -88,6 +90,8 @@ public class CentralActorPathTests : IAsyncLifetime return Task.CompletedTask; } + /// <summary>Releases the fixture after the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task DisposeAsync() { // try/finally: a throwing host teardown must not strand the process-wide @@ -107,38 +111,56 @@ public class CentralActorPathTests : IAsyncLifetime await Task.CompletedTask; } + /// <summary>Verifies that the dead letter monitor actor exists on a central node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CentralActors_DeadLetterMonitor_Exists() => await AssertActorExists("/user/dead-letter-monitor"); + /// <summary>Verifies that the central communication actor exists on a central node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CentralActors_CentralCommunication_Exists() => await AssertActorExists("/user/central-communication"); + /// <summary>Verifies that the management actor exists on a central node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CentralActors_Management_Exists() => await AssertActorExists("/user/management"); + /// <summary>Verifies that the notification outbox singleton exists on a central node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CentralActors_NotificationOutboxSingleton_Exists() => await AssertActorExists("/user/notification-outbox-singleton"); + /// <summary>Verifies that the notification outbox proxy exists on a central node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CentralActors_NotificationOutboxProxy_Exists() => await AssertActorExists("/user/notification-outbox-proxy"); + /// <summary>Verifies that the audit log purge singleton exists on a central node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CentralActors_AuditLogPurgeSingleton_Exists() => await AssertActorExists("/user/audit-log-purge-singleton"); + /// <summary>Verifies that the audit log purge proxy exists on a central node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CentralActors_AuditLogPurgeProxy_Exists() => await AssertActorExists("/user/audit-log-purge-proxy"); + /// <summary>Verifies that the site audit reconciliation singleton exists on a central node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CentralActors_SiteAuditReconciliationSingleton_Exists() => await AssertActorExists("/user/site-audit-reconciliation-singleton"); + /// <summary>Verifies that the site audit reconciliation proxy exists on a central node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CentralActors_SiteAuditReconciliationProxy_Exists() => await AssertActorExists("/user/site-audit-reconciliation-proxy"); @@ -165,6 +187,8 @@ public class SiteActorPathTests : IAsyncLifetime private string _tempDbPath = null!; private string _tempLocalDbPath = null!; + /// <summary>Initializes the fixture before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_actor_test_{Guid.NewGuid()}.db"); @@ -206,6 +230,8 @@ public class SiteActorPathTests : IAsyncLifetime _actorSystem = akkaService.ActorSystem; } + /// <summary>Releases the fixture after the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task DisposeAsync() { if (_host != null) @@ -217,22 +243,32 @@ public class SiteActorPathTests : IAsyncLifetime try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ } } + /// <summary>Verifies that the dead letter monitor actor exists on a site node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiteActors_DeadLetterMonitor_Exists() => await AssertActorExists("/user/dead-letter-monitor"); + /// <summary>Verifies that the data connection layer manager actor exists on a site node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiteActors_DclManager_Exists() => await AssertActorExists("/user/dcl-manager"); + /// <summary>Verifies that the deployment manager singleton exists on a site node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiteActors_DeploymentManagerSingleton_Exists() => await AssertActorExists("/user/deployment-manager-singleton"); + /// <summary>Verifies that the deployment manager proxy exists on a site node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiteActors_DeploymentManagerProxy_Exists() => await AssertActorExists("/user/deployment-manager-proxy"); + /// <summary>Verifies that the site communication actor exists on a site node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiteActors_SiteCommunication_Exists() => await AssertActorExists("/user/site-communication"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorSystemBridgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorSystemBridgeTests.cs index 6ca0145c..e423108f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorSystemBridgeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorSystemBridgeTests.cs @@ -20,6 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// </summary> public sealed class ActorSystemBridgeTests { + /// <summary>Verifies that actor system resolves null before hosted service starts.</summary> [Fact] public void ActorSystem_ResolvesNull_BeforeHostedServiceStarts() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaBootstrapTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaBootstrapTests.cs index c9e8173f..12f76699 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaBootstrapTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaBootstrapTests.cs @@ -4,17 +4,19 @@ using Akka.Configuration; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// WP-13: Tests for Akka.NET actor system bootstrap. +/// Tests for Akka.NET actor system bootstrap. /// </summary> public class AkkaBootstrapTests : IDisposable { private ActorSystem? _actorSystem; + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _actorSystem?.Dispose(); } + /// <summary>Verifies that the actor system is created with the cluster configuration.</summary> [Fact] public void ActorSystem_CreatesWithClusterConfig() { @@ -45,6 +47,7 @@ akka { Assert.Equal("scadabridge-test", _actorSystem.Name); } + /// <summary>Verifies that the HOCON configuration includes coordinated shutdown.</summary> [Fact] public void ActorSystem_HoconConfig_IncludesCoordinatedShutdown() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHealthReportTransportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHealthReportTransportTests.cs index 9f7ef0d2..1fd32017 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHealthReportTransportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHealthReportTransportTests.cs @@ -21,6 +21,8 @@ public sealed class AkkaHealthReportTransportTests { private sealed class AckActor : ReceiveActor { + /// <summary>Initializes the actor to acknowledge every health report with the given verdict.</summary> + /// <param name="accepted">Whether the acknowledgement reports the report as accepted.</param> public AckActor(bool accepted) => Receive<SiteHealthReport>(r => Sender.Tell(new SiteHealthReportAck( r.SiteId, r.SequenceNumber, accepted, accepted ? null : "rejected by test"))); @@ -55,6 +57,8 @@ public sealed class AkkaHealthReportTransportTests return svc; } + /// <summary>Verifies that sending completes when central acknowledges the report as accepted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_AckedAccepted_Completes() { @@ -68,6 +72,8 @@ public sealed class AkkaHealthReportTransportTests finally { await system.Terminate(); } } + /// <summary>Verifies that sending throws when central acknowledges the report as not accepted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_AckedNotAccepted_Throws() { @@ -82,6 +88,8 @@ public sealed class AkkaHealthReportTransportTests finally { await system.Terminate(); } } + /// <summary>Verifies that sending throws when no actor responds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_NoResponder_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHostedServiceAuditWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHostedServiceAuditWiringTests.cs index 31e21242..24fd7845 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHostedServiceAuditWiringTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHostedServiceAuditWiringTests.cs @@ -20,7 +20,7 @@ using ZB.MOM.WW.ScadaBridge.StoreAndForward; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// Bundle E (M2 Task E1) — verifies the Audit Log (#23) DI surface is wired +/// Verifies the Audit Log DI surface is wired /// into both composition roots and that the HOCON document emitted by /// <see cref="AkkaHostedService.BuildHocon"/> includes the dedicated /// <c>audit-telemetry-dispatcher</c> the site telemetry actor binds to. @@ -36,6 +36,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// </remarks> public class AkkaHostedServiceAuditWiringHoconTests { + /// <summary>Verifies that the generated HOCON contains the audit telemetry dispatcher block.</summary> [Fact] public void BuildHocon_Emits_AuditTelemetryDispatcher_Block() { @@ -84,7 +85,7 @@ public class AkkaHostedServiceAuditWiringHoconTests } /// <summary> -/// Verifies Audit Log (#23) services land in the Central composition root. +/// Verifies Audit Log services land in the Central composition root. /// </summary> [Collection(HostBootCollection.Name)] public class CentralAuditWiringTests : IDisposable @@ -93,6 +94,7 @@ public class CentralAuditWiringTests : IDisposable private readonly string? _previousEnv; private readonly CentralDbTestEnvironment _dbEnv; + /// <summary>Initializes the Central composition root host used by the tests.</summary> public CentralAuditWiringTests() { _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); @@ -119,7 +121,7 @@ public class CentralAuditWiringTests : IDisposable ["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552", ["ScadaBridge:Database:SkipMigrations"] = "true", ["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!", - // Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions). + // LDAP settings nest under Security:Ldap (shared LdapOptions). // ServiceAccountDn is now required by the library's LdapOptionsValidator // (ValidateOnStart), so it must be present for the host to start. ["ScadaBridge:Security:Ldap:Server"] = "localhost", @@ -155,6 +157,7 @@ public class CentralAuditWiringTests : IDisposable _ = _factory.Server; } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { // try/finally: a throwing host teardown must not strand the process-wide @@ -170,6 +173,7 @@ public class CentralAuditWiringTests : IDisposable } } + /// <summary>Verifies that the Central composition root resolves the audit writer as the fallback audit writer.</summary> [Fact] public void Central_Resolves_IAuditWriter_AsFallbackAuditWriter() { @@ -183,6 +187,7 @@ public class CentralAuditWiringTests : IDisposable Assert.IsType<FallbackAuditWriter>(writer); } + /// <summary>Verifies that the Central composition root resolves the audit log options.</summary> [Fact] public void Central_Resolves_AuditLogOptions() { @@ -191,6 +196,7 @@ public class CentralAuditWiringTests : IDisposable Assert.NotNull(opts!.Value); } + /// <summary>Verifies that the Central composition root resolves the SQLite audit writer options.</summary> [Fact] public void Central_Resolves_SqliteAuditWriterOptions() { @@ -199,6 +205,7 @@ public class CentralAuditWiringTests : IDisposable Assert.NotNull(opts!.Value); } + /// <summary>Verifies that the Central composition root resolves the site audit telemetry options.</summary> [Fact] public void Central_Resolves_SiteAuditTelemetryOptions() { @@ -207,6 +214,7 @@ public class CentralAuditWiringTests : IDisposable Assert.NotNull(opts!.Value); } + /// <summary>Verifies that the Central composition root resolves the site stream audit client as the no-op default.</summary> [Fact] public void Central_Resolves_ISiteStreamAuditClient_AsNoOpDefault() { @@ -216,7 +224,7 @@ public class CentralAuditWiringTests : IDisposable } /// <summary> - /// M3 Bundle F (T15): the Central composition root calls + /// The Central composition root calls /// <c>AddSiteCallAudit()</c>. Today that extension is a no-op placeholder, /// but invoking it must not throw and the central host's service collection /// must build successfully — the actor's Props are constructed inline in @@ -236,10 +244,10 @@ public class CentralAuditWiringTests : IDisposable } /// <summary> - /// M3 Bundle F: the Central composition root registers + /// The Central composition root registers /// <c>ICachedCallTelemetryForwarder</c> as a lazy singleton (the /// forwarder degrades to audit-only emission when the site-only - /// <c>IOperationTrackingStore</c> is absent, matching the M2 lazy chain + /// <c>IOperationTrackingStore</c> is absent, matching the lazy chain /// pattern). The binding is exercised here so a future regression that /// removes the registration or makes IOperationTrackingStore mandatory /// fails on the Central node, not just at first script execution. @@ -284,7 +292,7 @@ public class CentralAuditWiringTests : IDisposable } /// <summary> -/// Verifies Audit Log (#23) services land in the Site composition root. +/// Verifies Audit Log services land in the Site composition root. /// </summary> public class SiteAuditWiringTests : IDisposable { @@ -292,6 +300,7 @@ public class SiteAuditWiringTests : IDisposable private readonly string _tempDbPath; private readonly string _tempLocalDbPath; + /// <summary>Initializes the Site composition root host used by the tests.</summary> public SiteAuditWiringTests() { _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_audit_wiring_{Guid.NewGuid()}.db"); @@ -329,6 +338,7 @@ public class SiteAuditWiringTests : IDisposable _host = builder.Build(); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { (_host as IDisposable)?.Dispose(); @@ -336,6 +346,7 @@ public class SiteAuditWiringTests : IDisposable try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ } } + /// <summary>Verifies that the Site composition root resolves the audit writer as the fallback audit writer.</summary> [Fact] public void Site_Resolves_IAuditWriter_AsFallbackAuditWriter() { @@ -344,6 +355,7 @@ public class SiteAuditWiringTests : IDisposable Assert.IsType<FallbackAuditWriter>(writer); } + /// <summary>Verifies that the Site composition root resolves the SQLite audit writer as a singleton.</summary> [Fact] public void Site_Resolves_SqliteAuditWriter_AsSingleton() { @@ -354,6 +366,7 @@ public class SiteAuditWiringTests : IDisposable Assert.Same(a, b); } + /// <summary>Verifies that the site audit queue and the SQLite audit writer resolve to the same instance.</summary> [Fact] public void Site_ISiteAuditQueue_AndSqliteAuditWriter_AreSameInstance() { @@ -367,6 +380,7 @@ public class SiteAuditWiringTests : IDisposable Assert.Same(writer, queue); } + /// <summary>Verifies that the Site composition root resolves the ring buffer fallback.</summary> [Fact] public void Site_Resolves_RingBufferFallback() { @@ -374,10 +388,11 @@ public class SiteAuditWiringTests : IDisposable Assert.NotNull(ring); } + /// <summary>Verifies that the Site composition root resolves the audit write failure counter as the health metrics bridge.</summary> [Fact] public void Site_Resolves_IAuditWriteFailureCounter_AsHealthMetricsBridge() { - // Bundle G (M2-T11): site composition root calls + // The site composition root calls // AddAuditLogHealthMetricsBridge() after AddAuditLog + AddSiteHealthMonitoring, // which swaps the NoOp default for the real health-metrics bridge so // FallbackAuditWriter primary failures surface in the site health @@ -387,6 +402,7 @@ public class SiteAuditWiringTests : IDisposable Assert.IsType<HealthMetricsAuditWriteFailureCounter>(counter); } + /// <summary>Verifies that the Site composition root resolves the site stream audit client as the no-op default.</summary> [Fact] public void Site_Resolves_ISiteStreamAuditClient_AsNoOpDefault() { @@ -395,6 +411,7 @@ public class SiteAuditWiringTests : IDisposable Assert.IsType<NoOpSiteStreamAuditClient>(client); } + /// <summary>Verifies that the Site composition root resolves the site audit telemetry options with their default values.</summary> [Fact] public void Site_Resolves_SiteAuditTelemetryOptions_WithDefaults() { @@ -406,12 +423,12 @@ public class SiteAuditWiringTests : IDisposable } /// <summary> - /// M3 Bundle F (T15): the site composition root resolves the cached-call + /// The site composition root resolves the cached-call /// telemetry forwarder. ScriptExecutionActor consumes this through /// <c>GetService<ICachedCallTelemetryForwarder>()</c> on every script /// execution; a missing registration would silently degrade /// <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> to the - /// "no-emission" path and break the M3 audit pipeline. + /// "no-emission" path and break the cached-call audit pipeline. /// </summary> [Fact] public void Site_Resolves_ICachedCallTelemetryForwarder() @@ -422,7 +439,7 @@ public class SiteAuditWiringTests : IDisposable } /// <summary> - /// M3 Bundle F (T15): the site composition root resolves the lifecycle + /// The site composition root resolves the lifecycle /// bridge that translates S&F retry-loop attempt notifications into /// cached-call telemetry packets. /// </summary> @@ -437,7 +454,7 @@ public class SiteAuditWiringTests : IDisposable } /// <summary> - /// M3 Bundle F (T15): the lifecycle bridge is bound to the + /// The lifecycle bridge is bound to the /// <see cref="ICachedCallLifecycleObserver"/> contract that /// StoreAndForwardService consults at construction time. Without this /// binding the S&F service is built with a null observer and the @@ -454,7 +471,7 @@ public class SiteAuditWiringTests : IDisposable } /// <summary> - /// M3 Bundle F (T15): the Host registers an + /// The Host registers an /// <see cref="IStoreAndForwardSiteContext"/> adapter so the S&F service /// can resolve the site id at composition time WITHOUT introducing a /// StoreAndForward → HealthMonitoring project-reference cycle. diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/BootstrapCancellationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/BootstrapCancellationTests.cs index f60ddefe..e4b2abb6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/BootstrapCancellationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/BootstrapCancellationTests.cs @@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; public class BootstrapCancellationTests { + /// <summary>Verifies that a cancelled bootstrap neither resolves the actor system nor joins the cluster.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CancelledBootstrap_DoesNotResolveActorSystemOrJoin() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CaptiveDependencyGuard.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CaptiveDependencyGuard.cs index f95e9c7b..58bfec22 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CaptiveDependencyGuard.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CaptiveDependencyGuard.cs @@ -78,7 +78,7 @@ internal static class CaptiveDependencyGuard /// It previously held two entries — <c>AuditLogIngestActor._injectedRepository</c> and /// <c>SiteCallAuditActor._injectedRepository</c> — nullable repository fields assigned only /// by explicitly test-mode constructors. They were safe solely because no production call - /// site picked those overloads, which left the row-45 captive-dependency bug one + /// site picked those overloads, which left the captive-dependency bug one /// constructor call away. Register row 61 deleted the constructors instead: both actors now /// have a single <see cref="IServiceProvider"/> constructor, the fields are gone, and the /// test fixtures build stub providers that hand a repository out of a scope. With the shape diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CaptiveDependencyGuardTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CaptiveDependencyGuardTests.cs index 57ddb5ec..2719bb8e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CaptiveDependencyGuardTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CaptiveDependencyGuardTests.cs @@ -19,7 +19,7 @@ public class CaptiveDependencyGuardSelfTests private interface IPretendScopedService; /// <summary> - /// POSITIVE CONTROL — the pre-row-45 shape, preserved permanently. An actor that caches a + /// POSITIVE CONTROL — the original captive-dependency shape, preserved permanently. An actor that caches a /// scoped-typed service for its lifetime is exactly what <c>NotificationOutboxActor</c> /// looked like before 9e769735, and the guard must report it. /// </summary> @@ -67,6 +67,7 @@ public class CaptiveDependencyGuardSelfTests private static readonly IReadOnlySet<Type> PretendScopedSet = new HashSet<Type> { typeof(IPretendScopedService) }; + /// <summary>Verifies that the guard flags an actor that caches a scoped service.</summary> [Fact] public void Guard_Flags_ActorCachingAScopedService() { @@ -78,6 +79,7 @@ public class CaptiveDependencyGuardSelfTests Assert.Equal(typeof(IPretendScopedService), violation.FieldType); } + /// <summary>Verifies that the guard flags an actor that caches a service scope even when the scoped set is empty.</summary> [Fact] public void Guard_Flags_ActorCachingAServiceScope_EvenWhenTheScopedSetIsEmpty() { @@ -90,6 +92,7 @@ public class CaptiveDependencyGuardSelfTests Assert.Equal("_scope", violation.FieldName); } + /// <summary>Verifies that the guard flags an actor that caches a DbContext.</summary> [Fact] public void Guard_Flags_ActorCachingADbContext() { @@ -100,6 +103,7 @@ public class CaptiveDependencyGuardSelfTests Assert.Equal(typeof(ScadaBridgeDbContext), violation.FieldType); } + /// <summary>Verifies that the guard flags the compiler-generated backing field of an auto-property of a scoped type.</summary> [Fact] public void Guard_Flags_ScopedAutoPropertyBackingField() { @@ -110,6 +114,7 @@ public class CaptiveDependencyGuardSelfTests Assert.Contains("Captive", violation.FieldName, StringComparison.Ordinal); } + /// <summary>Verifies that the guard allows fields that hold the root provider or a scope factory.</summary> [Fact] public void Guard_Allows_RootProviderAndScopeFactoryFields() { @@ -252,6 +257,7 @@ public class CentralCaptiveDependencyGuardTests : IDisposable private readonly CentralDbTestEnvironment _dbEnv; private readonly List<ServiceDescriptor> _descriptors = []; + /// <summary>Initializes a new instance of the <c>CentralCaptiveDependencyGuardTests</c> class.</summary> public CentralCaptiveDependencyGuardTests() { _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); @@ -309,6 +315,7 @@ public class CentralCaptiveDependencyGuardTests : IDisposable _ = _factory.Server; } + /// <summary>Disposes the web application factory.</summary> public void Dispose() { try @@ -322,6 +329,7 @@ public class CentralCaptiveDependencyGuardTests : IDisposable } } + /// <summary>Verifies that no actor in the central composition root holds captive scoped state.</summary> [Fact] public void Central_NoActorHoldsCaptiveScopedState() { @@ -352,6 +360,7 @@ public class SiteCaptiveDependencyGuardTests : IDisposable private readonly string _tempAuditDbPath; private readonly IServiceCollection _services; + /// <summary>Initializes a new instance of the <c>SiteCaptiveDependencyGuardTests</c> class.</summary> public SiteCaptiveDependencyGuardTests() { _tempDbPath = Path.Combine(Path.GetTempPath(), $"sb_guard_{Guid.NewGuid()}.db"); @@ -385,6 +394,7 @@ public class SiteCaptiveDependencyGuardTests : IDisposable _services = builder.Services; } + /// <summary>Deletes the temporary database files, ignoring failures.</summary> public void Dispose() { foreach (var path in new[] { _tempDbPath, _tempTrackingDbPath, _tempLocalDbPath, _tempAuditDbPath }) @@ -393,6 +403,7 @@ public class SiteCaptiveDependencyGuardTests : IDisposable } } + /// <summary>Verifies that no actor in the site composition root holds captive scoped state.</summary> [Fact] public void Site_NoActorHoldsCaptiveScopedState() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlAuthInterceptorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlAuthInterceptorTests.cs index 93602c3e..49f5cc43 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlAuthInterceptorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlAuthInterceptorTests.cs @@ -30,11 +30,13 @@ public class CentralControlAuthInterceptorTests /// <summary>An <see cref="ISitePskProvider"/> backed by a fixed site→key map; throws for unknown sites.</summary> private sealed class MapPskProvider(IReadOnlyDictionary<string, string> keys) : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => keys.TryGetValue(siteId, out var key) ? new ValueTask<string>(key) : throw new InvalidOperationException($"no key for '{siteId}'"); + /// <inheritdoc /> public void Invalidate(string siteId) { } } @@ -58,22 +60,34 @@ public class CentralControlAuthInterceptorTests private sealed class FakeServerCallContext(string method, Metadata requestHeaders) : ServerCallContext { + /// <inheritdoc /> protected override string MethodCore => method; + /// <inheritdoc /> protected override string HostCore => "localhost"; + /// <inheritdoc /> protected override string PeerCore => "ipv4:127.0.0.1:12345"; + /// <inheritdoc /> protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); + /// <inheritdoc /> protected override Metadata RequestHeadersCore => requestHeaders; + /// <inheritdoc /> protected override CancellationToken CancellationTokenCore => CancellationToken.None; + /// <inheritdoc /> protected override Metadata ResponseTrailersCore { get; } = []; + /// <inheritdoc /> protected override Status StatusCore { get; set; } + /// <inheritdoc /> protected override WriteOptions? WriteOptionsCore { get; set; } + /// <inheritdoc /> protected override AuthContext AuthContextCore { get; } = new(null, new Dictionary<string, List<AuthProperty>>()); + /// <inheritdoc /> protected override ContextPropagationToken CreatePropagationTokenCore( ContextPropagationOptions? options) => throw new NotSupportedException(); + /// <inheritdoc /> protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; } @@ -83,6 +97,9 @@ public class CentralControlAuthInterceptorTests => interceptor.UnaryServerHandler<string, string>( "request", context, (_, _) => Task.FromResult("ok")); + /// <summary>Verifies that a verified site A cannot submit ownership of site B for the given kind of request.</summary> + /// <param name="kind">The kind.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("notification")] [InlineData("reconcile")] @@ -128,6 +145,8 @@ public class CentralControlAuthInterceptorTests Assert.False(continued); // no actor dispatch, token mint, or partial batch ingest } + /// <summary>Verifies that a method outside the gated prefixes passes through.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NonGatedMethod_PassesThrough() { @@ -139,6 +158,8 @@ public class CentralControlAuthInterceptorTests Assert.Equal("ok", await Invoke(interceptor, context)); } + /// <summary>Verifies that a request with the correct site and key is accepted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CorrectSiteAndKey_IsAccepted() { @@ -148,6 +169,8 @@ public class CentralControlAuthInterceptorTests Assert.Equal("ok", await Invoke(interceptor, context)); } + /// <summary>Verifies that a request with a missing site header is denied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MissingSiteHeader_IsDenied() { @@ -160,6 +183,8 @@ public class CentralControlAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a request with a blank site header is denied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BlankSiteHeader_IsDenied() { @@ -170,6 +195,8 @@ public class CentralControlAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a request from an unknown site is denied rather than passed through.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UnknownSite_IsDenied_NotPassedThrough() { @@ -182,6 +209,8 @@ public class CentralControlAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a request with no bearer token is denied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoBearerToken_IsDenied() { @@ -192,6 +221,8 @@ public class CentralControlAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a request with the wrong key for the site is denied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WrongKeyForTheSite_IsDenied() { @@ -204,6 +235,8 @@ public class CentralControlAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that each site is verified against its own key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EachSiteIsVerifiedAgainstItsOwnKey() { @@ -215,6 +248,7 @@ public class CentralControlAuthInterceptorTests Assert.Equal("ok", await Invoke(interceptor, context)); } + /// <summary>Verifies that the interceptor has exactly one public constructor.</summary> [Fact] public void TheInterceptorHasExactlyOnePublicConstructor() { @@ -229,6 +263,7 @@ public class CentralControlAuthInterceptorTests Assert.Single(publicCtors); } + /// <summary>Verifies that the default gated prefixes match the real central control service path.</summary> [Fact] public void DefaultGatedPrefixes_MatchTheRealCentralControlServicePath() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlEndToEndTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlEndToEndTests.cs index bcfa4f41..b7bdc10a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlEndToEndTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlEndToEndTests.cs @@ -53,7 +53,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime private ActorSystem _actorSystem = null!; private CentralControlGrpcService _service = null!; - /// <inheritdoc /> + /// <summary>Starts the in-memory host, actor system and gRPC service under test.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { _actorSystem = ActorSystem.Create("centralcontrol-e2e-test"); @@ -94,7 +95,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime _server = _host.GetTestServer(); } - /// <inheritdoc /> + /// <summary>Stops the host and actor system and releases test resources.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task DisposeAsync() { await _host.StopAsync(); @@ -118,6 +120,9 @@ public class CentralControlEndToEndTests : IAsyncLifetime // ---- Auth positives / negatives, all through the real pipeline ---- + /// <summary>Verifies that site A's key presented for a foreign site is denied before any actor dispatch.</summary> + /// <param name="rpc">The rpc.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("reconcile")] [InlineData("notification")] @@ -147,6 +152,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime Assert.Equal(0, Volatile.Read(ref _dispatches)); } + /// <summary>Verifies that the correct site and key reach the service on a unary call.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CorrectSiteAndKey_ReachesTheService_OnAUnaryCall() { @@ -157,6 +164,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime Assert.True(ack.Accepted); } + /// <summary>Verifies no credentials at all is rejected with permission denied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoCredentialsAtAll_IsRejected_WithPermissionDenied() { @@ -168,6 +177,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a wrong key for the site is rejected with a permission-denied status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WrongKeyForTheSite_IsRejected_WithPermissionDenied() { @@ -180,6 +191,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that an unknown site is rejected with a permission-denied status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UnknownSite_IsRejected_WithPermissionDenied() { @@ -191,6 +204,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies the accept and reject paths are distinguishable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TheAcceptAndRejectPathsAreDistinguishable() { @@ -207,6 +222,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime // ---- One RPC per shape: unary (above) + the ingest bridge ---- + /// <summary>Verifies that ingesting audit events decodes the batch, asks the ingest proxy and encodes the ack.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task IngestAuditEvents_DecodesTheBatch_AsksTheIngestProxy_EncodesTheAck() { @@ -224,6 +241,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime Assert.Contains(AcceptedId.ToString(), ack.AcceptedEventIds); } + /// <summary>Verifies that ingesting an empty audit-event batch short-circuits without asking the ingest proxy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task IngestAuditEvents_EmptyBatch_ShortCircuits_WithoutAskingTheIngestProxy() { @@ -259,17 +278,21 @@ public class CentralControlEndToEndTests : IAsyncLifetime private sealed class FixedPskProvider(string key) : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key); + /// <inheritdoc /> public void Invalidate(string siteId) { } } private sealed class MapPskProvider(IReadOnlyDictionary<string, string> keys) : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => keys.TryGetValue(siteId, out var key) ? new ValueTask<string>(key) : throw new InvalidOperationException($"no key for '{siteId}'"); + /// <inheritdoc /> public void Invalidate(string siteId) { } } @@ -280,6 +303,9 @@ public class CentralControlEndToEndTests : IAsyncLifetime /// </summary> private sealed class StubCentralActor : ReceiveActor { + /// <summary>Initializes a new instance of the <c>StubCentralActor</c> class.</summary> + /// <param name="acceptedId">The accepted id.</param> + /// <param name="dispatched">The dispatched.</param> public StubCentralActor(Guid acceptedId, Action dispatched) { Receive<NotificationSubmit>(msg => { dispatched(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralDbTestEnvironment.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralDbTestEnvironment.cs index 62a091fd..e858d329 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralDbTestEnvironment.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralDbTestEnvironment.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// Host-003: <c>appsettings.Central.json</c> no longer commits database connection +/// <c>appsettings.Central.json</c> no longer commits database connection /// strings — they are externalised to environment variables. Tests that exercise the /// full <c>Program</c> startup pipeline against the real SQL provider must therefore /// supply the local dev connection string the way a deployment would: via an @@ -13,7 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <c>DOTNET_ENVIRONMENT=Central</c> without an explicit pepper env var. /// /// Also supplies <c>ScadaBridge__Database__MachineDataDb</c> so the Central-role -/// StartupValidator preflight (reverts Host-008, REQ-HOST-3/4, M2.9 #17) does not +/// StartupValidator preflight (a missing MachineDataDb setting) does not /// fail for tests that set <c>DOTNET_ENVIRONMENT=Central</c> without an explicit /// MachineDataDb env var. /// @@ -78,6 +78,7 @@ internal sealed class CentralDbTestEnvironment : IDisposable /// </summary> private static int _liveCount; + /// <summary>Initializes a new instance of <c>CentralDbTestEnvironment</c>.</summary> public CentralDbTestEnvironment() { if (Interlocked.Increment(ref _liveCount) != 1) @@ -107,6 +108,7 @@ internal sealed class CentralDbTestEnvironment : IDisposable Environment.SetEnvironmentVariable(JwtSigningKeyKey, JwtSigningKey); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { Environment.SetEnvironmentVariable(ConfigKey, _previousConfig); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralHttpBindPortsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralHttpBindPortsTests.cs index dd2d4336..4739964b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralHttpBindPortsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralHttpBindPortsTests.cs @@ -15,6 +15,9 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// </remarks> public class CentralHttpBindPortsTests { + /// <summary>Verifies that the HTTP bind ports are parsed correctly from the server URLs.</summary> + /// <param name="urls">The server URL list to parse.</param> + /// <param name="expected">The ports expected from the parse.</param> [Theory] [InlineData("http://+:5000", new[] { 5000 })] [InlineData("http://0.0.0.0:5000", new[] { 5000 })] @@ -29,6 +32,9 @@ public class CentralHttpBindPortsTests Assert.Equal(expected, Program.ParseHttpBindPorts(urls)); } + /// <summary>Verifies that the scheme's default port is used when none is given and an explicit port is honoured.</summary> + /// <param name="url">The server URL to parse.</param> + /// <param name="expected">The port expected from the parse.</param> [Theory] [InlineData("https://+:443", 443)] // scheme default when no explicit port [InlineData("http://+:80", 80)] @@ -37,6 +43,8 @@ public class CentralHttpBindPortsTests Assert.Equal(new[] { expected }, Program.ParseHttpBindPorts(url)); } + /// <summary>Verifies that a null, empty or whitespace URL list yields no ports.</summary> + /// <param name="urls">The null, empty or whitespace URL list.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -46,6 +54,7 @@ public class CentralHttpBindPortsTests Assert.Empty(Program.ParseHttpBindPorts(urls)); } + /// <summary>Verifies that an unparseable URL is skipped rather than thrown on, while valid entries still yield ports.</summary> [Fact] public void UnparseableEntry_IsSkipped_NotThrown() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralListenerIsolationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralListenerIsolationTests.cs index 028d3ee6..a3ca38d1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralListenerIsolationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralListenerIsolationTests.cs @@ -14,6 +14,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; public class CentralListenerIsolationTests { + /// <summary>Verifies that the actual socket decides the endpoint even when the request authority is forged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task XC1_ActualSocketOwnsEndpoint_EvenWithForgedAuthority() { @@ -67,6 +69,7 @@ public class CentralListenerIsolationTests public sealed class EchoCentral : CentralControlService.CentralControlServiceBase { + /// <inheritdoc /> public override Task<Empty> Heartbeat(HeartbeatDto request, ServerCallContext context) => Task.FromResult(new Empty()); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ClusterActivityEvaluatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ClusterActivityEvaluatorTests.cs index b123435e..d2979486 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ClusterActivityEvaluatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ClusterActivityEvaluatorTests.cs @@ -14,6 +14,8 @@ public class ClusterActivityEvaluatorTests : IAsyncLifetime { private ActorSystem? _system; + /// <summary>Prepares the fixture before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { var port = FreePort(); @@ -34,8 +36,11 @@ akka {{ await Task.Delay(100); } + /// <summary>Releases the resources held by this fixture asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task DisposeAsync() { if (_system != null) await _system.Terminate(); } + /// <summary>Verifies that self is oldest, sole up member, returns true.</summary> [Fact] public void SelfIsOldest_SoleUpMember_ReturnsTrue() { @@ -44,6 +49,7 @@ akka {{ Assert.True(ClusterActivityEvaluator.SelfIsOldest(cluster, "Central")); } + /// <summary>Verifies that self is oldest, role not held, returns false.</summary> [Fact] public void SelfIsOldest_RoleNotHeld_ReturnsFalse() { @@ -52,12 +58,14 @@ akka {{ Assert.False(ClusterActivityEvaluator.SelfIsOldest(cluster, "site-nonexistent")); } + /// <summary>Verifies that active node gate, and, node provider, agree with evaluator.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ActiveNodeGate_And_NodeProvider_AgreeWithEvaluator() { // Both product gates must be backed by the oldest-member evaluator. // A single-node Up cluster: evaluator says true, so both must say true. - // (Pinning test — the leader==oldest divergence is proven in Task 8's + // (Pinning test — the leader==oldest divergence is proven in the // two-node integration test; here we assert the wiring holds.) var port = FreePort(); var self = $"akka.tcp://scadabridge@127.0.0.1:{port}"; diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs index 00705069..5fab8bac 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs @@ -45,6 +45,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// </summary> internal static class AkkaHostedServiceRemover { + /// <summary>Removes only the Akka hosted service registration, leaving the Akka services resolvable.</summary> + /// <param name="services">The service collection to modify.</param> internal static void RemoveAkkaHostedServiceOnly(IServiceCollection services) { // Pattern used in Program.cs: @@ -88,6 +90,7 @@ public class CentralCompositionRootTests : IDisposable private readonly string? _previousEnv; private readonly CentralDbTestEnvironment _dbEnv; + /// <summary>Initializes the tests by booting the central composition root with test configuration.</summary> public CentralCompositionRootTests() { _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); @@ -114,7 +117,7 @@ public class CentralCompositionRootTests : IDisposable ["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552", ["ScadaBridge:Database:SkipMigrations"] = "true", ["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!", - // Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions). + // LDAP settings nest under Security:Ldap (shared LdapOptions). // ServiceAccountDn is now required by the library's LdapOptionsValidator // (ValidateOnStart), so it must be present for the host to start. ["ScadaBridge:Security:Ldap:Server"] = "localhost", @@ -159,6 +162,7 @@ public class CentralCompositionRootTests : IDisposable _ = _factory.Server; } + /// <summary>Disposes the web application factory and restores the environment variables.</summary> public void Dispose() { // try/finally: a throwing host teardown must not strand the process-wide @@ -176,6 +180,8 @@ public class CentralCompositionRootTests : IDisposable // --- Singletons --- + /// <summary>Verifies that the service resolves as a singleton from the central composition root.</summary> + /// <param name="serviceType">The service type to resolve.</param> [Theory] [MemberData(nameof(CentralSingletonServices))] public void Central_ResolveSingleton(Type serviceType) @@ -184,6 +190,7 @@ public class CentralCompositionRootTests : IDisposable Assert.NotNull(service); } + /// <summary>Gets the service types expected to resolve as singletons on the central node.</summary> public static IEnumerable<object[]> CentralSingletonServices => new[] { new object[] { typeof(CommunicationService) }, @@ -194,12 +201,14 @@ public class CentralCompositionRootTests : IDisposable new object[] { typeof(OAuth2TokenService) }, new object[] { typeof(InboundScriptExecutor) }, // Security: the shared ZB.MOM.WW.Auth.Ldap service is registered as a stateless - // singleton by AddZbLdapAuth (Task 1.2), replacing the old scoped LdapAuthService. + // singleton by AddZbLdapAuth, replacing the old scoped LdapAuthService. new object[] { typeof(ILdapAuthService) }, }; // --- Scoped services --- + /// <summary>Verifies that the service resolves from a scope in the central composition root.</summary> + /// <param name="serviceType">The service type to resolve.</param> [Theory] [MemberData(nameof(CentralScopedServices))] public void Central_ResolveScoped(Type serviceType) @@ -209,6 +218,7 @@ public class CentralCompositionRootTests : IDisposable Assert.NotNull(service); } + /// <summary>Gets the service types expected to resolve as scoped services on the central node.</summary> public static IEnumerable<object[]> CentralScopedServices => new[] { // TemplateEngine @@ -235,7 +245,7 @@ public class CentralCompositionRootTests : IDisposable new object[] { typeof(IDatabaseGateway) }, // NotificationService — central-only SMTP primitives. The orphan // NotificationDeliveryService + INotificationDeliveryService were removed - // (NS-019) when sites stopped delivering notifications; the central + // when sites stopped delivering notifications; the central // EmailNotificationDeliveryAdapter is now the only resolver of these // primitives. new object[] { typeof(Func<ISmtpClientWrapper>) }, @@ -258,6 +268,8 @@ public class CentralCompositionRootTests : IDisposable // --- Transient services --- + /// <summary>Verifies that the service resolves as a transient from the central composition root.</summary> + /// <param name="serviceType">The service type to resolve.</param> [Theory] [MemberData(nameof(CentralTransientServices))] public void Central_ResolveTransient(Type serviceType) @@ -267,6 +279,7 @@ public class CentralCompositionRootTests : IDisposable Assert.NotNull(service); } + /// <summary>Gets the service types expected to resolve as transient services on the central node.</summary> public static IEnumerable<object[]> CentralTransientServices => new[] { new object[] { typeof(FlatteningService) }, @@ -279,6 +292,8 @@ public class CentralCompositionRootTests : IDisposable // --- Options --- + /// <summary>Verifies that the options type resolves from the central composition root.</summary> + /// <param name="optionsType">The options type to resolve.</param> [Theory] [MemberData(nameof(CentralOptions))] public void Central_ResolveOptions(Type optionsType) @@ -287,6 +302,7 @@ public class CentralCompositionRootTests : IDisposable Assert.NotNull(service); } + /// <summary>Gets the options types expected to be registered on the central node.</summary> public static IEnumerable<object[]> CentralOptions => new[] { new object[] { typeof(IOptions<NodeOptions>) }, @@ -304,6 +320,7 @@ public class CentralCompositionRootTests : IDisposable // --- Hosted services --- + /// <summary>Verifies that the central health aggregator is registered as a hosted service.</summary> [Fact] public void Central_CentralHealthAggregator_RegisteredAsHostedService() { @@ -311,10 +328,10 @@ public class CentralCompositionRootTests : IDisposable Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator)); } - // --- InboundAPI-022 regression --- + // --- Active-node gate regression --- /// <summary> - /// InboundAPI-022 regression: the Central composition root MUST register a + /// Regression test: the Central composition root MUST register a /// concrete <see cref="IActiveNodeGate"/> implementation. Without it, /// <see cref="InboundApiEndpointFilter"/> falls through to "allow" and a /// standby central node continues to serve the inbound API, racing the @@ -348,6 +365,7 @@ public class CentralCompositionRootTests : IDisposable /// the gap is invisible until the graph is actually built — the same defect class as the /// four caught in the secrets library itself. /// </summary> + /// <param name="serviceType">The Secrets.Ui component dependency type to resolve.</param> [Theory] [InlineData(typeof(ZB.MOM.WW.Secrets.Abstractions.ISecretStore))] [InlineData(typeof(ZB.MOM.WW.Secrets.Abstractions.ISecretCipher))] @@ -384,6 +402,7 @@ public class CentralCompositionRootTests : IDisposable Assert.Null(_factory.Services.GetService<IInboundApiKeyStoreImporter>()); } + /// <summary>Verifies that the shared audit seam resolves to the central direct-write bridge.</summary> [Fact] public void Central_SharedAuditSeam_IsTheCentralDirectWriteBridge() { @@ -508,11 +527,13 @@ public class CentralSqlServerKeyStoreCompositionRootTests : IDisposable _ = _factory.Server; } + /// <summary>Initializes the tests by booting the central composition root with the SQL Server key store provider.</summary> public CentralSqlServerKeyStoreCompositionRootTests() : this(explicitConnectionString: null) { } + /// <summary>Disposes the web application factory and restores the environment variables.</summary> public void Dispose() { try @@ -528,6 +549,9 @@ public class CentralSqlServerKeyStoreCompositionRootTests : IDisposable } } + /// <summary>Verifies that the SQL Server provider resolves the shared stores to their SQL Server implementations.</summary> + /// <param name="serviceType">The service type to resolve.</param> + /// <param name="expectedImplementation">The implementation type expected for the service.</param> [Theory] [InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStore), typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyStore))] @@ -583,6 +607,7 @@ public class CentralSqlServerKeyStoreCompositionRootTests : IDisposable public sealed class CentralSqlServerKeyStoreExplicitConnectionStringTests : CentralSqlServerKeyStoreCompositionRootTests { + /// <summary>Initializes the tests with an explicit key store connection string.</summary> public CentralSqlServerKeyStoreExplicitConnectionStringTests() : base(ExplicitConnectionString) { @@ -613,6 +638,7 @@ public class SiteCompositionRootTests : IDisposable private readonly string _tempTrackingDbPath; private readonly string _tempLocalDbPath; + /// <summary>Initializes the tests with temporary database paths for the site composition root.</summary> public SiteCompositionRootTests() { _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db"); @@ -655,6 +681,7 @@ public class SiteCompositionRootTests : IDisposable _host = builder.Build(); } + /// <summary>Disposes the host and deletes the temporary database files, ignoring failures.</summary> public void Dispose() { (_host as IDisposable)?.Dispose(); @@ -663,6 +690,7 @@ public class SiteCompositionRootTests : IDisposable try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ } } + /// <summary>Verifies that the operation tracking store resolves on a site node.</summary> [Fact] public void Site_Resolves_IOperationTrackingStore() { @@ -678,6 +706,8 @@ public class SiteCompositionRootTests : IDisposable // --- Singletons --- + /// <summary>Verifies that the service resolves as a singleton from the site composition root.</summary> + /// <param name="serviceType">The service type to resolve.</param> [Theory] [MemberData(nameof(SiteSingletonServices))] public void Site_ResolveSingleton(Type serviceType) @@ -686,6 +716,7 @@ public class SiteCompositionRootTests : IDisposable Assert.NotNull(service); } + /// <summary>Gets the service types expected to resolve as singletons on a site node.</summary> public static IEnumerable<object[]> SiteSingletonServices => new[] { new object[] { typeof(CommunicationService) }, @@ -703,15 +734,16 @@ public class SiteCompositionRootTests : IDisposable new object[] { typeof(IEventLogQueryService) }, new object[] { typeof(ISiteIdentityProvider) }, new object[] { typeof(IHealthReportTransport) }, - // M2.15 (#29): the active-node check must be registered on site nodes so - // EventLogPurge's log line can report whether this node is active (arch-review - // 2026-08-19 P4-4: it no longer gates which node purges — every node purges its - // own site_events file). + // The active-node check must be registered on site nodes so + // EventLogPurge's log line can report whether this node is active (it no + // longer gates which node purges — every node purges its own site_events file). new object[] { typeof(SiteEventLogActiveNodeCheck) }, }; // --- Scoped services --- + /// <summary>Verifies that the service resolves from a scope in the site composition root.</summary> + /// <param name="serviceType">The service type to resolve.</param> [Theory] [MemberData(nameof(SiteScopedServices))] public void Site_ResolveScoped(Type serviceType) @@ -721,11 +753,12 @@ public class SiteCompositionRootTests : IDisposable Assert.NotNull(service); } + /// <summary>Gets the service types expected to resolve as scoped services on a site node.</summary> public static IEnumerable<object[]> SiteScopedServices => new[] { new object[] { typeof(IExternalSystemRepository) }, // INotificationRepository is intentionally absent — notification config is - // central-only (arch-review 08 §1.3/#23); the site-side variant was removed. + // central-only; the site-side variant was removed. new object[] { typeof(ExternalSystemClient) }, new object[] { typeof(IExternalSystemClient) }, new object[] { typeof(DatabaseGateway) }, @@ -734,6 +767,7 @@ public class SiteCompositionRootTests : IDisposable // --- Implementation type assertions --- + /// <summary>Verifies that the external system repository resolves to the site implementation.</summary> [Fact] public void Site_ExternalSystemRepository_IsSiteImplementation() { @@ -742,6 +776,7 @@ public class SiteCompositionRootTests : IDisposable Assert.IsType<SiteExternalSystemRepository>(repo); } + /// <summary>Verifies that the notification repository is not registered on a site node.</summary> [Fact] public void Site_NotificationRepository_IsNotRegistered() { @@ -756,6 +791,8 @@ public class SiteCompositionRootTests : IDisposable // --- Options --- + /// <summary>Verifies that the options type resolves from the site composition root.</summary> + /// <param name="optionsType">The options type to resolve.</param> [Theory] [MemberData(nameof(SiteOptions))] public void Site_ResolveOptions(Type optionsType) @@ -764,6 +801,7 @@ public class SiteCompositionRootTests : IDisposable Assert.NotNull(service); } + /// <summary>Gets the options types expected to be registered on a site node.</summary> public static IEnumerable<object[]> SiteOptions => new[] { new object[] { typeof(IOptions<NodeOptions>) }, @@ -781,6 +819,8 @@ public class SiteCompositionRootTests : IDisposable // --- Hosted services --- + /// <summary>Verifies that the hosted service is registered on a site node.</summary> + /// <param name="expectedType">The hosted service type expected to be registered.</param> [Theory] [MemberData(nameof(SiteHostedServices))] public void Site_HostedServiceRegistered(Type expectedType) @@ -789,6 +829,7 @@ public class SiteCompositionRootTests : IDisposable Assert.Contains(hostedServices, s => s.GetType() == expectedType); } + /// <summary>Gets the hosted service types expected to be registered on a site node.</summary> public static IEnumerable<object[]> SiteHostedServices => new[] { new object[] { typeof(HealthReportSender) }, diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ConfigSecretsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ConfigSecretsTests.cs index 0dc510ad..c02c8efc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ConfigSecretsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ConfigSecretsTests.cs @@ -4,7 +4,7 @@ using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// Host-003 regression: secrets must not be committed in plaintext in the +/// Regression: secrets must not be committed in plaintext in the /// shipped <c>appsettings.Central.json</c>. Connection-string passwords, the LDAP /// service-account password and the JWT signing key must be supplied via /// environment variables (or another secret store) at deployment time — the @@ -35,6 +35,7 @@ public class ConfigSecretsTests return doc.RootElement.GetProperty("ScadaBridge").Clone(); } + /// <summary>Verifies that the central config's connection strings contain no plaintext password.</summary> [Fact] public void CentralConfig_ConnectionStrings_ContainNoPlaintextPassword() { @@ -58,10 +59,11 @@ public class ConfigSecretsTests } } + /// <summary>Verifies that the central config does not commit the LDAP service-account password.</summary> [Fact] public void CentralConfig_LdapServiceAccountPassword_IsNotCommitted() { - // Task 1.4 cutover: the LDAP service-account password moved out of the flat + // Cutover: the LDAP service-account password moved out of the flat // Security:LdapServiceAccountPassword key into the nested Security:Ldap // sub-section (Security:Ldap:ServiceAccountPassword), bound to the shared // ZB.MOM.WW.Auth LdapOptions. Walk into Security:Ldap and guard the nested @@ -78,6 +80,7 @@ public class ConfigSecretsTests } } + /// <summary>Verifies that the central config does not commit the JWT signing key.</summary> [Fact] public void CentralConfig_JwtSigningKey_IsNotCommitted() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthEndToEndTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthEndToEndTests.cs index b34bf836..5893d509 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthEndToEndTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthEndToEndTests.cs @@ -40,6 +40,7 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime private TestServer _server = null!; /// <summary>Boots the in-process gRPC host with the real interceptor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { _host = await new HostBuilder() @@ -69,7 +70,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime _server = _host.GetTestServer(); } - /// <inheritdoc /> + /// <summary>Stops and disposes the in-process gRPC host.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task DisposeAsync() { await _host.StopAsync(); @@ -93,7 +95,9 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime private sealed class FixedPskProvider(string key) : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key); + /// <inheritdoc /> public void Invalidate(string siteId) { } } @@ -103,6 +107,7 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime /// <summary>The site header the last accepted call carried.</summary> public string? LastSiteHeader { get; private set; } + /// <inheritdoc /> public override Task<PullAuditEventsResponse> PullAuditEvents( PullAuditEventsRequest request, ServerCallContext context) { @@ -111,6 +116,7 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime return Task.FromResult(new PullAuditEventsResponse { MoreAvailable = false }); } + /// <inheritdoc /> public override async Task SubscribeInstance( InstanceStreamRequest request, IServerStreamWriter<SiteStreamEvent> responseStream, @@ -125,6 +131,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime } } + /// <summary>Verifies that the control-plane channel can receive a worst-case audit-sized message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CM1_ControlPlaneChannelReceivesWorstCaseAuditSizedMessage() { @@ -135,6 +143,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime Assert.Equal(17 * 1024 * 1024, call.ResponseStream.Current.CorrelationId.Length); } + /// <summary>Verifies that a call carrying the correct key is accepted on a unary call.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CorrectKey_IsAccepted_OnAUnaryCall() { @@ -146,6 +156,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime Assert.False(reply.MoreAvailable); } + /// <summary>Verifies that a call carrying the wrong key is rejected with a permission-denied status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WrongKey_IsRejected_WithPermissionDenied() { @@ -158,6 +170,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a call with no credentials is rejected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoCredentialsAtAll_IsRejected() { @@ -171,6 +185,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that the credentials are applied to streaming calls as well as unary calls.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CredentialsApplyToStreamingCalls_NotJustUnaryOnes() { @@ -186,6 +202,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime Assert.Equal("c1", call.ResponseStream.Current.CorrelationId); } + /// <summary>Verifies that the wrong key is also rejected on streaming calls.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WrongKey_IsRejected_OnStreamingCallsToo() { @@ -200,6 +218,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a key that cannot be resolved fails the call instead of sending it without credentials.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AnUnresolvableKey_FailsTheCall_RatherThanDialingWithoutOne() { @@ -225,11 +245,15 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime private sealed class ThrowingPskProvider : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => throw new InvalidOperationException($"no key for '{siteId}'"); + /// <inheritdoc /> public void Invalidate(string siteId) { } } + /// <summary>Verifies that the site header is sent with the call so central can choose a per-site key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TheSiteHeaderTravels_SoCentralCanPickAPerSiteKeyInPhase1A() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthInterceptorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthInterceptorTests.cs index 6845a32c..c354a11d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthInterceptorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthInterceptorTests.cs @@ -52,22 +52,34 @@ public class ControlPlaneAuthInterceptorTests private sealed class FakeServerCallContext(string method, Metadata requestHeaders) : ServerCallContext { + /// <inheritdoc /> protected override string MethodCore => method; + /// <inheritdoc /> protected override string HostCore => "localhost"; + /// <inheritdoc /> protected override string PeerCore => "ipv4:127.0.0.1:12345"; + /// <inheritdoc /> protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); + /// <inheritdoc /> protected override Metadata RequestHeadersCore => requestHeaders; + /// <inheritdoc /> protected override CancellationToken CancellationTokenCore => CancellationToken.None; + /// <inheritdoc /> protected override Metadata ResponseTrailersCore { get; } = []; + /// <inheritdoc /> protected override Status StatusCore { get; set; } + /// <inheritdoc /> protected override WriteOptions? WriteOptionsCore { get; set; } + /// <inheritdoc /> protected override AuthContext AuthContextCore { get; } = new(null, new Dictionary<string, List<AuthProperty>>()); + /// <inheritdoc /> protected override ContextPropagationToken CreatePropagationTokenCore( ContextPropagationOptions? options) => throw new NotSupportedException(); + /// <inheritdoc /> protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; } @@ -78,6 +90,8 @@ public class ControlPlaneAuthInterceptorTests => interceptor.UnaryServerHandler<string, string>( "request", context, (_, _) => Task.FromResult("ok")); + /// <summary>Verifies that the LocalDb sync method passes through because it has its own gate and its own key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LocalDbSyncMethod_PassesThrough_BecauseItHasItsOwnGateAndItsOwnKey() { @@ -90,6 +104,8 @@ public class ControlPlaneAuthInterceptorTests Assert.Equal("ok", await Invoke(interceptor, context)); } + /// <summary>Verifies that a gated method is denied when no key is configured, even with a bearer token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GatedMethod_WithNoKeyConfigured_IsDenied_EvenWithABearerToken() { @@ -103,6 +119,8 @@ public class ControlPlaneAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a gated method is denied when no bearer token is supplied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GatedMethod_WithNoBearerToken_IsDenied() { @@ -113,6 +131,8 @@ public class ControlPlaneAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a gated method is denied when the bearer token is wrong.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GatedMethod_WithWrongBearerToken_IsDenied() { @@ -123,6 +143,8 @@ public class ControlPlaneAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a gated method passes through when the bearer token is correct.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GatedMethod_WithCorrectBearerToken_PassesThrough() { @@ -132,6 +154,8 @@ public class ControlPlaneAuthInterceptorTests Assert.Equal("ok", await Invoke(interceptor, context)); } + /// <summary>Verifies that a gated method is denied when the correct key is sent without the Bearer scheme.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GatedMethod_WithCorrectKey_ButNoBearerScheme_IsDenied() { @@ -144,6 +168,8 @@ public class ControlPlaneAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that token comparison requires an exact match rather than a prefix match.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GatedMethod_TokenComparison_IsNotAPrefixMatch() { @@ -156,6 +182,8 @@ public class ControlPlaneAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that server-streaming calls are gated, since that is how subscriptions actually run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ServerStreaming_IsGated_BecauseThatIsHowSubscriptionsActuallyRun() { @@ -174,6 +202,8 @@ public class ControlPlaneAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that the pull RPCs are gated because they return audit rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PullRpcs_AreGated_BecauseTheyReturnAuditRows() { @@ -187,6 +217,8 @@ public class ControlPlaneAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that the gated prefixes are supplied through the constructor, so later phases add services rather than interceptors.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GatedPrefixes_AreConstructorProvided_SoLaterPhasesAddServicesNotInterceptors() { @@ -205,6 +237,7 @@ public class ControlPlaneAuthInterceptorTests Assert.Equal("ok", await Invoke(interceptor, notGated)); } + /// <summary>Verifies that the interceptor exposes exactly one public constructor.</summary> [Fact] public void TheInterceptorHasExactlyOnePublicConstructor() { @@ -221,6 +254,7 @@ public class ControlPlaneAuthInterceptorTests Assert.Single(publicCtors); } + /// <summary>Verifies that the default gated prefixes match the real site stream service path.</summary> [Fact] public void DefaultGatedPrefixes_MatchTheRealSiteStreamServicePath() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CoordinatedShutdownTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CoordinatedShutdownTests.cs index 2dd5adbf..32dbb286 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CoordinatedShutdownTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CoordinatedShutdownTests.cs @@ -3,11 +3,12 @@ using System.Reflection; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// WP-16: Tests for CoordinatedShutdown configuration. +/// Tests for CoordinatedShutdown configuration. /// Verifies no Environment.Exit calls exist in source and HOCON config is correct. /// </summary> public class CoordinatedShutdownTests { + /// <summary>Verifies that host source does not contain environment exit.</summary> [Fact] public void HostSource_DoesNotContainEnvironmentExit() { @@ -48,6 +49,7 @@ public class CoordinatedShutdownTests } } + /// <summary>Verifies that akka hosted service hocon config includes coordinated shutdown settings.</summary> [Fact] public void AkkaHostedService_HoconConfig_IncludesCoordinatedShutdownSettings() { @@ -65,6 +67,7 @@ public class CoordinatedShutdownTests Assert.Contains("run-coordinated-shutdown-when-down = on", content); } + /// <summary>Verifies that all central singletons register through registrar with drain.</summary> [Fact] public void AllCentralSingletons_RegisterThroughRegistrarWithDrain() { @@ -91,6 +94,7 @@ public class CoordinatedShutdownTests Assert.Equal(0, CountOccurrences(content, "ClusterSingletonManager.Props(")); } + /// <summary>Verifies that site singletons register through registrar with drain.</summary> [Fact] public void SiteSingletons_RegisterThroughRegistrarWithDrain() { @@ -98,7 +102,7 @@ public class CoordinatedShutdownTests Assert.NotNull(hostProjectDir); var content = File.ReadAllText(Path.Combine(hostProjectDir!, "Actors", "AkkaHostedService.cs")); - // Round-2 N5: the two role-scoped SITE singletons previously stayed + // The two role-scoped SITE singletons previously stayed // hand-rolled (bare PoisonPill, no PhaseClusterLeave drain). They now // go through SingletonRegistrar.Start(..., role: siteRole), which // always adds the GracefulStop drain — in-flight SQLite writes diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/DeadLetterMonitorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/DeadLetterMonitorTests.cs index 00ed2ae8..b05745ba 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/DeadLetterMonitorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/DeadLetterMonitorTests.cs @@ -10,13 +10,14 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// WP-15: Tests for DeadLetterMonitorActor. +/// Tests for DeadLetterMonitorActor. /// </summary> public class DeadLetterMonitorTests : TestKit { private readonly ILogger<DeadLetterMonitorActor> _logger = NullLoggerFactory.Instance.CreateLogger<DeadLetterMonitorActor>(); + /// <summary>Verifies that a new dead-letter monitor starts with a count of zero.</summary> [Fact] public void DeadLetterMonitor_StartsWithZeroCount() { @@ -28,6 +29,7 @@ public class DeadLetterMonitorTests : TestKit Assert.Equal(0, response.Count); } + /// <summary>Verifies that the monitor count increments when a dead letter is published.</summary> [Fact] public void DeadLetterMonitor_IncrementsOnDeadLetter() { @@ -50,6 +52,8 @@ public class DeadLetterMonitorTests : TestKit }); } + /// <summary>Verifies that a dead-letter storm logs at most the per-window warning limit while still counting every dead letter.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeadLetterStorm_LogsAtMostWindowLimit_ButCountsAll() { @@ -77,6 +81,7 @@ public class DeadLetterMonitorTests : TestKit $"expected <= {DeadLetterMonitorActor.MaxWarningsPerWindow + 1} warnings, got {warnings}"); } + /// <summary>Verifies that the dead-letter count accumulates across multiple dead letters.</summary> [Fact] public void DeadLetterMonitor_CountAccumulates() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GrpcCentralTransportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GrpcCentralTransportTests.cs index 981c2cd9..1606b1a6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GrpcCentralTransportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GrpcCentralTransportTests.cs @@ -38,7 +38,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime private CentralNode _nodeA = null!; private CentralNode _nodeB = null!; - /// <inheritdoc /> + /// <summary>Starts the actor system and two in-process central nodes serving the same site key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { _system = ActorSystem.Create("grpc-central-transport-test"); @@ -46,7 +47,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime _nodeB = await CentralNode.StartAsync(_system, "B", SiteA, SiteAKey, repliesToSubmit: true); } - /// <inheritdoc /> + /// <summary>Disposes both central nodes and terminates the actor system.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task DisposeAsync() { await _nodeA.DisposeAsync(); @@ -81,6 +83,7 @@ public class GrpcCentralTransportTests : IAsyncLifetime private GrpcCentralTransport NewTransport(CentralChannelProvider provider, CommunicationOptions? options = null) => new(provider, options ?? new CommunicationOptions(), NullLogger<GrpcCentralTransport>.Instance); + /// <summary>Verifies that a reconcile whose response is lost after it was applied fails as outcome-unknown instead of being re-sent to the peer.</summary> [Fact] public void ReconcileAppliedThenResponseLost_DoesNotMintAgainOnPeer() { @@ -96,6 +99,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime Assert.Equal(0, _nodeB.ReconcileCount); } + /// <summary>Verifies that a submit reaches the preferred node and the ack is routed back to the caller.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HappyPath_ReachesThePreferredNode_AndRoutesTheAckBack() { @@ -113,6 +118,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime Assert.Equal(0, _nodeB.SubmitCount); } + /// <summary>Verifies that the transport stays on the preferred node while it remains healthy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sticky_StaysOnThePreferredNode_WhileHealthy() { @@ -131,6 +138,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime Assert.Equal(0, _nodeB.SubmitCount); } + /// <summary>Verifies that the transport fails over to the peer when the preferred node is unavailable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Failover_FlipsToThePeer_WhenThePreferredIsUnavailable() { @@ -148,6 +157,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime Assert.Equal(1, _nodeB.SubmitCount); } + /// <summary>Verifies that the transport returns to the preferred node once it is reachable again.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Failback_ReturnsToThePreferred_OnceItIsReachableAgain() { @@ -173,6 +184,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime Assert.True(_nodeA.SubmitCount >= 1); } + /// <summary>Verifies that the pre-shared key and site header are attached so the gated call reaches the service.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PskAndSiteHeader_AreAttached_SoTheGatedCallReachesTheService() { @@ -188,6 +201,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime Assert.True(ack.Accepted); } + /// <summary>Verifies that a wrong pre-shared key is rejected and not retried on the peer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WrongPsk_IsRejected_AndNotRetriedOnThePeer() { @@ -204,6 +219,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime Assert.Equal(0, _nodeB.SubmitCount); // peer never tried } + /// <summary>Verifies that a deadline-exceeded failure is not retried on the peer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeadlineExceeded_IsNotRetriedOnThePeer() { @@ -231,6 +248,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime Assert.Equal(0, _nodeB.SubmitCount); // peer never tried } + /// <summary>Verifies that a black-holed node does not hang the caller because a per-call deadline is applied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BlackHoledNode_DoesNotHang_APerCallDeadlineIsApplied() { @@ -298,13 +317,19 @@ public class GrpcCentralTransportTests : IAsyncLifetime { private readonly BlockingCollection<object> _messages = new(); + /// <summary>Initializes a new instance of the <see cref="Capture"/> class and starts its capture actor.</summary> + /// <param name="system">The actor system that hosts the capture actor.</param> public Capture(ActorSystem system) { Ref = system.ActorOf(Props.Create(() => new CaptureActor(_messages))); } + /// <summary>Gets the actor reference that tests pass as the reply target.</summary> public IActorRef Ref { get; } + /// <summary>Waits for the next captured message.</summary> + /// <param name="timeout">The maximum time to wait for a message.</param> + /// <returns>The next captured message.</returns> public object Receive(TimeSpan timeout) => _messages.TryTake(out var message, timeout) ? message @@ -312,6 +337,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime private sealed class CaptureActor : ReceiveActor { + /// <summary>Initializes a new instance of the <see cref="CaptureActor"/> class that forwards every message to the collection.</summary> + /// <param name="messages">The collection that receives every message.</param> public CaptureActor(BlockingCollection<object> messages) => ReceiveAny(messages.Add); } } @@ -329,12 +356,16 @@ public class GrpcCentralTransportTests : IAsyncLifetime { private readonly Func<Grpc.Core.StatusCode?> _forced; + /// <summary>Initializes a new instance of the <see cref="GrpcStatusHandler"/> class.</summary> + /// <param name="inner">The handler to delegate to when no status is forced.</param> + /// <param name="forced">Returns the gRPC status to force, or null to pass through.</param> public GrpcStatusHandler(HttpMessageHandler inner, Func<Grpc.Core.StatusCode?> forced) { InnerHandler = inner; _forced = forced; } + /// <inheritdoc /> protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { @@ -362,6 +393,7 @@ public class GrpcCentralTransportTests : IAsyncLifetime /// <summary>A gRPC channel handler that throws (a refused connection) while its node is "down".</summary> private sealed class LostReconcileResponseHandler(HttpMessageHandler inner, Func<bool> lose) : DelegatingHandler(inner) { + /// <inheritdoc /> protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) { var response = await base.SendAsync(request, ct); @@ -379,12 +411,16 @@ public class GrpcCentralTransportTests : IAsyncLifetime { private readonly Func<bool> _isUp; + /// <summary>Initializes a new instance of the <see cref="ToggleHandler"/> class.</summary> + /// <param name="inner">The handler to delegate to while the endpoint is up.</param> + /// <param name="isUp">Returns whether the endpoint should currently appear up.</param> public ToggleHandler(HttpMessageHandler inner, Func<bool> isUp) { InnerHandler = inner; _isUp = isUp; } + /// <inheritdoc /> protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (!_isUp()) @@ -398,9 +434,11 @@ public class GrpcCentralTransportTests : IAsyncLifetime private sealed class FixedPskProvider(string? key) : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => key is null ? throw new InvalidOperationException("no key") : new ValueTask<string>(key); + /// <inheritdoc /> public void Invalidate(string siteId) { } } @@ -411,11 +449,21 @@ public class GrpcCentralTransportTests : IAsyncLifetime private IActorRef _stub = null!; private readonly StubCounters _counters = new(); + /// <summary>Gets the in-process test server hosting the central control service.</summary> public TestServer Server { get; private set; } = null!; public volatile bool IsUp = true; + /// <summary>Gets the number of notification submits the stub actor has received.</summary> public int SubmitCount => _counters.Submits; + /// <summary>Gets the number of reconcile requests the stub actor has received.</summary> public int ReconcileCount => Volatile.Read(ref _counters.Reconciles); + /// <summary>Starts an in-process central node with a stub actor behind the gRPC control service.</summary> + /// <param name="system">The actor system that hosts the stub actor.</param> + /// <param name="label">A label that makes the stub actor name unique.</param> + /// <param name="site">The site id the node accepts.</param> + /// <param name="key">The pre-shared key for that site.</param> + /// <param name="repliesToSubmit">Whether the stub acknowledges notification submits.</param> + /// <returns>The started central node.</returns> public static async Task<CentralNode> StartAsync( ActorSystem system, string label, string site, string key, bool repliesToSubmit) { @@ -453,6 +501,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime /// <summary>Switches the node's actor to a black hole that counts but never replies.</summary> public void SetBlackHole() => _counters.BlackHole = true; + /// <summary>Releases the resources held by this test class asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async ValueTask DisposeAsync() { await _host.StopAsync(); @@ -463,13 +513,18 @@ public class GrpcCentralTransportTests : IAsyncLifetime { private int _submits; public int Reconciles; + /// <summary>Gets the number of notification submits received.</summary> public int Submits => Volatile.Read(ref _submits); + /// <summary>Records one received notification submit.</summary> public void IncrementSubmits() => Interlocked.Increment(ref _submits); public volatile bool BlackHole; } private sealed class StubCentralActor : ReceiveActor { + /// <summary>Initializes a new instance of the <see cref="StubCentralActor"/> class.</summary> + /// <param name="counters">The counters the actor increments as it receives messages.</param> + /// <param name="repliesToSubmit">Whether the actor replies to submit requests.</param> public StubCentralActor(StubCounters counters, bool repliesToSubmit) { Receive<NotificationSubmit>(msg => @@ -494,11 +549,13 @@ public class GrpcCentralTransportTests : IAsyncLifetime private sealed class MapPskProvider(IReadOnlyDictionary<string, string> keys) : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => keys.TryGetValue(siteId, out var key) ? new ValueTask<string>(key) : throw new InvalidOperationException($"no key for '{siteId}'"); + /// <inheritdoc /> public void Invalidate(string siteId) { } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GrpcSiteTransportFailoverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GrpcSiteTransportFailoverTests.cs index 122eb31c..f0539e60 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GrpcSiteTransportFailoverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GrpcSiteTransportFailoverTests.cs @@ -33,7 +33,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime private SitePairChannelProvider _provider = null!; private volatile bool _preferredReachable; - /// <inheritdoc /> + /// <summary>Starts the stub servers and builds the channel provider under test.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { (_hostA, _stubA) = await StartServerAsync(); @@ -52,7 +53,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime _provider.UpdateSite(SiteId, EndpointA, EndpointB); } - /// <inheritdoc /> + /// <summary>Stops the stub servers and disposes the channel provider after the tests.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task DisposeAsync() { _provider.Dispose(); @@ -99,6 +101,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime }, CancellationToken.None); + /// <summary>Verifies the case: healthy Call, hits Preferred Node A, with Psk And Site Header And Deadline.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HealthyCall_HitsPreferredNodeA_WithPskAndSiteHeaderAndDeadline() { @@ -113,6 +117,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime Assert.True(_provider.IsOnPreferredNode(SiteId)); } + /// <summary>Verifies the case: not Dispatched, on Node A, fails Over To Node B, and Stays Sticky.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NotDispatched_OnNodeA_FailsOverToNodeB_AndStaysSticky() { @@ -132,6 +138,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime Assert.Equal(2, _stubB.LifecycleCalls); } + /// <summary>Verifies the case: failback, returns To Preferred Node A, once Preferred Is Reachable Again.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Failback_ReturnsToPreferredNodeA_OncePreferredIsReachableAgain() { @@ -152,6 +160,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime Assert.Equal(beforeA + 1, _stubA.LifecycleCalls); // next call back on A } + /// <summary>Verifies the case: deadline Exceeded, on Node A, is Not Retried On Node B.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeadlineExceeded_OnNodeA_IsNotRetriedOnNodeB() { @@ -168,6 +178,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime Assert.True(_provider.IsOnPreferredNode(SiteId), "a deadline must not flip stickiness"); } + /// <summary>Verifies the case: ambiguous Unavailable, does Not Replay Lifecycle On Peer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AmbiguousUnavailable_DoesNotReplayLifecycleOnPeer() { @@ -181,6 +193,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime Assert.Equal(0, _stubB.LifecycleCalls); } + /// <summary>Verifies the case: unknown Site, throws, site Channel Unavailable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UnknownSite_Throws_SiteChannelUnavailable() { @@ -191,7 +205,9 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime private sealed class FixedPskProvider(string key) : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key); + /// <inheritdoc /> public void Invalidate(string siteId) { } } @@ -199,13 +215,18 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime private sealed class StubSiteCommandService : SiteCommandService.SiteCommandServiceBase { private int _lifecycleCalls; + /// <summary>Gets the number of lifecycle calls received.</summary> public int LifecycleCalls => Volatile.Read(ref _lifecycleCalls); + /// <summary>Gets the authorization header of the last call received.</summary> public string? LastAuthHeader { get; private set; } + /// <summary>Gets the site header of the last call received.</summary> public string? LastSiteHeader { get; private set; } + /// <summary>Gets a value indicating whether the last call carried a deadline.</summary> public bool DeadlineWasSet { get; private set; } public volatile bool RejectBeforeDispatch; public StatusCode? StatusToThrow; + /// <inheritdoc /> public override Task<LifecycleReply> ExecuteLifecycle(LifecycleRequest request, ServerCallContext context) { Interlocked.Increment(ref _lifecycleCalls); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HealthCheckTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HealthCheckTests.cs index b73843f7..76c3ec15 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HealthCheckTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HealthCheckTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Health; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// WP-12: Tests for the three-tier health endpoints after adopting the shared +/// Tests for the three-tier health endpoints after adopting the shared /// ZB.MOM.WW.Health probes. Verifies that /health/ready, /health/active and the new /// /healthz tier are mapped, and that the readiness/active tier split is now carried by /// the canonical <see cref="ZbHealthTags"/> (Ready for database + akka-cluster, Active for @@ -23,12 +23,14 @@ public class HealthCheckTests : IDisposable { private readonly List<IDisposable> _disposables = new(); + /// <summary>Initializes the test class and registers the central database test environment.</summary> public HealthCheckTests() { - // Host-003: connection strings are externalised; supply them via env vars. + // Connection strings are externalised; supply them via env vars. _disposables.Add(new CentralDbTestEnvironment()); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { foreach (var d in _disposables) @@ -64,6 +66,8 @@ public class HealthCheckTests : IDisposable private static IEnumerable<HealthCheckRegistration> Registrations(WebApplicationFactory<Program> factory) => factory.Services.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations; + /// <summary>Verifies that the /health/ready endpoint is mapped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HealthReady_Endpoint_IsMapped() { @@ -92,6 +96,8 @@ public class HealthCheckTests : IDisposable } } + /// <summary>Verifies that the /health/active endpoint is mapped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HealthActive_Endpoint_IsMapped() { @@ -117,6 +123,8 @@ public class HealthCheckTests : IDisposable } } + /// <summary>Verifies that the /healthz liveness endpoint is mapped and returns 200.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Healthz_LivenessEndpoint_IsMappedAndReturns200() { @@ -140,10 +148,11 @@ public class HealthCheckTests : IDisposable } } + /// <summary>Verifies that the Ready tier carries the database and akka-cluster checks.</summary> [Fact] public void ReadyTier_Carries_Database_And_AkkaCluster() { - // Host-001 regression guard: readiness reflects cluster membership + DB connectivity + // Regression guard: readiness reflects cluster membership + DB connectivity // only (REQ-HOST-4a), NOT cluster leadership. The split is now carried by the Ready tag // rather than a check-name predicate: database + akka-cluster are Ready-tagged, and the // leader-only active-node check is NOT — so a fully operational standby central node @@ -162,7 +171,7 @@ public class HealthCheckTests : IDisposable Assert.Contains(ZbHealthTags.Ready, registrations["database"].Tags); Assert.Contains(ZbHealthTags.Ready, registrations["akka-cluster"].Tags); - // M2.14 (#28): readiness ALSO reflects "required cluster singletons running" + // Readiness ALSO reflects "required cluster singletons running" // (REQ-HOST-4a). The Central-only required-singletons check is Ready-tagged so // it gates /health/ready alongside database + akka-cluster, but is leadership- // agnostic (it does NOT carry the Active tag), so a ready standby stays ready. @@ -180,6 +189,7 @@ public class HealthCheckTests : IDisposable } } + /// <summary>Verifies that the Active tier carries the active-node and database checks.</summary> [Fact] public void ActiveTier_Carries_ActiveNode_And_Database() { @@ -207,6 +217,7 @@ public class HealthCheckTests : IDisposable } } + /// <summary>Verifies that the active-node check is the shared oldest-Up-member check.</summary> [Fact] public void ActiveNodeCheck_IsTheSharedOldestUpMemberCheck() { @@ -233,6 +244,7 @@ public class HealthCheckTests : IDisposable } } + /// <summary>Verifies that the database check also gates the Active tier.</summary> [Fact] public void DatabaseCheck_AlsoGatesActive() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HoconBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HoconBuilderTests.cs index b95c22db..c7852764 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HoconBuilderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HoconBuilderTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// Host-006: the Akka HOCON document must be assembled with every interpolated value +/// The Akka HOCON document must be assembled with every interpolated value /// quoted/escaped, so a hostname, seed-node URI or split-brain strategy containing a /// quote, backslash or whitespace cannot corrupt the document or be silently /// misparsed. @@ -26,6 +26,7 @@ public class HoconBuilderTests FailureDetectionThreshold = TimeSpan.FromSeconds(10), }; + /// <summary>Verifies that the cluster leave phase timeout exceeds the drain budget.</summary> [Fact] public void BuildHocon_ClusterLeavePhaseTimeout_ExceedsDrainBudget() { @@ -43,6 +44,7 @@ public class HoconBuilderTests $"cluster-leave phase timeout ({phaseTimeout}) must exceed the 10s singleton GracefulStop budget"); } + /// <summary>Verifies that plain values produce a parseable document that preserves them.</summary> [Fact] public void BuildHocon_PlainValues_ParsesAndPreservesValues() { @@ -62,6 +64,7 @@ public class HoconBuilderTests Assert.Equal("keep-oldest", config.GetString("akka.cluster.split-brain-resolver.active-strategy")); } + /// <summary>Verifies that a hostname containing a quote does not corrupt the HOCON document.</summary> [Fact] public void BuildHocon_HostnameWithQuote_DoesNotCorruptDocument() { @@ -85,6 +88,7 @@ public class HoconBuilderTests Assert.Equal(1, config.GetInt("akka.cluster.min-nr-of-members")); } + /// <summary>Verifies that a strategy value containing whitespace remains quoted.</summary> [Fact] public void BuildHocon_StrategyWithWhitespace_RemainsQuoted() { @@ -112,10 +116,11 @@ public class HoconBuilderTests RemotingPort = 8081, }; + /// <summary>Verifies that setting DownIfAlone to true emits the on token.</summary> [Fact] public void BuildHocon_DownIfAloneTrue_EmitsOn() { - // Host-012: BuildHocon must consume ClusterOptions.DownIfAlone, not + // BuildHocon must consume ClusterOptions.DownIfAlone, not // hard-code the down-if-alone token. var cluster = DefaultCluster(); cluster.DownIfAlone = true; @@ -129,10 +134,11 @@ public class HoconBuilderTests "akka.cluster.split-brain-resolver.keep-oldest.down-if-alone")); } + /// <summary>Verifies that setting DownIfAlone to false emits the off token.</summary> [Fact] public void BuildHocon_DownIfAloneFalse_EmitsOff() { - // Host-012: setting DownIfAlone=false must actually flip the emitted token. + // Setting DownIfAlone=false must actually flip the emitted token. var cluster = DefaultCluster(); cluster.DownIfAlone = false; @@ -145,10 +151,11 @@ public class HoconBuilderTests "akka.cluster.split-brain-resolver.keep-oldest.down-if-alone")); } + /// <summary>Verifies that sub-second timings are preserved without rounding.</summary> [Fact] public void BuildHocon_SubSecondTimings_PreservedWithoutRounding() { - // Host-013: cluster timing values below one second must not be rounded to + // Cluster timing values below one second must not be rounded to // whole seconds. A 750ms heartbeat must survive as 750ms, not collapse to // 1s — and a 500ms value must not collapse to a degenerate 0s. var cluster = DefaultCluster(); @@ -178,6 +185,7 @@ public class HoconBuilderTests config.GetTimeSpan("akka.remote.transport-failure-detector.acceptable-heartbeat-pause")); } + /// <summary>Verifies that the document enables the split-brain resolver downing provider.</summary> [Fact] public void BuildHocon_EnablesSplitBrainResolverDowningProvider() { @@ -196,6 +204,7 @@ public class HoconBuilderTests config.GetString("akka.cluster.downing-provider-class")); } + /// <summary>Verifies that the auto-down strategy emits the auto-downing provider.</summary> [Fact] public void BuildHocon_AutoDownStrategy_EmitsAutoDowningProvider() { @@ -222,6 +231,7 @@ public class HoconBuilderTests Assert.False(config.HasPath("akka.cluster.split-brain-resolver.active-strategy")); } + /// <summary>Verifies that the auto-down strategy name is case-insensitive and the document stays intact.</summary> [Fact] public void BuildHocon_AutoDownStrategy_IsCaseInsensitive_AndDocumentStaysIntact() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HostStartupTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HostStartupTests.cs index 05a43234..57e7995b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HostStartupTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HostStartupTests.cs @@ -14,6 +14,7 @@ public class HostStartupTests : IDisposable { private readonly List<IDisposable> _disposables = new(); + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { foreach (var d in _disposables) @@ -22,6 +23,7 @@ public class HostStartupTests : IDisposable } } + /// <summary>Verifies that the Central role starts without error.</summary> [Fact] public void CentralRole_StartsWithoutError() { @@ -29,7 +31,7 @@ public class HostStartupTests : IDisposable // Set the environment to Central so appsettings.Central.json is loaded, // and set DOTNET_ENVIRONMENT before the factory creates the host. var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); - // Host-003: connection strings are externalised; supply them via env vars. + // Connection strings are externalised; supply them via env vars. using var dbEnv = new CentralDbTestEnvironment(); try { @@ -68,6 +70,7 @@ public class HostStartupTests : IDisposable } } + /// <summary>Verifies that the Site role starts without error.</summary> [Fact] public void SiteRole_StartsWithoutError() { @@ -98,6 +101,7 @@ public class HostStartupTests : IDisposable Assert.NotNull(app.Services); } + /// <summary>Verifies that the Site role configures Kestrel for gRPC.</summary> [Fact] public void SiteRole_ConfiguresKestrelForGrpc() { @@ -143,6 +147,7 @@ public class HostStartupTests : IDisposable (app as IDisposable)?.Dispose(); } + /// <summary>Verifies that the Host project does not use conditional compilation.</summary> [Fact] public void HostProject_DoesNotUseConditionalCompilation() { @@ -165,6 +170,7 @@ public class HostStartupTests : IDisposable } } + /// <summary>Verifies that the program registers an unhandled-exception exit handler.</summary> [Fact] public void Program_RegistersUnhandledExceptionExitHandler() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InProcessScriptArtifactChangeBusTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InProcessScriptArtifactChangeBusTests.cs index 44e705ef..b6e9c86c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InProcessScriptArtifactChangeBusTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InProcessScriptArtifactChangeBusTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Services; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// #05-T14: unit tests for the in-process script-artifact change bus — delivery, +/// Unit tests for the in-process script-artifact change bus — delivery, /// unsubscribe, and the swallow-and-log guard that keeps a faulting subscriber from /// faulting the producer. /// </summary> @@ -17,6 +17,7 @@ public sealed class InProcessScriptArtifactChangeBusTests private static ScriptArtifactsChanged Sample(string kind = ScriptArtifactKinds.ApiMethod) => new(kind, new[] { "M1" }, "Management", DateTimeOffset.UnixEpoch); + /// <summary>Verifies that publish delivers to all current subscribers.</summary> [Fact] public void Publish_delivers_to_all_current_subscribers() { @@ -33,6 +34,7 @@ public sealed class InProcessScriptArtifactChangeBusTests Assert.Same(n, Assert.Single(b)); } + /// <summary>Verifies that disposing subscription stops delivery.</summary> [Fact] public void Disposing_subscription_stops_delivery() { @@ -47,6 +49,7 @@ public sealed class InProcessScriptArtifactChangeBusTests Assert.Single(received); // only the pre-dispose publish landed. } + /// <summary>Verifies that dispose is idempotent.</summary> [Fact] public void Dispose_is_idempotent() { @@ -61,6 +64,7 @@ public sealed class InProcessScriptArtifactChangeBusTests Assert.Empty(received); } + /// <summary>Verifies that a faulting subscriber does not prevent delivery to others nor throw.</summary> [Fact] public void A_faulting_subscriber_does_not_prevent_delivery_to_others_nor_throw() { @@ -76,6 +80,7 @@ public sealed class InProcessScriptArtifactChangeBusTests Assert.Single(good); } + /// <summary>Verifies that publish with no subscribers is a noop.</summary> [Fact] public void Publish_with_no_subscribers_is_a_noop() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreImportServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreImportServiceTests.cs index 4b2e5f61..eed838a2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreImportServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreImportServiceTests.cs @@ -17,12 +17,14 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable { private readonly string _dir; + /// <summary>Initializes the tests with a fresh temporary directory.</summary> public InboundApiKeyStoreImportServiceTests() { _dir = Path.Combine(Path.GetTempPath(), $"sb_apikey_import_{Guid.NewGuid():N}"); Directory.CreateDirectory(_dir); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { try @@ -35,6 +37,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable } } + /// <summary>Verifies that a present file is imported and then renamed along with its sidecar files.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_FilePresent_ImportsThenRenamesFileAndSidecars() { @@ -56,6 +60,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable Assert.True(File.Exists(path + "-shm.imported")); } + /// <summary>Verifies that the importer is not called when the file is absent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_FileAbsent_DoesNotCallImporter() { @@ -67,6 +73,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable Assert.Empty(importer.Calls); } + /// <summary>Verifies that the importer is not called when the configured path is blank.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_BlankPath_DoesNotCallImporter() { @@ -78,6 +86,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable Assert.Empty(importer.Calls); } + /// <summary>Verifies that the import-from path overrides the path in the API key options.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_ImportFromSqlitePath_OverridesApiKeyOptionsPath() { @@ -94,6 +104,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable Assert.True(File.Exists(overridePath + ".imported")); } + /// <summary>Verifies that an importer failure does not throw and leaves the file in place.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_ImporterThrows_DoesNotThrowAndLeavesFileInPlace() { @@ -109,6 +121,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable Assert.False(File.Exists(path + ".imported")); } + /// <summary>Verifies that a primary key violation is retried once and then succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_PrimaryKeyViolation_RetriesOnceThenSucceeds() { @@ -126,6 +140,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable Assert.True(File.Exists(path + ".imported")); } + /// <summary>Verifies that two consecutive primary key violations do not throw and leave the file in place.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_PrimaryKeyViolationTwice_DoesNotThrowAndLeavesFileInPlace() { @@ -143,6 +159,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable Assert.True(File.Exists(path)); } + /// <summary>Verifies that a SQL exception other than a primary key violation is not retried.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_NonPrimaryKeySqlException_DoesNotRetry() { @@ -160,6 +178,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable Assert.True(File.Exists(path)); } + /// <summary>Verifies that a stale previously imported file is overwritten by the rename.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_StaleImportedFilePresent_IsOverwritten() { @@ -175,6 +195,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable Assert.Equal("fresh", File.ReadAllText(path + ".imported")); } + /// <summary>Verifies that stopping the service does nothing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StopAsync_IsANoOp() { @@ -211,11 +233,13 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable { private int _calls; + /// <summary>Gets the SQLite paths the importer was called with, in call order.</summary> public List<string> Calls { get; } = new(); /// <summary>Given the 1-based call number, returns the exception to throw, or null to succeed.</summary> public Func<int, Exception?>? ThrowOnCall { get; init; } + /// <inheritdoc /> public Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct) { Calls.Add(sqlitePath); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreOptionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreOptionsTests.cs index 0c4c5bef..9df03553 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreOptionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreOptionsTests.cs @@ -11,6 +11,7 @@ public class InboundApiKeyStoreOptionsTests .Build(); } + /// <summary>Verifies that the key store provider defaults to SQLite.</summary> [Fact] public void Provider_DefaultsToSqlite() { @@ -21,6 +22,9 @@ public class InboundApiKeyStoreOptionsTests Assert.Equal(InboundApiKeyStoreProvider.Sqlite, InboundApiKeyStoreOptions.ReadProvider(configuration)); } + /// <summary>Verifies that the provider name is parsed case-insensitively.</summary> + /// <param name="value">The configured provider text.</param> + /// <param name="expected">The provider the text should resolve to.</param> [Theory] [InlineData("sqlserver", InboundApiKeyStoreProvider.SqlServer)] [InlineData("SQLSERVER", InboundApiKeyStoreProvider.SqlServer)] @@ -36,6 +40,8 @@ public class InboundApiKeyStoreOptionsTests Assert.Equal(expected, InboundApiKeyStoreOptions.ReadProvider(configuration)); } + /// <summary>Verifies that an unknown provider value is rejected.</summary> + /// <param name="value">The unsupported provider text.</param> [Theory] [InlineData("Postgres")] [InlineData("7")] @@ -52,6 +58,7 @@ public class InboundApiKeyStoreOptionsTests Assert.Contains("SqlServer", ex.Message); } + /// <summary>Verifies that the import-from-SQLite path setting binds from configuration.</summary> [Fact] public void Binds_ImportFromSqlitePath() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundBodyLimitKestrelTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundBodyLimitKestrelTests.cs index 42dd08a4..9f3883ea 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundBodyLimitKestrelTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundBodyLimitKestrelTests.cs @@ -17,6 +17,11 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; public class InboundBodyLimitKestrelTests { + /// <summary>Verifies that an over-limit request body is rejected by the Kestrel cap regardless of audit body-capture settings.</summary> + /// <param name="http2">Whether the request uses HTTP/2 instead of HTTP/1.1.</param> + /// <param name="skipCapture">Whether the audit per-target override skips body capture.</param> + /// <param name="declared">Whether the request declares its content length.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false, false, false)] [InlineData(false, true, false)] @@ -55,7 +60,9 @@ public class InboundBodyLimitKestrelTests private sealed class UnknownLengthContent(byte[] bytes) : HttpContent { + /// <inheritdoc /> protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => stream.WriteAsync(bytes).AsTask(); + /// <inheritdoc /> protected override bool TryComputeLength(out long length) { length = 0; return false; } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/KpiRateFoldIntervalCrossValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/KpiRateFoldIntervalCrossValidatorTests.cs index 45f5b047..89436cdd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/KpiRateFoldIntervalCrossValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/KpiRateFoldIntervalCrossValidatorTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.SiteCallAudit; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// P5-5 (arch-review Low): <see cref="KpiRateFoldIntervalCrossValidator"/> is the +/// <see cref="KpiRateFoldIntervalCrossValidator"/> is the /// boot-time guardrail for the "rate-fold interval triple" — /// <see cref="KpiHistoryOptions.SampleInterval"/>, /// <see cref="NotificationOutboxOptions.DeliveredKpiWindow"/>, and @@ -30,6 +30,7 @@ public class KpiRateFoldIntervalCrossValidatorTests return validator.Validate(Options.DefaultName, kpiHistoryOptions); } + /// <summary>Verifies that the validator accepts all three values being equal at the documented defaults.</summary> [Fact] public void AllThreeEqual_AtDocumentedDefaults_IsValid() { @@ -39,6 +40,7 @@ public class KpiRateFoldIntervalCrossValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that the validator accepts all three values being equal at a common non-default value.</summary> [Fact] public void AllThreeEqual_AtANonDefaultCommonValue_IsValid() { @@ -51,6 +53,7 @@ public class KpiRateFoldIntervalCrossValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that the validator fails and names all three keys when the delivered KPI window diverges.</summary> [Fact] public void DeliveredKpiWindowDiverges_FailsAndNamesAllThreeKeys() { @@ -65,6 +68,7 @@ public class KpiRateFoldIntervalCrossValidatorTests Assert.Contains("ScadaBridge:SiteCallAudit:KpiInterval", result.FailureMessage); } + /// <summary>Verifies that the validator fails and names all three keys when the KPI interval diverges.</summary> [Fact] public void KpiIntervalDiverges_FailsAndNamesAllThreeKeys() { @@ -79,6 +83,7 @@ public class KpiRateFoldIntervalCrossValidatorTests Assert.Contains("ScadaBridge:SiteCallAudit:KpiInterval", result.FailureMessage); } + /// <summary>Verifies that the validator fails when both the delivered KPI window and the KPI interval diverge.</summary> [Fact] public void BothDeliveredKpiWindowAndKpiIntervalDiverge_Fails() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/LocalDbSyncAuthInterceptorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/LocalDbSyncAuthInterceptorTests.cs index bf0b3cac..f8721688 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/LocalDbSyncAuthInterceptorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/LocalDbSyncAuthInterceptorTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Host; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// LocalDb Phase 1 (Task 8) — the passive sync endpoint's inbound gate. +/// LocalDb Phase 1 — the passive sync endpoint's inbound gate. /// </summary> /// <remarks> /// The replication library's <c>LocalDbSyncService</c> verifies nothing; inbound auth is @@ -47,22 +47,34 @@ public class LocalDbSyncAuthInterceptorTests private sealed class FakeServerCallContext(string method, Metadata requestHeaders) : ServerCallContext { + /// <inheritdoc /> protected override string MethodCore => method; + /// <inheritdoc /> protected override string HostCore => "localhost"; + /// <inheritdoc /> protected override string PeerCore => "ipv4:127.0.0.1:12345"; + /// <inheritdoc /> protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); + /// <inheritdoc /> protected override Metadata RequestHeadersCore => requestHeaders; + /// <inheritdoc /> protected override CancellationToken CancellationTokenCore => CancellationToken.None; + /// <inheritdoc /> protected override Metadata ResponseTrailersCore { get; } = []; + /// <inheritdoc /> protected override Status StatusCore { get; set; } + /// <inheritdoc /> protected override WriteOptions? WriteOptionsCore { get; set; } + /// <inheritdoc /> protected override AuthContext AuthContextCore { get; } = new(null, new Dictionary<string, List<AuthProperty>>()); + /// <inheritdoc /> protected override ContextPropagationToken CreatePropagationTokenCore( ContextPropagationOptions? options) => throw new NotSupportedException(); + /// <inheritdoc /> protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; } @@ -73,6 +85,8 @@ public class LocalDbSyncAuthInterceptorTests => interceptor.UnaryServerHandler<string, string>( "request", context, (_, _) => Task.FromResult("ok")); + /// <summary>Verifies that a non-sync method passes through even when no key is configured.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NonSyncMethod_PassesThrough_EvenWithNoKeyConfigured() { @@ -85,6 +99,8 @@ public class LocalDbSyncAuthInterceptorTests Assert.Equal("ok", await Invoke(interceptor, context)); } + /// <summary>Verifies that a sync method is denied when no key is configured, even with a bearer token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SyncMethod_WithNoKeyConfigured_IsDenied_EvenWithABearerToken() { @@ -98,6 +114,8 @@ public class LocalDbSyncAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a sync method without a bearer token is denied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SyncMethod_WithNoBearerToken_IsDenied() { @@ -108,6 +126,8 @@ public class LocalDbSyncAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a sync method with the wrong bearer token is denied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SyncMethod_WithWrongBearerToken_IsDenied() { @@ -118,6 +138,8 @@ public class LocalDbSyncAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a sync method with the correct bearer token passes through.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SyncMethod_WithCorrectBearerToken_PassesThrough() { @@ -127,6 +149,8 @@ public class LocalDbSyncAuthInterceptorTests Assert.Equal("ok", await Invoke(interceptor, context)); } + /// <summary>Verifies that a sync method with the correct key but no Bearer scheme is denied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SyncMethod_WithCorrectKey_ButNoBearerScheme_IsDenied() { @@ -139,6 +163,8 @@ public class LocalDbSyncAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that the token comparison is not a prefix match.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SyncMethod_TokenComparison_IsNotAPrefixMatch() { @@ -151,6 +177,8 @@ public class LocalDbSyncAuthInterceptorTests Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that duplex streaming calls are gated, because that is how sync actually runs.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DuplexStreaming_IsGated_BecauseThatIsHowSyncActuallyRuns() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/LoggerConfigurationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/LoggerConfigurationTests.cs index 9e2a6df9..97cf2ccb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/LoggerConfigurationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/LoggerConfigurationTests.cs @@ -4,7 +4,7 @@ using Serilog.Events; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// Host-011: <c>ScadaBridge:Logging:MinimumLevel</c> must actually drive the Serilog +/// <c>ScadaBridge:Logging:MinimumLevel</c> must actually drive the Serilog /// minimum level. Previously the value was bound into <see cref="LoggingOptions"/> /// but never read, so editing it had no effect. /// </summary> @@ -18,6 +18,7 @@ public class LoggerConfigurationTests return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); } + /// <summary>Verifies that a Warning minimum level suppresses Information logs.</summary> [Fact] public void MinimumLevel_Warning_SuppressesInformationLogs() { @@ -34,6 +35,7 @@ public class LoggerConfigurationTests Assert.Equal(LogEventLevel.Warning, sink.LogEvents[0].Level); } + /// <summary>Verifies that a Debug minimum level allows Debug logs.</summary> [Fact] public void MinimumLevel_Debug_AllowsDebugLogs() { @@ -49,6 +51,7 @@ public class LoggerConfigurationTests Assert.Equal(LogEventLevel.Debug, sink.LogEvents[0].Level); } + /// <summary>Verifies that an absent minimum level defaults to Information.</summary> [Fact] public void MinimumLevel_Absent_DefaultsToInformation() { @@ -66,7 +69,7 @@ public class LoggerConfigurationTests } /// <summary> - /// Host-022: an unrecognised <c>ScadaBridge:Logging:MinimumLevel</c> (e.g. a typo + /// An unrecognised <c>ScadaBridge:Logging:MinimumLevel</c> (e.g. a typo /// like "Informaiton") must NOT abort startup but MUST emit a one-shot warning /// naming the offending value and the fallback so the silent coercion is /// visible. Null/blank is treated as "unset" and silently defaults. @@ -85,6 +88,7 @@ public class LoggerConfigurationTests Assert.Contains("Information", warning); } + /// <summary>Verifies that a null or blank level falls back silently.</summary> [Fact] public void ParseLevel_NullOrBlank_FallsBackSilently() { @@ -98,6 +102,7 @@ public class LoggerConfigurationTests Assert.Empty(writer.ToString()); } + /// <summary>Verifies that a recognised level value produces no warning.</summary> [Fact] public void ParseLevel_RecognisedValue_NoWarning() { @@ -110,7 +115,7 @@ public class LoggerConfigurationTests } /// <summary> - /// Host-020: <c>ScadaBridge:Logging:MinimumLevel</c> is the documented source + /// <c>ScadaBridge:Logging:MinimumLevel</c> is the documented source /// of truth for the Serilog floor, and the explicit <c>MinimumLevel.Is</c> /// call deliberately runs after <c>ReadFrom.Configuration(...)</c> so a /// <c>Serilog:MinimumLevel</c> entry is overridden. To make that precedence @@ -141,6 +146,7 @@ public class LoggerConfigurationTests Assert.Contains("Warning", warning); } + /// <summary>Verifies that setting only the ScadaBridge minimum level produces no override warning.</summary> [Fact] public void Build_OnlyScadaBridgeMinimumLevelSet_NoOverrideWarning() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MappedGrpcAuthenticationCoverageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MappedGrpcAuthenticationCoverageTests.cs index 3d3ac7bf..62cfe2d5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MappedGrpcAuthenticationCoverageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MappedGrpcAuthenticationCoverageTests.cs @@ -13,6 +13,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary>Every actually mapped generated gRPC method must have an authentication gate.</summary> public class MappedGrpcAuthenticationCoverageTests { + /// <summary>Verifies every mapped grpc service is covered including external mappings.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EveryMappedGrpcService_IsCoveredIncludingExternalMappings() { @@ -51,7 +53,7 @@ public class MappedGrpcAuthenticationCoverageTests Assert.True(serviceTypes.Count >= typeNames.Count + 1, "The external LocalDb mapping must also be exercised."); // The secrets hub — which used to install an all-method interceptor on its own service - // options and so needed a separate gate check here — is retired (Task 11): there is no + // options and so needed a separate gate check here — is retired: there is no // longer a mapped gRPC service whose only gate is anything other than one of these prefixes. var prefixes = ControlPlaneAuthInterceptor.DefaultGatedPrefixes .Concat(CentralControlAuthInterceptor.DefaultGatedPrefixes) @@ -89,13 +91,18 @@ public class MappedGrpcAuthenticationCoverageTests private sealed class MethodCollector : ServiceBinderBase { + /// <summary>Gets the full names of the gRPC methods registered with the fake binder.</summary> public List<string> Methods { get; } = []; + /// <inheritdoc /> public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, UnaryServerMethod<TRequest, TResponse>? handler) => Methods.Add(method.FullName); + /// <inheritdoc /> public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, ClientStreamingServerMethod<TRequest, TResponse>? handler) => Methods.Add(method.FullName); + /// <inheritdoc /> public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, ServerStreamingServerMethod<TRequest, TResponse>? handler) => Methods.Add(method.FullName); + /// <inheritdoc /> public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, DuplexStreamingServerMethod<TRequest, TResponse>? handler) => Methods.Add(method.FullName); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MetricsEndpointTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MetricsEndpointTests.cs index 563c8800..75d981e3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MetricsEndpointTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MetricsEndpointTests.cs @@ -18,12 +18,14 @@ public class MetricsEndpointTests : IDisposable { private readonly List<IDisposable> _disposables = new(); + /// <summary>Initializes a new instance of the <c>MetricsEndpointTests</c> class.</summary> public MetricsEndpointTests() { - // Host-003: connection strings are externalised; supply them via env vars. + // Connection strings are externalised; supply them via env vars. _disposables.Add(new CentralDbTestEnvironment()); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { foreach (var d in _disposables) @@ -56,6 +58,8 @@ public class MetricsEndpointTests : IDisposable return factory; } + /// <summary>Verifies that the metrics endpoint is mapped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Metrics_Endpoint_IsMapped() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/NodeIdentityProviderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/NodeIdentityProviderTests.cs index 563cc6ad..a7f16590 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/NodeIdentityProviderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/NodeIdentityProviderTests.cs @@ -16,6 +16,7 @@ public class NodeIdentityProviderTests return new NodeIdentityProvider(options); } + /// <summary>Verifies that the provider returns the configured node name.</summary> [Fact] public void NodeIdentityProvider_returns_configured_NodeName() { @@ -24,6 +25,7 @@ public class NodeIdentityProviderTests Assert.Equal("central-a", provider.NodeName); } + /// <summary>Verifies that the provider returns null when the node name is not set.</summary> [Fact] public void NodeIdentityProvider_returns_null_when_NodeName_unset() { @@ -32,6 +34,7 @@ public class NodeIdentityProviderTests Assert.Null(provider.NodeName); } + /// <summary>Verifies that the provider returns null when the node name is only whitespace.</summary> [Fact] public void NodeIdentityProvider_returns_null_when_NodeName_whitespace() { @@ -40,6 +43,7 @@ public class NodeIdentityProviderTests Assert.Null(provider.NodeName); } + /// <summary>Verifies that the provider trims surrounding whitespace from the node name.</summary> [Fact] public void NodeIdentityProvider_trims_whitespace() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/NodeOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/NodeOptionsValidatorTests.cs index 3a6267f8..25059dad 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/NodeOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/NodeOptionsValidatorTests.cs @@ -15,6 +15,7 @@ public class NodeOptionsValidatorTests { // ---- NodeOptions ---- + /// <summary>Verifies that default node options with a node name are valid.</summary> [Fact] public void Node_DefaultsWithNodeName_AreValid() { @@ -22,6 +23,8 @@ public class NodeOptionsValidatorTests Assert.True(validator.Validate(null, new NodeOptions { NodeName = "node-a" }).Succeeded); } + /// <summary>Verifies that an empty or whitespace node name fails validation.</summary> + /// <param name="nodeName">The invalid node name.</param> [Theory] [InlineData("")] [InlineData(" ")] @@ -34,6 +37,7 @@ public class NodeOptionsValidatorTests f => f.Contains(nameof(NodeOptions.NodeName), StringComparison.Ordinal)); } + /// <summary>Verifies that a remoting port of zero is valid.</summary> [Fact] public void Node_ZeroRemotingPort_IsValid() { @@ -43,6 +47,8 @@ public class NodeOptionsValidatorTests new NodeOptions { NodeName = "node-a", RemotingPort = 0 }).Succeeded); } + /// <summary>Verifies that a remoting port outside the valid range fails validation.</summary> + /// <param name="port">The out-of-range port number.</param> [Theory] [InlineData(-1)] [InlineData(65_536)] @@ -58,6 +64,7 @@ public class NodeOptionsValidatorTests // ---- DatabaseOptions ---- + /// <summary>Verifies that database options with all values null are valid.</summary> [Fact] public void Database_AllNull_IsValid() { @@ -66,6 +73,7 @@ public class NodeOptionsValidatorTests Assert.True(validator.Validate(null, new DatabaseOptions()).Succeeded); } + /// <summary>Verifies that a whitespace database value fails validation.</summary> [Fact] public void Database_WhitespaceValue_Fails() { @@ -78,6 +86,7 @@ public class NodeOptionsValidatorTests // ---- LoggingOptions ---- + /// <summary>Verifies that the default Information logging level is valid.</summary> [Fact] public void Logging_DefaultInformation_IsValid() { @@ -85,6 +94,8 @@ public class NodeOptionsValidatorTests Assert.True(validator.Validate(null, new LoggingOptions()).Succeeded); } + /// <summary>Verifies that a recognised logging level is valid.</summary> + /// <param name="level">The logging level name.</param> [Theory] [InlineData("Verbose")] [InlineData("debug")] @@ -95,6 +106,7 @@ public class NodeOptionsValidatorTests Assert.True(validator.Validate(null, new LoggingOptions { MinimumLevel = level }).Succeeded); } + /// <summary>Verifies that an unknown logging level fails validation.</summary> [Fact] public void Logging_UnknownLevel_Fails() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/PreHostSecretsContentRootTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/PreHostSecretsContentRootTests.cs index 1f58fb2c..b9eea1ce 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/PreHostSecretsContentRootTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/PreHostSecretsContentRootTests.cs @@ -78,6 +78,8 @@ public class PreHostSecretsContentRootTests + string.Join(", ", leftovers)); } + /// <summary>Verifies, as a control, that a valid path actually creates a secrets store.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Control_ValidPath_ActuallyCreatesAStore() { @@ -93,6 +95,8 @@ public class PreHostSecretsContentRootTests Assert.True(File.Exists(path), "the control must create a store, or the guards prove nothing"); } + /// <summary>Verifies that a path inside the content root is rejected and leaves no store behind.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PathInsideContentRoot_IsRejected_AndLeavesNoStore() { @@ -105,6 +109,8 @@ public class PreHostSecretsContentRootTests Assert.NotNull(thrown); } + /// <summary>Verifies that a relative path is rejected and leaves no store behind.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RelativePath_IsRejected_AndLeavesNoStore() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/RequiredSingletonsHealthCheckTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/RequiredSingletonsHealthCheckTests.cs index 61e87938..3af2e7ba 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/RequiredSingletonsHealthCheckTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/RequiredSingletonsHealthCheckTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Health; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// M2.14 (#28): unit tests for <see cref="RequiredSingletonsHealthCheck"/>. +/// Unit tests for <see cref="RequiredSingletonsHealthCheck"/>. /// /// The check probes each required central singleton through its local /// <c>ClusterSingletonProxy</c> by Asking an <see cref="Identify"/> with a short @@ -52,6 +52,8 @@ public class RequiredSingletonsHealthCheckTests : TestKit return await check.CheckHealthAsync(context, CancellationToken.None); } + /// <summary>Verifies all required singleton proxies reachable reports healthy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AllRequiredSingletonProxiesReachable_ReportsHealthy() { @@ -71,6 +73,8 @@ public class RequiredSingletonsHealthCheckTests : TestKit Assert.Equal(HealthStatus.Healthy, result.Status); } + /// <summary>Verifies that an unreachable required singleton makes the check report unhealthy and name it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OneRequiredSingletonUnreachable_ReportsUnhealthyNamingIt() { @@ -95,6 +99,8 @@ public class RequiredSingletonsHealthCheckTests : TestKit Assert.Contains(missing, result.Description!); } + /// <summary>Verifies that an actor system that is not yet available makes the check report unhealthy without throwing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ActorSystemNotYetAvailable_ReportsUnhealthy_DoesNotThrow() { @@ -111,6 +117,8 @@ public class RequiredSingletonsHealthCheckTests : TestKit Assert.Equal(HealthStatus.Unhealthy, result.Status); } + /// <summary>Verifies that an already-cancelled token makes the check report unhealthy without throwing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreCancelledToken_ReportsUnhealthy_DoesNotThrow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ScadaBridgeTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ScadaBridgeTelemetryTests.cs index a18c4875..63fe792d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ScadaBridgeTelemetryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ScadaBridgeTelemetryTests.cs @@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// </summary> public class ScadaBridgeTelemetryTests { + /// <summary>Verifies that the meter name is a stable value.</summary> + /// <returns>The result.</returns> [Fact] public void MeterName_IsStableValue() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsAuthorizationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsAuthorizationTests.cs index ac98b94f..2e6ad04c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsAuthorizationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsAuthorizationTests.cs @@ -7,14 +7,14 @@ using ZB.MOM.WW.Secrets.Ui; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// G-6 (Task 7) offline behavioral proof: composing the Secrets.Ui authorization +/// Offline behavioral proof: composing the Secrets.Ui authorization /// (<c>Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization())</c>) on top of /// ScadaBridge's own <see cref="AuthorizationPolicies.AddScadaBridgeAuthorization"/> yields the /// two named policies <c>secrets:manage</c> + <c>secrets:reveal</c>, and — because ScadaBridge /// builds identities with <c>RoleClaimType = ZbClaimTypes.Role</c> and the library's admin role is /// literally <c>"Administrator"</c> — an <c>Administrator</c> principal is authorized for BOTH, /// while a non-admin / anonymous principal is denied BOTH. This is the offline evidence for the -/// Task-6 "no app-specific policy remap needed" decision, exercised against the real +/// "no app-specific policy remap needed" decision, exercised against the real /// <see cref="IAuthorizationService"/> rather than mere registration. /// </summary> public class SecretsAuthorizationTests @@ -47,6 +47,8 @@ public class SecretsAuthorizationTests return new ClaimsPrincipal(identity); } + /// <summary>Verifies both secrets policies are registered.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BothSecretsPolicies_AreRegistered() { @@ -57,6 +59,8 @@ public class SecretsAuthorizationTests Assert.NotNull(await policyProvider.GetPolicyAsync(RevealPolicy)); } + /// <summary>Verifies that an administrator is authorized for both manage and reveal.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Administrator_IsAuthorized_ForBothManageAndReveal() { @@ -68,6 +72,8 @@ public class SecretsAuthorizationTests Assert.True((await authService.AuthorizeAsync(admin, RevealPolicy)).Succeeded); } + /// <summary>Verifies that a non-administrator role is denied both manage and reveal.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NonAdministratorRole_IsDenied_ForBothManageAndReveal() { @@ -79,6 +85,8 @@ public class SecretsAuthorizationTests Assert.False((await authService.AuthorizeAsync(viewer, RevealPolicy)).Succeeded); } + /// <summary>Verifies that an anonymous principal is denied both manage and reveal.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AnonymousPrincipal_IsDenied_ForBothManageAndReveal() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs index 01184ffa..eeee91a2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs @@ -9,10 +9,10 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> /// DI-resolution tests for <see cref="SecretsRegistration.AddScadaBridgeSecrets"/>. /// <para> -/// Task 11 retired every secrets REPLICATION mode in ScadaBridge — the pull-only gRPC hub (central +/// Every secrets REPLICATION mode is retired in ScadaBridge — the pull-only gRPC hub (central /// hosts, sites sweep) and the SQL-Server <c>ReplicatingSecretStore</c> mode that preceded it — on /// BOTH roles. What is left: the plain local SQLite store, which is every node's default and now a -/// Site's ONLY possible shape, and the still-supported Task 5 +/// Site's ONLY possible shape, and the still-supported /// <see cref="SecretsRegistration.StoreProviderKey"/>=SqlServer direct shared store (Central-only, /// no hub, no follower sweep — just two nodes reading and writing one database). /// </para> @@ -92,12 +92,13 @@ public class SecretsReplicationWiringTests // --------------------------------------------------------------------------------------- // Site: the ONLY shape left, on every configuration — plain local SQLite, never a - // replication decorator (Task 11, ruling #1). DeployedSecretsMirror deletes a name from + // replication decorator. DeployedSecretsMirror deletes a name from // this store directly the moment a deploy artifact stops referencing it; a decorator would // publish that delete to every other node sharing whatever it decorates against, turning // one site's unreferencing of a secret into a fleet-wide deletion. // --------------------------------------------------------------------------------------- + /// <summary>Verifies that a site resolves the plain SQLite secret store and never a replication decorator.</summary> [Fact] public void Site_ResolvesPlainSqliteStore_NeverAReplicationDecorator() { @@ -110,6 +111,7 @@ public class SecretsReplicationWiringTests Assert.IsType<SqliteSecretStore>(store); } + /// <summary>Verifies that a site ignores the store provider setting even when it is set to SqlServer.</summary> [Fact] public void Site_IgnoresStoreProvider_EvenSetToSqlServer() { @@ -123,6 +125,7 @@ public class SecretsReplicationWiringTests Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>()); } + /// <summary>Verifies that the site container holds no SQL Server secret-store package types.</summary> [Fact] public void Site_HasNoSqlServerPackageTypesInTheContainer() { @@ -145,6 +148,7 @@ public class SecretsReplicationWiringTests // Central, default — plain local SQLite, the shape every existing deployment is in. // --------------------------------------------------------------------------------------- + /// <summary>Verifies that central defaults to the plain SQLite secret store.</summary> [Fact] public void Central_DefaultsToPlainSqliteStore() { @@ -153,6 +157,7 @@ public class SecretsReplicationWiringTests Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>()); } + /// <summary>Verifies that setting the store provider to Sqlite behaves the same as leaving it unset on central.</summary> [Fact] public void Central_StoreProviderSqlite_IsIndistinguishableFromUnset() { @@ -166,10 +171,11 @@ public class SecretsReplicationWiringTests // --------------------------------------------------------------------------------------- // Secrets:Store:Provider=SqlServer — a clustered central sharing ONE SQL secret store - // directly, without a hub (task 5). The one replication-adjacent feature Task 11 left + // directly, without a hub. The one replication-adjacent feature that was left // standing, because it is a plain shared database, not a converging replica set. // --------------------------------------------------------------------------------------- + /// <summary>Verifies that central uses the shared SQL store when the provider is SqlServer.</summary> [Fact] public void Central_StoreProviderSqlServer_UsesSharedSqlStore() { @@ -182,6 +188,7 @@ public class SecretsReplicationWiringTests Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>()); } + /// <summary>Verifies that central throws when the provider is SqlServer and the connection string is blank.</summary> [Fact] public void Central_StoreProviderSqlServer_BlankConnectionString_Throws() { @@ -228,7 +235,7 @@ public class SecretsReplicationWiringTests // --------------------------------------------------------------------------------------- // The Layer-A pre-host ${secret:} expander (SecretsRegistration.AddPreHostExpander, called - // from Program.cs) — review round 1, Important #1. It MUST compose against the SAME store + // from Program.cs). It MUST compose against the SAME store // AddScadaBridgeSecrets registers for the running node: an expander that resolved boot-time // ${secret:} tokens (e.g. ScadaBridge:Security:JwtSigningKey) from the per-node SQLite file // while the running node served the shared SQL store would let two central nodes disagree on @@ -236,6 +243,7 @@ public class SecretsReplicationWiringTests // UsesCentralSharedStore is the single predicate shared by both methods. // --------------------------------------------------------------------------------------- + /// <summary>Verifies that the shared-store check is true only for central with the SqlServer provider.</summary> [Fact] public void UsesCentralSharedStore_TrueOnlyForCentralWithProviderSqlServer() { @@ -250,6 +258,7 @@ public class SecretsReplicationWiringTests SecretsRegistration.UsesCentralSharedStore(sqliteConfig, SecretsNodeRole.Central)); } + /// <summary>Verifies that the central pre-host expander resolves the shared SQL store when the provider is SqlServer.</summary> [Fact] public void ExpanderCentral_StoreProviderSqlServer_ResolvesSharedSqlStore() { @@ -273,6 +282,7 @@ public class SecretsReplicationWiringTests Assert.NotNull(provider.GetRequiredService<ISecretsStoreMigrator>()); } + /// <summary>Verifies that the central pre-host expander resolves the SQLite store by default.</summary> [Fact] public void ExpanderCentral_Default_ResolvesSqliteStore() { @@ -286,6 +296,7 @@ public class SecretsReplicationWiringTests Assert.NotNull(provider.GetRequiredService<ISecretsStoreMigrator>()); } + /// <summary>Verifies that the site pre-host expander still resolves the SQLite store when the provider is SqlServer.</summary> [Fact] public void ExpanderSite_StoreProviderSqlServer_StillResolvesSqliteStore() { @@ -304,6 +315,7 @@ public class SecretsReplicationWiringTests Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>()); } + /// <summary>Verifies that the central pre-host expander throws when the provider is SqlServer and the connection string is blank.</summary> [Fact] public void ExpanderCentral_StoreProviderSqlServer_BlankConnectionString_Throws() { @@ -321,6 +333,7 @@ public class SecretsReplicationWiringTests $"{SecretsRegistration.StoreProviderKey}=SqlServer", ex.Message, StringComparison.Ordinal); } + /// <summary>Verifies that the central pre-host expander throws when the SqlServer connection string is itself a secret reference.</summary> [Fact] public void ExpanderCentral_StoreProviderSqlServer_SecretReferenceConnectionString_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SerilogSinkConfigTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SerilogSinkConfigTests.cs index 7c88fbca..de23a209 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SerilogSinkConfigTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SerilogSinkConfigTests.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Configuration; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// Host-014 regression: REQ-HOST-8 requires the Host's Serilog sinks +/// Regression test: the Host requires its Serilog sinks /// (console and file at minimum) to be <em>configuration-driven</em>. The sinks /// must be defined in a <c>Serilog</c> section in <c>appsettings.json</c> and /// applied via <c>ReadFrom.Configuration</c> — they must not be hard-coded in @@ -28,6 +28,7 @@ public class SerilogSinkConfigTests throw new DirectoryNotFoundException("Could not locate src/ZB.MOM.WW.ScadaBridge.Host"); } + /// <summary>Verifies that the shipped appsettings contain a Serilog section with console and file sinks.</summary> [Fact] public void ShippedAppSettings_HasSerilogSection_WithConsoleAndFileSinks() { @@ -51,6 +52,7 @@ public class SerilogSinkConfigTests Assert.Contains("File", sinkNames); } + /// <summary>Verifies that the logger configuration factory applies the configured file sink.</summary> [Fact] public void LoggerConfigurationFactory_AppliesConfiguredFileSink() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SerilogTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SerilogTests.cs index 9b660368..61b2fad4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SerilogTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SerilogTests.cs @@ -4,10 +4,11 @@ using Serilog.Events; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// WP-14: Tests for Serilog structured logging with enriched properties. +/// Tests for Serilog structured logging with enriched properties. /// </summary> public class SerilogTests { + /// <summary>Verifies that the Serilog logger enriches events with node properties.</summary> [Fact] public void SerilogLogger_EnrichesWithNodeProperties() { @@ -35,6 +36,7 @@ public class SerilogTests Assert.Equal("\"Site\"", logEvent.Properties["NodeRole"].ToString()); } + /// <summary>Verifies that the logger enriches events with a site id of Central for the central role.</summary> [Fact] public void SerilogLogger_CentralRole_EnrichesSiteIdAsCentral() { @@ -63,8 +65,11 @@ public class SerilogTests /// </summary> public class InMemorySink : Serilog.Core.ILogEventSink { + /// <summary>Gets the log events captured so far.</summary> public List<LogEvent> LogEvents { get; } = new(); + /// <summary>Stores the log event for later assertions.</summary> + /// <param name="logEvent">The log event to capture.</param> public void Emit(LogEvent logEvent) { LogEvents.Add(logEvent); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ServiceProviderValidationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ServiceProviderValidationTests.cs index c16f2958..9e3b02ec 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ServiceProviderValidationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ServiceProviderValidationTests.cs @@ -28,6 +28,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// </remarks> public class ServiceProviderValidationOptionsTests { + /// <summary>Verifies that applying the options in development enables both scope and build validation.</summary> + /// <returns>The result.</returns> [Fact] public void Apply_InDevelopment_EnablesBothValidations() { @@ -39,6 +41,8 @@ public class ServiceProviderValidationOptionsTests Assert.True(options.ValidateOnBuild); } + /// <summary>Verifies that applying the options outside development turns both validations off.</summary> + /// <returns>The result.</returns> [Fact] public void Apply_OutsideDevelopment_LeavesBothValidationsOff() { @@ -65,6 +69,7 @@ public class CentralServiceProviderValidationTests : IDisposable private readonly CentralDbTestEnvironment _dbEnv; private WebApplicationFactory<Program>? _factory; + /// <summary>Initializes a new instance of the <c>CentralServiceProviderValidationTests</c> class.</summary> public CentralServiceProviderValidationTests() { _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); @@ -72,6 +77,7 @@ public class CentralServiceProviderValidationTests : IDisposable _dbEnv = new CentralDbTestEnvironment(); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { try @@ -169,6 +175,7 @@ public class SiteServiceProviderValidationTests : IDisposable private readonly string _tempAuditDbPath; private WebApplication? _host; + /// <summary>Initializes a new instance of the <c>SiteServiceProviderValidationTests</c> class.</summary> public SiteServiceProviderValidationTests() { _tempDbPath = Path.Combine(Path.GetTempPath(), $"sb_scopeval_{Guid.NewGuid()}.db"); @@ -177,6 +184,7 @@ public class SiteServiceProviderValidationTests : IDisposable _tempAuditDbPath = Path.Combine(Path.GetTempPath(), $"sb_scopeval_audit_{Guid.NewGuid()}.db"); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { (_host as IDisposable)?.Dispose(); @@ -186,6 +194,8 @@ public class SiteServiceProviderValidationTests : IDisposable } } + /// <summary>Verifies that the site composition root builds with scope validation enabled.</summary> + /// <returns>The result.</returns> [Fact] public void Site_CompositionRoot_BuildsWithScopeValidationEnabled() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SessionActivityHttpIntegrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SessionActivityHttpIntegrationTests.cs index cb18d9f5..57d6b693 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SessionActivityHttpIntegrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SessionActivityHttpIntegrationTests.cs @@ -16,6 +16,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; public class SessionActivityHttpIntegrationTests { + /// <summary>Verifies that a passive ping and a role replacement do not renew the cookie, and the session expires when idle.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PassivePing_RoleReplacementDoesNotRenewCookie_AndIdleExpires() { @@ -31,6 +33,8 @@ public class SessionActivityHttpIntegrationTests Assert.False((await fixture.Circuit!.GetAuthoritativeStateAsync()).User.Identity!.IsAuthenticated); } + /// <summary>Verifies that antiforgery-protected activity renews both the HTTP session and the existing circuit, and then both expire.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AntiforgeryActivity_RenewsHttpAndExistingCircuit_ThenBothExpire() { @@ -53,6 +57,8 @@ public class SessionActivityHttpIntegrationTests Assert.False((await fixture.Circuit.GetAuthoritativeStateAsync()).User.Identity!.IsAuthenticated); } + /// <summary>Verifies that missing antiforgery does not renew the session, and expired activity cannot revive it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MissingAntiforgeryDoesNotRenew_AndExpiredActivityCannotRevive() { @@ -74,10 +80,14 @@ public class SessionActivityHttpIntegrationTests private readonly WebApplication _app; private HttpClient _client = null!; private readonly Dictionary<string, string> _cookies = []; + /// <summary>Gets the fake clock that drives session expiry.</summary> public Clock Clock { get; } + /// <summary>Gets the authentication state provider of the circuit created at login, if any.</summary> public CookieAuthenticationStateProvider? Circuit { get; private set; } private Fixture(WebApplication app, Clock clock) { _app = app; Clock = clock; } + /// <summary>Creates and starts a test server fixture.</summary> + /// <returns>The started fixture.</returns> public static async Task<Fixture> CreateAsync() { var builder = WebApplication.CreateBuilder(); @@ -108,8 +118,17 @@ public class SessionActivityHttpIntegrationTests fixture._client = app.GetTestClient(); return fixture; } + /// <summary>Signs in through the test login endpoint.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task LoginAsync() { using var response = await SendAsync(HttpMethod.Get, "/test/login"); response.EnsureSuccessStatusCode(); } + /// <summary>Fetches an antiforgery request token from the test token endpoint.</summary> + /// <returns>The antiforgery request token.</returns> public async Task<string> TokenAsync() { using var response = await SendAsync(HttpMethod.Get, "/test/token"); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); } + /// <summary>Sends a request carrying the stored cookies and an optional antiforgery token.</summary> + /// <param name="method">The HTTP method.</param> + /// <param name="path">The request path.</param> + /// <param name="token">The antiforgery token to send, or null for none.</param> + /// <returns>The HTTP response.</returns> public async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, string? token = null) { using var request = new HttpRequestMessage(method, path); @@ -120,16 +139,26 @@ public class SessionActivityHttpIntegrationTests foreach (var value in values) { var pair = value.Split(';')[0].Split('=', 2); _cookies[pair[0]] = pair[1]; } return response; } + /// <summary>Disposes the HTTP client and the web application.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async ValueTask DisposeAsync() { _client.Dispose(); await _app.DisposeAsync(); } } private sealed class Clock : TimeProvider { + /// <summary>Gets or sets the current fake time.</summary> public DateTimeOffset Now { get; set; } = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => Now; + /// <summary>Advances the fake clock.</summary> + /// <param name="minutes">The number of minutes to advance.</param> public void Advance(int minutes) => Now = Now.AddMinutes(minutes); } private sealed class Mapper : IGroupRoleMapper<string> { + /// <summary>Maps every group set to the Deployer role.</summary> + /// <param name="groups">The directory groups to map (ignored).</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>A task yielding a fixed Deployer role mapping.</returns> public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct) => Task.FromResult(new GroupRoleMapping<string>([Roles.Deployer], new RoleMappingResult([Roles.Deployer], ["1"], false))); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SingletonRegistrarTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SingletonRegistrarTests.cs index 938af66b..c5ec7a99 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SingletonRegistrarTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SingletonRegistrarTests.cs @@ -9,9 +9,12 @@ public class SingletonRegistrarTests { private sealed class EchoActor : ReceiveActor { + /// <summary>Creates an actor that echoes every message back to its sender.</summary> public EchoActor() => ReceiveAny(m => Sender.Tell(m)); } + /// <summary>Verifies that starting a singleton creates the manager and proxy with canonical names and a drain task.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Start_CreatesManagerAndProxy_WithCanonicalNames_AndDrainTask() { @@ -36,10 +39,12 @@ public class SingletonRegistrarTests Assert.True(handle.Manager.IsNobody() || system.WhenTerminated.IsCompleted); } + /// <summary>Verifies that starting a singleton with a role scope creates a role-scoped singleton that answers requests.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Start_WithRoleScope_CreatesRoleScopedSingleton_ThatAnswers() { - // Round-2 N5: role scoping is what kept the two SITE singletons + // Role scoping is what kept the two SITE singletons // (deployment-manager, event-log-handler) hand-rolled and drain-less. using var system = CreateSingleNodeClusterSystem(); // roles = ["Central"] var handle = SingletonRegistrar.Start( diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteCommandGrpcServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteCommandGrpcServiceTests.cs index d20683f6..1c6bae99 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteCommandGrpcServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteCommandGrpcServiceTests.cs @@ -35,7 +35,7 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable private readonly ActorSystem _system = ActorSystem.Create("sitecmd-tests"); - /// <inheritdoc /> + /// <summary>Disposes the actor system used by the tests.</summary> public void Dispose() => _system.Dispose(); /// <summary>A dispatcher whose Deployment Manager proxy is a canned-reply responder.</summary> @@ -88,12 +88,16 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable private sealed class FixedPskProvider(string key) : ISitePskProvider { + /// <inheritdoc /> public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key); + /// <inheritdoc /> public void Invalidate(string siteId) { } } // ── Auth gating (delegated to ControlPlaneAuthInterceptor, proven wired here) ── + /// <summary>Verifies that a request without credentials is rejected with a permission-denied status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoCredentials_IsRejected_WithPermissionDenied() { @@ -106,6 +110,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } + /// <summary>Verifies that a request with the wrong key is rejected with a permission-denied status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WrongKey_IsRejected_WithPermissionDenied() { @@ -120,6 +126,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable // ── Readiness ── + /// <summary>Verifies that a request made before the service is set ready is rejected as unavailable, even with a correct key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BeforeSetReady_IsRejected_WithUnavailable_EvenWithACorrectKey() { @@ -136,6 +144,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable // ── decode → dispatch → encode, one command per oneof group ── + /// <summary>Verifies that a lifecycle command round-trips through the dispatcher.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteLifecycle_RoundTripsThroughTheDispatcher() { @@ -151,6 +161,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable Assert.Equal("cmd-1", decoded.CommandId); } + /// <summary>Verifies that an OPC UA command round-trips through the dispatcher.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteOpcUa_RoundTripsThroughTheDispatcher() { @@ -164,6 +176,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable Assert.IsType<BrowseNodeResult>(SiteCommandDtoMapper.FromOpcUaReply(reply)); } + /// <summary>Verifies that a debug snapshot query round-trips through the dispatcher.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteQuery_DebugSnapshot_RoundTripsThroughTheDispatcher() { @@ -178,6 +192,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable Assert.Equal("Site1.Pump1", decoded.InstanceUniqueName); } + /// <summary>Verifies that an unsubscribe-debug-view query returns the fire-and-forget acknowledgement.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteQuery_UnsubscribeDebugView_ReturnsTheFireAndForgetAck() { @@ -195,6 +211,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable Assert.Equal(QueryReply.ReplyOneofCase.UnsubscribeDebugView, reply.ReplyCase); } + /// <summary>Verifies that a parked-operation command round-trips through the node-local handler.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteParked_RoundTripsThroughTheNodeLocalHandler() { @@ -210,6 +228,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable Assert.Equal("corr-7", decoded.CorrelationId); } + /// <summary>Verifies that a route command round-trips through the dispatcher.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteRoute_RoundTripsThroughTheDispatcher() { @@ -228,6 +248,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable // ── Failover: the reply completes before the leave is initiated ── + /// <summary>Verifies that a trigger-failover command returns the acknowledgement before the leave is initiated.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TriggerFailover_ReturnsTheAck_BeforeTheLeaveIsInitiated() { @@ -267,6 +289,7 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable /// <summary>Deployment Manager stand-in: replies each command with a canned reply of the right group.</summary> private sealed class Responder : ReceiveActor { + /// <summary>Initializes the responder so it replies to every received command with a canned reply.</summary> public Responder() => ReceiveAny(msg => Sender.Tell(ReplyFor(msg))); private static object ReplyFor(object m) => m switch diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteEventLoggerLifetimePinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteEventLoggerLifetimePinTests.cs index 1f17c8a9..843249a9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteEventLoggerLifetimePinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteEventLoggerLifetimePinTests.cs @@ -24,7 +24,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <item><description><c>SiteRuntime/Actors/DeploymentManagerActor.cs</c> — per-call /// resolve.</description></item> /// <item><description><c>SiteRuntime/Scripts/ScriptRunLauncher.cs</c> — per-run resolve. -/// Not among the five the register row named: it is the run body WP3.1 lifted verbatim +/// Not among the five the register row named: it is the run body that was lifted verbatim /// out of the deleted ScriptExecutionActor/AlarmExecutionActor, so it inherited the /// pattern rather than adding it.</description></item> /// </list> @@ -114,6 +114,7 @@ public class SiteEventLoggerLifetimePinTests return services; } + /// <summary>Verifies that <c>ISiteEventLogger</c> is registered as a singleton in the site service graph.</summary> [Fact] public void ISiteEventLogger_IsRegisteredSingleton_InTheSiteServiceGraph() { @@ -125,6 +126,7 @@ public class SiteEventLoggerLifetimePinTests $"{Why}\nActual lifetime: {descriptor.Lifetime}."); } + /// <summary>Verifies that the concrete type the interface forwards to is also registered as a singleton.</summary> [Fact] public void SiteEventLogger_TheConcreteTypeTheInterfaceForwardsTo_IsAlsoSingleton() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteHealthCheckTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteHealthCheckTests.cs index fdf4c314..011777d1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteHealthCheckTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteHealthCheckTests.cs @@ -34,6 +34,7 @@ public class SiteHealthCheckTests : IDisposable private readonly string _tempSiteDbPath; private readonly string _tempTrackingDbPath; + /// <summary>Initializes the test host with a temporary site database and the site health checks.</summary> public SiteHealthCheckTests() { var stamp = Guid.NewGuid(); @@ -68,6 +69,7 @@ public class SiteHealthCheckTests : IDisposable _host = builder.Build(); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { (_host as IDisposable)?.Dispose(); @@ -82,6 +84,7 @@ public class SiteHealthCheckTests : IDisposable private IReadOnlyList<HealthCheckRegistration> Registrations => _host.Services.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations.ToList(); + /// <summary>Verifies that a site registers exactly the three site health checks.</summary> [Fact] public void Site_RegistersExactlyTheThreeSiteChecks() { @@ -93,6 +96,9 @@ public class SiteHealthCheckTests : IDisposable Registrations.Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray()); } + /// <summary>Verifies that each site check carries its intended tier tag.</summary> + /// <param name="name">The health check registration name.</param> + /// <param name="expectedTag">The tier tag the check is expected to carry.</param> [Theory] [InlineData("akka-cluster", ZbHealthTags.Ready)] [InlineData("localdb", ZbHealthTags.Ready)] @@ -106,6 +112,9 @@ public class SiteHealthCheckTests : IDisposable Assert.Equal(new[] { expectedTag }, registration.Tags.ToArray()); } + /// <summary>Verifies that each site check resolves to its intended implementation type.</summary> + /// <param name="name">The health check registration name.</param> + /// <param name="expected">The expected check implementation type.</param> [Theory] [InlineData("akka-cluster", typeof(AkkaClusterHealthCheck))] [InlineData("localdb", typeof(SiteLocalDbHealthCheck))] @@ -121,6 +130,7 @@ public class SiteHealthCheckTests : IDisposable Assert.IsType(expected, check); } + /// <summary>Verifies that the site active-node check is role-scoped and is not the unscoped central check.</summary> [Fact] public void Site_ActiveNodeCheck_IsRoleScoped_NotTheUnscopedCentralCheck() { @@ -136,6 +146,8 @@ public class SiteHealthCheckTests : IDisposable Assert.IsNotType<ActiveNodeHealthCheck>(check); } + /// <summary>Verifies that a site does not register central-only health checks.</summary> + /// <param name="centralOnlyName">The name of a check that only central should register.</param> [Theory] [InlineData("database")] [InlineData("required-singletons")] @@ -148,6 +160,8 @@ public class SiteHealthCheckTests : IDisposable Assert.DoesNotContain(Registrations, r => r.Name == centralOnlyName); } + /// <summary>Verifies that the LocalDb check reports healthy against the real site database.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Site_LocalDbCheck_ReportsHealthyAgainstTheRealSiteDatabase() { @@ -161,6 +175,8 @@ public class SiteHealthCheckTests : IDisposable Assert.Equal(HealthStatus.Healthy, result.Status); } + /// <summary>Verifies that the LocalDb check adds replication state without failing when replication is off by default.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Site_LocalDbCheck_EnrichesWithReplicationStateWithoutFailingOnDefaultOff() { @@ -176,6 +192,8 @@ public class SiteHealthCheckTests : IDisposable Assert.Equal(0L, Assert.IsType<long>(result.Data["replicationOplogBacklog"])); } + /// <summary>Verifies that the LocalDb check reports unhealthy when the store is unreachable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Site_LocalDbCheck_ReportsUnhealthyWhenTheStoreIsUnreachable() { @@ -186,6 +204,10 @@ public class SiteHealthCheckTests : IDisposable Assert.Equal(HealthStatus.Unhealthy, result.Status); } + /// <summary>Verifies that the active-node check separates the primary from the standby.</summary> + /// <param name="selfIsPrimary">Whether this node is the primary.</param> + /// <param name="expected">The health status the check is expected to report.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(true, HealthStatus.Healthy)] [InlineData(false, HealthStatus.Unhealthy)] @@ -207,16 +229,30 @@ public class SiteHealthCheckTests : IDisposable private sealed class StubNodeProvider(bool selfIsPrimary) : IClusterNodeProvider { + /// <inheritdoc /> public bool SelfIsPrimary { get; } = selfIsPrimary; + /// <inheritdoc /> public IReadOnlyList<NodeStatus> GetClusterNodes() => []; } private sealed class ThrowingLocalDb : ILocalDb { + /// <summary>Always throws to simulate an unreachable store.</summary> + /// <param name="sql">The SQL text (ignored).</param> + /// <param name="parameters">The statement parameters (ignored).</param> + /// <param name="ct">A token to cancel the operation (ignored).</param> + /// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns> public Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default) => throw new InvalidOperationException("store unreachable"); + /// <summary>Always throws to simulate an unreachable store.</summary> + /// <typeparam name="T">The row type.</typeparam> + /// <param name="sql">The SQL text (ignored).</param> + /// <param name="map">The row mapper (ignored).</param> + /// <param name="parameters">The query parameters (ignored).</param> + /// <param name="ct">A token to cancel the operation (ignored).</param> + /// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns> public Task<IReadOnlyList<T>> QueryAsync<T>( string sql, Func<Microsoft.Data.Sqlite.SqliteDataReader, T> map, @@ -224,21 +260,37 @@ public class SiteHealthCheckTests : IDisposable CancellationToken ct = default) => throw new InvalidOperationException("store unreachable"); + /// <summary>Always throws to simulate an unreachable store.</summary> + /// <param name="ct">A token to cancel the operation (ignored).</param> + /// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns> public Task<ILocalDbTransaction> BeginTransactionAsync(CancellationToken ct = default) => throw new InvalidOperationException("store unreachable"); + /// <summary>Always throws to simulate an unreachable store.</summary> + /// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns> public Microsoft.Data.Sqlite.SqliteConnection CreateConnection() => throw new InvalidOperationException("store unreachable"); + /// <summary>Always throws to simulate an unreachable store.</summary> + /// <param name="tableName">The table name (ignored).</param> + /// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns> public ReplicatedTable RegisterReplicated(string tableName) => throw new InvalidOperationException("store unreachable"); + /// <summary>Always throws to simulate an unreachable store.</summary> + /// <param name="tableName">The table name (ignored).</param> + /// <param name="baselineExistingRows">Whether to baseline existing rows (ignored).</param> + /// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns> public ReplicatedTable RegisterReplicated(string tableName, bool baselineExistingRows) => throw new InvalidOperationException("store unreachable"); + /// <summary>Always throws to simulate an unreachable store.</summary> + /// <param name="tableName">The table name (ignored).</param> + /// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns> public bool DeregisterReplicated(string tableName) => throw new InvalidOperationException("store unreachable"); + /// <summary>Gets the replicated tables; always throws to simulate an unreachable store.</summary> public IReadOnlyDictionary<string, ReplicatedTable> ReplicatedTables => throw new InvalidOperationException("store unreachable"); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteHealthEndpointTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteHealthEndpointTests.cs index d1088666..5c07fe8a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteHealthEndpointTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteHealthEndpointTests.cs @@ -29,13 +29,14 @@ public class SiteHealthEndpointTests : IDisposable private readonly string _tempDbPath; private readonly string _tempAuditDbPath; + /// <summary>Initializes the test class with temporary database paths and the environment overrides a site node needs to boot.</summary> public SiteHealthEndpointTests() { _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_ep_{Guid.NewGuid()}.db"); _tempAuditDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_ep_audit_{Guid.NewGuid()}.db"); // Whole-key env overrides, the sanctioned path: supplying GrpcPsk concretely makes the - // pre-host secrets expander skip appsettings.Site.json's ${secret:SB-GRPC-PSK-site-1} + // pre-host secrets expander skip appsettings.Site.json's ${secret:SB-GRPC-PSK-<siteId>} // token, so this test needs no seeded secrets store. // // The remoting port is moved off the site default (8082) so this fixture cannot collide @@ -52,7 +53,7 @@ public class SiteHealthEndpointTests : IDisposable SetEnv("ScadaBridge__Cluster__SeedNodes__1", "akka.tcp://scadabridge@localhost:18085"); SetEnv("ScadaBridge__Communication__GrpcPsk", "test-psk-0123456789"); SetEnv("LocalDb__Path", _tempDbPath); - // arch-review remediation WP1.2: SqliteAuditWriterOptions.DatabasePath has no default + // SqliteAuditWriterOptions.DatabasePath has no default // and StartupValidator now requires it for Site nodes — same reason LocalDb__Path is // overridden above (appsettings.Site.json's own default is CWD-relative and would // otherwise litter the test working directory when SiteAuditBacklogReporter probes it). @@ -65,6 +66,7 @@ public class SiteHealthEndpointTests : IDisposable Environment.SetEnvironmentVariable(key, value); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { foreach (var d in _disposables) @@ -109,6 +111,8 @@ public class SiteHealthEndpointTests : IDisposable return client; } + /// <summary>Verifies that a site node maps the ready health endpoint with the canonical JSON body.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Site_MapsHealthReady_WithTheCanonicalJsonBody() { @@ -130,6 +134,8 @@ public class SiteHealthEndpointTests : IDisposable Assert.False(entries.TryGetProperty("active-node", out _), "active-node belongs to the Active tier"); } + /// <summary>Verifies that a site node maps the active health endpoint carrying only the active-node check.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Site_MapsHealthActive_CarryingOnlyTheActiveNodeCheck() { @@ -145,6 +151,8 @@ public class SiteHealthEndpointTests : IDisposable entries.EnumerateObject().Select(p => p.Name).ToArray()); } + /// <summary>Verifies that the site health endpoints are reachable anonymously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Site_HealthEndpointsAreAnonymous() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteKestrelSetupTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteKestrelSetupTests.cs index 138c69b6..2a0f3d3b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteKestrelSetupTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteKestrelSetupTests.cs @@ -10,10 +10,11 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// </summary> public class SiteKestrelSetupTests { + /// <summary>Verifies that the site listener enables HTTP/2 keepalive pings.</summary> [Fact] public void SiteListener_EnablesHttp2Keepalive() { - // Arch-review 2026-08-19 P4-3. Kestrel's default KeepAlivePingDelay is TimeSpan.MaxValue — + // Kestrel's default KeepAlivePingDelay is TimeSpan.MaxValue — // no server-initiated pings — so a half-open HTTP/2 connection is never detected on a // stream that is legitimately silent. The LocalDb sync stream is exactly that: with the // dialling node crashed or partitioned, this node's handler stays parked on a dead stream @@ -32,6 +33,7 @@ public class SiteKestrelSetupTests <= TimeSpan.FromMinutes(2)); } + /// <summary>Verifies that the site listener keeps the gRPC port HTTP/2-only and the metrics port HTTP/1.1-capable.</summary> [Fact] public void SiteListener_KeepsGrpcHttp2Only_AndMetricsHttp1Capable() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbAutoVacuumTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbAutoVacuumTests.cs index bc4839a2..27ceedc1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbAutoVacuumTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbAutoVacuumTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.LocalDb; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// Arch-review 2026-08-19 P4-6 — <c>PRAGMA auto_vacuum = INCREMENTAL</c> only takes effect on +/// <c>PRAGMA auto_vacuum = INCREMENTAL</c> only takes effect on /// a table-less database, and <c>SqliteLocalDb</c>'s constructor creates LocalDb's own /// bookkeeping tables before anything else ever gets a connection to the consolidated site /// file. <see cref="SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile"/> is the fix: it pre-creates @@ -23,6 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// </remarks> public class SiteLocalDbAutoVacuumTests { + /// <summary>Verifies that a fresh file reports incremental auto-vacuum.</summary> [Fact] public void FreshFile_ReportsIncrementalAutoVacuum() { @@ -43,6 +44,7 @@ public class SiteLocalDbAutoVacuumTests } } + /// <summary>Verifies that incremental auto-vacuum survives LocalDb creating its own bookkeeping tables afterward.</summary> [Fact] public void FreshFile_AutoVacuumSurvives_LocalDbCreatingItsOwnBookkeepingTablesAfterward() { @@ -82,6 +84,7 @@ public class SiteLocalDbAutoVacuumTests } } + /// <summary>Verifies that an existing file is left alone and its auto-vacuum stays none.</summary> [Fact] public void ExistingFile_IsLeftAlone_AutoVacuumStaysNone() { @@ -118,6 +121,7 @@ public class SiteLocalDbAutoVacuumTests } } + /// <summary>Verifies that a missing LocalDb path does not throw.</summary> [Fact] public void MissingLocalDbPath_DoesNotThrow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs index a2469924..3bbbab58 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.LocalDb; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// WP1.3 — CDC capture is installed only on a node that actually replicates. +/// CDC capture is installed only on a node that actually replicates. /// </summary> /// <remarks> /// <para> @@ -27,12 +27,14 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable private readonly string _root; private readonly List<ServiceProvider> _providers = []; + /// <summary>Initializes the test with a fresh temporary directory for the database files.</summary> public SiteLocalDbCdcRegistrationTests() { _root = Path.Combine(Path.GetTempPath(), $"localdb-cdc-{Guid.NewGuid():N}"); Directory.CreateDirectory(_root); } + /// <summary>Disposes every created provider and clears the SQLite connection pools.</summary> public void Dispose() { foreach (var provider in _providers) @@ -45,6 +47,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable GC.SuppressFinalize(this); } + /// <summary>Verifies that an unreplicated node installs no capture triggers.</summary> [Fact] public void UnreplicatedNode_InstallsNoCaptureTriggers() { @@ -54,6 +57,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable Assert.Empty(CaptureTriggers(db)); } + /// <summary>Verifies that an unreplicated node still creates every table.</summary> [Fact] public void UnreplicatedNode_StillCreatesEveryTable() { @@ -68,6 +72,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable Assert.Contains(table, tables); } + /// <summary>Verifies that an initiator node installs capture triggers.</summary> [Fact] public void InitiatorNode_InstallsCaptureTriggers() { @@ -78,6 +83,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable AssertCaptureTriggersCoverTheReplicatedTables(db); } + /// <summary>Verifies that a passive node with an API key but no peer address still installs capture triggers.</summary> [Fact] public void PassiveNode_WithApiKeyButNoPeerAddress_StillInstallsCaptureTriggers() { @@ -90,6 +96,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable AssertCaptureTriggersCoverTheReplicatedTables(db); } + /// <summary>Verifies that a replicated node installs no capture triggers on the central-only notification tables.</summary> [Fact] public void ReplicatedNode_InstallsNoCaptureTriggersOnTheCentralOnlyNotificationTables() { @@ -104,6 +111,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable Assert.DoesNotContain(triggers, t => t.StartsWith("__localdb_smtp_configurations_", StringComparison.Ordinal)); } + /// <summary>Verifies that a replicated node installs no capture triggers on the node-local mirrored secrets ledger.</summary> [Fact] public void ReplicatedNode_InstallsNoCaptureTriggersOnTheNodeLocalMirroredSecretsLedger() { @@ -119,10 +127,11 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable Assert.DoesNotContain("mirrored_secrets", db.ReplicatedTables.Keys); } + /// <summary>Verifies that an unreplicated node drops triggers left behind by a previously replicated build.</summary> [Fact] public void UnreplicatedNode_DropsTriggersLeftByAPreviouslyReplicatedBuild() { - // WP3.3, the residual WP1.3 could only document: skipping registration never removed + // The residual that skipping registration alone could not close: skipping registration never removed // what an EARLIER build installed, so a long-lived unreplicated node upgraded in place // kept capturing forever. LocalDb 0.2.0's DeregisterReplicated closes it, and this is // the exact upgrade shape — the same file, booted first WITH replication configured and @@ -137,6 +146,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable Assert.Empty(CaptureTriggers(unreplicated)); } + /// <summary>Verifies that an unreplicated node prunes the oplog left behind by a previously replicated build.</summary> [Fact] public void UnreplicatedNode_PrunesTheOplogLeftByAPreviouslyReplicatedBuild() { @@ -156,6 +166,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable Assert.Equal(1, SiteEventCount(unreplicated)); } + /// <summary>Verifies that an unreplicated node over a file that was never registered is a no-op.</summary> [Fact] public void UnreplicatedNode_OverAFileThatWasNeverRegistered_IsANoOp() { @@ -172,6 +183,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable Assert.Contains(table, TableNames(second)); } + /// <summary>Verifies that a replicated node baselines rows that predate registration.</summary> [Fact] public void ReplicatedNode_BaselinesRowsThatPredateRegistration() { @@ -194,6 +206,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable Assert.Equal(0L, RowVersionHlc(replicated, "site_events")); } + /// <summary>Verifies that an unreplicated node still runs the legacy migrator.</summary> [Fact] public void UnreplicatedNode_StillRunsTheLegacyMigrator() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbDirectoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbDirectoryTests.cs index 78d6f1e6..77186e98 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbDirectoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbDirectoryTests.cs @@ -29,6 +29,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// </remarks> public class SiteLocalDbDirectoryTests { + /// <summary>Verifies that site registration creates the LocalDb directory when it does not exist.</summary> [Fact] public void SiteRegistration_CreatesTheLocalDbDirectory_WhenItDoesNotExist() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs index c8fc2e7b..3ea7014d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Host; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// LocalDb Phase 1 (Task 6) — the one-time copy of the pre-Phase-1 site databases into +/// LocalDb: the one-time copy of the pre-LocalDb site databases into /// the consolidated one. /// </summary> /// <remarks> @@ -21,12 +21,14 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable private readonly string _root; private readonly List<ServiceProvider> _providers = []; + /// <summary>Initializes a new instance of the <c>SiteLocalDbLegacyMigratorTests</c> class.</summary> public SiteLocalDbLegacyMigratorTests() { _root = Path.Combine(Path.GetTempPath(), $"localdb-migrator-{Guid.NewGuid():N}"); Directory.CreateDirectory(_root); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { foreach (var provider in _providers) @@ -43,9 +45,9 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable /// <summary>A consolidated database with the tables created and registered, as the host has it.</summary> /// <remarks> /// <c>sf_messages</c> is registered here even though production <c>OnReady</c> does not - /// register it until the Task 14 cutover. The oplog assertion below needs live capture + /// register it until the store-and-forward cutover. The oplog assertion below needs live capture /// triggers to mean anything, and the property under test — that the migrator runs after - /// registration — is the same either way. The corollary is a real constraint on Task 14: + /// registration — is the same either way. The corollary is a real constraint on that cutover: /// <c>Migrate</c> must stay the LAST call in <c>OnReady</c>, after every registration. /// </remarks> private ILocalDb CreateConsolidated(IConfiguration config) @@ -195,6 +197,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable return ids; } + /// <summary>Verifies that missing legacy files are a no op.</summary> [Fact] public void MissingLegacyFiles_AreANoOp() { @@ -210,6 +213,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.Equal(0, CountRows(db, "site_events")); } + /// <summary>Verifies that legacy tracking rows are copied and the file is renamed.</summary> [Fact] public void LegacyTrackingRows_AreCopiedAndTheFileIsRenamed() { @@ -228,6 +232,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.True(File.Exists(trackingPath + ".migrated")); } + /// <summary>Verifies that legacy reads disable pooling or the rename fails on windows.</summary> [Fact] public void LegacyReadsDisablePooling_OrTheRenameFailsOnWindows() { @@ -251,6 +256,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.Contains("Mode=ReadOnly", cs, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that legacy events get deterministic ids not fresh guids.</summary> [Fact] public void LegacyEvents_GetDeterministicIds_NotFreshGuids() { @@ -268,6 +274,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable SelectIds(db, "site_events", "id")); } + /// <summary>Verifies that second boot is a no op because the file is already marked migrated.</summary> [Fact] public void SecondBoot_IsANoOp_BecauseTheFileIsAlreadyMarkedMigrated() { @@ -285,6 +292,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.Equal(2, CountRows(db, "site_events")); } + /// <summary>Verifies that rerun against an unrenamed file does not duplicate.</summary> [Fact] public void RerunAgainstAnUnrenamedFile_DoesNotDuplicate() { @@ -311,6 +319,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.Equal(2, CountRows(db, "site_events")); } + /// <summary>Verifies that migrated rows enter the oplog so they actually replicate.</summary> [Fact] public void MigratedRows_EnterTheOplog_SoTheyActuallyReplicate() { @@ -332,10 +341,11 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.Equal(2L, (long)cmd.ExecuteScalar()!); } + /// <summary>Verifies that sf messages are copied from the legacy file.</summary> [Fact] public void SfMessages_AreCopiedFromTheLegacyFile() { - // Phase 2 (Task 8). Unlike the two Phase 1 files, the store-and-forward default path + // Unlike the two earlier site database files, the store-and-forward default path // is inside the data volume, so on a real deployment this actually moves data: // undelivered messages that a lost migration would silently discard. var sfPath = Path_("store-and-forward.db"); @@ -353,6 +363,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.True(File.Exists(sfPath + ".migrated")); } + /// <summary>Verifies that sf messages migration is idempotent when rerun after a crash before rename.</summary> [Fact] public void SfMessages_Migration_IsIdempotent_WhenRerunAfterACrashBeforeRename() { @@ -370,6 +381,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.Equal(2, CountRows(db, "sf_messages")); } + /// <summary>Verifies that migrated sf messages enter the oplog so they actually replicate.</summary> [Fact] public void MigratedSfMessages_EnterTheOplog_SoTheyActuallyReplicate() { @@ -389,6 +401,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.Equal(2L, (long)cmd.ExecuteScalar()!); } + /// <summary>Verifies that sf messages from an older build migrate despite missing columns.</summary> [Fact] public void SfMessages_FromAnOlderBuild_MigrateDespiteMissingColumns() { @@ -463,10 +476,11 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable cmd.ExecuteNonQuery(); } + /// <summary>Verifies that site config tables are copied from the legacy file.</summary> [Fact] public void SiteConfigTables_AreCopiedFromTheLegacyFile() { - // Phase 2 (Task 9). All seven migrated tables live in one legacy file and are copied + // All seven migrated tables live in one legacy file and are copied // in a single transaction: a partial config migration would leave a site node running // against half its old configuration, which is worse than failing startup. var sitePath = Path_("scadabridge.db"); @@ -483,6 +497,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.True(File.Exists(sitePath + ".migrated")); } + /// <summary>Verifies that migration does not copy notification or smtp rows even when the legacy file has them.</summary> [Fact] public void Migration_DoesNotCopyNotificationOrSmtpRows_EvenWhenTheLegacyFileHasThem() { @@ -510,6 +525,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.Equal(0L, (long)cmd.ExecuteScalar()!); } + /// <summary>Verifies that migrated config rows enter the oplog so they actually replicate.</summary> [Fact] public void MigratedConfigRows_EnterTheOplog_SoTheyActuallyReplicate() { @@ -527,6 +543,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.Equal(1L, (long)cmd.ExecuteScalar()!); } + /// <summary>Verifies that site config migration is idempotent when rerun after a crash before rename.</summary> [Fact] public void SiteConfigMigration_IsIdempotent_WhenRerunAfterACrashBeforeRename() { @@ -543,6 +560,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.Equal(1, CountRows(db, table.Table)); } + /// <summary>Verifies that migrator column lists match the live schema.</summary> [Fact] public void MigratorColumnLists_MatchTheLiveSchema() { @@ -571,6 +589,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable } } + /// <summary>Verifies that legacy file without the expected table is treated as empty not a failure.</summary> [Fact] public void LegacyFileWithoutTheExpectedTable_IsTreatedAsEmpty_NotAFailure() { @@ -593,6 +612,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.True(File.Exists(trackingPath + ".migrated")); } + /// <summary>Verifies that in memory legacy connection strings are skipped.</summary> [Fact] public void InMemoryLegacyConnectionStrings_AreSkipped() { @@ -616,6 +636,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.Equal(0, CountRows(db, "site_events")); } + /// <summary>Verifies that unset legacy keys fall back to the cwd relative code defaults.</summary> [Fact] public void UnsetLegacyKeys_FallBackToTheCwdRelativeCodeDefaults() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbReplicatedTableFkGuardTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbReplicatedTableFkGuardTests.cs index 1ecc0784..79e07aa0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbReplicatedTableFkGuardTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbReplicatedTableFkGuardTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.LocalDb; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// Deferred-work register row 105 (arch-review 2026-08-19 P4-7 → LocalDb 0.3.3): a replicated +/// Guards the LocalDb 0.3.3 restriction: a replicated /// table must never be the parent of a foreign key with a row-mutating <c>ON DELETE</c> action /// (<c>CASCADE</c> / <c>SET NULL</c> / <c>SET DEFAULT</c>). LocalDb's LWW applier upserts with /// <c>INSERT OR REPLACE</c> under <c>foreign_keys=ON</c>, so the implicit delete would fire the @@ -16,8 +16,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// pair. This test moves that discovery from the first replicated boot to the build: it walks /// the real site schema (every table <c>SiteLocalDbSetup.OnReady</c> creates) and asserts none /// of the twelve <see cref="SiteLocalDbSetup.ReplicatedTables"/> (incl. <c>deployed_secrets</c>, -/// added for site-secrets artifact deploy, and <c>secret_mirror_status</c>, added by review -/// finding M1) has such an inbound FK. +/// added for site-secrets artifact deploy, and <c>secret_mirror_status</c>, added by a +/// review finding) has such an inbound FK. /// </summary> /// <remarks> /// The schema is built with an UNREPLICATED config on purpose: a replicated one would make @@ -27,6 +27,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// </remarks> public class SiteLocalDbReplicatedTableFkGuardTests { + /// <summary>Verifies that no replicated table is the parent of a foreign key with a row-mutating <c>ON DELETE</c> action.</summary> [Fact] public void NoReplicatedTable_IsAParentOfARowMutatingOnDeleteForeignKey() { @@ -64,6 +65,7 @@ public class SiteLocalDbReplicatedTableFkGuardTests } } + /// <summary>Verifies, as a negative control, that the checker detects a child table with a cascading foreign key.</summary> [Fact] public void NegativeControl_CheckerFindsACascadingChild() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs index f5a473cd..4171bed0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs @@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// LocalDb Phase 1 (Task 3) — locks the consolidated site database into the REAL site +/// LocalDb Phase 1 — locks the consolidated site database into the REAL site /// composition root. /// <para> /// These tests build the actual container from @@ -34,6 +34,7 @@ public class SiteLocalDbWiringTests : IDisposable private readonly string _tempSiteDbPath; private readonly string _tempTrackingDbPath; + /// <summary>Builds the site host over a temporary LocalDb file.</summary> public SiteLocalDbWiringTests() { var stamp = Guid.NewGuid(); @@ -78,6 +79,7 @@ public class SiteLocalDbWiringTests : IDisposable _host = builder.Build(); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { (_host as IDisposable)?.Dispose(); @@ -89,12 +91,14 @@ public class SiteLocalDbWiringTests : IDisposable GC.SuppressFinalize(this); } + /// <summary>Verifies that the site composition root resolves the LocalDb service.</summary> [Fact] public void Site_Resolves_ILocalDb() { Assert.NotNull(_host.Services.GetService<ILocalDb>()); } + /// <summary>Verifies that the site LocalDb registers both Phase 1 replicated tables.</summary> [Fact] public void Site_LocalDb_RegistersBothReplicatedTables() { @@ -108,6 +112,7 @@ public class SiteLocalDbWiringTests : IDisposable Assert.Contains("site_events", db.ReplicatedTables.Keys); } + /// <summary>Verifies that the replicated tables declare explicit primary keys.</summary> [Fact] public void Site_LocalDb_ReplicatedTablesHaveExplicitPrimaryKeys() { @@ -121,6 +126,7 @@ public class SiteLocalDbWiringTests : IDisposable Assert.Equal(["id"], db.ReplicatedTables["site_events"].PkColumns); } + /// <summary>Verifies that the site LocalDb service is registered as a singleton.</summary> [Fact] public void Site_LocalDb_IsASingleton() { @@ -133,10 +139,11 @@ public class SiteLocalDbWiringTests : IDisposable Assert.Same(first, second); } + /// <summary>Verifies that replication is registered but inert when no peer is configured.</summary> [Fact] public void Site_Replication_IsRegistered_AndInertWithNoPeer() { - // Task 7's default-OFF pin. This fixture configures NO + // The default-OFF pin. This fixture configures NO // LocalDb:Replication:PeerAddress, which is the shipping default, so the // engine must resolve cleanly and sit idle rather than throw, retry, or // report a connection. "Default-off" here means inert, NOT unregistered — @@ -149,6 +156,7 @@ public class SiteLocalDbWiringTests : IDisposable Assert.Null(status.LastSyncUtc); } + /// <summary>Verifies that a healthy idle node reports an oplog backlog of zero rather than null.</summary> [Fact] public void Site_Replication_OplogBacklog_IsZero_NotNull_OnAHealthyIdleNode() { @@ -156,22 +164,24 @@ public class SiteLocalDbWiringTests : IDisposable // wired, or the poll failed) and must never be reported as a healthy 0. On a // node whose local database is present and readable, a real 0 proves the // backlog provider is actually wired to the oplog — a null here would mean - // Task 9's health signal is about to publish "unknown" forever. + // The health signal is about to publish "unknown" forever. var status = _host.Services.GetRequiredService<ISyncStatus>(); Assert.Equal(0L, status.OplogBacklog); } + /// <summary>Verifies that the replication health bridge is registered as a hosted service.</summary> [Fact] public void Site_Replication_HealthBridge_IsRegisteredAsAHostedService() { - // Task 9. Registered via a factory lambda, so ImplementationType is null and the + // Registered via a factory lambda, so ImplementationType is null and the // only honest assertion is on the resolved instance. var hosted = _host.Services.GetServices<IHostedService>(); Assert.Contains(hosted, h => h is LocalDbReplicationStatusReporter); } + /// <summary>Verifies that the replication health bridge publishes its status onto the health report.</summary> [Fact] public void Site_Replication_HealthBridge_PublishesStatusOntoTheHealthReport() { @@ -202,6 +212,7 @@ public class SiteLocalDbWiringTests : IDisposable Assert.Equal(0L, report.LocalDbOplogBacklog); } + /// <summary>Verifies that the LocalDb health report fields are null before the reporter has run.</summary> [Fact] public void Site_HealthReport_LocalDbFields_AreNull_BeforeTheReporterHasRun() { @@ -216,6 +227,7 @@ public class SiteLocalDbWiringTests : IDisposable Assert.Null(report.LocalDbOplogBacklog); } + /// <summary>Verifies that site telemetry allowlists the LocalDb replication meter.</summary> [Fact] public void Site_Telemetry_Allowlists_TheLocalDbReplicationMeter() { @@ -228,15 +240,16 @@ public class SiteLocalDbWiringTests : IDisposable // This asserts on the allowlist constant, not on OTel's observed-meter set: // AddZbTelemetry applies its options to a local instance and never registers // IOptions, so there is nothing resolvable to interrogate. The end-to-end proof - // that localdb_* actually reaches /metrics is the Task 12 live gate; this test + // that localdb_* actually reaches /metrics is the live gate; this test // exists to stop the entry being dropped again. Assert.Contains(LocalDbMetrics.MeterName, SiteServiceRegistration.ObservedMeters); } + /// <summary>Verifies that the Phase 2 tables are created in the consolidated LocalDb file.</summary> [Fact] public void Site_LocalDb_CreatesThePhase2Tables_InTheConsolidatedFile() { - // Phase 2 (Task 7). The nine configuration tables and the store-and-forward buffer + // Phase 2. The nine configuration tables and the store-and-forward buffer // now live in the consolidated file rather than in scadabridge.db and // store-and-forward.db. Asserting through the REAL composition root is what proves // OnReady applies both schemas — the stores themselves also call Apply, so a test @@ -261,11 +274,12 @@ public class SiteLocalDbWiringTests : IDisposable } } + /// <summary>Verifies that the site LocalDb registers exactly the twelve expected replicated tables.</summary> [Fact] public void Site_LocalDb_RegistersExactlyTheTwelveReplicatedTables() { // The Phase 2 cutover, extended by site-secrets artifact deploy's deployed_secrets - // and review finding M1's per-node secret_mirror_status. + // and the per-node secret_mirror_status. // This is an EXACTLY check rather than a Contains in both directions, and both // directions are load-bearing. // @@ -292,6 +306,7 @@ public class SiteLocalDbWiringTests : IDisposable db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); } + /// <summary>Verifies that the central-only notification tables are not replicated.</summary> [Fact] public void Site_LocalDb_DoesNotReplicateTheCentralOnlyNotificationTables() { @@ -305,6 +320,7 @@ public class SiteLocalDbWiringTests : IDisposable Assert.DoesNotContain("smtp_configurations", db.ReplicatedTables.Keys); } + /// <summary>Verifies that the node-local mirrored secrets ledger is not replicated.</summary> [Fact] public void Site_LocalDb_DoesNotReplicateTheNodeLocalMirroredSecretsLedger() { @@ -316,6 +332,7 @@ public class SiteLocalDbWiringTests : IDisposable Assert.DoesNotContain("mirrored_secrets", db.ReplicatedTables.Keys); } + /// <summary>Verifies that the replicated Phase 2 tables have their expected primary keys.</summary> [Fact] public void Site_LocalDb_ReplicatedPhase2TablesHaveTheirExpectedPrimaryKeys() { @@ -350,6 +367,7 @@ public class SiteLocalDbWiringTests : IDisposable return names; } + /// <summary>Verifies that the site LocalDb creates the configured database file.</summary> [Fact] public void Site_LocalDb_CreatesTheConfiguredFile() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SitePskProviderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SitePskProviderTests.cs index 449032e2..5cfa66bc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SitePskProviderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SitePskProviderTests.cs @@ -26,11 +26,20 @@ public class SitePskProviderTests private sealed class StaticOptionsMonitor(CommunicationOptions value) : IOptionsMonitor<CommunicationOptions> { + /// <summary>Gets the fixed options instance.</summary> public CommunicationOptions CurrentValue => value; + /// <summary>Returns the fixed options instance regardless of name.</summary> + /// <param name="name">The named-options key, ignored.</param> + /// <returns>The fixed options instance.</returns> public CommunicationOptions Get(string? name) => value; + /// <summary>Does nothing; the fake never raises change notifications.</summary> + /// <param name="listener">The change listener, ignored.</param> + /// <returns>Always null.</returns> public IDisposable? OnChange(Action<CommunicationOptions, string?> listener) => null; } + /// <summary>Verifies that the key is resolved from the secret store under the site-qualified name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResolvesFromTheSecretStore_UnderTheSiteQualifiedName() { @@ -43,6 +52,8 @@ public class SitePskProviderTests Assert.Equal("key-for-a", psk); } + /// <summary>Verifies that a configured key map takes precedence so a host without a master key can still dial.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConfiguredMapWins_SoAHostWithNoMasterKeyCanStillDial() { @@ -62,6 +73,8 @@ public class SitePskProviderTests await resolver.DidNotReceive().GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the store is still consulted for a site missing from the configured map.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreIsStillConsulted_ForASiteMissingFromTheMap() { @@ -78,6 +91,8 @@ public class SitePskProviderTests Assert.Equal("key-for-b", psk); } + /// <summary>Verifies that a missing secret throws and never yields an empty key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MissingSecret_Throws_AndNeverYieldsAnEmptyKey() { @@ -95,6 +110,8 @@ public class SitePskProviderTests Assert.Contains("SitePsks", ex.Message); } + /// <summary>Verifies that an empty secret is treated as missing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EmptySecret_IsTreatedAsMissing() { @@ -105,6 +122,8 @@ public class SitePskProviderTests async () => await Create(resolver).GetAsync("site-a", CancellationToken.None)); } + /// <summary>Verifies that a faulting store surfaces as a key-not-configured error rather than a crypto error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AFaultingStore_SurfacesAsKeyNotConfigured_NotAsACryptoError() { @@ -120,6 +139,8 @@ public class SitePskProviderTests Assert.Contains("SB-GRPC-PSK-site-a", ex.Message); } + /// <summary>Verifies that successful resolves are cached so not every call hits the store.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SuccessfulResolvesAreCached_SoEveryCallDoesNotHitTheStore() { @@ -134,6 +155,8 @@ public class SitePskProviderTests await resolver.Received(1).GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that failures are not cached so a newly seeded key is picked up without a restart.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FailuresAreNotCached_SoASeededKeyIsPickedUpWithoutARestart() { @@ -148,6 +171,8 @@ public class SitePskProviderTests Assert.Equal("seeded-later", await provider.GetAsync("site-a", CancellationToken.None)); } + /// <summary>Verifies that invalidating drops the cached key so a rotated key is read.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Invalidate_DropsTheCachedKey_SoARotatedKeyIsRead() { @@ -162,6 +187,8 @@ public class SitePskProviderTests Assert.Equal("rotated", await provider.GetAsync("site-a", CancellationToken.None)); } + /// <summary>Verifies that keys are scoped per site so one site never presents another site's key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task KeysAreScopedPerSite_SoOneSiteNeverPresentsAnothersKey() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StandbyHealthCheckLogNoiseTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StandbyHealthCheckLogNoiseTests.cs index 17d914d1..c4e432b3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StandbyHealthCheckLogNoiseTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StandbyHealthCheckLogNoiseTests.cs @@ -51,10 +51,11 @@ public class StandbyHealthCheckLogNoiseTests .ForContext("SourceContext", category) .Write(level, Template, checkName, status, 1.0, "some message"); + /// <summary>Verifies that the standby active-node verdict is not logged.</summary> [Fact] public void StandbyActiveNodeVerdict_IsNotLogged() { - // The row-71 regression: "Health check active-node with status Unhealthy … 'not the + // The regression: "Health check active-node with status Unhealthy … 'not the // site pair primary (standby)'" at Error, once per probe, forever. var sink = new InMemorySink(); var logger = BuildLogger(sink); @@ -65,6 +66,8 @@ public class StandbyHealthCheckLogNoiseTests Assert.Empty(sink.LogEvents); } + /// <summary>Verifies that a genuine failure of a health check still logs at Error.</summary> + /// <param name="checkName">The name of the failing health check.</param> [Theory] [InlineData("localdb")] [InlineData("akka-cluster")] @@ -85,6 +88,7 @@ public class StandbyHealthCheckLogNoiseTests Assert.Equal(LogEventLevel.Error, evt.Level); } + /// <summary>Verifies that a healthy active-node verdict is unaffected.</summary> [Fact] public void ActiveNodeHealthyVerdict_IsUnaffected() { @@ -99,6 +103,7 @@ public class StandbyHealthCheckLogNoiseTests Assert.Single(sink.LogEvents); } + /// <summary>Verifies that a degraded active-node verdict is unaffected.</summary> [Fact] public void ActiveNodeDegradedVerdict_IsUnaffected() { @@ -113,6 +118,7 @@ public class StandbyHealthCheckLogNoiseTests Assert.Single(sink.LogEvents); } + /// <summary>Verifies that an error mentioning the active node from another category is not suppressed.</summary> [Fact] public void AnErrorMentioningActiveNode_FromAnotherCategory_IsNotSuppressed() { @@ -131,6 +137,7 @@ public class StandbyHealthCheckLogNoiseTests Assert.Single(sink.LogEvents); } + /// <summary>Verifies that ordinary errors with no health-check properties are not suppressed.</summary> [Fact] public void OrdinaryErrorsWithNoHealthCheckProperties_AreNotSuppressed() { @@ -142,6 +149,7 @@ public class StandbyHealthCheckLogNoiseTests Assert.Single(sink.LogEvents); } + /// <summary>Verifies that the predicate rejects a null event.</summary> [Fact] public void Predicate_RejectsANullEvent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupRetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupRetryTests.cs index caca7db2..0962b412 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupRetryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupRetryTests.cs @@ -3,12 +3,14 @@ using Microsoft.Extensions.Logging.Abstractions; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// Host-010: startup preconditions (database migration) must tolerate a database +/// Startup preconditions (database migration) must tolerate a database /// that is briefly unavailable at boot — common when an app container and its DB /// container start together — via a bounded retry with backoff. /// </summary> public class StartupRetryTests { + /// <summary>Verifies that an operation that succeeds on the first try runs exactly once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteWithRetry_SucceedsFirstTry_RunsOnce() { @@ -23,6 +25,8 @@ public class StartupRetryTests Assert.Equal(1, attempts); } + /// <summary>Verifies that transient failures are retried until the operation succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteWithRetry_TransientFailures_RetriesUntilSuccess() { @@ -43,6 +47,8 @@ public class StartupRetryTests Assert.Equal(3, attempts); } + /// <summary>Verifies that exhausting all attempts rethrows the last exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteWithRetry_ExhaustsAttempts_RethrowsLastException() { @@ -64,10 +70,12 @@ public class StartupRetryTests Assert.Equal("failure 3", ex.Message); } + /// <summary>Verifies that a non-transient failure is rethrown after a single attempt.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteWithRetry_NonTransientFailure_RethrowsAfterSingleAttempt() { - // Host-015: a permanent failure (e.g. a schema-version mismatch) must NOT be + // A permanent failure (e.g. a schema-version mismatch) must NOT be // retried — retrying it cannot succeed and only delays the fatal exit by // minutes. The isTransient predicate classifies it as non-retryable, so the // operation runs exactly once before the exception propagates. @@ -89,6 +97,8 @@ public class StartupRetryTests Assert.Equal("permanent schema mismatch", ex.Message); } + /// <summary>Verifies that a transient failure followed by a permanent one stops at the permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteWithRetry_TransientThenPermanent_StopsAtPermanent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs index 5b88d844..05a825c9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Configuration; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// WP-11: Tests for StartupValidator configuration validation. +/// Tests for StartupValidator configuration validation. /// </summary> public class StartupValidatorTests { @@ -38,18 +38,20 @@ public class StartupValidatorTests ["ScadaBridge:Database:SiteDbPath"] = "./data/scadabridge.db", ["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@site-a-node1:8082", ["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node2:8082", - // T0.3: the gRPC control plane is fail-closed, so a Site node without a preshared + // The gRPC control plane is fail-closed, so a Site node without a preshared // key serves nothing while still looking healthy. Required at boot for that reason. ["ScadaBridge:Communication:GrpcPsk"] = "test-site-control-plane-key", // Phase 4: gRPC (CentralControlService) is the only site→central transport, so a Site // node must list at least one central gRPC endpoint to dial (no Akka fallback remains). ["ScadaBridge:Communication:CentralGrpcEndpoints:0"] = "http://central-a:8083", - // arch-review remediation WP1.2: the site hot-path audit writer's SQLite file has no + // The site hot-path audit writer's SQLite file has no // default path (mirrors LocalDb:Path) — required so it lands on the mounted data volume, // not the container's ephemeral overlayfs. ["AuditLog:SiteWriter:DatabasePath"] = "/app/data/auditlog.db", }; + /// <summary>Verifies that a valid central config passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void ValidCentralConfig_PassesValidation() { @@ -58,6 +60,8 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that a valid site config passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void ValidSiteConfig_PassesValidation() { @@ -66,6 +70,8 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that a site without gRPC PSK fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void SiteWithoutGrpcPsk_FailsValidation() { @@ -81,6 +87,8 @@ public class StartupValidatorTests Assert.Contains("GrpcPsk", ex.Message); } + /// <summary>Verifies that a site with a blank gRPC PSK fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void SiteWithBlankGrpcPsk_FailsValidation() { @@ -94,6 +102,8 @@ public class StartupValidatorTests Assert.Contains("GrpcPsk", ex.Message); } + /// <summary>Verifies that a central node without gRPC PSK passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void CentralWithoutGrpcPsk_PassesValidation() { @@ -104,6 +114,8 @@ public class StartupValidatorTests Assert.Null(Record.Exception(() => StartupValidator.Validate(config))); } + /// <summary>Verifies that a site without central gRPC endpoints fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void SiteWithoutCentralGrpcEndpoints_FailsValidation() { @@ -118,6 +130,8 @@ public class StartupValidatorTests Assert.Contains("CentralGrpcEndpoints", ex.Message); } + /// <summary>Verifies that a central node without central gRPC endpoints passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void CentralWithoutCentralGrpcEndpoints_PassesValidation() { @@ -128,10 +142,12 @@ public class StartupValidatorTests Assert.Null(Record.Exception(() => StartupValidator.Validate(config))); } + /// <summary>Verifies that a site without audit log database path fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void SiteWithoutAuditLogDatabasePath_FailsValidation() { - // arch-review remediation WP1.2: SqliteAuditWriterOptions.DatabasePath has no default + // SqliteAuditWriterOptions.DatabasePath has no default // (mirrors LocalDb:Path). An unset value used to fall back to a bare "auditlog.db" // resolved relative to CWD — on the docker rig, the container's ephemeral overlayfs — // so the node booted, looked healthy, and lost its pending audit backlog on every @@ -144,6 +160,8 @@ public class StartupValidatorTests Assert.Contains("DatabasePath", ex.Message); } + /// <summary>Verifies that a site with a blank audit log database path fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void SiteWithBlankAuditLogDatabasePath_FailsValidation() { @@ -155,6 +173,8 @@ public class StartupValidatorTests Assert.Contains("DatabasePath", ex.Message); } + /// <summary>Verifies that a central node without audit log database path passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void CentralWithoutAuditLogDatabasePath_PassesValidation() { @@ -165,6 +185,8 @@ public class StartupValidatorTests Assert.Null(Record.Exception(() => StartupValidator.Validate(config))); } + /// <summary>Verifies that a missing role fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void MissingRole_FailsValidation() { @@ -176,6 +198,8 @@ public class StartupValidatorTests Assert.Contains("Role must be 'Central' or 'Site'", ex.Message); } + /// <summary>Verifies that an invalid role fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void InvalidRole_FailsValidation() { @@ -187,6 +211,8 @@ public class StartupValidatorTests Assert.Contains("Role must be 'Central' or 'Site'", ex.Message); } + /// <summary>Verifies that an empty hostname fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void EmptyHostname_FailsValidation() { @@ -198,6 +224,8 @@ public class StartupValidatorTests Assert.Contains("NodeHostname is required", ex.Message); } + /// <summary>Verifies that a missing hostname fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void MissingHostname_FailsValidation() { @@ -209,6 +237,9 @@ public class StartupValidatorTests Assert.Contains("NodeHostname is required", ex.Message); } + /// <summary>Verifies that an invalid port fails validation.</summary> + /// <param name="port">The port.</param> + /// <returns>The result.</returns> [Theory] [InlineData("0")] [InlineData("-1")] @@ -225,6 +256,9 @@ public class StartupValidatorTests Assert.Contains("RemotingPort must be 1-65535", ex.Message); } + /// <summary>Verifies that a valid port passes validation.</summary> + /// <param name="port">The port.</param> + /// <returns>The result.</returns> [Theory] [InlineData("1")] [InlineData("8081")] @@ -243,6 +277,8 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that a site without a site id fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_MissingSiteId_FailsValidation() { @@ -254,6 +290,8 @@ public class StartupValidatorTests Assert.Contains("SiteId is required for Site nodes", ex.Message); } + /// <summary>Verifies that a central node missing configuration database fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Central_MissingConfigurationDb_FailsValidation() { @@ -265,14 +303,16 @@ public class StartupValidatorTests Assert.Contains("ConfigurationDb connection string required for Central", ex.Message); } + /// <summary>Verifies that a central node missing machine data database fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Central_MissingMachineDataDb_FailsValidation() { - // Reverts Host-008. REQ-HOST-3/REQ-HOST-4 require MachineDataDb to be + // The host requirements require MachineDataDb to be // validated at startup for Central nodes, and the shipped docker appsettings // (docker/central-node-a/appsettings.Central.json and central-node-b) carry - // the key. The prior Host-008 decision (which removed the Require) is reversed - // here (#17, M2.9): a missing MachineDataDb must fail fast with a clear error. + // the key. The earlier decision (which removed the Require) is reversed + // here: a missing MachineDataDb must fail fast with a clear error. var values = ValidCentralConfig(); values.Remove("ScadaBridge:Database:MachineDataDb"); var config = BuildConfig(values); @@ -281,10 +321,12 @@ public class StartupValidatorTests Assert.Contains("MachineDataDb connection string required for Central", ex.Message); } + /// <summary>Verifies that a central node missing LDAP server fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Central_MissingLdapServer_FailsValidation() { - // Task 1.4: the LDAP server key nests under Security:Ldap now. The pre-host + // The LDAP server key nests under Security:Ldap now. The pre-host // preflight validates the nested key and still fails fast for Central. var values = ValidCentralConfig(); values.Remove("ScadaBridge:Security:Ldap:Server"); @@ -294,6 +336,8 @@ public class StartupValidatorTests Assert.Contains("Ldap:Server required for Central", ex.Message); } + /// <summary>Verifies that a central node missing JWT signing key fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Central_MissingJwtSigningKey_FailsValidation() { @@ -305,6 +349,8 @@ public class StartupValidatorTests Assert.Contains("JwtSigningKey required for Central", ex.Message); } + /// <summary>Verifies that a central node missing API key pepper fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Central_MissingApiKeyPepper_FailsValidation() { @@ -319,6 +365,8 @@ public class StartupValidatorTests Assert.Contains("ApiKeyPepper", ex.Message); } + /// <summary>Verifies that a central node with a short API key pepper fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Central_ShortApiKeyPepper_FailsValidation() { @@ -331,6 +379,8 @@ public class StartupValidatorTests Assert.Contains("ApiKeyPepper", ex.Message); } + /// <summary>Verifies that a site does not require an API key pepper.</summary> + /// <returns>The result.</returns> [Fact] public void Site_ApiKeyPepper_NotRequired() { @@ -363,6 +413,8 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that fewer than two seed nodes fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void FewerThanTwoSeedNodes_FailsValidation() { @@ -374,6 +426,8 @@ public class StartupValidatorTests Assert.Contains("SeedNodes must have at least 2 entries", ex.Message); } + /// <summary>Verifies that having no seed nodes fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void NoSeedNodes_FailsValidation() { @@ -386,6 +440,8 @@ public class StartupValidatorTests Assert.Contains("SeedNodes must have at least 2 entries", ex.Message); } + /// <summary>Verifies that a peer-first seed order fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void PeerFirstSeedOrder_FailsValidation() { @@ -403,6 +459,8 @@ public class StartupValidatorTests Assert.Contains("must list this node itself first", ex.Message); } + /// <summary>Verifies that a self-first seed order on a site node passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void SelfFirstSeedOrder_OnASiteNode_PassesValidation() { @@ -415,6 +473,8 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that the self-first seed check matches on host and port, not just host.</summary> + /// <returns>The result.</returns> [Fact] public void SelfFirstSeed_MatchedOnHostAndPort_NotJustHost() { @@ -432,6 +492,9 @@ public class StartupValidatorTests Assert.Contains("must list this node itself first", ex.Message); } + /// <summary>Verifies that a site with an invalid gRPC port fails validation.</summary> + /// <param name="grpcPort">The grpc port.</param> + /// <returns>The result.</returns> [Theory] [InlineData("0")] [InlineData("-1")] @@ -447,6 +510,8 @@ public class StartupValidatorTests Assert.Contains("GrpcPort must be 1-65535", ex.Message); } + /// <summary>Verifies that a site with a valid gRPC port passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_ValidGrpcPort_PassesValidation() { @@ -458,6 +523,8 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that an invalid gRPC port is not validated on a central node.</summary> + /// <returns>The result.</returns> [Fact] public void Central_InvalidGrpcPort_NotValidated() { @@ -469,10 +536,12 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that a site seed node on the gRPC port fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_SeedNodeOnGrpcPort_FailsValidation() { - // Host-004 regression: a site seed node must reference an Akka remoting + // Regression: a site seed node must reference an Akka remoting // endpoint, never the Kestrel HTTP/2 gRPC port. A seed node whose port // equals this node's GrpcPort would make a joining node attempt an // Akka.Remote TCP association against the gRPC listener and fail. @@ -485,6 +554,8 @@ public class StartupValidatorTests Assert.Contains("must not target the gRPC port", ex.Message); } + /// <summary>Verifies that a site seed node on the metrics port fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_SeedNodeOnMetricsPort_FailsValidation() { @@ -501,6 +572,8 @@ public class StartupValidatorTests Assert.Contains("must not target the metrics port", ex.Message); } + /// <summary>Verifies that a site seed node on the default metrics port fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_SeedNodeOnDefaultMetricsPort_FailsValidation() { @@ -515,6 +588,8 @@ public class StartupValidatorTests Assert.Contains("must not target the metrics port", ex.Message); } + /// <summary>Verifies that a site seed node on the default gRPC port fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_SeedNodeOnDefaultGrpcPort_FailsValidation() { @@ -528,6 +603,8 @@ public class StartupValidatorTests Assert.Contains("must not target the gRPC port", ex.Message); } + /// <summary>Verifies that site seed nodes on the remoting port pass validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_SeedNodesOnRemotingPort_PassesValidation() { @@ -542,6 +619,8 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that a central seed node on port 8083 passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void Central_SeedNodeOnPort8083_PassesValidation() { @@ -555,10 +634,12 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that a site gRPC port equal to the remoting port fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_GrpcPortEqualsRemotingPort_FailsValidation() { - // Host-007 regression: REQ-HOST-4 requires GrpcPort to differ from + // Regression: the host requirements require GrpcPort to differ from // RemotingPort. Identical values cause Kestrel and Akka.Remote to // contend for the same port at runtime. var values = ValidSiteConfig(); @@ -570,6 +651,8 @@ public class StartupValidatorTests Assert.Contains("GrpcPort must differ from RemotingPort", ex.Message); } + /// <summary>Verifies that a site default gRPC port equal to the remoting port fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_DefaultGrpcPortEqualsRemotingPort_FailsValidation() { @@ -583,6 +666,8 @@ public class StartupValidatorTests Assert.Contains("GrpcPort must differ from RemotingPort", ex.Message); } + /// <summary>Verifies that a site gRPC port that differs from the remoting port passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_GrpcPortDiffersFromRemotingPort_PassesValidation() { @@ -595,6 +680,9 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that a site with an invalid metrics port fails validation.</summary> + /// <param name="metricsPort">The metrics port.</param> + /// <returns>The result.</returns> [Theory] [InlineData("0")] [InlineData("-1")] @@ -610,6 +698,8 @@ public class StartupValidatorTests Assert.Contains("MetricsPort must be 1-65535", ex.Message); } + /// <summary>Verifies that a site with a valid metrics port passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_ValidMetricsPort_PassesValidation() { @@ -621,10 +711,12 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that a site metrics port equal to the remoting port fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_MetricsPortEqualsRemotingPort_FailsValidation() { - // Host-007 regression: the Kestrel metrics (HTTP/1.1) listener port must + // Regression: the Kestrel metrics (HTTP/1.1) listener port must // differ from RemotingPort. Identical values cause the metrics listener // and Akka.Remote to contend for the same port at runtime. var values = ValidSiteConfig(); @@ -636,10 +728,12 @@ public class StartupValidatorTests Assert.Contains("MetricsPort must differ from RemotingPort", ex.Message); } + /// <summary>Verifies that a site metrics port equal to the gRPC port fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_MetricsPortEqualsGrpcPort_FailsValidation() { - // Host-007 regression: the metrics listener port must differ from GrpcPort. + // Regression: the metrics listener port must differ from GrpcPort. var values = ValidSiteConfig(); values["ScadaBridge:Node:GrpcPort"] = "8083"; values["ScadaBridge:Node:MetricsPort"] = "8083"; @@ -649,6 +743,8 @@ public class StartupValidatorTests Assert.Contains("MetricsPort must differ from GrpcPort", ex.Message); } + /// <summary>Verifies that a site default metrics port equal to the remoting port fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_DefaultMetricsPortEqualsRemotingPort_FailsValidation() { @@ -668,6 +764,8 @@ public class StartupValidatorTests Assert.Contains("MetricsPort must differ from RemotingPort", ex.Message); } + /// <summary>Verifies that a site metrics port that differs from the remoting and gRPC ports passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void Site_MetricsPortDiffersFromRemotingAndGrpc_PassesValidation() { @@ -681,6 +779,8 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that an invalid metrics port is not validated on a central node.</summary> + /// <returns>The result.</returns> [Fact] public void Central_InvalidMetricsPort_NotValidated() { @@ -694,6 +794,8 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that collecting warnings warns for a two-seed central node on SQLite.</summary> + /// <returns>The result.</returns> [Fact] public void CollectWarnings_WarnsForTwoSeedCentralOnSqlite() { @@ -713,6 +815,8 @@ public class StartupValidatorTests Assert.Contains("Provider=SqlServer", warning); } + /// <summary>Verifies that collecting warnings warns when the provider is explicitly SQLite.</summary> + /// <returns>The result.</returns> [Fact] public void CollectWarnings_WarnsWhenProviderExplicitlySqlite() { @@ -726,6 +830,8 @@ public class StartupValidatorTests Assert.Single(warnings); } + /// <summary>Verifies that collecting warnings is silent for SQL Server.</summary> + /// <returns>The result.</returns> [Fact] public void CollectWarnings_SilentForSqlServer() { @@ -739,10 +845,12 @@ public class StartupValidatorTests Assert.Empty(warnings); } + /// <summary>Verifies that a clustered central node on a SQLite secret store produces a warning.</summary> + /// <returns>The result.</returns> [Fact] public void ClusteredCentral_SqliteSecretStore_Warns() { - // Task 5: a clustered central left on the per-node SQLite secret store silently drifts + // A clustered central left on the per-node SQLite secret store silently drifts // the moment anyone edits a secret on only one node, then ships an inconsistent set to // sites. ApiKeyStore:Provider is set to SqlServer here to isolate this warning from the // pre-existing one above. @@ -758,6 +866,8 @@ public class StartupValidatorTests Assert.Contains("Secrets:Store:Provider=SqlServer", warning); } + /// <summary>Verifies that a clustered central node on a SQL Server secret store produces no warning.</summary> + /// <returns>The result.</returns> [Fact] public void ClusteredCentral_SqlServerSecretStore_Silent() { @@ -772,7 +882,7 @@ public class StartupValidatorTests } /// <summary> - /// Task 11: secrets replication (the gRPC hub, and the SQL-Server ReplicatingSecretStore mode + /// Secrets replication (the gRPC hub, and the SQL-Server ReplicatingSecretStore mode /// that preceded it) is retired in ScadaBridge — sites receive secrets exclusively via Deploy /// Artifacts, and a replicated site store would publish DeployedSecretsMirror's local deletes /// fleet-wide. Checked on BOTH roles: a Site used to be the gRPC hub's follower half, so the @@ -781,10 +891,11 @@ public class StartupValidatorTests /// because CollectWarnings never runs with this flag true on a config that also passed /// Validate. /// </summary> + /// <param name="value">The rejected replication flag value.</param> [Theory] [InlineData("true")] [InlineData("True")] - // Review round 1, Minor #3: these are not valid booleans. GetValue<bool> used to throw a raw + // These are not valid booleans. GetValue<bool> used to throw a raw // OptionsValidationException-style binder crash on them instead of the designed message; the // key is read as a raw string now, so any non-"false" value is "set" and rejected the same way. [InlineData("yes")] @@ -803,6 +914,8 @@ public class StartupValidatorTests Assert.Contains("central uses Secrets:Store:Provider=SqlServer", ex.Message); } + /// <summary>Verifies that enabling secrets replication is rejected on a site.</summary> + /// <returns>The result.</returns> [Fact] public void ReplicationEnabled_IsRejected_OnSite() { @@ -816,6 +929,8 @@ public class StartupValidatorTests "Secrets replication (the gRPC hub) is retired in ScadaBridge", ex.Message); } + /// <summary>Verifies that disabling secrets replication passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void ReplicationDisabled_PassesValidation() { @@ -830,6 +945,8 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that a case-insensitive replication-disabled value passes validation.</summary> + /// <returns>The result.</returns> [Fact] public void ReplicationDisabled_CaseInsensitive_PassesValidation() { @@ -843,7 +960,7 @@ public class StartupValidatorTests } /// <summary> - /// Deliberately stricter than <c>bool</c> parsing (review round 1, Minor #3): only an absent + /// Deliberately stricter than <c>bool</c> parsing (review feedback): only an absent /// key or exactly (case-insensitively) <c>"false"</c> is safe. <c>"0"</c> is a legal /// <c>bool.TryParse</c>-adjacent falsy spelling in some conventions but is NOT exempted here — /// a leftover key this malformed should surface the retirement message, not silently pass. @@ -862,8 +979,8 @@ public class StartupValidatorTests } /// <summary> - /// Review round 1, Minor #4: a leftover <c>Secrets:Replication</c>/<c>Secrets:GrpcHub</c> key - /// from before Task 11 is not itself a boot failure (Validate already rejects Enabled=true + /// A leftover <c>Secrets:Replication</c>/<c>Secrets:GrpcHub</c> key + /// from before secrets replication was retired is not itself a boot failure (Validate already rejects Enabled=true /// outright), but every OTHER key under either section is now silently ignored — worth a /// Warning so an operator cleans up QA/prod appsettings. /// </summary> @@ -882,6 +999,8 @@ public class StartupValidatorTests && w.Contains("the secrets hub is retired; remove them", StringComparison.Ordinal)); } + /// <summary>Verifies that collecting warnings warns for a leftover gRPC hub key.</summary> + /// <returns>The result.</returns> [Fact] public void CollectWarnings_WarnsForLeftoverGrpcHubKey() { @@ -896,6 +1015,8 @@ public class StartupValidatorTests w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal)); } + /// <summary>Verifies that collecting warnings warns for leftover keys on a site too.</summary> + /// <returns>The result.</returns> [Fact] public void CollectWarnings_WarnsForLeftoverKeys_OnSiteToo() { @@ -911,6 +1032,8 @@ public class StartupValidatorTests w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal)); } + /// <summary>Verifies that collecting warnings is silent for a bare replication-enabled false value.</summary> + /// <returns>The result.</returns> [Fact] public void CollectWarnings_SilentForBareReplicationEnabledFalse() { @@ -928,6 +1051,8 @@ public class StartupValidatorTests w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal)); } + /// <summary>Verifies that collecting warnings is silent when neither section is present.</summary> + /// <returns>The result.</returns> [Fact] public void CollectWarnings_SilentWhenNeitherSectionPresent() { @@ -941,6 +1066,8 @@ public class StartupValidatorTests w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal)); } + /// <summary>Verifies that collecting warnings is silent for a single-seed central node.</summary> + /// <returns>The result.</returns> [Fact] public void CollectWarnings_SilentForSingleSeedCentral() { @@ -955,6 +1082,8 @@ public class StartupValidatorTests Assert.Empty(warnings); } + /// <summary>Verifies that collecting warnings is silent for a site.</summary> + /// <returns>The result.</returns> [Fact] public void CollectWarnings_SilentForSite() { @@ -965,6 +1094,8 @@ public class StartupValidatorTests Assert.Empty(warnings); } + /// <summary>Verifies that validation rejects an unknown API key store provider.</summary> + /// <returns>The result.</returns> [Fact] public void Validate_RejectsUnknownApiKeyStoreProvider() { @@ -976,6 +1107,8 @@ public class StartupValidatorTests Assert.Contains("ScadaBridge:InboundApi:ApiKeyStore:Provider", ex.Message); } + /// <summary>Verifies that validation accepts the API key store provider case-insensitively.</summary> + /// <returns>The result.</returns> [Fact] public void Validate_AcceptsProviderCaseInsensitively() { @@ -987,6 +1120,8 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that an unknown secret store provider fails validation.</summary> + /// <returns>The result.</returns> [Fact] public void UnknownSecretStoreProvider_Fails() { @@ -998,6 +1133,8 @@ public class StartupValidatorTests Assert.Contains("Secrets:Store:Provider", ex.Message); } + /// <summary>Verifies that validation accepts the secret store provider case-insensitively.</summary> + /// <returns>The result.</returns> [Fact] public void Validate_AcceptsSecretStoreProviderCaseInsensitively() { @@ -1009,6 +1146,8 @@ public class StartupValidatorTests Assert.Null(ex); } + /// <summary>Verifies that multiple errors are all reported.</summary> + /// <returns>The result.</returns> [Fact] public void MultipleErrors_AllReported() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/UnexpectedTerminationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/UnexpectedTerminationTests.cs index f4e6ee01..c685c093 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/UnexpectedTerminationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/UnexpectedTerminationTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// PLAN-01 Task 20 (review 01 underdeveloped #2): with SBR down-if-alone + +/// With SBR down-if-alone + /// run-coordinated-shutdown-when-down live, a self-downed node terminates its /// ActorSystem out from under the still-running Host process. Unless the process /// then exits, the node idles forever — serving nothing, restarted by nobody. @@ -53,6 +53,8 @@ public sealed class UnexpectedTerminationTests lifetime); } + /// <summary>Verifies that the host application is stopped when the actor system terminates outside of a stop request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ActorSystemTerminatedOutsideStopAsync_StopsHostApplication() { @@ -69,6 +71,8 @@ public sealed class UnexpectedTerminationTests lifetime.Received(1).StopApplication(); } + /// <summary>Verifies that a normal stop does not trigger an application stop.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StopAsync_DoesNotTriggerStopApplication() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/UnreplicatedSiteLocalDbHealthTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/UnreplicatedSiteLocalDbHealthTests.cs index cc4ccc71..9771fce4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/UnreplicatedSiteLocalDbHealthTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/UnreplicatedSiteLocalDbHealthTests.cs @@ -23,9 +23,9 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <para> /// The replication engine is registered unconditionally and idles, so <c>ISyncStatus</c> /// still answers <c>Connected=false</c> with a real <c>0</c> backlog. Publishing that pair -/// is the row-80 bug: a dashboard reader sees <c>connected: false</c> on a perfectly healthy +/// is the bug: a dashboard reader sees <c>connected: false</c> on a perfectly healthy /// unreplicated site and reads a fault, and central's null branches — the conditional KPI -/// emission and the sustained-badge anchor exclusion built for register row 33 — never +/// emission and the sustained-badge anchor exclusion built for unreplicated sites — never /// receive the null they exist for, so unreplicated sites emitted a misleading /// <c>localDbReplicationConnected=0</c> series. /// </para> @@ -42,6 +42,7 @@ public class UnreplicatedSiteLocalDbHealthTests : IDisposable private readonly string _tempSiteDbPath; private readonly string _tempTrackingDbPath; + /// <summary>Builds an unreplicated site host over a temporary LocalDb file.</summary> public UnreplicatedSiteLocalDbHealthTests() { var stamp = Guid.NewGuid(); @@ -79,6 +80,7 @@ public class UnreplicatedSiteLocalDbHealthTests : IDisposable _host = builder.Build(); } + /// <summary>Releases the resources used by this instance.</summary> public void Dispose() { (_host as IDisposable)?.Dispose(); @@ -90,10 +92,11 @@ public class UnreplicatedSiteLocalDbHealthTests : IDisposable GC.SuppressFinalize(this); } + /// <summary>Verifies that an unreplicated site reports null for both LocalDb health fields.</summary> [Fact] public void UnreplicatedSite_HealthReport_ReportsNullForBothLocalDbFields() { - // The row-80 regression, end-to-end through the real container and the real + // The regression, end-to-end through the real container and the real // collector. Before the fix this produced false/0. var reporter = _host.Services.GetServices<IHostedService>() .OfType<LocalDbReplicationStatusReporter>() @@ -107,6 +110,7 @@ public class UnreplicatedSiteLocalDbHealthTests : IDisposable Assert.Null(report.LocalDbOplogBacklog); } + /// <summary>Verifies that an unreplicated site still registers the health bridge.</summary> [Fact] public void UnreplicatedSite_StillRegistersTheHealthBridge() { @@ -119,10 +123,11 @@ public class UnreplicatedSiteLocalDbHealthTests : IDisposable Assert.Contains(hosted, h => h is LocalDbReplicationStatusReporter); } + /// <summary>Verifies that the replication engine of an unreplicated site still resolves and idles.</summary> [Fact] public void UnreplicatedSite_ReplicationEngine_StillResolvesAndIdles() { - // Establishes the premise the row-80 fix rests on: the engine really does answer + // Establishes the premise the fix rests on: the engine really does answer // false/0 here. If this ever starts returning null on its own, the gate becomes // redundant rather than wrong — but the test above still pins the observable // contract either way. diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/WindowsServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/WindowsServiceTests.cs index 8a70cfcf..6c6f5991 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/WindowsServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/WindowsServiceTests.cs @@ -3,11 +3,12 @@ using System.Reflection; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// <summary> -/// WP-17: Tests for Windows Service support. +/// Tests for Windows Service support. /// Verifies UseWindowsService() is called in Program.cs. /// </summary> public class WindowsServiceTests { + /// <summary>Verifies that Program.cs calls UseWindowsService.</summary> [Fact] public void ProgramCs_CallsUseWindowsService() { @@ -25,6 +26,7 @@ public class WindowsServiceTests $"Expected UseWindowsService() to be called at least twice (Central and Site paths), found {occurrences} occurrence(s)"); } + /// <summary>Verifies that the host project references the Windows Services hosting package.</summary> [Fact] public void HostProject_ReferencesWindowsServicesPackage() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ApiMethodCacheTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ApiMethodCacheTests.cs index 99a4e262..b4980ce9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ApiMethodCacheTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ApiMethodCacheTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// <summary> -/// WP2.6b (arch-review misc — Inbound API per-request SQL): <see cref="ApiMethodCache"/> +/// <see cref="ApiMethodCache"/> /// hit/miss/expiry/invalidation behavior, independent of the endpoint and subscriber wiring /// (covered separately by <see cref="ScriptArtifactChangeSubscriberTests"/>). /// </summary> @@ -11,6 +11,8 @@ public class ApiMethodCacheTests { private static ApiMethod Method(string name) => new(name, "return 1;") { Id = 1 }; + /// <summary>Verifies that a second call within the TTL is served from the cache without refetching.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetOrFetchAsync_SecondCallWithinTtl_ServesFromCache_NoRefetch() { @@ -29,6 +31,8 @@ public class ApiMethodCacheTests Assert.Same(first, second); } + /// <summary>Verifies that a call after the TTL expires refetches the method.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetOrFetchAsync_AfterTtlExpiry_Refetches() { @@ -48,6 +52,8 @@ public class ApiMethodCacheTests Assert.Equal(2, fetchCount); } + /// <summary>Verifies that a negative result is cached until the TTL expires.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetOrFetchAsync_NegativeResult_IsCachedUntilTtlExpires() { @@ -67,6 +73,8 @@ public class ApiMethodCacheTests Assert.Equal(1, fetchCount); } + /// <summary>Verifies that invalidating an entry makes the next call refetch it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Invalidate_DropsEntry_NextCallRefetches() { @@ -85,6 +93,7 @@ public class ApiMethodCacheTests Assert.Equal(2, fetchCount); } + /// <summary>Verifies that invalidating an unknown method name does not throw.</summary> [Fact] public void Invalidate_UnknownName_DoesNotThrow() { @@ -92,6 +101,7 @@ public class ApiMethodCacheTests cache.Invalidate("never-cached"); } + /// <summary>Verifies that a non-positive TTL makes the constructor throw.</summary> [Fact] public void Constructor_NonPositiveTtl_Throws() { @@ -103,8 +113,13 @@ public class ApiMethodCacheTests private sealed class FakeTimeProvider : TimeProvider { private DateTimeOffset _now; + /// <summary>Initializes the provider at the given start time.</summary> + /// <param name="start">The initial UTC time.</param> public FakeTimeProvider(DateTimeOffset start) => _now = start; + /// <summary>Advances the current time.</summary> + /// <param name="by">The amount of time to advance.</param> public void Advance(TimeSpan by) => _now += by; + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => _now; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs index fbf8db84..835b34b9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointContentTypeTests.cs @@ -22,7 +22,7 @@ using System.Text; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// <summary> -/// InboundAPI-020: the inbound API handler must accept JSON content types +/// The inbound API handler must accept JSON content types /// case-insensitively. A request with <c>application/JSON</c>, /// <c>Application/Json</c>, or <c>application/json</c> must all enter the /// JSON-deserialization path — the previous <c>Contains("json")</c> check @@ -46,6 +46,9 @@ public sealed class EndpointContentTypeTests : IDisposable private readonly string _sqlitePath = Path.Combine(Path.GetTempPath(), $"inbound-api-keys-ct-{Guid.NewGuid():N}.sqlite"); + /// <summary>Verifies that the content-type check is case-insensitive and the body is parsed for any casing.</summary> + /// <param name="contentType">The JSON content type in the casing under test.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("application/json")] [InlineData("application/JSON")] @@ -92,10 +95,12 @@ public sealed class EndpointContentTypeTests : IDisposable Assert.Contains("42", body); } + /// <summary>Verifies that a body with a non-JSON content type is rejected with 415.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NonJsonContentType_WithBody_Returns415() { - // Task 5 (S9): a body with a non-JSON Content-Type must be rejected with 415 + + // A body with a non-JSON Content-Type must be rejected with 415 + // UNSUPPORTED_MEDIA_TYPE rather than a misleading 400 "invalid JSON". A body // with NO Content-Type header is still parsed leniently as JSON (preserving // existing callers). @@ -187,7 +192,7 @@ public sealed class EndpointContentTypeTests : IDisposable services.AddRouting(); services.AddSingleton(repo); // RouteHelper depends on IInstanceLocator + IInstanceRouter - // (InboundAPI-017). Tests for content-type handling never + // . Tests for content-type handling never // route, so both can be no-op stubs. services.AddSingleton(Substitute.For<IInstanceLocator>()); services.Configure<InboundApiOptions>(_ => { }); @@ -207,6 +212,7 @@ public sealed class EndpointContentTypeTests : IDisposable return await hostBuilder.StartAsync(); } + /// <summary>Releases the resources used by this instance.</summary> public void Dispose() { try @@ -227,6 +233,8 @@ public sealed class EndpointContentTypeTests : IDisposable } } + /// <summary>Verifies that a chunked non-JSON body is rejected with 415.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ChunkedNonJsonBody_Returns415() { @@ -261,6 +269,8 @@ public sealed class EndpointContentTypeTests : IDisposable Assert.Contains("UNSUPPORTED_MEDIA_TYPE", body); } + /// <summary>Verifies that a chunked JSON body with no content type is still parsed leniently.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ChunkedJsonBody_NoContentType_StillParsesLeniently() { @@ -302,10 +312,12 @@ public sealed class EndpointContentTypeTests : IDisposable /// </summary> private sealed class ChunkedByteContent(byte[] bytes) : HttpContent { + /// <inheritdoc /> protected override Task SerializeToStreamAsync( Stream stream, System.Net.TransportContext? context) => stream.WriteAsync(bytes, 0, bytes.Length); + /// <inheritdoc /> protected override bool TryComputeLength(out long length) { length = -1; diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs index 45cbff53..b17e6237 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs @@ -63,12 +63,12 @@ public sealed class EndpointExtensionsTests : IDisposable private const string TokenPrefix = "sbk"; private const string ApiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore"; - // Review #1: the EXACT canonical 403 body shared by the "method not found" and + // The EXACT canonical 403 body shared by the "method not found" and // "key not in scope" branches. Asserting against this literal (not just that the // two bodies are equal-to-each-other) catches a single-branch divergence where // BOTH branches change in lockstep but away from the agreed enumeration-safe // body — the equal-to-each-other check alone would still pass in that case. - // Pinned to EndpointExtensions.NotApprovedMessage's JSON shape. Task 5 (C3): the + // Pinned to EndpointExtensions.NotApprovedMessage's JSON shape. The // error body now additionally carries the machine-readable "code" field; both 403 // branches still emit this identical body, preserving enumeration-safety. private const string NotApprovedBodyJson = @@ -87,6 +87,7 @@ public sealed class EndpointExtensionsTests : IDisposable /// </summary> private sealed class AuditActorCapture { + /// <summary>Gets or sets the actor captured from the request.</summary> public string? CapturedActor { get; set; } } @@ -101,6 +102,8 @@ public sealed class EndpointExtensionsTests : IDisposable }; } + /// <summary>Verifies that a valid bearer token in scope returns 200 with the script result as JSON.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HappyPath_ValidBearerInScope_Returns200WithScriptResultJson() { @@ -121,6 +124,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Contains("7", body); } + /// <summary>Verifies that a valid X-API-Key header in scope returns 200 with the script result as JSON.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HappyPath_ValidXApiKeyInScope_Returns200WithScriptResultJson() { @@ -144,6 +149,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Contains("7", body); } + /// <summary>Verifies that when both headers are present the Authorization header wins, so a valid bearer beats a garbage X-API-Key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BothHeadersPresent_AuthorizationWins_ValidBearerBeatsGarbageXApiKey() { @@ -172,6 +179,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Contains("7", body); } + /// <summary>Verifies that when both headers are present the Authorization header wins, so a garbage bearer beats a valid X-API-Key and returns 401.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BothHeadersPresent_AuthorizationWins_GarbageBearerBeatsValidXApiKey_Returns401() { @@ -199,6 +208,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + /// <summary>Verifies that a request with neither header returns 401.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NeitherHeader_Returns401() { @@ -220,6 +231,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + /// <summary>Verifies that a whitespace Authorization header with a valid X-API-Key returns 200.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WhitespaceAuthorization_ValidXApiKey_Returns200() { @@ -245,6 +258,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Contains("7", body); } + /// <summary>Verifies that a garbage bearer token returns 401.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GarbageBearer_Returns401() { @@ -266,6 +281,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + /// <summary>Verifies that a revoked key returns 401.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RevokedKey_Returns401() { @@ -285,6 +302,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + /// <summary>Verifies that a valid key without the method in scope returns 403.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ValidKey_MethodNotInScope_Returns403() { @@ -302,12 +321,14 @@ public sealed class EndpointExtensionsTests : IDisposable var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - // Review #1: pin the not-in-scope body to the exact canonical literal so a + // Pin the not-in-scope body to the exact canonical literal so a // single-branch divergence is caught here too (not only in the // identical-bodies comparison test). Assert.Equal(NotApprovedBodyJson, body); } + /// <summary>Verifies that an unknown method returns 403 with a body identical to the not-in-scope body.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UnknownMethod_Returns403_WithIdenticalBodyToNotInScope() { @@ -337,7 +358,7 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Equal(HttpStatusCode.Forbidden, notInScopeResponse.StatusCode); // The crux of the enumeration-safety invariant: identical bodies. Assert.Equal(notInScopeBody, unknownBody); - // Review #1: pin BOTH bodies to the exact canonical literal, so a future + // Pin BOTH bodies to the exact canonical literal, so a future // change that diverges either branch from the agreed body is caught even if // the two branches change together (the equal-to-each-other check above // would not catch that on its own). @@ -345,10 +366,12 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Equal(NotApprovedBodyJson, notInScopeBody); } + /// <summary>Verifies that error bodies carry a machine-readable code.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ErrorBodies_CarryMachineReadableCode() { - // Task 5 (C3): every error body carries a machine-readable "code" field for + // Every error body carries a machine-readable "code" field for // external integrators — UNAUTHORIZED (401), INVALID_JSON (400), // VALIDATION_FAILED (400). var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];", @@ -384,6 +407,8 @@ public sealed class EndpointExtensionsTests : IDisposable return doc.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null; } + /// <summary>Verifies that an invalid JSON body returns 400.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InvalidJsonBody_Returns400() { @@ -401,6 +426,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Contains("Invalid JSON", body); } + /// <summary>Verifies that a missing required parameter returns 400 from the parameter validator.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MissingRequiredParameter_Returns400_FromParameterValidator() { @@ -421,6 +448,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Contains("value", body); } + /// <summary>Verifies that a throwing script returns 500 with a sanitized error body.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ScriptThrows_Returns500_WithSanitizedErrorBody() { @@ -467,6 +496,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Contains("boom-msg", capturedDetail); } + /// <summary>Verifies that successful authentication stashes the verified key display name on the HTTP context items.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SuccessfulAuth_StashesVerifiedKeyDisplayNameOnHttpContextItems() { @@ -505,6 +536,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Equal("audit-actor-name", capture.CapturedActor); } + /// <summary>Verifies that a valid request emits the inbound API request counter tagged with the resolved method name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ValidRequest_EmitsInboundApiRequestCounter_TaggedWithResolvedMethodName() { @@ -532,6 +565,8 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Equal(1, echoTotal); } + /// <summary>Verifies that an unknown method emits the inbound API request counter with the bounded forbidden sentinel.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UnknownMethod_EmitsInboundApiRequestCounter_WithBoundedForbiddenSentinel() { @@ -569,6 +604,7 @@ public sealed class EndpointExtensionsTests : IDisposable private readonly List<(long Value, string? Method)> _measurements = new(); private readonly object _gate = new(); + /// <summary>Initializes the collector and starts listening for the inbound API request counter.</summary> public InboundApiRequestCounterCollector() { _listener = new MeterListener @@ -601,6 +637,7 @@ public sealed class EndpointExtensionsTests : IDisposable _listener.Start(); } + /// <summary>Gets a snapshot of the recorded counter measurements.</summary> public IReadOnlyList<(long Value, string? Method)> Measurements { get @@ -612,19 +649,22 @@ public sealed class EndpointExtensionsTests : IDisposable } } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() => _listener.Dispose(); } - // --- InboundAPI-028: drive the new script-facing capabilities (Database + + // --- Drive the new script-facing capabilities (Database + // WaitForAttribute) end-to-end through the real POST /api/{methodName} flow, // so a wiring regression in executor→context→helper / RouteHelper is caught. --- + /// <summary>Verifies that a script using the database helper runs end to end through the endpoint.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Script_UsingDatabase_RunsEndToEndThroughEndpoint() { // A method whose script reads through Database.QueryAsync — proving the executor // builds the Database helper, resolves IDatabaseGateway from the per-execution - // DI scope, and the parameterized read reaches the body. (InboundAPI-028) + // DI scope, and the parameterized read reaches the body. var method = SeedMethod(1, "movein", "return await Database.QuerySingleAsync<string>(\"BTDB\", \"SELECT Code FROM Machine WHERE SAPID=@s\", new { s = (string)Parameters[\"sap\"] });", """[{"name":"sap","type":"String","required":true}]"""); @@ -644,12 +684,14 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Contains("Z28061A", body); } + /// <summary>Verifies that a script using WaitForAttribute runs end to end through the endpoint.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Script_UsingWaitForAttribute_RunsEndToEndThroughEndpoint() { // A method whose script awaits Route.To(...).WaitForAttribute(...) — proving the // executor binds the RouteHelper and the wait routes through IInstanceRouter and - // returns its result through the endpoint. (InboundAPI-028) + // returns its result through the endpoint. var method = SeedMethod(2, "waitmethod", "return await Route.To(\"inst-1\").WaitForAttribute(\"Flag\", true, System.TimeSpan.FromSeconds(1));"); @@ -690,6 +732,8 @@ public sealed class EndpointExtensionsTests : IDisposable { private readonly string _cs; private readonly SqliteConnection _keepAlive; + /// <summary>Initializes the gateway with an in-memory SQLite connection string and seeds the machine table.</summary> + /// <param name="cs">The SQLite connection string.</param> public SqliteGateway(string cs) { _cs = cs; @@ -699,12 +743,15 @@ public sealed class EndpointExtensionsTests : IDisposable cmd.CommandText = "CREATE TABLE IF NOT EXISTS Machine(Code TEXT, SAPID TEXT); DELETE FROM Machine; INSERT INTO Machine VALUES('Z28061A','131453');"; cmd.ExecuteNonQuery(); } + /// <inheritdoc /> public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default) { var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; } + /// <inheritdoc /> public Task<ExternalCallResult> CachedWriteAsync(string c, string s, IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default, TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null) => throw new NotImplementedException(); + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() => _keepAlive.Dispose(); } @@ -836,6 +883,7 @@ public sealed class EndpointExtensionsTests : IDisposable return await hostBuilder.StartAsync(); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { try diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointGatingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointGatingTests.cs index 1eb62eab..fc50144b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointGatingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointGatingTests.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// <summary> -/// InboundAPI-006 / InboundAPI-008: the POST /api/{methodName} endpoint must be +/// The POST /api/{methodName} endpoint must be /// gated to the active central node and must cap the request body size. These /// behaviours are enforced by <see cref="InboundApiEndpointFilter"/>. /// </summary> @@ -46,8 +46,10 @@ public class EndpointGatingTests }; } - // --- InboundAPI-008: standby node must not serve inbound API calls --- + // --- Standby node must not serve inbound API calls --- + /// <summary>Verifies that a standby node short-circuits with a 503 and does not run the handler.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StandbyNode_ShortCircuitsWith503_AndDoesNotRunHandler() { @@ -61,6 +63,8 @@ public class EndpointGatingTests Assert.Equal(StatusCodes.Status503ServiceUnavailable, status.StatusCode); } + /// <summary>Verifies that an active node passes the gate and runs the handler.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ActiveNode_PassesGate_RunsHandler() { @@ -72,6 +76,8 @@ public class EndpointGatingTests Assert.True(handlerRan()); } + /// <summary>Verifies that the handler runs when no gate is registered.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoGateRegistered_PassesGate_RunsHandler() { @@ -85,8 +91,10 @@ public class EndpointGatingTests Assert.True(handlerRan()); } - // --- InboundAPI-006: request body size must be capped --- + // --- Request body size must be capped --- + /// <summary>Verifies that an oversized body short-circuits with a 413 and does not run the handler.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OversizedBody_ShortCircuitsWith413_AndDoesNotRunHandler() { @@ -102,6 +110,8 @@ public class EndpointGatingTests Assert.Equal(StatusCodes.Status413PayloadTooLarge, status.StatusCode); } + /// <summary>Verifies that a body exactly at the limit runs the handler.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BodyAtLimit_RunsHandler() { @@ -115,6 +125,8 @@ public class EndpointGatingTests Assert.True(handlerRan()); } + /// <summary>Verifies that the filter caps the max request body size feature.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FilterCapsMaxRequestBodySizeFeature() { @@ -135,18 +147,25 @@ public class EndpointGatingTests private sealed class StubActiveNodeGate : IActiveNodeGate { private readonly bool _isActive; + /// <summary>Initializes the gate with a fixed active-node answer.</summary> + /// <param name="isActive">Whether this node reports itself as active.</param> public StubActiveNodeGate(bool isActive) => _isActive = isActive; + /// <inheritdoc /> public bool IsActiveNode => _isActive; } private sealed class StubMaxBodySizeFeature : IHttpMaxRequestBodySizeFeature { + /// <summary>Gets a value indicating the feature is writable (always false).</summary> public bool IsReadOnly => false; + /// <summary>Gets or sets the maximum request body size in bytes.</summary> public long? MaxRequestBodySize { get; set; } } // --- InboundAPI hot-reload: options must be re-read per request, not cached at ctor time --- + /// <summary>Verifies that the body size cap hot-reloads without a restart.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BodySizeCap_HotReloads_WithoutRestart() { @@ -172,9 +191,18 @@ public class EndpointGatingTests private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T> { + /// <summary>Initializes the monitor with a fixed current value.</summary> + /// <param name="currentValue">The value returned by the monitor.</param> public TestOptionsMonitor(T currentValue) => CurrentValue = currentValue; + /// <summary>Gets the fixed options instance.</summary> public T CurrentValue { get; set; } + /// <summary>Returns the fixed options instance regardless of name.</summary> + /// <param name="name">The named-options key, ignored.</param> + /// <returns>The fixed options instance.</returns> public T Get(string? name) => CurrentValue; + /// <summary>Does nothing; the fake never raises change notifications.</summary> + /// <param name="listener">The change listener, ignored.</param> + /// <returns>Always null.</returns> public IDisposable? OnChange(Action<T, string?> listener) => null; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ForbiddenApiCheckerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ForbiddenApiCheckerTests.cs index 7cebaf3d..4a9b6a0c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ForbiddenApiCheckerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ForbiddenApiCheckerTests.cs @@ -1,16 +1,16 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// <summary> -/// InboundAPI-005 / InboundAPI-015: tests for the script-trust-model checker. +/// Tests for the script-trust-model checker. /// -/// Since M3.4 <see cref="ForbiddenApiChecker"/> delegates to the shared +/// The <see cref="ForbiddenApiChecker"/> delegates to the shared /// <c>ScriptTrustValidator</c> in <c>ZB.MOM.WW.ScadaBridge.ScriptAnalysis</c>. /// The unified policy differs from the old local deny-list in one notable way: /// <c>System.Diagnostics</c> is loosened to <c>System.Diagnostics.Process</c> /// only — <c>Stopwatch</c>, <c>Debug</c>, and other non-Process Diagnostics /// types are now ALLOWED. /// -/// InboundAPI-015 hardening (reflection-gateway / dynamic / Activator) is +/// Hardening (reflection-gateway / dynamic / Activator) is /// preserved unchanged via the shared validator. /// /// Violation message wording is intentionally not asserted — callers should @@ -23,6 +23,8 @@ public class ForbiddenApiCheckerTests // --- Baseline: legitimate scripts must still pass --- + /// <summary>Verifies that a legitimate script is not rejected.</summary> + /// <param name="script">The permitted script source.</param> [Theory] [InlineData("return 1 + 1;")] [InlineData("var list = new List<int> { 1, 2, 3 }; return list.Sum();")] @@ -36,9 +38,10 @@ public class ForbiddenApiCheckerTests } // --- System.Diagnostics loosening: Process is still forbidden; non-Process types - // (Stopwatch, Debug) are now ALLOWED by the unified policy (M3.4). The old + // (Stopwatch, Debug) are now ALLOWED by the unified policy. The old // InboundAPI checker blocked the entire System.Diagnostics namespace. --- + /// <summary>Verifies that System.Diagnostics.Process is still forbidden.</summary> [Fact] public void Diagnostics_Process_StillForbidden() { @@ -46,6 +49,7 @@ public class ForbiddenApiCheckerTests Assert.True(IsRejected("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;")); } + /// <summary>Verifies that Stopwatch from System.Diagnostics is now allowed.</summary> [Fact] public void Diagnostics_Stopwatch_NowAllowed() { @@ -55,6 +59,7 @@ public class ForbiddenApiCheckerTests Assert.False(IsRejected("var sw = new System.Diagnostics.Stopwatch(); sw.Start(); return null;")); } + /// <summary>Verifies that Debug from System.Diagnostics is now allowed.</summary> [Fact] public void Diagnostics_Debug_NowAllowed() { @@ -64,6 +69,8 @@ public class ForbiddenApiCheckerTests // --- Baseline: forbidden namespaces (textual) must still be rejected --- + /// <summary>Verifies that a script using a forbidden namespace is rejected.</summary> + /// <param name="script">The script source that uses a forbidden namespace.</param> [Theory] [InlineData("System.IO.File.Delete(\"/tmp/x\"); return null;")] [InlineData("using System.Reflection; return null;")] @@ -73,8 +80,9 @@ public class ForbiddenApiCheckerTests Assert.True(IsRejected(script), script); } - // --- InboundAPI-015: reflection reachable without a forbidden namespace token --- + // --- Reflection reachable without a forbidden namespace token --- + /// <summary>Verifies that reflection through assembly property access is rejected.</summary> [Fact] public void Reflection_AssemblyPropertyAccess_Rejected() { @@ -82,6 +90,7 @@ public class ForbiddenApiCheckerTests Assert.True(IsRejected("var a = typeof(string).Assembly; return null;")); } + /// <summary>Verifies that reflection through Assembly.GetType is rejected.</summary> [Fact] public void Reflection_AssemblyGetType_Rejected() { @@ -90,6 +99,7 @@ public class ForbiddenApiCheckerTests "var t = typeof(string).Assembly.GetType(\"System.IO.File\"); return null;")); } + /// <summary>Verifies that reflection through Object.GetType is rejected.</summary> [Fact] public void Reflection_ObjectGetType_Rejected() { @@ -97,12 +107,14 @@ public class ForbiddenApiCheckerTests Assert.True(IsRejected("var t = \"\".GetType(); return null;")); } + /// <summary>Verifies that the static Type.GetType reflection entry point is rejected.</summary> [Fact] public void Reflection_TypeGetTypeStatic_Rejected() { Assert.True(IsRejected("var t = Type.GetType(\"System.IO.File\"); return null;")); } + /// <summary>Verifies that Activator.CreateInstance is rejected.</summary> [Fact] public void Reflection_ActivatorCreateInstance_Rejected() { @@ -110,6 +122,7 @@ public class ForbiddenApiCheckerTests "var o = Activator.CreateInstance(Type.GetType(\"System.IO.File\")); return null;")); } + /// <summary>Verifies that Type.InvokeMember is rejected.</summary> [Fact] public void Reflection_InvokeMember_Rejected() { @@ -117,6 +130,7 @@ public class ForbiddenApiCheckerTests "typeof(object).InvokeMember(\"x\", default, null, null, null); return null;")); } + /// <summary>Verifies that GetMethod followed by Invoke is rejected.</summary> [Fact] public void Reflection_GetMethodInvoke_Rejected() { @@ -124,12 +138,14 @@ public class ForbiddenApiCheckerTests "var m = typeof(object).GetMethod(\"ToString\"); return null;")); } + /// <summary>Verifies that GetTypeInfo is rejected.</summary> [Fact] public void Reflection_GetTypeInfo_Rejected() { Assert.True(IsRejected("var ti = \"\".GetType().GetTypeInfo(); return null;")); } + /// <summary>Verifies that the dynamic keyword is rejected.</summary> [Fact] public void DynamicKeyword_Rejected() { @@ -139,6 +155,8 @@ public class ForbiddenApiCheckerTests // --- FindViolations returns a non-null, non-throwing result for edge inputs --- + /// <summary>Verifies that an empty, whitespace or null script yields an empty violation list.</summary> + /// <param name="script">The empty, whitespace or null script source.</param> [Theory] [InlineData("")] [InlineData(" ")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundApiOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundApiOptionsValidatorTests.cs index 9ba46776..a58e9cb4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundApiOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundApiOptionsValidatorTests.cs @@ -16,6 +16,8 @@ public class InboundApiOptionsValidatorTests private static ValidateOptionsResult Validate(InboundApiOptions options) => new InboundApiOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the default options are valid.</summary> + /// <returns>The result.</returns> [Fact] public void DefaultOptions_AreValid() { @@ -23,6 +25,8 @@ public class InboundApiOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that an empty API key pepper is valid.</summary> + /// <returns>The result.</returns> [Fact] public void EmptyApiKeyPepper_IsValid() { @@ -30,6 +34,8 @@ public class InboundApiOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero default method timeout is rejected.</summary> + /// <returns>The result.</returns> [Fact] public void ZeroDefaultMethodTimeout_IsRejected() { @@ -39,6 +45,8 @@ public class InboundApiOptionsValidatorTests Assert.Contains("DefaultMethodTimeout", result.FailureMessage); } + /// <summary>Verifies that a negative default method timeout is rejected.</summary> + /// <returns>The result.</returns> [Fact] public void NegativeDefaultMethodTimeout_IsRejected() { @@ -48,6 +56,8 @@ public class InboundApiOptionsValidatorTests Assert.Contains("DefaultMethodTimeout", result.FailureMessage); } + /// <summary>Verifies that a zero maximum request body size is rejected.</summary> + /// <returns>The result.</returns> [Fact] public void ZeroMaxRequestBodyBytes_IsRejected() { @@ -57,6 +67,8 @@ public class InboundApiOptionsValidatorTests Assert.Contains("MaxRequestBodyBytes", result.FailureMessage); } + /// <summary>Verifies that a negative maximum request body size is rejected.</summary> + /// <returns>The result.</returns> [Fact] public void NegativeMaxRequestBodyBytes_IsRejected() { @@ -66,6 +78,8 @@ public class InboundApiOptionsValidatorTests Assert.Contains("MaxRequestBodyBytes", result.FailureMessage); } + /// <summary>Verifies that a zero API method cache TTL is rejected.</summary> + /// <returns>The result.</returns> [Fact] public void ZeroApiMethodCacheTtl_IsRejected() { @@ -75,6 +89,8 @@ public class InboundApiOptionsValidatorTests Assert.Contains("ApiMethodCacheTtl", result.FailureMessage); } + /// <summary>Verifies that a negative API method cache TTL is rejected.</summary> + /// <returns>The result.</returns> [Fact] public void NegativeApiMethodCacheTtl_IsRejected() { @@ -86,6 +102,8 @@ public class InboundApiOptionsValidatorTests // --- Standby forwarding (2026-08-28) --- + /// <summary>Verifies that absolute HTTP URLs are valid standby forward peer endpoints.</summary> + /// <returns>The result.</returns> [Fact] public void StandbyForwardPeerEndpoints_AbsoluteHttpUrls_AreValid() { @@ -96,6 +114,9 @@ public class InboundApiOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that non-HTTP or relative standby forward peer endpoints are rejected.</summary> + /// <param name="peer">The peer.</param> + /// <returns>The result.</returns> [Theory] [InlineData("central-b:5000")] [InlineData("/api")] @@ -109,6 +130,8 @@ public class InboundApiOptionsValidatorTests Assert.Contains("StandbyForwardPeerEndpoints", result.FailureMessage); } + /// <summary>Verifies that a zero standby forward timeout is rejected.</summary> + /// <returns>The result.</returns> [Fact] public void ZeroStandbyForwardTimeout_IsRejected() { @@ -120,6 +143,9 @@ public class InboundApiOptionsValidatorTests // --- Flow Debugger step logs (design 2026-09-09 D7) --- + /// <summary>Verifies that a non-positive maximum log line count per run is rejected.</summary> + /// <param name="max">The max.</param> + /// <returns>The result.</returns> [Theory] [InlineData(0)] [InlineData(-1)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperAuditTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperAuditTests.cs index 4230c963..4b66ac11 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperAuditTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperAuditTests.cs @@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// <summary> -/// Flow Debugger outbound calls (plan 2026-09-06, Task 4 / D7): every +/// Flow Debugger outbound calls (plan 2026-09-06, D7): every /// <see cref="InboundDatabaseHelper"/> call made by a central method script writes ONE /// <c>DbOutbound.DbWrite</c> row through <see cref="ICentralAuditWriter"/> — best-effort, /// never into the script — using the site's <c>AuditingDbCommand</c> vocabulary @@ -32,6 +32,7 @@ public class InboundDatabaseHelperAuditTests private readonly string _cs = $"DataSource=file:dbaudit-{Guid.NewGuid():N}?mode=memory&cache=shared"; private readonly SqliteConnection _keepAlive; + /// <summary>Opens the keep-alive connection and seeds the Machine table.</summary> public SqliteGateway() { _keepAlive = new SqliteConnection(_cs); @@ -41,8 +42,10 @@ public class InboundDatabaseHelperAuditTests cmd.ExecuteNonQuery(); } + /// <inheritdoc /> public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default) { var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; } + /// <inheritdoc /> public Task<ExternalCallResult> CachedWriteAsync(string c, string s, IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default, TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null) @@ -51,8 +54,10 @@ public class InboundDatabaseHelperAuditTests private sealed class ThrowingGateway : IDatabaseGateway { + /// <inheritdoc /> public Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default) => throw new InvalidOperationException("no such connection: " + name); + /// <inheritdoc /> public Task<ExternalCallResult> CachedWriteAsync(string c, string s, IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default, TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null) @@ -66,11 +71,26 @@ public class InboundDatabaseHelperAuditTests /// </summary> private sealed class RecordingLogger : ILogger { + /// <summary>Gets the log entries recorded so far.</summary> public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new(); + /// <summary>Returns no scope; scopes are not captured.</summary> + /// <typeparam name="TState">The scope state type.</typeparam> + /// <param name="state">The scope state (ignored).</param> + /// <returns>Always null.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level being queried.</param> + /// <returns>Always true.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the formatted log entry for later assertions.</summary> + /// <typeparam name="TState">The log state type.</typeparam> + /// <param name="logLevel">The entry's log level.</param> + /// <param name="eventId">The entry's event id.</param> + /// <param name="state">The entry's state.</param> + /// <param name="exception">The exception attached to the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) @@ -87,6 +107,7 @@ public class InboundDatabaseHelperAuditTests private readonly Guid _exec = Guid.NewGuid(); private readonly Guid _parent = Guid.NewGuid(); + /// <summary>Wires the audit writer double to record written events and names the node.</summary> public InboundDatabaseHelperAuditTests() { _writer.WriteAsync(Arg.Do<AuditEvent>(e => _written.Add(e)), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask); @@ -99,6 +120,8 @@ public class InboundDatabaseHelperAuditTests auditWriter: writer ?? _writer, nodeIdentity: _node, executionId: _exec, parentExecutionId: _parent, methodName: "movein", traceSink: sink); + /// <summary>Verifies that a successful execute writes a delivered database-write row carrying the rows-affected count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteAsync_WritesDbWriteDeliveredRow_WithRowsAffected() { @@ -126,6 +149,8 @@ public class InboundDatabaseHelperAuditTests Assert.Contains("\"@c\":\"Z28061A\"", row.RequestSummary); } + /// <summary>Verifies that a query writes a read row carrying the rows-returned count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_WritesReadRow_WithRowsReturned() { @@ -140,6 +165,8 @@ public class InboundDatabaseHelperAuditTests Assert.Equal("{\"op\":\"read\",\"rowsReturned\":1}", row.Extra); } + /// <summary>Verifies that a single-row query with no match writes a read row reporting zero rows returned.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QuerySingleAsync_WritesReadRow_ZeroRowsWhenNoMatch() { @@ -152,6 +179,8 @@ public class InboundDatabaseHelperAuditTests Assert.Equal("{\"op\":\"read\",\"rowsReturned\":0}", row.Extra); } + /// <summary>Verifies that a gateway failure writes a failed row and the exception still reaches the script.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ThrowingGateway_WritesFailedRow_AndRethrowsToScript() { @@ -170,6 +199,8 @@ public class InboundDatabaseHelperAuditTests Assert.Contains("\"sql\":\"DELETE FROM Machine\"", row.RequestSummary); } + /// <summary>Verifies that a faulting audit writer does not fail the database call.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditWriterFaulting_DoesNotFailTheCall() { @@ -187,6 +218,8 @@ public class InboundDatabaseHelperAuditTests Assert.Equal(1, n2); } + /// <summary>Verifies that a faulting audit writer logs a warning while the call still succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditWriterFaulting_LogsWarning_AndCallStillSucceeds() { @@ -213,6 +246,8 @@ public class InboundDatabaseHelperAuditTests Assert.Contains("MesDb", entry.Message); } + /// <summary>Verifies that the trace sink receives the same event instance before the audit writer does.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TraceSink_ReceivesTheSameInstance_BeforeTheWriter() { @@ -226,6 +261,8 @@ public class InboundDatabaseHelperAuditTests Assert.Same(order[0].Evt, order[1].Evt); } + /// <summary>Verifies that a throwing trace sink does not fail the call and the writer still receives the row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ThrowingSink_DoesNotFailTheCall_AndWriterStillGetsTheRow() { @@ -237,6 +274,8 @@ public class InboundDatabaseHelperAuditTests Assert.Single(_written); } + /// <summary>Verifies that with no writer and no sink nothing is emitted and the call still works.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoWriterNoSink_EmitsNothing_AndStillWorks() { @@ -248,6 +287,8 @@ public class InboundDatabaseHelperAuditTests Assert.Empty(_written); } + /// <summary>Verifies that the script executor passes the writer, node identity, execution id and route sink to the helper.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Executor_WiresWriterNodeIdentityExecutionIdAndRouteSink_IntoTheHelper() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs index 47508bbf..b50c2fb3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs @@ -12,14 +12,21 @@ public class InboundDatabaseHelperTests private sealed class SqliteGateway : IDatabaseGateway { private readonly string _cs; - // InboundAPI-027 test seam: when false the open ignores the passed token, so a + // Test seam: When false the open ignores the passed token, so a // pre-cancelled token surfaces at Execute/Read (not at open) — letting a test // prove the EXECUTE path honours the deadline token, not just the open. private readonly bool _honorTokenOnOpen; + /// <summary> + /// Initializes a new instance of the <c>SqliteGateway</c> class. + /// </summary> + /// <param name="cs">The cs.</param> + /// <param name="honorTokenOnOpen">The honor token on open.</param> public SqliteGateway(string cs, bool honorTokenOnOpen = true) { _cs = cs; _honorTokenOnOpen = honorTokenOnOpen; } + /// <inheritdoc /> public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default) { var c = new SqliteConnection(_cs); await c.OpenAsync(_honorTokenOnOpen ? ct : CancellationToken.None); return c; } + /// <inheritdoc /> public Task<ExternalCallResult> CachedWriteAsync(string c, string s, IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default, TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null) @@ -36,6 +43,8 @@ public class InboundDatabaseHelperTests return new SqliteGateway("DataSource=file:movein?mode=memory&cache=shared", honorTokenOnOpen); } + /// <summary>Verifies that query single async returns first column with bound parameter.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QuerySingleAsync_returns_first_column_with_bound_parameter() { @@ -44,6 +53,8 @@ public class InboundDatabaseHelperTests Assert.Equal("Z28061A", code); } + /// <summary>Verifies that query single async returns default when no rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QuerySingleAsync_returns_default_when_no_rows() { @@ -52,6 +63,8 @@ public class InboundDatabaseHelperTests Assert.Null(code); } + /// <summary>Verifies that query async returns row with case insensitive keys.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_returns_row_with_case_insensitive_keys() { @@ -63,6 +76,8 @@ public class InboundDatabaseHelperTests Assert.Equal("Z28061A", row["CODE"]); } + /// <summary>Verifies that query async returns empty list when no rows match.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_returns_empty_list_when_no_rows_match() { @@ -71,8 +86,10 @@ public class InboundDatabaseHelperTests Assert.Empty(rows); } - // --- InboundAPI-026: writes are permitted (design decision) --- + // --- Writes are permitted (design decision) --- + /// <summary>Verifies that execute async performs write and returns rows affected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteAsync_performs_write_and_returns_rows_affected() { @@ -86,8 +103,10 @@ public class InboundDatabaseHelperTests Assert.Equal("UPDATED", code); } - // --- InboundAPI-026: SQL-injection protection — values are bound, not concatenated --- + // --- SQL-injection protection — values are bound, not concatenated --- + /// <summary>Verifies that parameter values are bound not concatenated.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ParameterValues_are_bound_not_concatenated() { @@ -101,8 +120,10 @@ public class InboundDatabaseHelperTests Assert.Null(code); } - // --- InboundAPI-028: negative paths --- + // --- Negative paths --- + /// <summary>Verifies that query single async null gateway throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QuerySingleAsync_null_gateway_throws() { @@ -111,6 +132,8 @@ public class InboundDatabaseHelperTests () => helper.QuerySingleAsync<string>("BTDB", "SELECT 1")); } + /// <summary>Verifies that query async null gateway throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QueryAsync_null_gateway_throws() { @@ -119,6 +142,8 @@ public class InboundDatabaseHelperTests () => helper.QueryAsync("BTDB", "SELECT 1")); } + /// <summary>Verifies that execute async null gateway throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteAsync_null_gateway_throws() { @@ -127,8 +152,10 @@ public class InboundDatabaseHelperTests () => helper.ExecuteAsync("BTDB", "DELETE FROM Machine")); } - // --- InboundAPI-027: the method-deadline token bounds the query on the EXECUTE path --- + // --- The method-deadline token bounds the query on the EXECUTE path --- + /// <summary>Verifies that query single async honours cancellation token on execute.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task QuerySingleAsync_honours_cancellation_token_on_execute() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorStalenessTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorStalenessTests.cs index 88f8e8d0..cfcd140d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorStalenessTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorStalenessTests.cs @@ -16,6 +16,7 @@ public class InboundScriptExecutorStalenessTests private readonly InboundScriptExecutor _executor; private readonly RouteHelper _route; + /// <summary>Initializes the test class with an executor and route helper backed by substitutes.</summary> public InboundScriptExecutorStalenessTests() { _executor = new InboundScriptExecutor( @@ -27,6 +28,8 @@ public class InboundScriptExecutorStalenessTests private Task<InboundScriptResult> Run(ApiMethod m) => _executor.ExecuteAsync( m, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10)); + /// <summary>Verifies that an updated script is recompiled rather than served from a stale handler.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdatedScript_RecompilesInsteadOfServingStaleHandler() { @@ -45,6 +48,8 @@ public class InboundScriptExecutorStalenessTests Assert.Contains("2", r2.ResultJson); // old cache would have returned 1 } + /// <summary>Verifies that a known-bad method whose script was fixed is compiled instead of fast-failing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task KnownBadMethod_FixedScript_CompilesInsteadOfFastFailing() { @@ -63,6 +68,8 @@ public class InboundScriptExecutorStalenessTests Assert.Contains("42", r2.ResultJson); } + /// <summary>Verifies that a known-bad method with an unchanged script still fast-fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task KnownBadMethod_SameScript_StillFastFails() { @@ -76,6 +83,8 @@ public class InboundScriptExecutorStalenessTests Assert.Contains("Script compilation failed", r.ErrorMessage); } + /// <summary>Verifies that a handler registered through the test seam without a script is not revision checked.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TestSeamRegisterHandler_WithoutScript_IsNotRevisionChecked() { @@ -87,6 +96,8 @@ public class InboundScriptExecutorStalenessTests Assert.Contains("99", r.ResultJson); } + /// <summary>Verifies that invalidating a method drops both its handler and its known-bad record.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InvalidateMethod_DropsHandlerAndKnownBadRecord() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs index 728b30f0..091bb383 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs @@ -17,14 +17,15 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// <summary> -/// WP-3: Tests for script execution on central — timeout, handler dispatch, error handling. -/// WP-5: Safe error messages. +/// Tests for script execution on central — timeout, handler dispatch, error handling. +/// Also covers safe error messages. /// </summary> public class InboundScriptExecutorTests { private readonly InboundScriptExecutor _executor; private readonly RouteHelper _route; + /// <summary>Initializes a new instance of the <see cref="InboundScriptExecutorTests"/> class.</summary> public InboundScriptExecutorTests() { _executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>()); @@ -33,6 +34,8 @@ public class InboundScriptExecutorTests _route = new RouteHelper(locator, router); } + /// <summary>Verifies that the trace flag reaches the routed call request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteAsync_TraceFlag_ReachesTheRoutedCallRequest() { @@ -62,6 +65,8 @@ public class InboundScriptExecutorTests Assert.False(captured[1].Trace); } + /// <summary>Verifies that a method with SkipBodyCapture set routes with body capture turned off.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Executor_MethodWithSkipBodyCapture_RoutesWithBodyCaptureOff() { @@ -99,6 +104,8 @@ public class InboundScriptExecutorTests Assert.Null(row.ResponseSummary); } + /// <summary>Verifies that a method without SkipBodyCapture routes with body capture turned on.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Executor_MethodWithoutSkipBodyCapture_RoutesWithBodyCaptureOn() { @@ -133,6 +140,8 @@ public class InboundScriptExecutorTests Assert.Equal("1", row.ResponseSummary); } + /// <summary>Verifies that a registered handler executes successfully.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RegisteredHandler_ExecutesSuccessfully() { @@ -154,6 +163,8 @@ public class InboundScriptExecutorTests Assert.Contains("42", result.ResultJson); } + /// <summary>Verifies that an unregistered handler with an invalid script returns a compilation failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UnregisteredHandler_InvalidScript_ReturnsCompilationFailure() { @@ -170,6 +181,8 @@ public class InboundScriptExecutorTests Assert.Contains("Script compilation failed", result.ErrorMessage); } + /// <summary>Verifies that an unregistered handler with a valid script is compiled lazily.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UnregisteredHandler_ValidScript_LazyCompiles() { @@ -185,6 +198,8 @@ public class InboundScriptExecutorTests Assert.True(result.Success); } + /// <summary>Verifies that a throwing handler returns a safe error message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HandlerThrows_ReturnsSafeErrorMessage() { @@ -198,10 +213,12 @@ public class InboundScriptExecutorTests TimeSpan.FromSeconds(10)); Assert.False(result.Success); - // WP-5: Safe error message — should NOT contain "internal detail leak" + // Safe error message — should NOT contain "internal detail leak" Assert.Equal("Internal script error", result.ErrorMessage); } + /// <summary>Verifies that the error detail carries the exception while the message stays safe.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HandlerThrows_ErrorDetailCarriesTheException_WhileTheMessageStaysSafe() { @@ -229,6 +246,8 @@ public class InboundScriptExecutorTests Assert.DoesNotContain("real reason", result.ErrorMessage!); } + /// <summary>Verifies that a handler that times out carries no error detail.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HandlerTimesOut_CarriesNoErrorDetail() { @@ -252,6 +271,8 @@ public class InboundScriptExecutorTests Assert.Null(result.ErrorDetail); } + /// <summary>Verifies that a timed-out routed ask returns a 500 with the site-unreachable code.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RoutedAskTimeout_Returns500WithSiteUnreachableCode() { @@ -276,6 +297,8 @@ public class InboundScriptExecutorTests Assert.Equal("SITE_UNREACHABLE", result.ErrorCode); } + /// <summary>Verifies that a site failure whose message mentions unreachable is reported as a script error rather than site unreachable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiteRespondedFailure_MessageMentioningUnreachable_IsScriptErrorNotSiteUnreachable() { @@ -303,6 +326,8 @@ public class InboundScriptExecutorTests Assert.Equal("SCRIPT_ERROR", result.ErrorCode); } + /// <summary>Verifies that a handler that times out returns a timeout error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HandlerTimesOut_ReturnsTimeoutError() { @@ -323,6 +348,8 @@ public class InboundScriptExecutorTests Assert.Contains("timed out", result.ErrorMessage); } + /// <summary>Verifies that a handler can access the request parameters.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HandlerAccessesParameters() { @@ -342,8 +369,10 @@ public class InboundScriptExecutorTests Assert.Contains("ScadaBridge", result.ResultJson!); } - // --- InboundAPI-001: concurrent lazy-compile must not corrupt the handler cache --- + // --- Concurrent lazy-compile must not corrupt the handler cache --- + /// <summary>Verifies that concurrent lazy compilation of the same method does not corrupt the handler cache.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConcurrentLazyCompile_SameMethod_DoesNotCorruptCache() { @@ -368,6 +397,8 @@ public class InboundScriptExecutorTests }); } + /// <summary>Verifies that concurrent registration and execution do not throw.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConcurrentRegisterAndExecute_DoesNotThrow() { @@ -397,8 +428,10 @@ public class InboundScriptExecutorTests await Task.WhenAll(writers.Concat(readers)); } - // --- InboundAPI-005: compiled scripts must not bypass the script trust model --- + // --- Compiled scripts must not bypass the script trust model --- + /// <summary>Verifies that compiling and registering a script that uses a forbidden API rejects the script.</summary> + /// <param name="script">The script source that uses a forbidden API.</param> [Theory] [InlineData("System.IO.File.Delete(\"/tmp/x\"); return null;")] [InlineData("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;")] @@ -414,6 +447,8 @@ public class InboundScriptExecutorTests Assert.False(registered); } + /// <summary>Verifies that executing a script that uses a forbidden API does not run it and returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteAsync_ForbiddenApiScript_DoesNotRunAndReturnsFailure() { @@ -436,6 +471,7 @@ public class InboundScriptExecutorTests Assert.False(System.IO.File.Exists(marker)); } + /// <summary>Verifies that a permitted script still registers.</summary> [Fact] public void CompileAndRegister_PermittedScript_StillRegisters() { @@ -451,6 +487,7 @@ public class InboundScriptExecutorTests // --- Compile-diagnostics overload: surface errors so a non-compiling save is not a silent no-op --- + /// <summary>Verifies that a script that does not compile reports errors.</summary> [Fact] public void CompileAndRegister_NonCompilingScript_ReportsErrors() { @@ -465,6 +502,7 @@ public class InboundScriptExecutorTests Assert.NotEmpty(errors); } + /// <summary>Verifies that a valid script reports no errors.</summary> [Fact] public void CompileAndRegister_ValidScript_ReportsNoErrors() { @@ -476,6 +514,8 @@ public class InboundScriptExecutorTests Assert.Empty(errors); } + /// <summary>Verifies that executing after a non-compiling update surfaces the compile failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CompileAndRegister_NonCompilingUpdate_ExecuteSurfacesCompileFailure() { @@ -509,8 +549,10 @@ public class InboundScriptExecutorTests Assert.Contains("111", stillGood.ResultJson); } - // --- InboundAPI-002: lazy compile-and-fetch must be atomic, never KeyNotFoundException --- + // --- Lazy compile-and-fetch must be atomic, never KeyNotFoundException --- + /// <summary>Verifies that a lazy compile racing a handler removal never throws a <see cref="KeyNotFoundException"/>.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LazyCompile_RacingRemoveHandler_NeverThrowsKeyNotFound() { @@ -540,8 +582,10 @@ public class InboundScriptExecutorTests await Task.WhenAll(removers.Concat(callers)); } - // --- InboundAPI-004: a client disconnect must NOT be reported as a script timeout --- + // --- A client disconnect must NOT be reported as a script timeout --- + /// <summary>Verifies that a client disconnect is not reported as a timeout.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ClientDisconnect_IsNotReportedAsTimeout() { @@ -570,11 +614,13 @@ public class InboundScriptExecutorTests Assert.DoesNotContain("timed out", result.ErrorMessage ?? string.Empty); } + /// <summary>Verifies that a genuine timeout is still reported as a timeout.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GenuineTimeout_StillReportedAsTimeout() { // A method that exceeds its timeout with no client abort must still be - // reported as "timed out" (regression guard for the InboundAPI-004 fix). + // reported as "timed out" (regression guard for the client-disconnect fix). var method = new ApiMethod("genuine", "return 1;") { Id = 1, TimeoutSeconds = 1 }; _executor.RegisterHandler("genuine", async ctx => { @@ -593,9 +639,11 @@ public class InboundScriptExecutorTests Assert.Contains("timed out", result.ErrorMessage); } - // --- InboundAPI-009: a script that fails to compile must be compiled at most + // --- A script that fails to compile must be compiled at most // once — repeated calls must not re-run the expensive Roslyn compilation. --- + /// <summary>Verifies that a failed compilation is not retried on every request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FailedCompilation_IsNotRetriedOnEveryRequest() { @@ -620,6 +668,7 @@ public class InboundScriptExecutorTests Assert.Equal(1, counter.CompilationFailures); } + /// <summary>Verifies that a failed compilation is recompiled after compile-and-register is called again.</summary> [Fact] public void FailedCompilation_RecompilesAfterCompileAndRegisterCalledAgain() { @@ -632,9 +681,10 @@ public class InboundScriptExecutorTests Assert.True(_executor.CompileAndRegister(good)); } - // --- InboundAPI-024: _knownBadMethods must be bounded so a spam attack of + // --- _knownBadMethods must be bounded so a spam attack of // unique method names cannot grow the cache without bound. --- + /// <summary>Verifies that the known-bad-methods cache never exceeds its cap under a flood of unique method names.</summary> [Fact] public void KnownBadMethodsCache_SizeNeverExceedsCap_UnderUniqueNameFlood() { @@ -656,6 +706,8 @@ public class InboundScriptExecutorTests $"known-bad cache size {_executor.KnownBadMethodCount} must not exceed cap {cap}"); } + /// <summary>Verifies that the lazy-compile path also keeps the known-bad-methods cache capped under a flood of unique method names.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task KnownBadMethodsCache_LazyCompilePath_AlsoCappedUnderUniqueNameFlood() { @@ -678,8 +730,10 @@ public class InboundScriptExecutorTests $"known-bad cache size {_executor.KnownBadMethodCount} must not exceed cap {cap}"); } - // --- InboundAPI-014: the script return value is validated against ReturnDefinition --- + // --- The script return value is validated against ReturnDefinition --- + /// <summary>Verifies that a return value matching the return definition succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReturnValue_MatchingReturnDefinition_Succeeds() { @@ -701,6 +755,8 @@ public class InboundScriptExecutorTests Assert.True(result.Success, result.ErrorMessage); } + /// <summary>Verifies that a return value not matching the return definition returns a failure instead of a malformed 200.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReturnValue_NotMatchingReturnDefinition_ReturnsFailureNotMalformed200() { @@ -725,6 +781,8 @@ public class InboundScriptExecutorTests Assert.Null(result.ResultJson); } + /// <summary>Verifies that a method with no return definition leaves the return value unconstrained.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReturnValue_NoReturnDefinition_IsUnconstrained() { @@ -744,9 +802,11 @@ public class InboundScriptExecutorTests Assert.Contains("whatever", result.ResultJson!); } - // --- Audit Log #23 (ParentExecutionId, T3): the inbound request's + // --- ParentExecutionId: the inbound request's // ExecutionId is threaded through ExecuteAsync onto routed calls --- + /// <summary>Verifies that executing with a parent execution id makes the routed call carry it as its parent execution id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteAsync_WithParentExecutionId_RoutedCallCarriesItAsParentExecutionId() { @@ -780,6 +840,8 @@ public class InboundScriptExecutorTests Assert.Equal(inboundExecutionId, captured!.ParentExecutionId); } + /// <summary>Verifies that executing without a parent execution id gives the routed call a null parent execution id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteAsync_WithoutParentExecutionId_RoutedCallHasNullParentExecutionId() { @@ -812,6 +874,8 @@ public class InboundScriptExecutorTests // --- IpsenMES MoveIn: the read-only Database helper is exposed on the script // context and resolves IDatabaseGateway from a DI scope per execution. --- + /// <summary>Verifies that a script that uses Database queries through the gateway.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Script_UsingDatabase_QueriesViaGateway() { @@ -844,6 +908,8 @@ public class InboundScriptExecutorTests Assert.Contains("Z28061A", result.ResultJson!); } + /// <summary>Verifies that a script that does not use Database runs with a service provider that lacks the gateway.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Script_NotUsingDatabase_RunsWithProviderLackingGateway() { @@ -865,6 +931,8 @@ public class InboundScriptExecutorTests Assert.Contains("99", result.ResultJson!); } + /// <summary>Verifies that a throwing script still disposes its DI scope.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ScriptThrows_DisposesDiScope() { @@ -915,9 +983,13 @@ public class InboundScriptExecutorTests private sealed class SqliteGateway : IDatabaseGateway { private readonly string _cs; + /// <summary>Initializes a new instance of the <see cref="SqliteGateway"/> class.</summary> + /// <param name="cs">The SQLite connection string.</param> public SqliteGateway(string cs) => _cs = cs; + /// <inheritdoc /> public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default) { var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; } + /// <inheritdoc /> public Task<ExternalCallResult> CachedWriteAsync(string c, string s, IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default, TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null) @@ -934,6 +1006,8 @@ public class InboundScriptExecutorTests return new SqliteGateway("DataSource=file:movein-exec?mode=memory&cache=shared"); } + /// <summary>Verifies that a timed-out script has its scope disposal deferred so no scope is orphaned.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TimedOutScript_ScopeDisposalDeferred_NoOrphanLeak() { @@ -956,6 +1030,8 @@ public class InboundScriptExecutorTests // --- Flow Debugger step logs (design 2026-09-09 D4/D5/D7): the Log global --- + /// <summary>Verifies that a traced run emits one ScriptLog row on the trace sink.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Log_TracedRun_EmitsOneScriptLogRowOnTheTraceSink() { @@ -987,6 +1063,8 @@ public class InboundScriptExecutorTests Assert.Null(extra.StepEventId); } + /// <summary>Verifies that a traced run writes the ScriptLog row to the central audit store.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Log_TracedRun_WritesTheScriptLogRowToTheCentralAuditStore() { @@ -1018,6 +1096,8 @@ public class InboundScriptExecutorTests Arg.Any<CancellationToken>()); } + /// <summary>Verifies that an untraced run writes only to the node logger.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Log_UntracedRun_WritesOnlyToTheNodeLogger() { @@ -1045,6 +1125,8 @@ public class InboundScriptExecutorTests Assert.Contains(recorded, m => m.Contains("unwatched", StringComparison.Ordinal)); } + /// <summary>Verifies that a traced log call with an exception carries both the message and the detail.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Log_TracedWithException_CarriesMessageAndDetail() { @@ -1068,6 +1150,8 @@ public class InboundScriptExecutorTests Assert.Equal("warn", ScriptTraceExtra.TryParse(details.Extra)!.LogLevel); } + /// <summary>Verifies that log lines past the per-run maximum are dropped and a single closing runtime warning is emitted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Log_PastMaxLogLinesPerRun_DropsAndEmitsOneClosingRuntimeWarning() { @@ -1110,9 +1194,23 @@ public class InboundScriptExecutorTests // logger saw a line even when nothing was audited. private sealed class RecordingLogger<T>(List<string> messages) : ILogger<T> { + /// <summary>Returns no scope; the fake logger does not track scopes.</summary> + /// <typeparam name="TState">The scope state type.</typeparam> + /// <param name="state">The scope state.</param> + /// <returns>Always null.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level being queried.</param> + /// <returns>Always true.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The log state type.</typeparam> + /// <param name="logLevel">The entry's log level.</param> + /// <param name="eventId">The entry's event id.</param> + /// <param name="state">The entry's state.</param> + /// <param name="exception">The entry's exception, if any.</param> + /// <param name="formatter">Formats the state and exception into the message.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) @@ -1128,9 +1226,24 @@ public class InboundScriptExecutorTests private sealed class CountingLogger<T>(CompileLogCounter counter) : ILogger<T> { + /// <summary>Returns no scope; the fake does not track scopes.</summary> + /// <typeparam name="TState">The type of the scope state.</typeparam> + /// <param name="state">The scope state (ignored).</param> + /// <returns>Always null.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level to check.</param> + /// <returns>Always true.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Counts entries whose message reports a script compilation failure.</summary> + /// <typeparam name="TState">The type of the log state.</typeparam> + /// <param name="logLevel">The severity of the entry.</param> + /// <param name="eventId">The event identifier.</param> + /// <param name="state">The state object to format.</param> + /// <param name="exception">The exception associated with the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptLoggerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptLoggerTests.cs index 9feb4b8f..daf9891b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptLoggerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptLoggerTests.cs @@ -7,7 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// <summary> /// Flow Debugger step logs (design 2026-09-09 D7): the central <c>Log</c> global's per-message cap. -/// Final review M2 — the site runtime's <c>ScriptLogger.Clip</c> already backed off a char when the +/// The site runtime's <c>ScriptLogger.Clip</c> already backed off a char when the /// cap landed between the halves of a surrogate pair; this side did not, so an emoji straddling the /// boundary left a LONE surrogate on the row (which breaks JSON serialization further down the pipe). /// </summary> @@ -21,6 +21,7 @@ public class InboundScriptLoggerTests return (logger, rows); } + /// <summary>Verifies that a surrogate pair straddling the cap: is dropped whole.</summary> [Fact] public void ASurrogatePairStraddlingTheCap_IsDroppedWhole() { @@ -35,6 +36,7 @@ public class InboundScriptLoggerTests Assert.DoesNotContain(message, static c => char.IsSurrogate(c)); } + /// <summary>Verifies that an overlong message with no surrogate at the boundary: keeps the full cap.</summary> [Fact] public void AnOverlongMessageWithNoSurrogateAtTheBoundary_KeepsTheFullCap() { @@ -47,6 +49,7 @@ public class InboundScriptLoggerTests Assert.Equal(new string('x', InboundScriptLogger.MaxMessageChars) + "…", message); } + /// <summary>Verifies that a message exactly at the cap: passes through unclipped.</summary> [Fact] public void AMessageExactlyAtTheCap_PassesThroughUnclipped() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs index f0da0290..085ffdc3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs @@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware; /// <summary> -/// M4 Bundle D (D1) — verifies <see cref="AuditWriteMiddleware"/> emits exactly one +/// Verifies <see cref="AuditWriteMiddleware"/> emits exactly one /// <see cref="AuditChannel.ApiInbound"/> row per request via /// <see cref="ICentralAuditWriter"/> covering all outcome shapes: /// success (InboundRequest/Delivered), client/server error (InboundRequest/Failed), @@ -25,6 +25,10 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware; /// </summary> public class AuditWriteMiddlewareTests { + /// <summary>Verifies that only the peer that serves a relayed request writes the local failure audit row.</summary> + /// <param name="scenario">The scenario selector.</param> + /// <param name="localRows">The expected number of locally written audit rows.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(0, 1)] [InlineData(1, 1)] @@ -58,19 +62,35 @@ public class AuditWriteMiddlewareTests if (localRows > 0) Assert.NotNull(writer.Events[0].ExecutionId); } - private sealed class StandbyGate : IActiveNodeGate { public bool IsActiveNode => false; } + private sealed class StandbyGate : IActiveNodeGate + { + /// <inheritdoc /> + public bool IsActiveNode => false; + } private sealed class ResultForwarder(IResult result) : IStandbyRequestForwarder { + /// <inheritdoc /> public bool IsConfigured => true; + /// <inheritdoc /> public Task<IResult> ForwardAsync(HttpContext context, CancellationToken cancellationToken) => Task.FromResult(result); } private sealed class InboundOptions(InboundApiOptions value) : IOptionsMonitor<InboundApiOptions> { + /// <summary>Gets the fixed options instance.</summary> public InboundApiOptions CurrentValue => value; + /// <summary>Returns the fixed options instance regardless of name.</summary> + /// <param name="name">The named-options key, ignored.</param> + /// <returns>The fixed options instance.</returns> public InboundApiOptions Get(string? name) => value; + /// <summary>Does nothing; the fake never raises change notifications.</summary> + /// <param name="listener">The change listener, ignored.</param> + /// <returns>Always null.</returns> public IDisposable? OnChange(Action<InboundApiOptions, string?> listener) => null; } + /// <summary>Verifies that skipping body capture suppresses the exception text in the audit row.</summary> + /// <param name="escaped">Whether the exception text contains characters that need escaping.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] @@ -100,11 +120,14 @@ public class AuditWriteMiddlewareTests /// </summary> private sealed class RecordingAuditWriter : ICentralAuditWriter { - // C3 (Task 2.5): store the decomposed row view so assertions keep + // Store the decomposed row view so assertions keep // reading the ScadaBridge domain fields as typed properties. + /// <summary>Gets the recorded audit rows.</summary> public List<AuditRowProjection.AuditRowValues> Events { get; } = new(); + /// <summary>Gets or sets an optional callback invoked on each write.</summary> public Func<AuditEvent, Task>? OnWrite { get; set; } + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) @@ -170,8 +193,11 @@ public class AuditWriteMiddlewareTests /// </summary> private sealed class StubAuditActorAccessor : IAuditActorAccessor { + /// <summary>Initializes the accessor with a fixed actor.</summary> + /// <param name="currentActor">The actor string to return, or null for none.</param> public StubAuditActorAccessor(string? currentActor) => CurrentActor = currentActor; + /// <inheritdoc /> public string? CurrentActor { get; } } @@ -185,12 +211,21 @@ public class AuditWriteMiddlewareTests { private readonly AuditLogOptions _value; + /// <summary>Initializes the monitor with a fixed options value.</summary> + /// <param name="value">The options value returned on every read.</param> public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value; + /// <summary>Gets the fixed options instance.</summary> public AuditLogOptions CurrentValue => _value; + /// <summary>Returns the fixed options instance regardless of name.</summary> + /// <param name="name">The named-options key, ignored.</param> + /// <returns>The fixed options instance.</returns> public AuditLogOptions Get(string? name) => _value; + /// <summary>Does nothing; the fake never raises change notifications.</summary> + /// <param name="listener">The change listener, ignored.</param> + /// <returns>Always null.</returns> public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null; } @@ -198,6 +233,8 @@ public class AuditWriteMiddlewareTests // 1. Happy path — InboundRequest/Delivered/HttpStatus 200 // --------------------------------------------------------------------- + /// <summary>Verifies that a successful request emits one inbound-request event with Delivered status and HTTP 200.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Pipeline_Success_EmitsOneEvent_KindInboundRequest_StatusDelivered_HttpStatus200() { @@ -226,6 +263,8 @@ public class AuditWriteMiddlewareTests // 2. 400 — script/validation failure path // --------------------------------------------------------------------- + /// <summary>Verifies that a 400 response emits an event with Failed status and HTTP 400.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Pipeline_400_EmitsEvent_Status_Failed_HttpStatus400() { @@ -251,6 +290,8 @@ public class AuditWriteMiddlewareTests // 3. 401 — auth failure path // --------------------------------------------------------------------- + /// <summary>Verifies that a 401 response emits an inbound-auth-failure event with Failed status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Pipeline_401_EmitsEvent_KindInboundAuthFailure_StatusFailed() { @@ -273,6 +314,8 @@ public class AuditWriteMiddlewareTests Assert.Null(evt.Actor); } + /// <summary>Verifies that a 403 response emits an inbound-auth-failure event with Failed status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Pipeline_403_EmitsEvent_KindInboundAuthFailure_StatusFailed() { @@ -296,6 +339,8 @@ public class AuditWriteMiddlewareTests // 4. 500 — handler threw OR returned 500 // --------------------------------------------------------------------- + /// <summary>Verifies that a 500 response emits an event with Failed status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Pipeline_500_EmitsEvent_Status_Failed() { @@ -315,6 +360,8 @@ public class AuditWriteMiddlewareTests Assert.Equal(500, evt.HttpStatus); } + /// <summary>Verifies that a throwing pipeline emits a Failed event and rethrows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Pipeline_Throws_EmitsEvent_Status_Failed_And_Rethrows() { @@ -340,6 +387,8 @@ public class AuditWriteMiddlewareTests // message), and the middleware lifts it onto the row. // --------------------------------------------------------------------- + /// <summary>Verifies that a script error detail stashed on the items lands on the audit row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ScriptErrorDetail_StashedOnItems_LandsOnTheRow() { @@ -360,6 +409,8 @@ public class AuditWriteMiddlewareTests Assert.Equal(detail, evt.ErrorDetail); } + /// <summary>Verifies that no stashed error detail leaves the row error detail null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoErrorDetailStashed_LeavesErrorDetailNull() { @@ -377,6 +428,8 @@ public class AuditWriteMiddlewareTests Assert.Null(evt.ErrorDetail); } + /// <summary>Verifies that the error detail of a thrown exception wins over the stashed item.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ThrownException_ErrorDetail_WinsOverTheStashedItem() { @@ -408,6 +461,8 @@ public class AuditWriteMiddlewareTests // HttpContext.Items. // --------------------------------------------------------------------- + /// <summary>Verifies that the API key name resolved from the HTTP context is recorded as the actor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApiKeyName_Resolved_From_HttpContext_AsActor() { @@ -437,6 +492,8 @@ public class AuditWriteMiddlewareTests // null — never echo an unauthenticated principal back. // --------------------------------------------------------------------- + /// <summary>Verifies that the authenticated user from the accessor is recorded as the actor when there is no API key stash.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuthenticatedUser_FromAccessor_RecordedAsActor_WhenNoApiKeyStash() { @@ -459,6 +516,8 @@ public class AuditWriteMiddlewareTests Assert.Equal("alice", evt.Actor); } + /// <summary>Verifies that the API key stash takes precedence over the authenticated principal.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApiKeyStash_TakesPrecedence_OverAuthenticatedPrincipal() { @@ -483,6 +542,8 @@ public class AuditWriteMiddlewareTests Assert.Equal("integration-svc", evt.Actor); } + /// <summary>Verifies that an auth failure keeps the actor null even when the accessor resolves a principal.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuthFailure_KeepsActorNull_EvenWhenAccessorResolvesPrincipal() { @@ -506,6 +567,8 @@ public class AuditWriteMiddlewareTests Assert.Null(evt.Actor); } + /// <summary>Verifies that a request with no API key and no authenticated principal leaves the actor null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoApiKey_NoAuthenticatedPrincipal_LeavesActorNull() { @@ -532,6 +595,8 @@ public class AuditWriteMiddlewareTests // 6. Writer failure must NEVER alter the HTTP response // --------------------------------------------------------------------- + /// <summary>Verifies that a throwing audit writer leaves a successful HTTP response unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditWriter_Throws_HttpResponse_Unchanged_Success_Stays_Success() { @@ -553,6 +618,8 @@ public class AuditWriteMiddlewareTests Assert.Equal(200, ctx.Response.StatusCode); } + /// <summary>Verifies that a throwing audit writer leaves a failed HTTP response unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditWriter_Throws_OnFailedRequest_HttpResponse_Unchanged() { @@ -576,6 +643,8 @@ public class AuditWriteMiddlewareTests // 7. Provenance — RemoteIp + User-Agent surface in Extra JSON // --------------------------------------------------------------------- + /// <summary>Verifies that the remote IP and user agent appear in the Extra JSON.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RemoteIp_And_UserAgent_AppearInExtra() { @@ -605,6 +674,8 @@ public class AuditWriteMiddlewareTests // JsonDocument.Parse) still see the full payload. // --------------------------------------------------------------------- + /// <summary>Verifies that the request body is buffered and stashed on the request summary.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RequestBody_IsBuffered_AndStashed_OnRequestSummary() { @@ -631,12 +702,14 @@ public class AuditWriteMiddlewareTests } // --------------------------------------------------------------------- - // Execution id — Audit Log #23: each inbound row carries a fresh + // Execution id — each inbound row carries a fresh // per-request execution id so inbound rows are correlatable. The inbound // row's CorrelationId stays null — CorrelationId is purely the // per-operation-lifecycle id and an inbound request is a one-shot. // --------------------------------------------------------------------- + /// <summary>Verifies that an inbound row carries a non-null execution id and a null correlation id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InboundRow_CarriesNonNull_ExecutionId_And_NullCorrelationId() { @@ -658,6 +731,8 @@ public class AuditWriteMiddlewareTests Assert.Null(evt.CorrelationId); } + /// <summary>Verifies that separate requests get distinct execution ids.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SeparateRequests_GetDistinct_ExecutionIds() { @@ -676,7 +751,7 @@ public class AuditWriteMiddlewareTests } // --------------------------------------------------------------------- - // ParentExecutionId — Audit Log #23 (ParentExecutionId feature, T3): the + // ParentExecutionId — the // inbound request's ExecutionId is minted ONCE, early, and stashed on // HttpContext.Items so the endpoint handler can carry it onto the routed // RouteToCallRequest as ParentExecutionId. The inbound row that the @@ -684,6 +759,8 @@ public class AuditWriteMiddlewareTests // NEVER set. // --------------------------------------------------------------------- + /// <summary>Verifies that the inbound execution id is stashed on the HTTP items before the endpoint runs.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InboundExecutionId_IsStashedOnHttpItems_BeforeEndpointRuns() { @@ -705,6 +782,8 @@ public class AuditWriteMiddlewareTests Assert.NotEqual(Guid.Empty, stashed); } + /// <summary>Verifies that the inbound row execution id equals the early-minted stashed id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InboundRow_ExecutionId_Equals_TheEarlyMintedStashedId() { @@ -727,6 +806,8 @@ public class AuditWriteMiddlewareTests Assert.Equal(stashedDuringHandler, evt.ExecutionId); } + /// <summary>Verifies that the inbound row's own parent execution id stays null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InboundRow_OwnParentExecutionId_StaysNull() { @@ -747,6 +828,8 @@ public class AuditWriteMiddlewareTests Assert.Null(evt.ParentExecutionId); } + /// <summary>Verifies that the duration in milliseconds is recorded.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DurationMs_IsRecorded() { @@ -769,13 +852,15 @@ public class AuditWriteMiddlewareTests } // --------------------------------------------------------------------- - // Response body capture — Audit Log #23 (inbound full-response feature). - // Until the M5-deferred work landed, ResponseSummary was always null. + // Response body capture (inbound full-response feature). + // Until response capture was implemented, ResponseSummary was always null. // These tests pin the new contract: the middleware wraps Response.Body, // runs the pipeline, copies the buffered bytes back to the real stream, // and stashes a UTF-8 string copy on ResponseSummary. // --------------------------------------------------------------------- + /// <summary>Verifies that the response body is captured on the response summary.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResponseBody_IsCaptured_OnResponseSummary() { @@ -795,6 +880,8 @@ public class AuditWriteMiddlewareTests Assert.Equal(responseJson, evt.ResponseSummary); } + /// <summary>Verifies that the response body is forwarded to the original stream so downstream readers see it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResponseBody_IsForwardedToOriginalStream_DownstreamReadersSeeIt() { @@ -817,6 +904,8 @@ public class AuditWriteMiddlewareTests Assert.Equal(responseJson, Encoding.UTF8.GetString(captured.ToArray())); } + /// <summary>Verifies that an empty response body leaves the response summary null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResponseBody_Empty_LeavesResponseSummaryNull() { @@ -837,6 +926,8 @@ public class AuditWriteMiddlewareTests Assert.Equal(204, evt.HttpStatus); } + /// <summary>Verifies that when the handler throws, the body written up to the throw is captured.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow() { @@ -870,6 +961,8 @@ public class AuditWriteMiddlewareTests // real client still see every byte. // --------------------------------------------------------------------- + /// <summary>Verifies that a request body above the inbound maximum is truncated to the cap and flagged as payload truncated.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RequestBody_AboveInboundMaxBytes_TruncatedToCap_PayloadTruncatedTrue() { @@ -909,6 +1002,8 @@ public class AuditWriteMiddlewareTests Assert.True(evt.PayloadTruncated); } + /// <summary>Verifies that a response body above the inbound maximum is truncated to the cap, the client still receives all bytes, and the payload is flagged as truncated.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResponseBody_AboveInboundMaxBytes_TruncatedToCap_ClientStillReceivesAllBytes_PayloadTruncatedTrue() { @@ -945,7 +1040,7 @@ public class AuditWriteMiddlewareTests } // --------------------------------------------------------------------- - // InboundAPI-018: asynchronously faulted audit-write tasks must be + // Asynchronously faulted audit-write tasks must be // observed (logged) rather than silently dropped — but must still NOT // alter the user-facing HTTP response (alog.md §13). // --------------------------------------------------------------------- @@ -954,10 +1049,11 @@ public class AuditWriteMiddlewareTests /// Test-only writer whose <see cref="WriteAsync"/> returns a Task that /// faults AFTER an asynchronous boundary, so the throw happens after /// <see cref="AuditWriteMiddleware"/>'s synchronous try/catch can see it — - /// exactly the fire-and-forget bug InboundAPI-018 closes. + /// exactly the fire-and-forget bug this test guards against. /// </summary> private sealed class AsyncFaultingAuditWriter : ICentralAuditWriter { + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { return FaultAsync(); @@ -980,12 +1076,27 @@ public class AuditWriteMiddlewareTests /// </summary> private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger<AuditWriteMiddleware> { + /// <summary>Gets the captured log entries.</summary> public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new(); + /// <summary>Returns no scope; the fake logger does not track scopes.</summary> + /// <typeparam name="TState">The type of the scope state.</typeparam> + /// <param name="state">The scope state, ignored.</param> + /// <returns>Always null or a no-op scope.</returns> public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level being queried.</param> + /// <returns>Always true.</returns> public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The type of the log state.</typeparam> + /// <param name="logLevel">The severity of the entry.</param> + /// <param name="eventId">The event identifier.</param> + /// <param name="state">The log state.</param> + /// <param name="exception">The associated exception, if any.</param> + /// <param name="formatter">Formats the state and exception into the message.</param> public void Log<TState>( Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, @@ -1002,10 +1113,13 @@ public class AuditWriteMiddlewareTests private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { } } } + /// <summary>Verifies that an asynchronous audit writer fault is observed as a warning and does not alter the response.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditWriter_AsyncFault_IsObserved_AsWarning_AndDoesNotAlterResponse() { @@ -1056,12 +1170,15 @@ public class AuditWriteMiddlewareTests } // --------------------------------------------------------------------- - // InboundAPI-019 — bodyless requests skip EnableBuffering so the + // Bodyless requests skip EnableBuffering so the // FileBufferingReadStream allocation is avoided on GET/HEAD/DELETE // and any request whose Content-Length is 0. The audit row still emits // with a null RequestSummary, mirroring the bodyless-POST contract. // --------------------------------------------------------------------- + /// <summary>Verifies that a bodyless HTTP method skips enabling buffering so the request stream is not replaced.</summary> + /// <param name="method">The bodyless HTTP method under test.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("GET")] [InlineData("HEAD")] @@ -1102,6 +1219,8 @@ public class AuditWriteMiddlewareTests Assert.Null(evt.RequestSummary); } + /// <summary>Verifies that a bodyless POST with content length zero skips enabling buffering.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BodylessPost_ContentLengthZero_SkipsEnableBuffering() { @@ -1135,6 +1254,8 @@ public class AuditWriteMiddlewareTests Assert.Null(evt.RequestSummary); } + /// <summary>Verifies that a POST with a body still enables buffering and captures the request summary.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PostWithBody_StillEnablesBuffering_AndCapturesRequestSummary() { @@ -1161,13 +1282,15 @@ public class AuditWriteMiddlewareTests } // --------------------------------------------------------------------- - // M5.3 (T7) Increment 1: Request headers in Extra JSON + // Increment 1: Request headers in Extra JSON // Request headers are captured into the Extra JSON object alongside the // existing remoteIp / userAgent fields. Sensitive headers (e.g. // Authorization, X-Api-Key) are redacted to "<redacted>" using the same // HeaderRedactList as ScadaBridgeAuditRedactor. // --------------------------------------------------------------------- + /// <summary>Verifies that request headers appear in the Extra JSON under the request headers key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RequestHeaders_AppearInExtra_UnderRequestHeadersKey() { @@ -1197,6 +1320,8 @@ public class AuditWriteMiddlewareTests Assert.Equal("custom-value", customVal.GetString()); } + /// <summary>Verifies that the Authorization header is redacted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RequestHeaders_AuthorizationHeader_IsRedacted() { @@ -1224,6 +1349,8 @@ public class AuditWriteMiddlewareTests Assert.Equal("<redacted>", authVal.GetString()); } + /// <summary>Verifies that the X-API-Key header is redacted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RequestHeaders_XApiKeyHeader_IsRedacted() { @@ -1249,6 +1376,8 @@ public class AuditWriteMiddlewareTests Assert.Equal("<redacted>", keyVal.GetString()); } + /// <summary>Verifies that an entry on the custom redact list is redacted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RequestHeaders_CustomRedactListEntry_IsRedacted() { @@ -1284,6 +1413,8 @@ public class AuditWriteMiddlewareTests Assert.Equal("safe-value", headers.GetProperty("X-Safe-Header").GetString()); } + /// <summary>Verifies that header redaction is case-insensitive.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RequestHeaders_Redaction_IsCaseInsensitive() { @@ -1312,7 +1443,7 @@ public class AuditWriteMiddlewareTests } // --------------------------------------------------------------------- - // M5.3 (T7) Increment 2: AuditInboundCeilingHits counter + // Increment 2: AuditInboundCeilingHits counter // When request OR response exceeds InboundMaxBytes, the middleware // increments IAuditInboundCeilingHitsCounter once per request. // --------------------------------------------------------------------- @@ -1324,7 +1455,9 @@ public class AuditWriteMiddlewareTests private sealed class RecordingCeilingHitsCounter : ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter { private int _count; + /// <summary>Gets the number of recorded increments.</summary> public int Count => Volatile.Read(ref _count); + /// <inheritdoc /> public void Increment() => Interlocked.Increment(ref _count); } @@ -1341,6 +1474,8 @@ public class AuditWriteMiddlewareTests actorAccessor: null, ceilingHitsCounter: counter); + /// <summary>Verifies that a request body above the inbound maximum increments the ceiling-hits counter.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RequestBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter() { @@ -1367,6 +1502,8 @@ public class AuditWriteMiddlewareTests Assert.True(evt.PayloadTruncated); } + /// <summary>Verifies that a response body above the inbound maximum increments the ceiling-hits counter.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResponseBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter() { @@ -1394,6 +1531,8 @@ public class AuditWriteMiddlewareTests Assert.True(evt.PayloadTruncated); } + /// <summary>Verifies that a normal request within the cap does not increment the ceiling-hits counter.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NormalRequest_WithinCap_DoesNotIncrementCeilingHitsCounter() { @@ -1418,7 +1557,7 @@ public class AuditWriteMiddlewareTests } // --------------------------------------------------------------------- - // M5.3 (T7) Increment 3: SkipBodyCapture per-method opt-out + // Increment 3: SkipBodyCapture per-method opt-out // A target with SkipBodyCapture=true produces an audit row with // headers/metadata but empty/omitted body. A normal target still captures. // --------------------------------------------------------------------- @@ -1444,6 +1583,8 @@ public class AuditWriteMiddlewareTests return ctx; } + /// <summary>Verifies that with body capture skipped, an audit row is still emitted but its body is null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SkipBodyCapture_True_AuditRowEmitted_ButBodyIsNull() { @@ -1489,6 +1630,8 @@ public class AuditWriteMiddlewareTests Assert.Equal(200, evt.HttpStatus); } + /// <summary>Verifies that with body capture skipped, the ceiling-hits counter is not incremented.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SkipBodyCapture_True_CeilingHitsCounter_NotIncremented() { @@ -1526,6 +1669,8 @@ public class AuditWriteMiddlewareTests Assert.Equal(0, counter.Count); } + /// <summary>Verifies that with body capture not skipped, a normal target still captures the body.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SkipBodyCapture_False_NormalTarget_StillCapturesBody() { @@ -1561,6 +1706,8 @@ public class AuditWriteMiddlewareTests Assert.Equal("{\"result\":1}", evt.ResponseSummary); } + /// <summary>Verifies that with no override, the default target still captures the body.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SkipBodyCapture_NoOverride_DefaultTarget_StillCapturesBody() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/MiddlewareOrderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/MiddlewareOrderTests.cs index b47b836e..df0bad17 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/MiddlewareOrderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/MiddlewareOrderTests.cs @@ -18,7 +18,7 @@ using System.Security.Claims; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware; /// <summary> -/// M4 Bundle D (D2) — verifies the production pipeline order from +/// Verifies the production pipeline order from /// <c>ZB.MOM.WW.ScadaBridge.Host.Program.cs</c> for the inbound API: /// <c>UseAuthentication → UseAuthorization → UseAuditWriteMiddleware → endpoint</c>. /// @@ -42,7 +42,10 @@ public class MiddlewareOrderTests /// </summary> private sealed class OrderingRecorder { + /// <summary>Gets the stages recorded so far, in order.</summary> public List<string> Stages { get; } = new(); + /// <summary>Appends a stage name to the recorded list.</summary> + /// <param name="stage">The name of the stage that ran.</param> public void Record(string stage) { lock (Stages) { Stages.Add(stage); } @@ -51,9 +54,11 @@ public class MiddlewareOrderTests private sealed class RecordingAuditWriter : ICentralAuditWriter { - // C3 (Task 2.5): store the decomposed row view so assertions keep + // Store the decomposed row view so assertions keep // reading the ScadaBridge domain fields as typed properties. + /// <summary>Gets the audit events written, as decomposed row views.</summary> public List<AuditRowProjection.AuditRowValues> Events { get; } = new(); + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) { Events.Add(evt.AsRow()); } @@ -61,6 +66,8 @@ public class MiddlewareOrderTests } } + /// <summary>Verifies that the audit-write middleware runs after authentication and before script execution.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Middleware_Pipeline_PlacesAuditWriteAfterAuth_BeforeScriptExecution() { @@ -99,6 +106,8 @@ public class MiddlewareOrderTests Assert.Equal("test-actor", evt.Actor); } + /// <summary>Verifies that the pipeline records an authentication failure without reaching the endpoint.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Middleware_Pipeline_Records_AuthFailure_Beyond_Endpoint() { @@ -225,12 +234,17 @@ public class MiddlewareOrderTests /// </summary> private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions> { + /// <summary>Initializes the handler with the standard authentication-handler dependencies.</summary> + /// <param name="options">The authentication scheme options monitor.</param> + /// <param name="logger">The logger factory.</param> + /// <param name="encoder">The URL encoder.</param> public AlwaysAuthenticatedHandler( Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options, Microsoft.Extensions.Logging.ILoggerFactory logger, System.Text.Encodings.Web.UrlEncoder encoder) : base(options, logger, encoder) { } + /// <inheritdoc /> protected override Task<AuthenticateResult> HandleAuthenticateAsync() { var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "framework-user") }, "TestScheme"); @@ -249,12 +263,21 @@ public class MiddlewareOrderTests { private readonly AuditLogOptions _value; + /// <summary>Initializes the monitor with a fixed options value.</summary> + /// <param name="value">The options value to return.</param> public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value; + /// <summary>Gets the fixed options value.</summary> public AuditLogOptions CurrentValue => _value; + /// <summary>Returns the fixed options value regardless of name.</summary> + /// <param name="name">The named-options name, ignored.</param> + /// <returns>The fixed options value.</returns> public AuditLogOptions Get(string? name) => _value; + /// <summary>Ignores the listener because the value never changes.</summary> + /// <param name="listener">The change listener, ignored.</param> + /// <returns>Always null.</returns> public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs index 5425a3ef..8f8e09ee 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// <summary> -/// WP-2 / InboundAPI-M2.6: tests for parameter validation — type checking, +/// Tests for parameter validation — type checking, /// required fields, the extended type system, and RECURSIVE (nested Object / /// List element) type validation with path-qualified errors. /// @@ -25,6 +25,7 @@ public class ParameterValidatorTests // ── No / empty definitions ──────────────────────────────────────────────── + /// <summary>Verifies that a missing body with no parameter definitions is valid.</summary> [Fact] public void NoDefinitions_NoBody_ReturnsValid() { @@ -33,6 +34,7 @@ public class ParameterValidatorTests Assert.Empty(result.Parameters); } + /// <summary>Verifies that an empty object schema is valid.</summary> [Fact] public void EmptyObjectSchema_ReturnsValid() { @@ -40,6 +42,7 @@ public class ParameterValidatorTests Assert.True(result.IsValid); } + /// <summary>Verifies that an empty legacy array definition is valid.</summary> [Fact] public void EmptyLegacyArray_ReturnsValid() { @@ -49,6 +52,7 @@ public class ParameterValidatorTests // ── Required / body shape ────────────────────────────────────────────────── + /// <summary>Verifies that a missing required parameter is invalid.</summary> [Fact] public void RequiredParameterMissing_ReturnsInvalid() { @@ -59,6 +63,7 @@ public class ParameterValidatorTests Assert.Contains("Missing required parameter", result.ErrorMessage); } + /// <summary>Verifies that a body that is not an object is invalid.</summary> [Fact] public void BodyNotObject_ReturnsInvalid() { @@ -69,6 +74,7 @@ public class ParameterValidatorTests Assert.Contains("must be a JSON object", result.ErrorMessage); } + /// <summary>Verifies that a missing body is valid when the parameter is optional.</summary> [Fact] public void OptionalParameter_MissingBody_ReturnsValid() { @@ -80,6 +86,10 @@ public class ParameterValidatorTests // ── Scalar coercion ──────────────────────────────────────────────────────── + /// <summary>Verifies that a valid value of each scalar type is coerced successfully.</summary> + /// <param name="type">The JSON Schema type name.</param> + /// <param name="jsonValue">The JSON text of the value.</param> + /// <param name="expected">The expected coerced CLR value.</param> [Theory] [InlineData("boolean", "true", true)] [InlineData("integer", "42", (long)42)] @@ -94,6 +104,7 @@ public class ParameterValidatorTests Assert.Equal(expected, result.Parameters["val"]); } + /// <summary>Verifies that a wrong scalar type is invalid.</summary> [Fact] public void WrongScalarType_ReturnsInvalid() { @@ -105,6 +116,7 @@ public class ParameterValidatorTests Assert.Contains("Integer", result.ErrorMessage); } + /// <summary>Verifies that an unknown type is invalid.</summary> [Fact] public void UnknownType_ReturnsInvalid() { @@ -117,6 +129,7 @@ public class ParameterValidatorTests // ── Object / List shape + materialization ────────────────────────────────── + /// <summary>Verifies that an object type with no declared fields is materialized shape-only.</summary> [Fact] public void ObjectType_NoDeclaredFields_ShapeOnly_Materialized() { @@ -127,6 +140,7 @@ public class ParameterValidatorTests Assert.IsType<Dictionary<string, object?>>(result.Parameters["data"]); } + /// <summary>Verifies that a list type with no declared element type is materialized shape-only.</summary> [Fact] public void ListType_NoDeclaredElement_ShapeOnly_Materialized() { @@ -139,6 +153,7 @@ public class ParameterValidatorTests // ── Undeclared / unexpected fields (rejected, recursively) ───────────────── + /// <summary>Verifies that an unexpected top-level field is invalid.</summary> [Fact] public void UnexpectedTopLevelField_ReturnsInvalid() { @@ -152,6 +167,7 @@ public class ParameterValidatorTests Assert.Contains("not a declared field", result.ErrorMessage); } + /// <summary>Verifies that a body containing only declared fields is still valid.</summary> [Fact] public void OnlyDeclaredFields_StillValid() { @@ -163,6 +179,7 @@ public class ParameterValidatorTests Assert.Equal((long)1, result.Parameters["value"]); } + /// <summary>Verifies that an undeclared nested field is invalid with a path-qualified error.</summary> [Fact] public void UndeclaredNestedField_ReturnsInvalid_PathQualified() { @@ -180,7 +197,7 @@ public class ParameterValidatorTests Assert.Contains("not a declared field", result.ErrorMessage); } - // ── Nested validation: the M2.6 core ─────────────────────────────────────── + // ── Nested validation: the core cases ─────────────────────────────────────── private const string NestedDef = """ { @@ -211,6 +228,7 @@ public class ParameterValidatorTests } """; + /// <summary>Verifies that a valid nested payload passes.</summary> [Fact] public void ValidNestedPayload_Passes() { @@ -229,6 +247,7 @@ public class ParameterValidatorTests Assert.True(result.IsValid); } + /// <summary>Verifies that a wrong scalar two levels deep is invalid with the exact path.</summary> [Fact] public void WrongScalarTwoLevelsDeep_ReturnsInvalid_WithExactPath() { @@ -247,6 +266,7 @@ public class ParameterValidatorTests Assert.Contains("Boolean", result.ErrorMessage); } + /// <summary>Verifies that a wrong scalar inside a list element is invalid with the element index in the path.</summary> [Fact] public void WrongScalarInsideListElement_ReturnsInvalid_WithElementIndexInPath() { @@ -268,6 +288,7 @@ public class ParameterValidatorTests Assert.Contains("Integer", result.ErrorMessage); } + /// <summary>Verifies that a list element of the wrong shape is invalid with the element index in the path.</summary> [Fact] public void ListElementWrongShape_ReturnsInvalid_WithElementIndexInPath() { @@ -286,6 +307,7 @@ public class ParameterValidatorTests Assert.Contains("Object", result.ErrorMessage); } + /// <summary>Verifies that a missing required nested field is invalid with a path-qualified error.</summary> [Fact] public void MissingRequiredNestedField_ReturnsInvalid_PathQualified() { @@ -306,6 +328,7 @@ public class ParameterValidatorTests // ── Empty / null edge cases ──────────────────────────────────────────────── + /// <summary>Verifies that an empty list against a typed element passes.</summary> [Fact] public void EmptyList_AgainstTypedElement_Passes() { @@ -317,6 +340,7 @@ public class ParameterValidatorTests Assert.True(result.IsValid); } + /// <summary>Verifies that a null for an optional nested scalar passes.</summary> [Fact] public void NullForOptionalNestedScalar_Passes() { @@ -333,6 +357,7 @@ public class ParameterValidatorTests Assert.True(result.IsValid); } + /// <summary>Verifies that a null for a required nested scalar passes.</summary> [Fact] public void NullForRequiredNestedScalar_Passes() { @@ -350,7 +375,7 @@ public class ParameterValidatorTests Assert.True(result.IsValid); } - // ── Recursive materialization: nested values coerced to typed CLR (#55) ───── + // ── Recursive materialization: nested values coerced to typed CLR ───── /// <summary> /// Recursively asserts that no value anywhere in a materialized parameter @@ -382,6 +407,7 @@ public class ParameterValidatorTests } } + /// <summary>Verifies that a nested object field scalar is materialized as a typed CLR value rather than a JSON element.</summary> [Fact] public void NestedObjectFieldScalar_IsTypedClr_NotJsonElement() { @@ -410,6 +436,7 @@ public class ParameterValidatorTests Assert.IsType<long>(order["id"]); } + /// <summary>Verifies that a list of scalars is fully coerced with no JSON element left.</summary> [Fact] public void ListOfScalars_FullyCoerced_NoJsonElement() { @@ -424,6 +451,7 @@ public class ParameterValidatorTests AssertNoJsonElement(nums); } + /// <summary>Verifies that the element fields of a list of objects are coerced with no JSON element left.</summary> [Fact] public void ListOfObjects_ElementFieldsCoerced_NoJsonElement() { @@ -453,6 +481,7 @@ public class ParameterValidatorTests Assert.IsType<double>(first["price"]); } + /// <summary>Verifies that an object in a list in an object is fully coerced with no JSON element left.</summary> [Fact] public void DeeplyNested_ObjectInListInObject_FullyCoerced_NoJsonElement() { @@ -512,6 +541,7 @@ public class ParameterValidatorTests Assert.Equal(false, tag1["active"]); } + /// <summary>Verifies that a shape-only nested object is still fully coerced.</summary> [Fact] public void ShapeOnlyNestedObject_StillFullyCoerced_NoJsonElement() { @@ -540,6 +570,7 @@ public class ParameterValidatorTests // ── Legacy flat-array backward-compat ────────────────────────────────────── + /// <summary>Verifies that a legacy flat array definition is still accepted.</summary> [Fact] public void LegacyFlatArrayDefinition_StillAccepted() { @@ -556,6 +587,8 @@ public class ParameterValidatorTests // FIX 1: legacy "required":"false" string → field is optional ───────────── + /// <summary>Verifies that a legacy flat array field with required set to a string false is treated as optional.</summary> + /// <param name="def">The legacy flat array definition JSON.</param> [Theory] [InlineData("""[{"name":"opt","type":"String","required":"false"}]""")] [InlineData("""[{"name":"opt","type":"String","required":"False"}]""")] @@ -569,6 +602,7 @@ public class ParameterValidatorTests Assert.True(result.IsValid, $"Expected optional field to be valid when absent; error: {result.ErrorMessage}"); } + /// <summary>Verifies that a legacy flat array field with required false that is present and correctly typed passes.</summary> [Fact] public void LegacyFlatArray_RequiredStringFalse_FieldPresentAndTypedCorrectly_Passes() { @@ -597,6 +631,7 @@ public class ParameterValidatorTests return schema; } + /// <summary>Verifies that a schema at the depth ceiling parses successfully.</summary> [Fact] public void SchemaAtDepthCeiling_ParsesSuccessfully() { @@ -606,6 +641,7 @@ public class ParameterValidatorTests Assert.NotNull(schema); } + /// <summary>Verifies that a schema exceeding the depth ceiling throws a JSON exception rather than overflowing the stack.</summary> [Fact] public void SchemaExceedingDepthCeiling_ThrowsJsonException_NotStackOverflow() { @@ -615,6 +651,7 @@ public class ParameterValidatorTests Assert.Throws<System.Text.Json.JsonException>(() => InboundApiSchema.Parse(def)); } + /// <summary>Verifies that the parameter validator returns invalid for a schema exceeding the depth ceiling.</summary> [Fact] public void SchemaExceedingDepthCeiling_ParameterValidator_ReturnsInvalid() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RelayProvenanceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RelayProvenanceTests.cs index 1d7b3b40..48cbf892 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RelayProvenanceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RelayProvenanceTests.cs @@ -15,6 +15,9 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; public class RelayProvenanceTests { + /// <summary>Verifies that a genuine relay preserves the caller IP only when the signed request is intact.</summary> + /// <param name="tamper">The kind of tampering applied to the relayed request.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("none")] [InlineData("body")] @@ -59,6 +62,8 @@ public class RelayProvenanceTests if (tamper == "none") Assert.Equal(original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey], row.ExecutionId); } + /// <summary>Verifies that directly forged forwarding headers cannot change the audited caller.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EI5_DirectForgedForwardingHeadersCannotChangeAuditedCaller() { @@ -72,6 +77,8 @@ public class RelayProvenanceTests Assert.Equal("10.4.5.6", extra.RootElement.GetProperty("remoteIp").GetString()); } + /// <summary>Verifies that when every authenticated peer is standby, only the origin node emits the failure audit.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EI1_AllAuthenticatedPeersStandby_OnlyOriginEmitsFailureAudit() { @@ -98,7 +105,11 @@ public class RelayProvenanceTests Assert.Equal(original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey], row.ExecutionId); } - private sealed class Standby : IActiveNodeGate { public bool IsActiveNode => false; } + private sealed class Standby : IActiveNodeGate + { + /// <inheritdoc /> + public bool IsActiveNode => false; + } private static async Task AuditAsync(HttpContext context, Writer writer, IOptionsMonitor<InboundApiOptions> options, IActiveNodeGate? gate = null, IStandbyRequestForwarder? forwarder = null) { @@ -132,21 +143,34 @@ public class RelayProvenanceTests } private sealed class Writer : ICentralAuditWriter { + /// <summary>Gets the audit events captured by this writer.</summary> public List<AuditEvent> Events { get; } = []; + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; } } private sealed class Monitor<T>(T value) : IOptionsMonitor<T> { + /// <summary>Gets the fixed options value.</summary> public T CurrentValue => value; + /// <summary>Returns the fixed options value regardless of name.</summary> + /// <param name="name">The options name, ignored.</param> + /// <returns>The fixed options value.</returns> public T Get(string? name) => value; + /// <summary>Ignores the change listener; the fake never raises change notifications.</summary> + /// <param name="listener">The change listener, ignored.</param> + /// <returns>Always <see langword="null"/>.</returns> public IDisposable? OnChange(Action<T, string?> listener) => null; } private sealed class Handler(Func<HttpRequestMessage, Task<HttpResponseMessage>> send) : HttpMessageHandler { + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => send(request); } private sealed class Factory(HttpMessageHandler handler) : IHttpClientFactory { + /// <summary>Creates an HTTP client over the fake message handler.</summary> + /// <param name="name">The logical client name.</param> + /// <returns>An HTTP client backed by the fake handler.</returns> public HttpClient CreateClient(string name) => new(handler, false); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ReturnValueValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ReturnValueValidatorTests.cs index cacd1dd3..0f61d9e4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ReturnValueValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ReturnValueValidatorTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// <summary> -/// InboundAPI-014 / InboundAPI-M2.6: tests for return-value validation against a +/// Tests for return-value validation against a /// method's <c>ReturnDefinition</c>. Mirrors <see cref="ParameterValidatorTests"/> /// (shared recursive engine) — RECURSIVE nested Object / List-element type /// validation with path-qualified errors. @@ -17,6 +17,8 @@ public class ReturnValueValidatorTests { // ── No definition → no validation (backward compatible) ─────────────────── + /// <summary>Verifies that any result is valid when no return definition is declared.</summary> + /// <param name="returnDefinition">The return definition under test, which is null or blank.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -27,6 +29,7 @@ public class ReturnValueValidatorTests Assert.True(result.IsValid); } + /// <summary>Verifies that a null result is valid when no return definition is declared.</summary> [Fact] public void NoReturnDefinition_NullResult_IsValid() { @@ -36,6 +39,7 @@ public class ReturnValueValidatorTests // ── Happy path: result matches the declared object shape ────────────────── + /// <summary>Verifies that a result matching the declared definition is valid.</summary> [Fact] public void ResultMatchingDefinition_IsValid() { @@ -52,6 +56,7 @@ public class ReturnValueValidatorTests Assert.True(result.IsValid); } + /// <summary>Verifies that a list of scalars whose elements have the declared type is valid.</summary> [Fact] public void ResultWithListOfScalars_TypeChecked_IsValid() { @@ -69,6 +74,7 @@ public class ReturnValueValidatorTests // ── Scalar / shape mismatches must be reported ──────────────────────────── + /// <summary>Verifies that a result missing a declared field is invalid.</summary> [Fact] public void ResultMissingDeclaredField_IsInvalid() { @@ -86,6 +92,7 @@ public class ReturnValueValidatorTests Assert.Contains("totalUnits", result.ErrorMessage); } + /// <summary>Verifies that a result field of the wrong type is invalid.</summary> [Fact] public void ResultFieldWrongType_IsInvalid() { @@ -98,6 +105,7 @@ public class ReturnValueValidatorTests Assert.Contains("totalUnits", result.ErrorMessage); } + /// <summary>Verifies that a null result is invalid when a structure is required.</summary> [Fact] public void NullResultWhenStructureRequired_IsInvalid() { @@ -108,6 +116,7 @@ public class ReturnValueValidatorTests Assert.False(result.IsValid); } + /// <summary>Verifies that a non-object result is invalid when a structure is required.</summary> [Fact] public void NonObjectResultWhenStructureRequired_IsInvalid() { @@ -118,6 +127,7 @@ public class ReturnValueValidatorTests Assert.False(result.IsValid); } + /// <summary>Verifies that a list field given a non-array value is invalid.</summary> [Fact] public void ListFieldGivenNonArray_IsInvalid() { @@ -130,6 +140,7 @@ public class ReturnValueValidatorTests Assert.Contains("lines", result.ErrorMessage); } + /// <summary>Verifies that a malformed return definition is invalid.</summary> [Fact] public void MalformedReturnDefinition_IsInvalid() { @@ -138,7 +149,7 @@ public class ReturnValueValidatorTests Assert.False(result.IsValid); } - // ── Nested validation: the M2.6 core (production-report shape) ───────────── + // ── Nested validation: the core (production-report shape) ───────────── private const string ReportDef = """ { @@ -163,6 +174,7 @@ public class ReturnValueValidatorTests } """; + /// <summary>Verifies that a valid nested return value passes validation.</summary> [Fact] public void ValidNestedReturn_Passes() { @@ -181,6 +193,7 @@ public class ReturnValueValidatorTests Assert.True(result.IsValid); } + /// <summary>Verifies that a wrong scalar inside a list element is invalid and the error path includes the element index.</summary> [Fact] public void WrongScalarInsideListElement_IsInvalid_WithElementIndexInPath() { @@ -202,6 +215,7 @@ public class ReturnValueValidatorTests Assert.Contains("Integer", result.ErrorMessage); } + /// <summary>Verifies that a wrong list element type is invalid and the error path includes the element index.</summary> [Fact] public void WrongListElementType_IsInvalid_WithElementIndexInPath() { @@ -220,6 +234,7 @@ public class ReturnValueValidatorTests Assert.Contains("Object", result.ErrorMessage); } + /// <summary>Verifies that a missing required nested field is invalid and the error path is qualified.</summary> [Fact] public void MissingRequiredNestedField_IsInvalid_PathQualified() { @@ -238,6 +253,7 @@ public class ReturnValueValidatorTests Assert.Contains("'lines[0].units'", result.ErrorMessage); } + /// <summary>Verifies that an undeclared nested field is invalid and the error path is qualified.</summary> [Fact] public void UndeclaredNestedField_IsInvalid_PathQualified() { @@ -258,6 +274,7 @@ public class ReturnValueValidatorTests // ── Empty / null edge cases ──────────────────────────────────────────────── + /// <summary>Verifies that an empty list against a typed element definition passes.</summary> [Fact] public void EmptyListAgainstTypedElement_Passes() { @@ -267,6 +284,7 @@ public class ReturnValueValidatorTests Assert.True(result.IsValid); } + /// <summary>Verifies that any result is valid against an empty object schema.</summary> [Fact] public void EmptyObjectSchema_AnythingIsValid() { @@ -276,6 +294,7 @@ public class ReturnValueValidatorTests Assert.True(result.IsValid); } + /// <summary>Verifies that a null optional nested scalar passes validation.</summary> [Fact] public void NullOptionalNestedScalar_Passes() { @@ -294,6 +313,7 @@ public class ReturnValueValidatorTests // ── Legacy flat-array backward-compat ────────────────────────────────────── + /// <summary>Verifies that the legacy flat-array return definition is still accepted.</summary> [Fact] public void LegacyFlatArrayDefinition_StillAccepted() { @@ -311,6 +331,7 @@ public class ReturnValueValidatorTests // (Guards the intentional ParameterValidator/ReturnValueValidator asymmetry: // ReturnValueValidator must NOT short-circuit on non-object schema types.) + /// <summary>Verifies that a scalar string return schema validates a scalar string return.</summary> [Fact] public void ScalarStringReturnSchema_ValidatesScalarStringReturn() { @@ -319,6 +340,7 @@ public class ReturnValueValidatorTests Assert.True(result.IsValid); } + /// <summary>Verifies that a scalar integer return schema validates a scalar integer return.</summary> [Fact] public void ScalarIntegerReturnSchema_ValidatesScalarIntegerReturn() { @@ -326,6 +348,7 @@ public class ReturnValueValidatorTests Assert.True(result.IsValid); } + /// <summary>Verifies that a scalar string return schema rejects an integer return.</summary> [Fact] public void ScalarStringReturnSchema_RejectsIntegerReturn() { @@ -334,6 +357,7 @@ public class ReturnValueValidatorTests Assert.Contains("String", result.ErrorMessage); } + /// <summary>Verifies that a scalar boolean return schema validates a boolean return.</summary> [Fact] public void ScalarBooleanReturnSchema_ValidatesBooleanReturn() { @@ -343,6 +367,7 @@ public class ReturnValueValidatorTests // FIX 2: recursion depth guard on Validate ───────────────────────────────── + /// <summary>Verifies that validating a schema nested beyond the depth ceiling adds a depth error instead of throwing.</summary> [Fact] public void ValidateExceedingDepthCeiling_AddsDepthError_DoesNotThrow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs index 168e3a47..bd72e1b2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs @@ -8,11 +8,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// <summary> -/// WP-4: Tests for <see cref="RouteHelper"/>/<see cref="RouteTarget"/> — the +/// Tests for <see cref="RouteHelper"/>/<see cref="RouteTarget"/> — the /// cross-site Route.To() routing surface inbound API scripts use. /// -/// InboundAPI-017: this surface previously had zero coverage. -/// InboundAPI-016: routed calls must inherit the executing method's deadline token. +/// This surface previously had zero coverage. +/// Routed calls must inherit the executing method's deadline token. /// </summary> public class RouteHelperTests { @@ -27,6 +27,8 @@ public class RouteHelperTests // --- Call --- + /// <summary>Verifies that a routed call resolves the site and returns the value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_HappyPath_ResolvesSiteAndReturnsValue() { @@ -40,6 +42,8 @@ public class RouteHelperTests Assert.Equal(99, result); } + /// <summary>Verifies that a routed call generates a correlation id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_GeneratesCorrelationId() { @@ -55,6 +59,8 @@ public class RouteHelperTests Assert.True(Guid.TryParse(captured!.CorrelationId, out _)); } + /// <summary>Verifies that a remote failure on a routed call throws an invalid-operation exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_RemoteFailure_ThrowsInvalidOperationException() { @@ -68,6 +74,8 @@ public class RouteHelperTests Assert.Equal("site exploded", ex.Message); } + /// <summary>Verifies that an unresolved instance throws an invalid-operation exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_UnresolvedInstance_ThrowsInvalidOperationException() { @@ -82,6 +90,8 @@ public class RouteHelperTests // --- GetAttribute(s) --- + /// <summary>Verifies that getting attributes returns the values.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAttributes_HappyPath_ReturnsValues() { @@ -98,6 +108,8 @@ public class RouteHelperTests Assert.Equal(2, result["b"]); } + /// <summary>Verifies that getting a single attribute delegates to the batch call and returns one value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAttribute_DelegatesToBatch_AndReturnsSingleValue() { @@ -113,6 +125,8 @@ public class RouteHelperTests Assert.Equal(21.5, value); } + /// <summary>Verifies that getting an absent attribute key returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAttribute_AbsentKey_ReturnsNull() { @@ -128,6 +142,8 @@ public class RouteHelperTests Assert.Null(value); } + /// <summary>Verifies that a remote failure when getting attributes throws an invalid-operation exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAttributes_RemoteFailure_ThrowsInvalidOperationException() { @@ -144,6 +160,8 @@ public class RouteHelperTests // --- WaitForAttribute (spec §6) --- + /// <summary>Verifies that waiting for an attribute returns true when the value matches.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAttribute_Matched_ReturnsTrue() { @@ -160,6 +178,8 @@ public class RouteHelperTests Assert.True(matched); } + /// <summary>Verifies that waiting for an attribute returns false when the wait times out.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAttribute_TimedOut_ReturnsFalse() { @@ -176,6 +196,8 @@ public class RouteHelperTests Assert.False(matched); } + /// <summary>Verifies that a routing failure while waiting for an attribute throws an invalid-operation exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAttribute_RoutingFailure_ThrowsInvalidOperationException() { @@ -193,6 +215,8 @@ public class RouteHelperTests Assert.Equal("instance not found", ex.Message); } + /// <summary>Verifies that waiting for an attribute encodes the target value on the request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAttribute_EncodesTargetValue_OnRequest() { @@ -215,10 +239,12 @@ public class RouteHelperTests Assert.True(Guid.TryParse(captured.CorrelationId, out _)); } + /// <summary>Verifies that the wait timeout, not the method deadline, governs a wait for an attribute.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAttribute_IsNotBoundByMethodDeadline_WaitTimeoutGoverns() { - // InboundAPI-029 (spec §6): unlike Call/GetAttributes/SetAttributes, the wait is + // Spec §6: unlike Call/GetAttributes/SetAttributes, the wait is // bounded by its OWN timeout, not the method deadline. Even with the method // deadline ALREADY cancelled, the wait still proceeds and returns the site's // wait-timeout result — the deadline does not cut it short. @@ -240,6 +266,8 @@ public class RouteHelperTests Assert.NotEqual(deadline.Token, seen); // it is a per-wait token, not the method deadline } + /// <summary>Verifies that an explicit cancellation token is honored when waiting for an attribute.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAttribute_ExplicitToken_IsHonoured() { @@ -260,6 +288,8 @@ public class RouteHelperTests Assert.True(seen.IsCancellationRequested); // the explicit caller token cancels the wait } + /// <summary>Verifies that a client disconnect cancels a wait for an attribute.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAttribute_ClientDisconnect_CancelsTheWait() { @@ -281,13 +311,15 @@ public class RouteHelperTests Assert.True(seen.IsCancellationRequested); // the client-abort token cancels the wait } + /// <summary>Verifies that a slow timed-out response is not preempted by a local backstop timeout.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAttribute_SlowTimedOutResponse_NotPreemptedByLocalBackstop() { - // InboundAPI-031: WaitForAttribute must NOT impose a local wait-timeout backstop. + // WaitForAttribute must NOT impose a local wait-timeout backstop. // The site enforces the wait timeout and returns Matched=false; the round trip is // bounded by CommunicationService's Ask (timeout + IntegrationTimeout). A local CTS - // of `timeout + small grace` (the prior InboundAPI-029 approach) was TIGHTER than + // of `timeout + small grace` (the prior approach) was TIGHTER than // that round-trip budget, so a slow-but-valid timed-out response would be cancelled // into an exception (a 500) instead of the spec §6 `false`. With the backstop // removed, a response arriving well after the (tiny) wait timeout still returns a @@ -316,6 +348,8 @@ public class RouteHelperTests Assert.False(seen.IsCancellationRequested); // no local wait-timeout backstop fired } + /// <summary>Verifies that waiting for an attribute carries the parent execution id on the request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAttribute_WithParentExecutionId_CarriesItOnRequest() { @@ -337,6 +371,8 @@ public class RouteHelperTests // --- SetAttribute(s) --- + /// <summary>Verifies that setting a single attribute delegates to the batch call with one entry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttribute_DelegatesToBatch_WithSingleEntry() { @@ -353,6 +389,8 @@ public class RouteHelperTests Assert.Single(captured.AttributeValues); } + /// <summary>Verifies that a remote failure when setting attributes throws an invalid-operation exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttributes_RemoteFailure_ThrowsInvalidOperationException() { @@ -367,8 +405,10 @@ public class RouteHelperTests Assert.Equal("write rejected", ex.Message); } - // --- InboundAPI-016: routed calls inherit the method deadline token --- + // --- Routed calls inherit the method deadline token --- + /// <summary>Verifies that a routed call without an explicit token inherits the method deadline token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_WithNoExplicitToken_InheritsMethodDeadlineToken() { @@ -388,6 +428,8 @@ public class RouteHelperTests Assert.Equal(deadline.Token, seen); } + /// <summary>Verifies that a routed call observes cancellation when the method deadline is cancelled.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_WhenMethodDeadlineCancelled_RoutedCallObservesCancellation() { @@ -411,6 +453,8 @@ public class RouteHelperTests () => bound.To("inst-1").Call("doWork")); } + /// <summary>Verifies that an explicit token overrides the deadline token on a routed call.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_ExplicitToken_OverridesDeadlineToken() { @@ -429,9 +473,11 @@ public class RouteHelperTests Assert.Equal(explicitCts.Token, seen); } - // --- Audit Log #23 (ParentExecutionId, T3): a routed call carries the + // --- ParentExecutionId: a routed call carries the // inbound request's ExecutionId as RouteToCallRequest.ParentExecutionId --- + /// <summary>Verifies that a routed call without a parent execution id leaves it null on the request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_WithoutParentExecutionId_LeavesParentExecutionIdNull() { @@ -449,6 +495,8 @@ public class RouteHelperTests Assert.Null(captured!.ParentExecutionId); } + /// <summary>Verifies that a routed call carries the parent execution id on the route-to-call request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_WithParentExecutionId_CarriesItOnRouteToCallRequest() { @@ -472,6 +520,8 @@ public class RouteHelperTests Assert.True(Guid.TryParse(captured.CorrelationId, out _)); } + /// <summary>Verifies that setting the parent execution id preserves the deadline token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WithParentExecutionId_PreservesDeadlineToken() { @@ -494,6 +544,8 @@ public class RouteHelperTests Assert.NotNull(captured!.ParentExecutionId); } + /// <summary>Verifies that getting attributes without an explicit token inherits the method deadline token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken() { @@ -511,6 +563,8 @@ public class RouteHelperTests Assert.Equal(deadline.Token, seen); } + /// <summary>Verifies that setting attributes without an explicit token inherits the method deadline token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken() { @@ -527,8 +581,10 @@ public class RouteHelperTests Assert.Equal(deadline.Token, seen); } - // --- InboundAPI-021: ParentExecutionId flows through Get/SetAttributes too --- + // --- ParentExecutionId flows through Get/SetAttributes too --- + /// <summary>Verifies that getting attributes without a parent execution id leaves it null on the request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAttributes_WithoutParentExecutionId_LeavesParentExecutionIdNull() { @@ -545,6 +601,8 @@ public class RouteHelperTests Assert.Null(captured!.ParentExecutionId); } + /// <summary>Verifies that getting attributes carries the parent execution id on the route-to-get-attributes request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAttributes_WithParentExecutionId_CarriesItOnRouteToGetAttributesRequest() { @@ -566,6 +624,8 @@ public class RouteHelperTests Assert.Equal(inboundExecutionId, captured!.ParentExecutionId); } + /// <summary>Verifies that setting attributes without a parent execution id leaves it null on the request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttributes_WithoutParentExecutionId_LeavesParentExecutionIdNull() { @@ -581,6 +641,8 @@ public class RouteHelperTests Assert.Null(captured!.ParentExecutionId); } + /// <summary>Verifies that setting attributes carries the parent execution id on the route-to-set-attributes request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttributes_WithParentExecutionId_CarriesItOnRouteToSetAttributesRequest() { @@ -600,6 +662,8 @@ public class RouteHelperTests // --- N3: typed unreachability at the Ask boundary --- + /// <summary>Verifies that an ask timeout on a routed call throws a site-unreachable exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_AskTimeout_ThrowsSiteUnreachableException() { @@ -612,6 +676,8 @@ public class RouteHelperTests () => CreateHelper().To("inst").Call("s")); } + /// <summary>Verifies that an ask timeout when getting attributes throws a site-unreachable exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAttributes_AskTimeout_ThrowsSiteUnreachableException() { @@ -632,6 +698,8 @@ public class RouteHelperTests private static IReadOnlySet<string> Scope(params string[] siteIdentifiers) => new HashSet<string>(siteIdentifiers, StringComparer.OrdinalIgnoreCase); + /// <summary>Verifies that a routed call outside the permitted site scope throws before any routing happens.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_OutsideSiteScope_ThrowsRouteSiteScopeException_BeforeRouting() { @@ -647,6 +715,8 @@ public class RouteHelperTests await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default); } + /// <summary>Verifies that a routed call inside the permitted site scope is routed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_InsideSiteScope_Routes() { @@ -660,6 +730,8 @@ public class RouteHelperTests Assert.Equal(42, await helper.To("inst-1").Call("doWork")); } + /// <summary>Verifies that a null site scope leaves routed calls unscoped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_NullScope_IsUnscoped() { @@ -673,6 +745,8 @@ public class RouteHelperTests Assert.Equal(42, await helper.To("inst-1").Call("doWork")); } + /// <summary>Verifies that setting attributes outside the permitted site scope throws before any routing happens.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttributes_OutsideSiteScope_ThrowsRouteSiteScopeException_BeforeRouting() { @@ -687,6 +761,8 @@ public class RouteHelperTests await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default); } + /// <summary>Verifies that the site scope survives the other With-style copies.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WithSiteScope_SurvivesTheOtherWithCopies() { @@ -708,6 +784,8 @@ public class RouteHelperTests // --- Arch-review F11: WithBodyCapture --- + /// <summary>Verifies that the route-call audit row has no request or response summary when body capture is off.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteCallRow_WithBodyCaptureOff_HasNoRequestOrResponseSummary() { @@ -730,6 +808,8 @@ public class RouteHelperTests Assert.Null(row.ResponseSummary); } + /// <summary>Verifies that the route-call audit row has request and response summaries when body capture is on by default.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteCallRow_WithBodyCaptureOn_ByDefault_HasRequestAndResponseSummary() { @@ -751,10 +831,12 @@ public class RouteHelperTests Assert.Equal("99", row.ResponseSummary); } + /// <summary>Verifies that the body-capture flag survives the other With-style copies.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WithBodyCapture_SurvivesTheOtherWithCopies() { - // Mirrors WithSiteScope_SurvivesTheOtherWithCopies (Task 3): the flag must not be + // Mirrors WithSiteScope_SurvivesTheOtherWithCopies: the flag must not be // dropped by the copies InboundScriptExecutor makes (WithDeadline / // WithRequestAborted / WithParentExecutionId / WithTrace / WithSiteScope). SiteResolves("inst-1", "SiteA"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTraceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTraceTests.cs index 16fca1a0..a3d592dd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTraceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTraceTests.cs @@ -21,6 +21,7 @@ public class RouteHelperTraceTests private readonly ICentralAuditWriter _writer = Substitute.For<ICentralAuditWriter>(); private readonly INodeIdentityProvider _node = Substitute.For<INodeIdentityProvider>(); + /// <summary>Wires the instance locator and audit writer doubles.</summary> public RouteHelperTraceTests() { _locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA"); @@ -33,6 +34,8 @@ public class RouteHelperTraceTests private static AuditEvent SiteEvent(Guid exec) => ScadaBridgeAuditEventFactory.Create( AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, executionId: exec, target: "doWork"); + /// <summary>Verifies that a route failure does not persist an echoed secret when body capture is disabled.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EI2_RouteFailureWithBodyCaptureDisabled_DoesNotPersistEchoedSecret() { @@ -44,6 +47,8 @@ public class RouteHelperTraceTests Assert.DoesNotContain("SECRET123", System.Text.Json.JsonSerializer.Serialize(row)); } + /// <summary>Verifies that a call writes a route-call row using the parent as execution id and the child id from the reply.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_WritesRouteCallRow_WithParentAsExecutionId_AndChildFromReply() { @@ -70,6 +75,8 @@ public class RouteHelperTraceTests Assert.Equal("99", row.ResponseSummary); } + /// <summary>Verifies that the route-call row is always written, even when the run is not traced, and the request carries trace false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_IsAlwaysOn_EvenWhenNotTraced_AndTraceFalseOnRequest() { @@ -83,6 +90,8 @@ public class RouteHelperTraceTests Assert.Single(_written); } + /// <summary>Verifies that enabling tracing propagates the trace flag to both the call and the set-attributes request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WithTrace_PropagatesOnCallAndSetAttributes() { @@ -101,6 +110,8 @@ public class RouteHelperTraceTests Assert.True(set!.Trace); } + /// <summary>Verifies that the sink receives the route-call row followed by the reply's trace events, in order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sink_ReceivesRouteCallRow_ThenTheReplysTraceEvents_InOrder() { @@ -119,6 +130,8 @@ public class RouteHelperTraceTests Assert.Same(siteEvents[1], sunk[2]); } + /// <summary>Verifies that a routing failure writes a failed row and still throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Failure_WritesFailedRow_AndStillThrows() { @@ -132,6 +145,8 @@ public class RouteHelperTraceTests Assert.Equal("script faulted", row.ErrorMessage); } + /// <summary>Verifies that with no writer and no sink nothing is written and no exception is thrown.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoWriterAndNoSink_WritesNothing_AndNeverThrows() { @@ -144,6 +159,8 @@ public class RouteHelperTraceTests Assert.Empty(_written); } + /// <summary>Verifies that a faulting audit writer does not fail the call.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FaultingWriter_DoesNotFailTheCall() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/SchemaRefRuntimeValidationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/SchemaRefRuntimeValidationTests.cs index ed98c33e..636b147d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/SchemaRefRuntimeValidationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/SchemaRefRuntimeValidationTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// <summary> -/// M9-T32b runtime regression: the InboundAPI RUNTIME validators +/// Runtime regression: the InboundAPI RUNTIME validators /// (<see cref="ParameterValidator"/> / <see cref="ReturnValueValidator"/>) must /// resolve <c>{"$ref":"lib:Name"}</c> library references through the same seam the /// deploy-time path uses. Before this fix the runtime validators called the @@ -31,6 +31,7 @@ public class SchemaRefRuntimeValidationTests // ── ParameterValidator: $ref in parameter definitions ───────────────────── + /// <summary>Verifies that the parameter validator resolves a valid library reference and validates against it.</summary> [Fact] public void ParameterValidator_ValidLibRef_ResolvesAndValidates() { @@ -60,6 +61,7 @@ public class SchemaRefRuntimeValidationTests Assert.DoesNotContain("definitions in method configuration", bad.ErrorMessage); } + /// <summary>Verifies that the parameter validator returns a clear invalid result naming a dangling library reference.</summary> [Fact] public void ParameterValidator_DanglingLibRef_ReturnsClearInvalidNamingTheRef() { @@ -78,6 +80,7 @@ public class SchemaRefRuntimeValidationTests Assert.Contains("GoneLib", result.ErrorMessage!, StringComparison.Ordinal); } + /// <summary>Verifies that the parameter validator behaves unchanged without a resolver when there is no reference.</summary> [Fact] public void ParameterValidator_NoRef_UnchangedWithoutResolver() { @@ -96,6 +99,7 @@ public class SchemaRefRuntimeValidationTests // ── ReturnValueValidator: $ref in return definition ─────────────────────── + /// <summary>Verifies that the return value validator resolves a valid library reference and validates against it.</summary> [Fact] public void ReturnValueValidator_ValidLibRef_ResolvesAndValidates() { @@ -121,6 +125,7 @@ public class SchemaRefRuntimeValidationTests Assert.DoesNotContain("definition in method configuration", bad.ErrorMessage); } + /// <summary>Verifies that the return value validator returns a clear invalid result naming a dangling library reference.</summary> [Fact] public void ReturnValueValidator_DanglingLibRef_ReturnsClearInvalidNamingTheRef() { @@ -135,6 +140,7 @@ public class SchemaRefRuntimeValidationTests Assert.Contains("GoneReturn", result.ErrorMessage, StringComparison.Ordinal); } + /// <summary>Verifies that the return value validator behaves unchanged without a resolver when there is no reference.</summary> [Fact] public void ReturnValueValidator_NoRef_UnchangedWithoutResolver() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ScriptArtifactChangeSubscriberTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ScriptArtifactChangeSubscriberTests.cs index d03d7f52..b5802b39 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ScriptArtifactChangeSubscriberTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ScriptArtifactChangeSubscriberTests.cs @@ -20,13 +20,16 @@ public class ScriptArtifactChangeSubscriberTests private sealed class RecordingBus : IScriptArtifactChangeBus { private readonly List<Action<ScriptArtifactsChanged>> _handlers = new(); + /// <summary>Gets the number of currently registered handlers.</summary> public int SubscriberCount => _handlers.Count; + /// <inheritdoc /> public void Publish(ScriptArtifactsChanged notification) { foreach (var handler in _handlers.ToArray()) handler(notification); } + /// <inheritdoc /> public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler) { _handlers.Add(handler); @@ -35,6 +38,7 @@ public class ScriptArtifactChangeSubscriberTests private sealed class Token(Action dispose) : IDisposable { + /// <summary>Releases the resources used by this instance.</summary> public void Dispose() => dispose(); } } @@ -55,6 +59,8 @@ public class ScriptArtifactChangeSubscriberTests private static ScriptArtifactsChanged ApiMethodChanged(params string[] names) => new(ScriptArtifactKinds.ApiMethod, names, "BundleImport", DateTimeOffset.UtcNow); + /// <summary>Verifies that an ApiMethod publish invalidates the cached handler without waiting for the revision-check self-heal.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApiMethodPublish_InvalidatesCachedHandler_WithoutRevisionCheckSelfHeal() { @@ -77,6 +83,8 @@ public class ScriptArtifactChangeSubscriberTests Assert.Contains("1", after.ResultJson); // recompiled from the fresh row — invalidation, not self-heal } + /// <summary>Verifies that an ApiMethod publish purges the known-bad record.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApiMethodPublish_PurgesKnownBadRecord() { @@ -92,6 +100,8 @@ public class ScriptArtifactChangeSubscriberTests Assert.Equal(0, _executor.KnownBadMethodCount); } + /// <summary>Verifies that non-ApiMethod artifact kinds are ignored.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NonApiMethodKinds_AreIgnored() { @@ -110,6 +120,8 @@ public class ScriptArtifactChangeSubscriberTests Assert.Contains("99", result.ResultJson); // pinned handler untouched } + /// <summary>Verifies that stopping the subscriber unsubscribes it from the bus.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StopAsync_Unsubscribes() { @@ -123,10 +135,11 @@ public class ScriptArtifactChangeSubscriberTests } /// <summary> - /// WP2.6b: an ApiMethod change notification must also drop the resolved-row cache + /// An ApiMethod change notification must also drop the resolved-row cache /// entry (not just the compiled handler) — proving EndpointExtensions' next lookup /// re-fetches instead of serving a stale cached row for the rest of the TTL window. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApiMethodPublish_InvalidatesResolvedMethodCache() { @@ -150,6 +163,8 @@ public class ScriptArtifactChangeSubscriberTests Assert.Equal(2, fetchCount); // cache entry was dropped — re-fetched } + /// <summary>Verifies that a null bus is tolerated and the subscriber does nothing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NullBus_NoOps() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/StandbyRequestForwardingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/StandbyRequestForwardingTests.cs index fcb748ae..3f186207 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/StandbyRequestForwardingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/StandbyRequestForwardingTests.cs @@ -20,6 +20,8 @@ public class StandbyRequestForwardingTests // Filter: when does a standby relay, and when does it refuse? // ------------------------------------------------------------------ + /// <summary>Verifies that the relay does not negotiate an encoding it cannot forward.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Relay_DoesNotNegotiateAnEncodingItCannotForward() { @@ -34,6 +36,9 @@ public class StandbyRequestForwardingTests } + /// <summary>Verifies that a request the peer applied whose response was lost is not replayed.</summary> + /// <param name="timeout">True to simulate the lost response as a timeout, false to simulate a connection failure.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] @@ -56,6 +61,8 @@ public class StandbyRequestForwardingTests Assert.Contains("OUTCOME_UNKNOWN", await new StreamReader(ctx.Response.Body).ReadToEndAsync()); } + /// <summary>Verifies that a standby with a forwarder configured relays the request and does not run the handler.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Standby_WithForwarderConfigured_RelaysAndDoesNotRunHandler() { @@ -71,6 +78,8 @@ public class StandbyRequestForwardingTests Assert.True(ctx.Items.ContainsKey(InboundApiEndpointFilter.RelayedToActiveItemKey)); } + /// <summary>Verifies that a standby without a configured forwarder returns 503 STANDBY_NODE marked for peers.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Standby_WithForwarderUnconfigured_Returns503StandbyNode_MarkedForPeers() { @@ -87,6 +96,8 @@ public class StandbyRequestForwardingTests Assert.False(ctx.Items.ContainsKey(InboundApiEndpointFilter.RelayedToActiveItemKey)); } + /// <summary>Verifies that a standby does not relay an already forwarded request again (loop guard).</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Standby_AlreadyForwardedRequest_IsNotRelayedAgain_LoopGuard() { @@ -103,6 +114,8 @@ public class StandbyRequestForwardingTests Assert.Equal("1", ctx.Response.Headers[HttpStandbyRequestForwarder.StandbyResponseHeader]); } + /// <summary>Verifies that a standby returns 413 for an oversized body before relaying.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Standby_OversizedBody_Returns413_BeforeRelaying() { @@ -119,6 +132,8 @@ public class StandbyRequestForwardingTests AssertStatus(result, StatusCodes.Status413PayloadTooLarge); } + /// <summary>Verifies that an active node serves a forwarded request locally.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ActiveNode_ForwardedRequest_IsServedLocally() { @@ -139,6 +154,8 @@ public class StandbyRequestForwardingTests // Forwarder: relay mechanics against a fake HTTP peer // ------------------------------------------------------------------ + /// <summary>Verifies that the forwarder relays the method, path, query, headers and body and replays the peer response.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Forwarder_RelaysMethodPathQueryHeadersAndBody_AndReplaysPeerResponse() { @@ -175,6 +192,8 @@ public class StandbyRequestForwardingTests Assert.Equal("application/json", seen.Content!.Headers.ContentType!.MediaType); } + /// <summary>Verifies that the forwarder replays a peer error status unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Forwarder_ReplaysPeerErrorStatusUnchanged() { @@ -191,6 +210,8 @@ public class StandbyRequestForwardingTests Assert.Contains("NOT_APPROVED", Encoding.UTF8.GetString(relayed.Body)); } + /// <summary>Verifies that the forwarder tries the next peer with the same body when a peer is unreachable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Forwarder_PeerUnreachable_TriesNextPeer_WithSameBody() { @@ -211,6 +232,8 @@ public class StandbyRequestForwardingTests Assert.Equal(new[] { "{\"n\":7}", "{\"n\":7}" }, bodies); } + /// <summary>Verifies that the forwarder tries the next peer when a peer is also a standby.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Forwarder_PeerIsAlsoStandby_TriesNextPeer() { @@ -233,6 +256,8 @@ public class StandbyRequestForwardingTests Assert.Equal("served", Encoding.UTF8.GetString(relayed.Body)); } + /// <summary>Verifies that a genuine 503 from the active peer is replayed rather than retried.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Forwarder_Genuine503FromActivePeer_IsReplayed_NotRetried() { @@ -257,6 +282,8 @@ public class StandbyRequestForwardingTests Assert.Equal(1, calls); } + /// <summary>Verifies that the forwarder returns 503 ACTIVE_NODE_UNREACHABLE when all peers fail.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Forwarder_AllPeersFail_Returns503ActiveNodeUnreachable() { @@ -269,6 +296,8 @@ public class StandbyRequestForwardingTests AssertStatus(result, StatusCodes.Status503ServiceUnavailable); } + /// <summary>Verifies that a peer timeout returns an uncertain result without trying the next peer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Forwarder_PeerTimesOut_ReturnsUncertainWithoutTryingNextPeer() { @@ -288,6 +317,8 @@ public class StandbyRequestForwardingTests AssertStatus(result, StatusCodes.Status502BadGateway); } + /// <summary>Verifies that a caller abort propagates and does not try the next peer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Forwarder_CallerAborts_Propagates_DoesNotTryNextPeer() { @@ -307,6 +338,7 @@ public class StandbyRequestForwardingTests Assert.Equal(1, calls); } + /// <summary>Verifies that IsConfigured tracks the peer list.</summary> [Fact] public void Forwarder_IsConfigured_TracksPeerList() { @@ -314,6 +346,8 @@ public class StandbyRequestForwardingTests Assert.True(CreateForwarder(new FakeHandler(_ => throw new InvalidOperationException()), "http://b:5000").IsConfigured); } + /// <summary>Verifies that a relayed result writes the status, content type and body.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RelayedResult_WritesStatusContentTypeAndBody() { @@ -392,10 +426,17 @@ public class StandbyRequestForwardingTests private sealed class StubForwarder : IStandbyRequestForwarder { + /// <summary>Initializes the stub with its configured flag and canned result.</summary> + /// <param name="configured">Whether the forwarder reports itself as configured.</param> + /// <param name="result">The result returned from forwarding.</param> public StubForwarder(bool configured, IResult result) { IsConfigured = configured; Result = result; } + /// <inheritdoc /> public bool IsConfigured { get; } + /// <summary>Gets the canned result returned from forwarding.</summary> public IResult Result { get; } + /// <summary>Gets the number of times forwarding was invoked.</summary> public int Calls { get; private set; } + /// <inheritdoc /> public Task<IResult> ForwardAsync(HttpContext context, CancellationToken cancellationToken) { Calls++; @@ -405,15 +446,23 @@ public class StandbyRequestForwardingTests private sealed class StubActiveNodeGate : IActiveNodeGate { + /// <summary>Initializes the gate with a fixed active-node answer.</summary> + /// <param name="isActive">Whether this node reports itself as active.</param> public StubActiveNodeGate(bool isActive) => IsActiveNode = isActive; + /// <inheritdoc /> public bool IsActiveNode { get; } } private sealed class FakeHandler : HttpMessageHandler { private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _send; + /// <summary>Initializes the handler with a send delegate that ignores the cancellation token.</summary> + /// <param name="send">The delegate that produces the response for a request.</param> public FakeHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> send) => _send = (r, _) => send(r); + /// <summary>Initializes the handler with a send delegate that observes the cancellation token.</summary> + /// <param name="send">The delegate that produces the response for a request.</param> public FakeHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send) => _send = send; + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) => _send(request, ct); } @@ -421,16 +470,30 @@ public class StandbyRequestForwardingTests private sealed class FakeHttpClientFactory : IHttpClientFactory { private readonly HttpMessageHandler _handler; + /// <summary>Initializes the factory with the handler every client will use.</summary> + /// <param name="handler">The message handler shared by created clients.</param> public FakeHttpClientFactory(HttpMessageHandler handler) => _handler = handler; + /// <summary>Returns a client over the canned handler.</summary> + /// <param name="name">The requested client name (ignored).</param> + /// <returns>An <see cref="HttpClient"/> that uses the fake handler.</returns> public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false) { Timeout = Timeout.InfiniteTimeSpan }; } private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T> { + /// <summary>Initializes the monitor with a fixed value.</summary> + /// <param name="value">The value returned by the monitor.</param> public StaticOptionsMonitor(T value) => CurrentValue = value; + /// <summary>Gets the canned options value.</summary> public T CurrentValue { get; } + /// <summary>Returns the canned options value regardless of name.</summary> + /// <param name="name">The requested options name (ignored).</param> + /// <returns>The canned options value.</returns> public T Get(string? name) => CurrentValue; + /// <summary>Ignores the change listener; the fake never raises changes.</summary> + /// <param name="listener">The change listener (ignored).</param> + /// <returns>Always null.</returns> public IDisposable? OnChange(Action<T, string?> listener) => null; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs index 739055b5..feb9605b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs @@ -23,7 +23,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Actors; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.AuditLog; /// <summary> -/// End-to-end integration test for the Audit Log (#23) site→central push path +/// End-to-end integration test for the Audit Log site→central push path /// introduced by the "real site audit push client" follow-up. /// </summary> /// <remarks> @@ -61,6 +61,7 @@ public class SiteAuditPushFlowTests : TestKit private readonly IActorRef _central; private readonly IActorRef _auditIngest; + /// <summary>Creates a transport that forwards each call to the supplied central actors.</summary> /// <param name="central">Stands in for the central control plane's non-audit RPCs.</param> /// <param name="auditIngest"> /// The central audit-ingest singleton. The two ingest RPCs go straight here, mirroring @@ -73,12 +74,19 @@ public class SiteAuditPushFlowTests : TestKit _auditIngest = auditIngest; } + /// <inheritdoc /> public void SubmitNotification(NotificationSubmit message, IActorRef replyTo) => _central.Tell(message, replyTo); + /// <inheritdoc /> public void QueryNotificationStatus(NotificationStatusQuery message, IActorRef replyTo) => _central.Tell(message, replyTo); + /// <inheritdoc /> public void IngestAuditEvents(IngestAuditEventsCommand message, IActorRef replyTo) => _auditIngest.Tell(message, replyTo); + /// <inheritdoc /> public void IngestCachedTelemetry(IngestCachedTelemetryCommand message, IActorRef replyTo) => _auditIngest.Tell(message, replyTo); + /// <inheritdoc /> public void ReconcileSite(ReconcileSiteRequest message, IActorRef replyTo) => _central.Tell(message, replyTo); + /// <inheritdoc /> public void ReportSiteHealth(SiteHealthReport message, IActorRef replyTo) => _central.Tell(message, replyTo); + /// <inheritdoc /> public void SendHeartbeat(HeartbeatMessage message, IActorRef self) => _central.Tell(message, self); } @@ -89,13 +97,16 @@ public class SiteAuditPushFlowTests : TestKit /// </summary> private sealed class InMemoryAuditLogRepository : IAuditLogRepository { + /// <inheritdoc /> public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); private readonly ConcurrentDictionary<Guid, AuditEvent> _rows = new(); + /// <summary>Gets a snapshot of all rows inserted so far.</summary> public IReadOnlyCollection<AuditEvent> Rows => _rows.Values.ToList(); + /// <inheritdoc /> public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(evt); @@ -104,38 +115,46 @@ public class SiteAuditPushFlowTests : TestKit return Task.CompletedTask; } + /// <inheritdoc /> public Task<IReadOnlyList<AuditEvent>> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task<long> PurgeChannelOlderThanAsync( string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) => throw new NotSupportedException(); + /// <inheritdoc /> public Task<long> BackfillSourceNodeAsync( string sentinel, DateTime before, int batchSize, CancellationToken ct = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) => throw new NotSupportedException(); + /// <inheritdoc /> public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) => throw new NotSupportedException(); } - // C3 (Task 2.5): canonical record via the shared factory; ForwardState is a + // Canonical record via the shared factory; ForwardState is a // site-storage-only concern (defaulted to Pending by the SQLite writer), not a // field on the canonical record. private static AuditEvent NewPendingEvent(Guid id) => @@ -148,6 +167,8 @@ public class SiteAuditPushFlowTests : TestKit target: "ext-system-1", sourceSiteId: "site-1"); + /// <summary>Verifies that a site audit event drains to central and the site row is flipped to forwarded.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiteAuditEvent_DrainsToCentral_AndFlipsSiteRowToForwarded() { @@ -233,7 +254,7 @@ public class SiteAuditPushFlowTests : TestKit // The site row reached AuditForwardState.Forwarded specifically — // not merely "no longer Pending" (a Reconciled row would also leave // ReadPendingAsync, so we assert the positive Forwarded state). - // C3 (Task 2.5): ForwardState is a site-storage-only column, no longer a + // ForwardState is a site-storage-only column, no longer a // field on the canonical record. ReadForwardedAsync only returns rows in // the Forwarded state, so a single match here proves the row reached it. var forwarded = await writer.ReadForwardedAsync(256, CancellationToken.None); diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditTransactionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditTransactionTests.cs index 57bdb6ab..f3236481 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditTransactionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditTransactionTests.cs @@ -8,17 +8,21 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// WP-22: Audit transactional guarantee — entity change + audit log in same transaction. +/// Audit transactional guarantee — entity change + audit log in same transaction. /// </summary> public class AuditTransactionTests : IClassFixture<ScadaBridgeWebApplicationFactory> { private readonly ScadaBridgeWebApplicationFactory _factory; + /// <summary>Initializes a new instance of the <see cref="AuditTransactionTests"/> class.</summary> + /// <param name="factory">The factory used by the tests.</param> public AuditTransactionTests(ScadaBridgeWebApplicationFactory factory) { _factory = factory; } + /// <summary>Verifies the case: audit Log, is Committed With Entity Change, in Same Transaction.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditLog_IsCommittedWithEntityChange_InSameTransaction() { @@ -54,6 +58,8 @@ public class AuditTransactionTests : IClassFixture<ScadaBridgeWebApplicationFact Assert.Contains(auditEntries, a => a.EntityName == "test-group-audit" && a.Action == "Create"); } + /// <summary>Verifies the case: audit Log, is Not Persisted When Save Not Called.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditLog_IsNotPersistedWhenSaveNotCalled() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuthFlowTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuthFlowTests.cs index 80a3747c..3896bd94 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuthFlowTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuthFlowTests.cs @@ -7,18 +7,22 @@ using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// WP-22: Auth flow integration tests. +/// Auth flow integration tests. /// Tests that require a running LDAP server are marked with Integration trait. /// </summary> public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory> { private readonly ScadaBridgeWebApplicationFactory _factory; + /// <summary>Initializes the test with the shared web application factory.</summary> + /// <param name="factory">The web application factory hosting the app under test.</param> public AuthFlowTests(ScadaBridgeWebApplicationFactory factory) { _factory = factory; } + /// <summary>Verifies that the login endpoint with empty credentials redirects to the login page with an error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoginEndpoint_WithEmptyCredentials_RedirectsToLoginWithError() { @@ -37,6 +41,8 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory> Assert.Contains("error", location, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that the logout endpoint clears the cookie and redirects.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogoutEndpoint_ClearsCookieAndRedirects() { @@ -52,6 +58,7 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory> Assert.Contains("/login", location); } + /// <summary>Verifies that the JWT token service round-trips a generated token through validation.</summary> [Fact] public void JwtTokenService_GenerateAndValidate_RoundTrips() { @@ -79,6 +86,7 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory> Assert.Contains("Designer", roles); } + /// <summary>Verifies that tokens generated with site scopes include site id claims.</summary> [Fact] public void JwtTokenService_WithSiteScopes_IncludesSiteIdClaims() { @@ -99,6 +107,8 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory> Assert.Contains("3", siteIds); } + /// <summary>Verifies that the login endpoint with valid LDAP credentials sets the cookie and redirects.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Trait("Category", "Integration")] [Fact] public async Task LoginEndpoint_WithValidLdapCredentials_SetsCookieAndRedirects() @@ -132,6 +142,8 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory> Assert.Contains("ZB.MOM.WW.ScadaBridge.Auth", setCookieHeader); } + /// <summary>Verifies that the login endpoint without an antiforgery token is rejected without an auth cookie.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoginEndpoint_WithoutAntiforgeryToken_RejectsWithoutAuthCookie() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs index 00dc9c6b..7e6db7f1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs @@ -28,7 +28,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// P7-5: a bulk site deploy runs all three phases over ONE scoped +/// A bulk site deploy runs all three phases over ONE scoped /// <c>DbContext</c>, so a persistent <c>SaveChanges</c> failure on a single /// instance used to fail every instance that came after it. /// @@ -62,6 +62,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable private readonly PoisonedEntityInterceptor _poison = new(); private int _siteId; + /// <summary>Creates an in-memory SQLite database with the poisoning interceptor and seeds it.</summary> public BulkDeployPoisonedContextTests() { _connection = new SqliteConnection("DataSource=:memory:"); @@ -92,6 +93,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable /// <c>Failed</c>, not stranded), and 3 and 4 save cleanly. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BulkDeploy_PersistentSaveFailureOnOneInstance_DoesNotCascadeToTheRest() { @@ -135,6 +137,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable /// the NEXT save re-attempts it, and /// <c>IDeploymentManagerRepository.DetachPendingChanges</c> is what clears it. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FailedSave_KeepsTheEntityTracked_UntilPendingChangesAreDetached() { @@ -253,6 +256,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) .Options); + /// <summary>Disposes the database context and connection.</summary> public new void Dispose() { _context.Dispose(); @@ -269,8 +273,11 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable { private string? _poisonedUniqueName; + /// <summary>Makes every save that includes the named instance fail.</summary> + /// <param name="uniqueName">The unique name of the instance whose saves should fail.</param> public void PoisonInstanceNamed(string uniqueName) => _poisonedUniqueName = uniqueName; + /// <inheritdoc /> public override InterceptionResult<int> SavingChanges( DbContextEventData eventData, InterceptionResult<int> result) { @@ -278,6 +285,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable return base.SavingChanges(eventData, result); } + /// <inheritdoc /> public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, @@ -305,6 +313,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable /// <summary>Answers every deployment with Success, so only persistence can fail here.</summary> private sealed class AcceptingSiteActor : ReceiveActor { + /// <summary>Registers a handler that answers every deployment command with Success.</summary> public AcceptingSiteActor() { Receive<SiteEnvelope>(env => @@ -325,11 +334,14 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable /// </summary> private sealed class SqliteContext : ScadaBridgeDbContext { + /// <summary>Creates a context over the given options using an ephemeral data protection provider.</summary> + /// <param name="options">The EF Core context options.</param> public SqliteContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options, new EphemeralDataProtectionProvider()) { } + /// <inheritdoc /> protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/CentralFailoverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/CentralFailoverTests.cs index 8f2e741f..0fd199f9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/CentralFailoverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/CentralFailoverTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// WP-1 (Phase 8): Full-system failover testing — Central. +/// Full-system failover testing — Central. /// Verifies that JWT tokens and deployment state survive central node failover. /// Multi-process failover tests are marked with Integration trait for separate runs. /// </summary> @@ -24,6 +24,7 @@ public class CentralFailoverTests return new JwtTokenService(options, NullLogger<JwtTokenService>.Instance); } + /// <summary>Verifies that a JWT generated before failover validates after failover.</summary> [Fact] public void JwtToken_GeneratedBeforeFailover_ValidatesAfterFailover() { @@ -46,6 +47,7 @@ public class CentralFailoverTests Assert.Equal("failover_test", username); } + /// <summary>Verifies that a JWT with site scopes survives revalidation.</summary> [Fact] public void JwtToken_WithSiteScopes_SurvivesRevalidation() { @@ -67,6 +69,7 @@ public class CentralFailoverTests Assert.Contains("site-5", siteIds); } + /// <summary>Verifies that a JWT validated with a different signing key fails validation.</summary> [Fact] public void JwtToken_DifferentSigningKeys_FailsValidation() { @@ -85,6 +88,7 @@ public class CentralFailoverTests Assert.Null(principal); } + /// <summary>Verifies that optimistic concurrency on deployment status detects stale writes.</summary> [Trait("Category", "Integration")] [Fact] public void DeploymentStatus_OptimisticConcurrency_DetectsStaleWrites() @@ -101,6 +105,7 @@ public class CentralFailoverTests Assert.Equal(Commons.Types.Enums.DeploymentStatus.Success, status2.Status); } + /// <summary>Verifies that a JWT that expired before failover is rejected after failover.</summary> [Fact] public void JwtToken_ExpiredBeforeFailover_RejectedAfterFailover() { @@ -119,6 +124,7 @@ public class CentralFailoverTests Assert.NotNull(expClaim); } + /// <summary>Verifies that an idle timeout is detected.</summary> [Fact] public void JwtToken_IdleTimeout_Detected() { @@ -137,6 +143,7 @@ public class CentralFailoverTests Assert.False(jwtService.IsIdleTimedOut(principal!)); } + /// <summary>Verifies that the refresh check detects a token near expiry.</summary> [Fact] public void JwtToken_ShouldRefresh_DetectsNearExpiry() { @@ -155,6 +162,7 @@ public class CentralFailoverTests Assert.False(jwtService.ShouldRefresh(principal!)); } + /// <summary>Verifies that deployment status for multiple instances is tracked independently.</summary> [Trait("Category", "Integration")] [Fact] public void DeploymentStatus_MultipleInstances_IndependentTracking() diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ActiveNodeSemanticsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ActiveNodeSemanticsTests.cs index 9778ecf1..ea21cba8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ActiveNodeSemanticsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ActiveNodeSemanticsTests.cs @@ -25,6 +25,8 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster; /// </summary> public class ActiveNodeSemanticsTests { + /// <summary>Verifies that after the oldest node restarts, the surviving node stays active and the rejoining node becomes standby.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AfterOldestNodeRestarts_SurvivorStaysActive_RejoinerIsStandby() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ClusterBootstrapCoordinatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ClusterBootstrapCoordinatorTests.cs index 442c7286..91549b3b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ClusterBootstrapCoordinatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ClusterBootstrapCoordinatorTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster; /// <summary> -/// Real-ActorSystem tests for the simultaneous-cold-start split-brain guard (Gitea #33): +/// Real-ActorSystem tests for the simultaneous-cold-start split-brain guard: /// <see cref="ClusterBootstrapCoordinator"/> + <see cref="ClusterBootstrapGuard"/>, driven exactly as /// production drives them — the node's ActorSystem is built from the production <c>BuildHocon</c> output /// with the guard enabled (which emits an EMPTY seed list, so Akka does not auto-join), then the real @@ -82,6 +82,7 @@ public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime /// The FOUNDER (lower address) with the guard on forms a cluster alone when its partner is dead — /// exactly as un-guarded self-first does. The guard must not regress the preferred founder. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Founder_forms_alone_when_partner_is_dead() { @@ -102,6 +103,7 @@ public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime /// If the guard's peer-first logic were wrong, this node would hang in JoinSeedNodeProcess forever — /// the very regression a naive "always let the lower node found" rule would introduce. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Higher_node_forms_alone_when_partner_is_dead_after_probing() { @@ -122,6 +124,7 @@ public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime /// window — it is genuinely waiting/probing, not self-forming immediately (which would mean the /// guard is inert). If this ever starts seeing a member before the window, the probe is not gating. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Higher_node_does_not_form_before_the_probe_window_expires() { @@ -142,6 +145,7 @@ public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime /// then the higher node — its probe finds the founder reachable, it commits peer-first, and the /// pair converges to ONE cluster of two. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Higher_node_joins_a_live_founder() { @@ -163,11 +167,12 @@ public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime } /// <summary> - /// THE HEADLINE #33 CASE. Both nodes cold-start truly simultaneously with the guard on. Without the + /// THE HEADLINE CASE. Both nodes cold-start truly simultaneously with the guard on. Without the /// guard each would run FirstSeedNodeProcess, race, and form its own 1-node cluster (split brain). /// With it, the founder forms and the higher node probes-then-joins, so the pair converges to /// EXACTLY ONE cluster of two — no two oldest, no dual singletons. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Both_nodes_cold_starting_together_form_exactly_one_cluster() { @@ -188,8 +193,12 @@ public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime "the higher node must be in the SAME cluster as the founder"); } + /// <summary>Prepares the fixture before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task InitializeAsync() => Task.CompletedTask; + /// <summary>Releases the resources held by this fixture asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task DisposeAsync() { foreach (var c in _coordinators) diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ManualFailoverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ManualFailoverTests.cs index 5c6983e1..c9acbbb6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ManualFailoverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ManualFailoverTests.cs @@ -45,6 +45,8 @@ public sealed class ManualFailoverTests : IAsyncDisposable return system; } + /// <summary>Verifies that failover, makes, the, oldest, leave, and, the, survivor, take, over.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Failover_makes_the_oldest_leave_and_the_survivor_take_over() { @@ -66,6 +68,8 @@ public sealed class ManualFailoverTests : IAsyncDisposable Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(Akka.Cluster.Cluster.Get(f.NodeB), "Central")); } + /// <summary>Verifies that failover, refuses, when, no, peer, exists.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Failover_refuses_when_no_peer_exists() { @@ -82,6 +86,8 @@ public sealed class ManualFailoverTests : IAsyncDisposable Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(Akka.Cluster.Cluster.Get(solo), "Central")); } + /// <summary>Verifies that failover, dry, run, names, the, target, without, moving, the, cluster.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Failover_dry_run_names_the_target_without_moving_the_cluster() { @@ -98,6 +104,8 @@ public sealed class ManualFailoverTests : IAsyncDisposable Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(oldest, "Central")); } + /// <summary>Terminates the actor systems and disposes the database environment.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async ValueTask DisposeAsync() { foreach (var s in _systems) diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SbrFailoverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SbrFailoverTests.cs index 392d37fc..33ec94bb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SbrFailoverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SbrFailoverTests.cs @@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster; /// <item><c>keep-oldest</c> — downs the side that does NOT contain the oldest member, /// and its <c>down-if-alone</c> escape only fires when the surviving side has ≥2 /// members (<c>KeepOldest.OldestDecision</c>: <c>otherSide == 1 && thisSide >= 2</c>). -/// With 1-vs-1 the younger survivor therefore takes <c>DownReachable</c> — it downs +/// With a two-node cluster the younger survivor therefore takes <c>DownReachable</c> — it downs /// ITSELF — so only a YOUNGER-node crash is survivable.</item> /// <item><c>auto-down</c> (production default, decision 2026-07-21) — the leader among /// the reachable members downs the unreachable peer after the stability window, so a @@ -30,6 +30,7 @@ public class SbrFailoverTests { private sealed class EchoActor : ReceiveActor { + /// <summary>Initializes the actor to echo every message back to its sender.</summary> public EchoActor() => ReceiveAny(msg => Sender.Tell(msg)); } @@ -47,6 +48,8 @@ public class SbrFailoverTests return (manager, proxy); } + /// <summary>Verifies that when the younger node crashes hard, the split brain resolver downs it and the oldest node keeps the singleton.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HardCrashOfYoungerNode_SbrDownsIt_AndOldestKeepsSingleton() { @@ -84,6 +87,8 @@ public class SbrFailoverTests throw new Xunit.Sdk.XunitException($"Singleton stopped answering on the surviving oldest node after SBR downing: {last}"); } + /// <summary>Verifies that with auto-down, a hard crash of the oldest node lets the younger survivor take over the singleton.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AutoDown_HardCrashOfOldestNode_YoungerSurvivorTakesOverSingleton() { @@ -128,6 +133,8 @@ public class SbrFailoverTests $"Singleton never migrated to the younger survivor after the oldest crashed under auto-down: {last}"); } + /// <summary>Verifies that with auto-down, a hard crash of the younger node leaves the singleton on the oldest node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AutoDown_HardCrashOfYoungerNode_OldestKeepsSingleton() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SelfFirstSeedBootstrapTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SelfFirstSeedBootstrapTests.cs index 60edcb73..dff628aa 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SelfFirstSeedBootstrapTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SelfFirstSeedBootstrapTests.cs @@ -68,6 +68,8 @@ public sealed class SelfFirstSeedBootstrapTests : IAsyncLifetime return system; } + /// <summary>Verifies that a lone node cold-starting with a dead peer forms a cluster on its own.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Lone_cold_start_forms_a_cluster_when_the_peer_is_dead() { @@ -81,6 +83,8 @@ public sealed class SelfFirstSeedBootstrapTests : IAsyncLifetime Assert.Equal(MemberStatus.Up, Akka.Cluster.Cluster.Get(node).SelfMember.Status); } + /// <summary>Verifies that listing the peer first in the seed nodes is the outage gap, so the lone node never forms a cluster.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Peer_first_ordering_is_the_outage_gap_and_never_forms() { @@ -100,6 +104,8 @@ public sealed class SelfFirstSeedBootstrapTests : IAsyncLifetime await TwoNodeClusterFixture.WaitForMembersUp(node, 1, TimeSpan.FromSeconds(30)); } + /// <summary>Verifies that a restarting node rejoins its live peer instead of forming its own cluster.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Restarting_node_rejoins_its_live_peer_instead_of_self_forming() { @@ -123,6 +129,8 @@ public sealed class SelfFirstSeedBootstrapTests : IAsyncLifetime await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 2, TimeSpan.FromSeconds(90)); } + /// <summary>Verifies that two nodes cold-starting together converge on a single cluster.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Both_nodes_cold_starting_together_converge_on_one_cluster() { @@ -140,8 +148,12 @@ public sealed class SelfFirstSeedBootstrapTests : IAsyncLifetime await TwoNodeClusterFixture.WaitForMembersUp(nodeB, 2, TimeSpan.FromSeconds(60)); } + /// <summary>Prepares the fixture before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task InitializeAsync() => Task.CompletedTask; + /// <summary>Releases the resources held by this fixture asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task DisposeAsync() { foreach (var s in _systems) diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SiteFailoverRelayTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SiteFailoverRelayTests.cs index 5853ad13..d89d0aa6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SiteFailoverRelayTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SiteFailoverRelayTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.ClusterState; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster; /// <summary> -/// Real-cluster proof for the central→site failover relay (Task 10). The unit tests in +/// Real-cluster proof for the central→site failover relay. The unit tests in /// <c>SiteCommunicationActorTests</c> pin the routing and guard logic with the failover action /// stubbed out; this pins the part they cannot — that the shared /// <see cref="ClusterFailoverCoordinator"/> actually moves a SITE pair when scoped to the @@ -17,6 +17,8 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster; /// </summary> public sealed class SiteFailoverRelayTests { + /// <summary>Verifies that failing over a site pair moves the oldest node and the survivor takes over.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Failing_over_a_site_pair_moves_the_oldest_and_the_survivor_takes_over() { @@ -35,6 +37,8 @@ public sealed class SiteFailoverRelayTests Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(Akka.Cluster.Cluster.Get(f.NodeB), "site-SiteA")); } + /// <summary>Verifies that a site role scope matching no member is refused.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task A_site_role_scope_that_matches_no_member_is_refused() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixture.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixture.cs index faee3dd3..a063680c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixture.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixture.cs @@ -20,11 +20,24 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster; /// </summary> public sealed class TwoNodeClusterFixture : IAsyncDisposable { + /// <summary>Gets the first cluster node, which is always started first and is therefore the oldest member.</summary> public ActorSystem NodeA { get; private set; } = null!; + /// <summary>Gets the second cluster node, which joins after the first.</summary> public ActorSystem NodeB { get; private set; } = null!; + /// <summary>Gets the remoting port used by the first node.</summary> public int PortA { get; private set; } + /// <summary>Gets the remoting port used by the second node.</summary> public int PortB { get; private set; } + /// <summary>Starts both nodes in order and waits until each sees two Up members.</summary> + /// <param name="role">The Akka role assigned to both nodes.</param> + /// <param name="stableAfter">How long membership must be stable before downing decisions; defaults to a scaled test value.</param> + /// <param name="portA">The remoting port for the first node, or null to pick a free port.</param> + /// <param name="portB">The remoting port for the second node, or null to pick a free port.</param> + /// <param name="heartbeatInterval">The failure-detector heartbeat interval; defaults to a scaled test value.</param> + /// <param name="failureDetectionThreshold">The failure-detector threshold; defaults to a scaled test value.</param> + /// <param name="strategy">The split-brain resolver strategy to configure.</param> + /// <returns>A task that yields the started two-node fixture.</returns> public static async Task<TwoNodeClusterFixture> StartAsync( string role = "Central", TimeSpan? stableAfter = null, int? portA = null, int? portB = null, @@ -45,6 +58,13 @@ public sealed class TwoNodeClusterFixture : IAsyncDisposable /// <summary>Starts a node from production HOCON; used by StartAsync and by restart-scenarios. /// <paramref name="strategy"/> defaults to the production posture (auto-down, decision /// 2026-07-21); pass "keep-oldest" to exercise the legacy SBR path.</summary> + /// <param name="port">The remoting port for the node.</param> + /// <param name="role">The Akka role assigned to the node.</param> + /// <param name="stableAfter">How long membership must be stable before downing decisions; defaults to a scaled test value.</param> + /// <param name="heartbeatInterval">The failure-detector heartbeat interval; defaults to a scaled test value.</param> + /// <param name="failureDetectionThreshold">The failure-detector threshold; defaults to a scaled test value.</param> + /// <param name="strategy">The split-brain resolver strategy to configure.</param> + /// <returns>The started actor system.</returns> public ActorSystem StartNode(int port, string role, TimeSpan? stableAfter = null, TimeSpan? heartbeatInterval = null, TimeSpan? failureDetectionThreshold = null, string strategy = "auto-down") @@ -76,8 +96,15 @@ public sealed class TwoNodeClusterFixture : IAsyncDisposable } /// <summary>Hard-crash a node: abrupt terminate, no cluster leave.</summary> + /// <param name="node">The node to crash.</param> + /// <returns>A task that represents the asynchronous termination.</returns> public static Task CrashNode(ActorSystem node) => node.Terminate(); + /// <summary>Waits until the given system sees exactly the given number of Up members and none unreachable.</summary> + /// <param name="system">The actor system whose cluster view is polled.</param> + /// <param name="count">The expected number of Up members.</param> + /// <param name="timeout">The maximum time to wait.</param> + /// <returns>A task that completes when the expected membership is reached, or throws <see cref="TimeoutException"/> on timeout.</returns> public static async Task WaitForMembersUp(ActorSystem system, int count, TimeSpan timeout) { var cluster = Akka.Cluster.Cluster.Get(system); @@ -95,6 +122,11 @@ public sealed class TwoNodeClusterFixture : IAsyncDisposable $"Unreachable: [{string.Join(", ", cluster.State.Unreachable)}]"); } + /// <summary>Waits until the given address is neither a member nor unreachable in the survivor's cluster view.</summary> + /// <param name="survivor">The actor system whose cluster view is polled.</param> + /// <param name="removed">The address expected to disappear from the view.</param> + /// <param name="timeout">The maximum time to wait.</param> + /// <returns>A task that completes when the member is gone, or throws <see cref="TimeoutException"/> on timeout.</returns> public static async Task WaitForMemberRemoved(ActorSystem survivor, Address removed, TimeSpan timeout) { var cluster = Akka.Cluster.Cluster.Get(survivor); @@ -109,6 +141,8 @@ public sealed class TwoNodeClusterFixture : IAsyncDisposable throw new TimeoutException($"Member {removed} was never removed from {cluster.SelfAddress}'s view — SBR did not down it."); } + /// <summary>Finds a free loopback TCP port.</summary> + /// <returns>A port number that was free when probed.</returns> public static int GetFreeTcpPort() { var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); @@ -118,6 +152,8 @@ public sealed class TwoNodeClusterFixture : IAsyncDisposable return port; } + /// <summary>Releases the resources used by this instance asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async ValueTask DisposeAsync() { foreach (var sys in new[] { NodeA, NodeB }) diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixtureTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixtureTests.cs index abf71bcd..8adf3a18 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixtureTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixtureTests.cs @@ -4,6 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster; public class TwoNodeClusterFixtureTests { + /// <summary>Verifies that the fixture forms a two-node cluster in which node A is the oldest member.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Fixture_FormsTwoNodeCluster_NodeAIsOldest() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DeployNotifyFetchTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DeployNotifyFetchTests.cs index ae85ac4a..ccab5568 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DeployNotifyFetchTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DeployNotifyFetchTests.cs @@ -57,11 +57,14 @@ public sealed class DeployNotifyFetchTests /// </summary> private sealed class SqliteFetchDbContext : ScadaBridgeDbContext { + /// <summary>Creates the SQLite-adapted context from the supplied options.</summary> + /// <param name="options">The EF Core options for the context.</param> public SqliteFetchDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options, new EphemeralDataProtectionProvider()) { } + /// <inheritdoc /> protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -113,6 +116,7 @@ public sealed class DeployNotifyFetchTests private readonly IHost _host; private readonly DbContextOptions<ScadaBridgeDbContext> _dbOptions; + /// <summary>Gets the in-memory HTTP client that drives the config-fetch endpoint.</summary> public HttpClient Client { get; } /// <summary>Base URL the site presents to the fetcher (the test server's address).</summary> @@ -131,6 +135,8 @@ public sealed class DeployNotifyFetchTests BaseUrl = client.BaseAddress!.ToString(); } + /// <summary>Builds the harness: opens the in-memory database, starts the test server and creates the client.</summary> + /// <returns>The ready-to-use harness.</returns> public static async Task<FetchHarness> CreateAsync() { var connection = new SqliteConnection("DataSource=:memory:"); @@ -177,6 +183,8 @@ public sealed class DeployNotifyFetchTests /// and returns its generated id. The PendingDeployment → Instance FK is enforced by SQLite, so a /// real row is required. /// </summary> + /// <param name="uniqueName">The unique name of the instance to create.</param> + /// <returns>The generated instance id.</returns> public async Task<int> SeedInstanceAsync(string uniqueName) { await using var ctx = new SqliteFetchDbContext(_dbOptions); @@ -197,6 +205,8 @@ public sealed class DeployNotifyFetchTests /// (<see cref="DeploymentManagerRepository.AddPendingDeploymentAsync"/> + SaveChanges), so /// supersession of any prior row for the same instance is exercised exactly as in production. /// </summary> + /// <param name="pending">The pending deployment to stage.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task StageAsync(PendingDeployment pending) { await using var ctx = new SqliteFetchDbContext(_dbOptions); @@ -205,6 +215,7 @@ public sealed class DeployNotifyFetchTests await repo.SaveChangesAsync(); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { Client.Dispose(); @@ -239,6 +250,8 @@ public sealed class DeployNotifyFetchTests // 1. Large config round-trips: the core proof that the 128 KB size limit is gone. // ---------------------------------------------------------------------------------------- + /// <summary>Verifies that a config larger than 128 KB is fetched with its exact bytes intact.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_LargeConfigOver128Kb_RoundTripsExactBytes() { @@ -269,6 +282,8 @@ public sealed class DeployNotifyFetchTests // Fetching A -> 404 (IsSuperseded); fetching B -> B's config. // ---------------------------------------------------------------------------------------- + /// <summary>Verifies that fetching a superseded deployment throws a 404 superseded error while the newest deployment is still served.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_SupersededDeployment_Throws404SupersededAndServesNewest() { @@ -295,6 +310,8 @@ public sealed class DeployNotifyFetchTests // 3. Wrong token: a live, non-expired row with a bad token -> 401 (NOT 404). // ---------------------------------------------------------------------------------------- + /// <summary>Verifies that fetching with a wrong token throws a 401 error that is not reported as superseded.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_WrongToken_Throws401NotSuperseded() { @@ -318,6 +335,8 @@ public sealed class DeployNotifyFetchTests // even with the correct token. // ---------------------------------------------------------------------------------------- + /// <summary>Verifies that fetching an expired row throws a 404 superseded error even with the correct token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_ExpiredRow_Throws404SupersededEvenWithCorrectToken() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DualNodeRecoveryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DualNodeRecoveryTests.cs index 87c3d89f..a7b4f52b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DualNodeRecoveryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DualNodeRecoveryTests.cs @@ -6,12 +6,14 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// WP-3 (Phase 8): Dual-node failure recovery. +/// Dual-node failure recovery. /// Both nodes down, first up forms cluster, rebuilds from persistent storage. /// Tests for both central and site topologies. /// </summary> public class DualNodeRecoveryTests { + /// <summary>Verifies that after both site nodes go down the first node rebuilds its state from SQLite.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Trait("Category", "Integration")] [Fact] public async Task SiteTopology_BothNodesDown_FirstNodeRebuildsFromSQLite() @@ -83,6 +85,8 @@ public class DualNodeRecoveryTests } } + /// <summary>Verifies that parked messages are preserved across a dual crash of a site pair.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Trait("Category", "Integration")] [Fact] public async Task SiteTopology_DualCrash_ParkedMessagesPreserved() @@ -154,6 +158,7 @@ public class DualNodeRecoveryTests } } + /// <summary>Verifies that after both central nodes go down the first node forms a single-node cluster.</summary> [Trait("Category", "Integration")] [Fact] public void CentralTopology_BothNodesDown_FirstNodeFormsSingleNodeCluster() @@ -185,6 +190,8 @@ public class DualNodeRecoveryTests Assert.Equal(DeploymentStatus.InProgress, statuses[1].Status); } + /// <summary>Verifies that initializing the SQLite storage repeatedly is idempotent and safe during recovery.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Trait("Category", "Integration")] [Fact] public async Task SQLiteStorage_InitializeIdempotent_SafeOnRecovery() diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Grpc/GrpcStreamBatchingIntegrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Grpc/GrpcStreamBatchingIntegrationTests.cs index 650b777d..b12aac35 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Grpc/GrpcStreamBatchingIntegrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Grpc/GrpcStreamBatchingIntegrationTests.cs @@ -51,6 +51,8 @@ public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : Test // ── Round trip: batched frames deliver every event, in order ──────────────── + /// <summary>Verifies that a negotiated stream round-trips every event through the wire in order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NegotiatedStream_RoundTripsEveryEventThroughTheWire_InOrder() { @@ -95,6 +97,8 @@ public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : Test // ── Latency cost of the default window ───────────────────────────────────── + /// <summary>Verifies that the default batching window keeps per-event latency far below the threshold.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DefaultWindow_KeepsPerEventLatencyFarBelowTheThreshold() { @@ -163,6 +167,8 @@ public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : Test // ── Version skew: OLD central ↔ NEW site ─────────────────────────────────── + /// <summary>Verifies that an old central talking to a new site never receives a batch frame.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OldCentral_AgainstNewSite_NeverReceivesABatchFrame() { @@ -189,7 +195,7 @@ public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : Test var wire = SnapshotThroughTheWire(frames); // One event per frame, and — checked at the byte level, since that is what the - // old peer's parser sees — never the field-4 batch tag. + // old peer's parser sees — never the field 4 batch tag. Assert.Equal(total, wire.Count); Assert.All(wire, f => Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, f.EventCase)); @@ -198,6 +204,7 @@ public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : Test GC.KeepAlive(server); } + /// <summary>Verifies that the batch frame rides field 4, which a pre-batching parser would drop silently.</summary> [Fact] public void BatchFrameRidesFieldFour_WhichAPreBatchingParserWouldDropSilently() { @@ -232,6 +239,8 @@ public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : Test // ── Version skew: NEW central ↔ OLD site ─────────────────────────────────── + /// <summary>Verifies that a new central talking to an old site still receives every event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NewCentral_AgainstOldSite_StillReceivesEveryEvent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Grpc/GrpcStreamIntegrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Grpc/GrpcStreamIntegrationTests.cs index 62828901..f26fa444 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Grpc/GrpcStreamIntegrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Grpc/GrpcStreamIntegrationTests.cs @@ -30,6 +30,7 @@ public class GrpcStreamIntegrationTests : TestKit /// events flow through Channel to gRPC response stream. /// Validates attribute value changes arrive with correct protobuf mapping. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Pipeline_AttributeValueChanged_FlowsToResponseStream() { @@ -105,6 +106,7 @@ public class GrpcStreamIntegrationTests : TestKit /// <summary> /// End-to-end pipeline test for alarm state changes. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Pipeline_AlarmStateChanged_FlowsToResponseStream() { @@ -164,6 +166,7 @@ public class GrpcStreamIntegrationTests : TestKit /// <summary> /// Verifies that mixed event types (attribute + alarm) flow through the same stream. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Pipeline_MixedEvents_FlowInOrder() { @@ -226,6 +229,7 @@ public class GrpcStreamIntegrationTests : TestKit /// Verifies that when a stream is cancelled, the subscriber is cleaned up /// and the active stream count returns to zero. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Pipeline_Cancellation_CleansUpRelayActorAndSubscription() { @@ -265,6 +269,7 @@ public class GrpcStreamIntegrationTests : TestKit /// Verifies that a duplicate correlation ID cancels the first stream and /// the second stream continues to receive events. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Pipeline_DuplicateCorrelationId_ReplacesStream() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Grpc/SiteAlarmStreamEndToEndTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Grpc/SiteAlarmStreamEndToEndTests.cs index de43d967..8dd34cea 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Grpc/SiteAlarmStreamEndToEndTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Grpc/SiteAlarmStreamEndToEndTests.cs @@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Grpc; /// <summary> -/// End-to-end trace for the aggregated live alarm stream (plan #10 deferred item, Task 7). +/// End-to-end trace for the aggregated live alarm stream. /// /// Every per-task unit test (T1–T6) exercises exactly one layer with its neighbour mocked: /// T1 the site broadcast filter (subscriber = TestKit probe, domain events), T2/the existing @@ -41,7 +41,7 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Grpc; /// for MULTIPLE instances (no per-instance filter); and a snapshot-seed row and a live delta /// for the same native alarm — both mapped through the real pipe — collapse onto ONE cache row. /// -/// Full gRPC-over-HTTP/2 remains a manual docker-cluster smoke (plan §4 Task 7). +/// Full gRPC-over-HTTP/2 remains a manual docker-cluster smoke (plan §4). /// </summary> public class SiteAlarmStreamEndToEndTests : TestKit { @@ -50,6 +50,8 @@ public class SiteAlarmStreamEndToEndTests : TestKit // ── The full site→proto→client trace ──────────────────────────────────────── + /// <summary>Verifies that a site-wide alarm traverses the manager, relay and client, preserving identity and enrichment while dropping attributes and placeholders.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiteWideAlarm_TraversesManagerToRelayToClient_PreservingIdentityAndEnrichment_DroppingAttributesAndPlaceholders() { @@ -173,6 +175,8 @@ public class SiteAlarmStreamEndToEndTests : TestKit // ── Snapshot-seed vs live-delta identity parity, through the real pipe ──────── + /// <summary>Verifies that a seed row and a live delta for the same native alarm collapse onto one cache row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RealPipeMapped_SeedRow_And_LiveDelta_ForSameNativeAlarm_CollapseOntoOneCacheRow() { @@ -226,7 +230,7 @@ public class SiteAlarmStreamEndToEndTests : TestKit sink.Publish, factory, "http://a:5100", "http://b:5100", TimeSpan.FromMinutes(10), TimeSpan.Zero, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(60), // Props.Create is an expression tree, which may not omit optional arguments - // (CS0854) — jitter and the P3-4 open-attempt deadline are passed explicitly. + // (CS0854) — jitter and the open-attempt deadline are passed explicitly. 0.0, SiteAlarmAggregatorActor.DefaultOpenAttemptTimeout))); await WaitForConditionAsync(() => sink.Latest is { Count: 1 }); // seed applied @@ -268,10 +272,14 @@ public class SiteAlarmStreamEndToEndTests : TestKit { private readonly object _lock = new(); private IReadOnlyList<AlarmStateChanged>? _latest; + /// <summary>Records the latest published snapshot.</summary> + /// <param name="snapshot">The alarm snapshot published to the sink.</param> + /// <param name="streamLive">Whether the live stream is connected (ignored).</param> public void Publish(IReadOnlyList<AlarmStateChanged> snapshot, bool streamLive) { lock (_lock) { _latest = snapshot; } } + /// <summary>Gets the most recently published snapshot, or null if none has been published.</summary> public IReadOnlyList<AlarmStateChanged>? Latest { get { lock (_lock) { return _latest; } } @@ -281,6 +289,7 @@ public class SiteAlarmStreamEndToEndTests : TestKit /// <summary>Aggregator transport double: its live stream simply stays open until cancelled.</summary> private sealed class NoopSiteStreamClient : SiteStreamGrpcClient { + /// <inheritdoc /> public override Task SubscribeSiteAsync( string correlationId, Action<AlarmStateChanged> onAlarmEvent, Action<Exception> onError, Action onCompleted, CancellationToken ct, Action? onConnected = null) @@ -291,14 +300,18 @@ public class SiteAlarmStreamEndToEndTests : TestKit return tcs.Task; } + /// <inheritdoc /> public override void Unsubscribe(string correlationId) { } } private sealed class NoopSiteStreamClientFactory : SiteStreamGrpcClientFactory { private readonly NoopSiteStreamClient _client = new(); + /// <summary>Initializes the factory with a null logger factory.</summary> public NoopSiteStreamClientFactory() : base(NullLoggerFactory.Instance) { } + /// <inheritdoc /> public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint) => _client; + /// <inheritdoc /> public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint) => _client; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs index a5e58f01..8b87789d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.InboundAPI; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// WP-14: End-to-end integration tests for Phase 7 integration surfaces. +/// End-to-end integration tests for Phase 7 integration surfaces. /// </summary> public class IntegrationSurfaceTests { @@ -17,6 +17,7 @@ public class IntegrationSurfaceTests // ZB.MOM.WW.Auth.ApiKeys verifier (Bearer sbk_<keyId>_<secret>); its coverage // lives in the InboundAPI endpoint + Auth-library tests. + /// <summary>Verifies that inbound API parameter validation handles the extended types and reports the right error codes.</summary> [Fact] public void InboundAPI_ParameterValidation_ExtendedTypes() { @@ -46,10 +47,11 @@ public class IntegrationSurfaceTests // ── External System: error classification ── + /// <summary>Verifies that external system HTTP errors are classified as transient or permanent.</summary> [Fact] public void ExternalSystem_ErrorClassification_TransientVsPermanent() { - // WP-8: Verify the full classification spectrum + // 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)); @@ -62,7 +64,7 @@ public class IntegrationSurfaceTests } // ── Notification: mock SMTP delivery ── - // NS-019: the site-shaped NotificationDeliveryService that this case exercised + // The site-shaped NotificationDeliveryService that this case exercised // was removed when sites stopped delivering notifications. The central SMTP // delivery path is now covered end-to-end by // ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery.EmailNotificationDeliveryAdapterTests; @@ -70,6 +72,8 @@ public class IntegrationSurfaceTests // ── Script Context: integration API wiring ── + /// <summary>Verifies that the script context routes external system calls to the configured client.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ScriptContext_ExternalSystem_Call_Wired() { @@ -86,6 +90,8 @@ public class IntegrationSurfaceTests Assert.Equal("{\"value\":1}", result.ResponseJson); } + /// <summary>Verifies that the script context routes notification sends to the configured service.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ScriptContext_Notify_Send_Wired() { @@ -111,6 +117,8 @@ public class IntegrationSurfaceTests Assert.NotNull(buffered); } + /// <summary>Verifies that an external system call throws when no client is configured.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ScriptContext_ExternalSystem_NoClient_Throws() { @@ -120,6 +128,8 @@ public class IntegrationSurfaceTests () => context.ExternalSystem.Call("api", "method")); } + /// <summary>Verifies that a database call throws when no gateway is configured.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ScriptContext_Database_NoGateway_Throws() { @@ -129,6 +139,8 @@ public class IntegrationSurfaceTests () => context.Database.Connection("db")); } + /// <summary>Verifies that a notification send throws when no service is configured.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ScriptContext_Notify_NoService_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbConfigConvergenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbConfigConvergenceTests.cs index 418afe6b..643ff5a8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbConfigConvergenceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbConfigConvergenceTests.cs @@ -59,6 +59,8 @@ public sealed class LocalDbConfigConvergenceTests : LocalDbSitePairHarness // ---- scenarios -------------------------------------------------------------------- + /// <summary>Verifies that a node with newer local rows keeps them when a peer snapshot arrives.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ANodeWithNewerLocalRows_KeepsThem_WhenAPeerSnapshotArrives() { @@ -110,13 +112,15 @@ public sealed class LocalDbConfigConvergenceTests : LocalDbSitePairHarness "both nodes to converge on the newer config for the contended instance"); } + /// <summary>Verifies that a config deployed on node A converges to node B through replication alone, without any fetch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConfigDeployedOnA_ConvergesToB_WithoutAnyFetch() { - // Was: SiteReplicationActorTests #1-#5 (notify-and-fetch — the standby is told a + // Was: the SiteReplicationActor tests (notify-and-fetch — the standby is told a // deploy happened, then HTTP-fetches the config itself, with retries and a - // superseded-404 path). Under CDC the config row simply replicates, so that whole - // exchange is deleted at Task 15. + // superseded-config 404 path). Under CDC the config row simply replicates, so that whole + // exchange is deleted. // // The plan called for asserting a fetcher test double records zero invocations. That // would be THEATRE here and is deliberately not done: this harness has no actor @@ -126,7 +130,7 @@ public sealed class LocalDbConfigConvergenceTests : LocalDbSitePairHarness // // What this test honestly proves is the positive half: the config reaches node B // through replication ALONE, in a process where fetching is not merely unused but - // absent. The negative half — that no fetch path survives — is proved by Task 15 + // absent. The negative half — that no fetch path survives — is proved by // deleting the code and the build still passing, which is a stronger check than any // runtime counter. // @@ -140,6 +144,8 @@ public sealed class LocalDbConfigConvergenceTests : LocalDbSitePairHarness "the deployed config to reach node B over replication alone"); } + /// <summary>Verifies that redeploying an instance converges both nodes to the new revision.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RedeployingAnInstance_ConvergesToTheNewRevision_OnBothNodes() { @@ -160,6 +166,8 @@ public sealed class LocalDbConfigConvergenceTests : LocalDbSitePairHarness "both nodes to converge on the redeployed revision"); } + /// <summary>Verifies that a config deployed on node B converges to node A.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConfigDeployedOnB_ConvergesToA() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbPhase2ConvergenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbPhase2ConvergenceTests.cs index 610a9e4c..32a49579 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbPhase2ConvergenceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbPhase2ConvergenceTests.cs @@ -66,6 +66,8 @@ public sealed class LocalDbPhase2ConvergenceTests : LocalDbSitePairHarness // ---- scenarios -------------------------------------------------------------------- + /// <summary>Verifies that a deployed configuration row converges to node B column for column.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeployedConfigRow_ConvergesToB_ColumnForColumn() { @@ -85,6 +87,8 @@ public sealed class LocalDbPhase2ConvergenceTests : LocalDbSitePairHarness "node B's deployed_configurations row to match node A's in EVERY column"); } + /// <summary>Verifies that removing an instance converges deletions across all three cascade tables.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RemovingAnInstance_ConvergesAllThreeCascadeTables() { @@ -132,11 +136,13 @@ public sealed class LocalDbPhase2ConvergenceTests : LocalDbSitePairHarness Assert.Equal(1, await CountAsync(B, "native_alarm_state", "inst-survivor")); } + /// <summary>Verifies that a native alarm burst converges to the peer and the oplog drains afterwards.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ANativeAlarmBurst_Converges_AndTheOplogDrains() { - // native_alarm_state is the highest-rate replicated table (Task 1 found it bounded by - // per-SourceReference coalescing on a 100 ms flush, correcting the plan's D4). This + // native_alarm_state is the highest-rate replicated table (it is bounded by + // per-SourceReference coalescing on a 100 ms flush). This // asserts the burst converges AND that the oplog goes back to empty afterwards — // convergence alone would still pass if entries replicated but were never acked, and // an oplog that only grows eventually trips the caps and forces a snapshot resync. @@ -159,6 +165,8 @@ public sealed class LocalDbPhase2ConvergenceTests : LocalDbSitePairHarness "node A's oplog to drain once node B acknowledges the burst"); } + /// <summary>Verifies that rows written on node B while its listener is down survive the rejoin.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RowsWrittenOnBWhileItsListenerIsDown_SurviveTheRejoin() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairConvergenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairConvergenceTests.cs index a8ee9ad1..d97337ad 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairConvergenceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairConvergenceTests.cs @@ -55,6 +55,8 @@ public sealed class LocalDbSitePairConvergenceTests : LocalDbSitePairHarness // ---- scenarios -------------------------------------------------------------------- + /// <summary>Verifies that a tracking row written on node A becomes readable on node B.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TrackingRow_WrittenOnA_BecomesReadableOnB() { @@ -65,6 +67,8 @@ public sealed class LocalDbSitePairConvergenceTests : LocalDbSitePairHarness "the tracking row written on node A to appear on node B"); } + /// <summary>Verifies that a tracking row written on node B becomes readable on node A.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TrackingRow_WrittenOnB_BecomesReadableOnA() { @@ -77,6 +81,8 @@ public sealed class LocalDbSitePairConvergenceTests : LocalDbSitePairHarness "the tracking row written on node B to appear on node A"); } + /// <summary>Verifies that the same operation updated on both nodes converges to a single winning value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SameOperation_UpdatedOnBothNodes_ConvergesToOneWinner() { @@ -104,6 +110,8 @@ public sealed class LocalDbSitePairConvergenceTests : LocalDbSitePairHarness Assert.Contains(winner, new[] { "Delivered", "Parked" }); } + /// <summary>Verifies that events logged on both nodes converge to their union with no id collisions.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EventsLoggedOnBothNodes_ConvergeToTheUnion_WithNoIdCollisions() { @@ -125,6 +133,8 @@ public sealed class LocalDbSitePairConvergenceTests : LocalDbSitePairHarness "both nodes to hold the union of all 10 events"); } + /// <summary>Verifies that a peer that goes offline and rejoins catches up on everything it missed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PeerOffline_ThenRejoins_CatchesUpOnEverythingItMissed() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairHarness.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairHarness.cs index 20bf2e48..cfe06d8f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairHarness.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairHarness.cs @@ -70,6 +70,8 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime /// <summary>Node B — the passive node, which listens.</summary> protected ILocalDb B => _dbProviderB.GetRequiredService<ILocalDb>(); + /// <summary>Prepares the test class before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { _dbProviderA = BuildDatabaseProvider(_pathA, "node-a"); @@ -83,6 +85,8 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime await StartInitiatorAsync(); } + /// <summary>Releases the resources held by this test class asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task DisposeAsync() { await StopHostAsync(_initiatorHost); @@ -179,6 +183,7 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime } /// <summary>Starts node B, the passive listener.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> protected async Task StartPassiveAsync() { var config = ReplicationConfig(peerAddress: null); @@ -208,6 +213,7 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime } /// <summary>Starts node A, which dials the passive node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> protected async Task StartInitiatorAsync() { var config = ReplicationConfig(PassiveAddress()); @@ -223,6 +229,7 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime } /// <summary>Takes node B's listener down, leaving its database intact and writable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> protected async Task StopPassiveAsync() { await StopHostAsync(_serverHost); @@ -233,6 +240,7 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime /// Brings node B back on a NEW loopback port and re-dials from A. The initiator's channel /// factory re-reads the peer address on each reconnect, so this is a genuine rejoin. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> protected async Task RestartPairAsync() { await StartPassiveAsync(); @@ -252,6 +260,9 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime } /// <summary>Polls <paramref name="condition"/> until true or the deadline passes.</summary> + /// <param name="condition">The asynchronous condition to poll.</param> + /// <param name="because">The failure message used if the condition never becomes true.</param> + /// <returns>A task that represents the asynchronous operation.</returns> protected static async Task WaitUntilAsync(Func<Task<bool>> condition, string because) { var deadline = DateTime.UtcNow + ConvergeTimeout; diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbStoreAndForwardConvergenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbStoreAndForwardConvergenceTests.cs index 409f23d8..2859660e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbStoreAndForwardConvergenceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbStoreAndForwardConvergenceTests.cs @@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <para> /// These are <b>specifications, written before the deletion they justify</b>. The bespoke /// <c>ReplicationService</c> (an explicit Add/Remove/Park/Requeue operation stream over Akka) -/// is deleted at Task 14 and replaced by LocalDb's trigger-based change capture. Each test +/// was deleted and replaced by LocalDb's trigger-based change capture. Each test /// here is one behaviour the old mechanism provided, restated as an outcome the replacement /// must still deliver — so the cutover has something to be judged against other than "it /// compiles". @@ -83,6 +83,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn // ---- scenarios -------------------------------------------------------------------- + /// <summary>Verifies that a buffered message materialises on the peer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BufferedMessage_MaterialisesOnThePeer() { @@ -94,6 +96,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn "a message buffered on node A to appear on node B"); } + /// <summary>Verifies that a delivered message disappears from the peer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliveredMessage_DisappearsFromThePeer() { @@ -110,6 +114,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn "the delivered message to be gone from node B"); } + /// <summary>Verifies that a parked message shows as parked on the peer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ParkedMessage_ShowsAsParkedOnThePeer() { @@ -124,6 +130,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn "the parked status to reach node B"); } + /// <summary>Verifies that a requeued message resets its status and retry count on the peer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RequeuedMessage_ResetsStatusAndRetryCountOnThePeer() { @@ -145,6 +153,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn "node B to show the requeued message as Pending with retry_count 0"); } + /// <summary>Verifies that a message that is added and then removed never converges to present on the peer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MessageAddedThenRemoved_NeverConvergesToPresent() { @@ -182,6 +192,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn Assert.True(await ExistsAsync(B, "msg-ordering-control")); } + /// <summary>Verifies that a message buffered twice converges to the newer state on the peer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SameMessage_BufferedTwice_ConvergesToTheNewerState() { @@ -199,6 +211,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn "the newer state to win on node B"); } + /// <summary>Verifies that a park arriving without its preceding add still materialises the row on the peer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ParkArrivingWithoutItsAdd_StillMaterialisesTheRow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/NotificationOutboxFlowTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/NotificationOutboxFlowTests.cs index 675f156a..9e36ebd0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/NotificationOutboxFlowTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/NotificationOutboxFlowTests.cs @@ -22,7 +22,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// Task 25: end-to-end integration test for the central Notification Outbox flow. Exercises +/// End-to-end integration test for the central Notification Outbox flow. Exercises /// the full ingest -> dispatch -> status pipeline against a real <see cref="NotificationOutboxActor"/>, /// a real <see cref="NotificationOutboxRepository"/> over a real (SQLite in-memory) database, and /// a stub <see cref="INotificationDeliveryAdapter"/>. Two scenarios are covered: a successful @@ -39,11 +39,14 @@ public class NotificationOutboxFlowTests : TestKit /// </summary> private sealed class SqliteOutboxDbContext : ScadaBridgeDbContext { + /// <summary>Initializes the context over the given SQLite options with an ephemeral data protection provider.</summary> + /// <param name="options">The EF Core options configured for the SQLite connection.</param> public SqliteOutboxDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options, new EphemeralDataProtectionProvider()) { } + /// <inheritdoc /> protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -81,10 +84,14 @@ public class NotificationOutboxFlowTests : TestKit { private readonly DeliveryOutcome _outcome; + /// <summary>Initializes the adapter with the outcome every delivery returns.</summary> + /// <param name="outcome">The fixed delivery outcome to return.</param> public StubEmailAdapter(DeliveryOutcome outcome) => _outcome = outcome; + /// <inheritdoc /> public NotificationType Type => NotificationType.Email; + /// <inheritdoc /> public Task<DeliveryOutcome> DeliverAsync( Notification notification, CancellationToken cancellationToken = default) => Task.FromResult(_outcome); @@ -97,15 +104,21 @@ public class NotificationOutboxFlowTests : TestKit /// </summary> private sealed class OutboxHarness : IDisposable { + /// <summary>Gets the kept-open SQLite connection.</summary> public SqliteConnection Connection { get; } + /// <summary>Gets the composed service provider.</summary> public IServiceProvider Services { get; } + /// <summary>Initializes the harness with the connection and service provider it owns.</summary> + /// <param name="connection">The kept-open SQLite connection.</param> + /// <param name="services">The composed service provider.</param> public OutboxHarness(SqliteConnection connection, IServiceProvider services) { Connection = connection; Services = services; } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { (Services as IDisposable)?.Dispose(); @@ -182,11 +195,12 @@ public class NotificationOutboxFlowTests : TestKit /// <summary> /// Test-only no-op <see cref="ICentralAuditWriter"/>. The integration tests - /// in this file pre-date M4 Bundle B's audit-writer injection; they do not + /// in this file pre-date the audit-writer injection; they do not /// assert on emission, just need a non-null collaborator. /// </summary> private sealed class NoOpCentralAuditWriter : ICentralAuditWriter { + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask; } @@ -210,6 +224,7 @@ public class NotificationOutboxFlowTests : TestKit return await repository.GetByIdAsync(notificationId); } + /// <summary>Verifies that a successfully dispatched notification reaches the Delivered status.</summary> [Fact] public void Submit_DispatchedSuccessfully_ReachesDeliveredStatus() { @@ -239,6 +254,7 @@ public class NotificationOutboxFlowTests : TestKit interval: TimeSpan.FromMilliseconds(100)); } + /// <summary>Verifies that a permanently failed notification reaches Parked and a retry returns it to Pending.</summary> [Fact] public void Submit_PermanentlyFailed_ReachesParked_ThenRetryReturnsToPending() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ObservabilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ObservabilityTests.cs index 20f0004d..1f8ac6de 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ObservabilityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ObservabilityTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// WP-8 (Phase 8): Observability validation. +/// Observability validation. /// Verifies structured logs contain SiteId/NodeHostname/NodeRole, /// correlation IDs flow through request chains, and health dashboard shows all metric types. /// </summary> @@ -16,11 +16,14 @@ public class ObservabilityTests : IClassFixture<ScadaBridgeWebApplicationFactory { private readonly ScadaBridgeWebApplicationFactory _factory; + /// <summary>Initializes the test class with the shared web application factory.</summary> + /// <param name="factory">The shared web application factory.</param> public ObservabilityTests(ScadaBridgeWebApplicationFactory factory) { _factory = factory; } + /// <summary>Verifies that the Serilog output template includes the required node fields.</summary> [Fact] public void StructuredLog_SerilogTemplate_IncludesRequiredFields() { @@ -33,6 +36,7 @@ public class ObservabilityTests : IClassFixture<ScadaBridgeWebApplicationFactory Assert.Contains("{Level", template); } + /// <summary>Verifies that the Serilog enrichment is configured with the site id.</summary> [Fact] public void SerilogEnrichment_SiteId_Configured() { @@ -48,6 +52,7 @@ public class ObservabilityTests : IClassFixture<ScadaBridgeWebApplicationFactory } } + /// <summary>Verifies that every message contract carries a correlation id.</summary> [Fact] public void CorrelationId_MessageContracts_AllHaveCorrelationId() { @@ -75,6 +80,7 @@ public class ObservabilityTests : IClassFixture<ScadaBridgeWebApplicationFactory Assert.Equal("cmd-456", disableCmd.CommandId); } + /// <summary>Verifies that every metric type is represented in the site health report.</summary> [Fact] public void HealthDashboard_AllMetricTypes_RepresentedInReport() { @@ -128,6 +134,7 @@ public class ObservabilityTests : IClassFixture<ScadaBridgeWebApplicationFactory Assert.Equal(5, report.DeadLetterCount); } + /// <summary>Verifies that a registered site is marked online by the health aggregator.</summary> [Fact] public void HealthAggregator_SiteRegistration_MarkedOnline() { @@ -162,6 +169,7 @@ public class ObservabilityTests : IClassFixture<ScadaBridgeWebApplicationFactory Assert.Equal(3, state.LatestReport!.ScriptErrorCount); } + /// <summary>Verifies that health report sequence numbers increase monotonically.</summary> [Fact] public void HealthReport_SequenceNumbers_Monotonic() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ReadinessTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ReadinessTests.cs index 0c5a0187..27d88df4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ReadinessTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ReadinessTests.cs @@ -3,17 +3,21 @@ using System.Net; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// WP-22: Readiness gating — /health/ready endpoint returns status code. +/// Readiness gating — /health/ready endpoint returns status code. /// </summary> public class ReadinessTests : IClassFixture<ScadaBridgeWebApplicationFactory> { private readonly ScadaBridgeWebApplicationFactory _factory; + /// <summary>Initializes the test class with the web application factory.</summary> + /// <param name="factory">The factory.</param> public ReadinessTests(ScadaBridgeWebApplicationFactory factory) { _factory = factory; } + /// <summary>Verifies that the health ready endpoint returns a success status code.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task HealthReady_ReturnsSuccessStatusCode() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/RecoveryDrillTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/RecoveryDrillTests.cs index b9b4cb7c..2c1100fe 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/RecoveryDrillTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/RecoveryDrillTests.cs @@ -8,11 +8,12 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// WP-7 (Phase 8): Recovery drill test scaffolds. +/// Recovery drill test scaffolds. /// Mid-deploy failover, communication drops, and site restart with persisted configs. /// </summary> public class RecoveryDrillTests { + /// <summary>Verifies that a site state query after a mid-deploy failover is followed by a successful redeploy.</summary> [Trait("Category", "Integration")] [Fact] public void MidDeployFailover_SiteStateQuery_ThenRedeploy() @@ -53,6 +54,8 @@ public class RecoveryDrillTests Assert.Equal(DeploymentStatus.Success, completedStatus.Status); } + /// <summary>Verifies that a communication drop during artifact deployment buffers the work for retry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Trait("Category", "Integration")] [Fact] public async Task CommunicationDrop_DuringArtifactDeployment_BuffersForRetry() @@ -113,6 +116,8 @@ public class RecoveryDrillTests } } + /// <summary>Verifies that a site restart with persisted configurations rebuilds its state from SQLite.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Trait("Category", "Integration")] [Fact] public async Task SiteRestart_WithPersistedConfigs_RebuildFromSQLite() @@ -172,6 +177,7 @@ public class RecoveryDrillTests } } + /// <summary>Verifies that redeploying the same revision hash is a no-op.</summary> [Fact] public void DeploymentIdempotency_SameRevisionHash_NoOp() { @@ -191,6 +197,7 @@ public class RecoveryDrillTests Assert.Equal(cmd1.InstanceUniqueName, cmd2.InstanceUniqueName); } + /// <summary>Verifies that the flattened configuration snapshot contains the revision hash.</summary> [Fact] public void FlattenedConfigSnapshot_ContainsRevisionHash() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ScadaBridgeWebApplicationFactory.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ScadaBridgeWebApplicationFactory.cs index 9fe3bebd..f650bfde 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ScadaBridgeWebApplicationFactory.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ScadaBridgeWebApplicationFactory.cs @@ -22,6 +22,7 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program> /// </summary> private readonly Dictionary<string, string?> _previousEnvVars = new(); + /// <summary>Initializes the factory and sets the environment variables that configure a Central node for tests.</summary> public ScadaBridgeWebApplicationFactory() { // The initial ConfigurationBuilder in Program.cs reads env vars with AddEnvironmentVariables(). @@ -30,7 +31,7 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program> { ["DOTNET_ENVIRONMENT"] = "Development", ["ScadaBridge__Node__Role"] = "Central", - // NodeName is eagerly validated at boot (arch-review 08 round 2 NF4 / plan R2-08 T7): + // NodeName is eagerly validated at boot: // an empty node name stamps the SourceNode audit column NULL, so the validator now // fails host boot without it. The integration harness must supply one. ["ScadaBridge__Node__NodeName"] = "central-a", @@ -46,7 +47,7 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program> // enforces a >=16-char floor; it backs the peppered-HMAC verifier). Supply a fixed // test pepper so host boot passes validation in the test environment. ["ScadaBridge__InboundApi__ApiKeyPepper"] = "integration-test-api-key-pepper-0123456789", - // Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions) and use + // LDAP settings nest under Security:Ldap (shared LdapOptions) and use // the renamed keys (Transport replaces LdapUseTls; None == plaintext for the // GLAuth dev directory, paired with AllowInsecure=true). ["ScadaBridge__Security__Ldap__Server"] = "localhost", @@ -59,7 +60,7 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program> // succeeds but its searches return `50 Insufficient access`, i.e. auth reads as // "service unavailable"). Matches the local redundant pair // (scadaproj/infra/glauth-redundant), whose serviceaccount password is the - // well-known dev value — the shared 10.100.0.35 instance's is rotated (SEC-36). + // well-known dev value — the shared 10.100.0.35 instance's has been rotated. ["ScadaBridge__Security__Ldap__ServiceAccountDn"] = "cn=serviceaccount,dc=zb,dc=local", ["ScadaBridge__Security__Ldap__ServiceAccountPassword"] = "serviceaccount123", }; @@ -71,6 +72,7 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program> } } + /// <inheritdoc /> protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Development"); @@ -105,6 +107,7 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program> }); } + /// <inheritdoc /> protected override void Dispose(bool disposing) { base.Dispose(disposing); diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SecurityHardeningTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SecurityHardeningTests.cs index 65157191..48a214d9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SecurityHardeningTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SecurityHardeningTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// WP-5 (Phase 8): Security hardening tests. +/// Security hardening tests. /// Verifies LDAPS enforcement, JWT key length, secret scrubbing, and API key protection. /// </summary> public class SecurityHardeningTests @@ -23,6 +23,7 @@ public class SecurityHardeningTests return new JwtTokenService(options, NullLogger<JwtTokenService>.Instance); } + /// <summary>Verifies the case: ldap Options, transport, defaults To Ldaps.</summary> [Fact] public void LdapOptions_Transport_DefaultsToLdaps() { @@ -32,6 +33,7 @@ public class SecurityHardeningTests Assert.Equal(LdapTransport.Ldaps, options.Transport); } + /// <summary>Verifies the case: ldap Options, allow Insecure, defaults To False.</summary> [Fact] public void LdapOptions_AllowInsecure_DefaultsToFalse() { @@ -39,6 +41,7 @@ public class SecurityHardeningTests Assert.False(options.AllowInsecure); } + /// <summary>Verifies the case: jwt Signing Key, minimum Length, enforced.</summary> [Fact] public void JwtSigningKey_MinimumLength_Enforced() { @@ -55,6 +58,7 @@ public class SecurityHardeningTests Assert.True(token.Length > 0); } + /// <summary>Verifies the case: jwt Signing Key, short Key, fails Validation.</summary> [Fact] public void JwtSigningKey_ShortKey_FailsValidation() { @@ -63,6 +67,7 @@ public class SecurityHardeningTests "Test key must be shorter than 32 chars to verify minimum length enforcement"); } + /// <summary>Verifies the case: log Output Template, does Not Contain Secrets.</summary> [Fact] public void LogOutputTemplate_DoesNotContainSecrets() { @@ -76,6 +81,7 @@ public class SecurityHardeningTests Assert.DoesNotContain("ConnectionString", template, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies the case: log Enrichment, contains Expected Properties.</summary> [Fact] public void LogEnrichment_ContainsExpectedProperties() { @@ -88,6 +94,7 @@ public class SecurityHardeningTests } } + /// <summary>Verifies the case: jwt Token, does Not Contain Signing Key.</summary> [Fact] public void JwtToken_DoesNotContainSigningKey() { @@ -103,6 +110,7 @@ public class SecurityHardeningTests Assert.DoesNotContain("signing-key", token, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies the case: security Options, jwt Expiry Defaults, are Secure.</summary> [Fact] public void SecurityOptions_JwtExpiryDefaults_AreSecure() { @@ -113,6 +121,7 @@ public class SecurityHardeningTests Assert.Equal(5, options.JwtRefreshThresholdMinutes); } + /// <summary>Verifies the case: jwt Token, tampered Payload, fails Validation.</summary> [Fact] public void JwtToken_TamperedPayload_FailsValidation() { @@ -142,6 +151,7 @@ public class SecurityHardeningTests Assert.Null(principal); } + /// <summary>Verifies the case: jwt Refresh Token, preserves Identity.</summary> [Fact] public void JwtRefreshToken_PreservesIdentity() { @@ -171,6 +181,7 @@ public class SecurityHardeningTests Assert.Equal("orig_user", refreshedPrincipal.FindFirst(JwtTokenService.UsernameClaimType)?.Value); } + /// <summary>Verifies the case: startup Validator, rejects Insecure Ldap In Production.</summary> [Fact] public void StartupValidator_RejectsInsecureLdapInProduction() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SiteFailoverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SiteFailoverTests.cs index 762f64b1..7eea7d53 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SiteFailoverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SiteFailoverTests.cs @@ -9,12 +9,14 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// WP-2 (Phase 8): Full-system failover testing — Site. +/// Full-system failover testing — Site. /// Verifies S&F buffer takeover, DCL reconnection structure, alarm re-evaluation, /// and script trigger resumption after site failover. /// </summary> public class SiteFailoverTests { + /// <summary>Verifies that buffered store-and-forward messages are retained across a restart.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Trait("Category", "Integration")] [Fact] public async Task StoreAndForward_BufferSurvivesRestart_MessagesRetained() @@ -72,6 +74,8 @@ public class SiteFailoverTests } } + /// <summary>Verifies that parked store-and-forward messages survive a failover.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Trait("Category", "Integration")] [Fact] public async Task StoreAndForward_ParkedMessages_SurviveFailover() @@ -123,6 +127,7 @@ public class SiteFailoverTests } } + /// <summary>Verifies that an incoming value triggers alarm re-evaluation and a new alarm state.</summary> [Fact] public void AlarmReEvaluation_IncomingValue_TriggersNewState() { @@ -151,6 +156,7 @@ public class SiteFailoverTests Assert.True(clearedEvent.Timestamp > alarmEvent.Timestamp); } + /// <summary>Verifies that a value change triggers its script after failover.</summary> [Fact] public void ScriptTriggerResumption_ValueChangeTriggersScript() { @@ -171,6 +177,8 @@ public class SiteFailoverTests Assert.Equal("Good", valueChange.Quality); } + /// <summary>Verifies that the store-and-forward buffer depth is reported after a failover.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Trait("Category", "Integration")] [Fact] public async Task StoreAndForward_BufferDepth_ReportedAfterFailover() diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/StartupValidationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/StartupValidationTests.cs index dcd0cbfb..5f4b6eae 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/StartupValidationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/StartupValidationTests.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc.Testing; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// <summary> -/// WP-22: Startup validation — missing required config fails with clear error. +/// Startup validation — missing required config fails with clear error. /// Tests the StartupValidator that runs on boot. /// /// Note: These tests temporarily set environment variables because Program.cs reads @@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// </summary> public class StartupValidationTests { + /// <summary>Verifies that a missing role throws an InvalidOperationException.</summary> [Fact] public void MissingRole_ThrowsInvalidOperationException() { @@ -34,6 +35,7 @@ public class StartupValidationTests factory.Dispose(); } + /// <summary>Verifies that a missing JWT signing key for the Central role throws an InvalidOperationException.</summary> [Fact] public void MissingJwtSigningKey_ForCentral_ThrowsInvalidOperationException() { @@ -59,6 +61,7 @@ public class StartupValidationTests factory.Dispose(); } + /// <summary>Verifies that the Central role starts successfully with a valid configuration.</summary> [Fact] public void CentralRole_StartsSuccessfully_WithValidConfig() { @@ -101,6 +104,8 @@ public class StartupValidationTests "ScadaBridge__Security__Ldap__ServiceAccountPassword", }; + /// <summary>Sets the given environment variables, remembering the prior values so they can be restored.</summary> + /// <param name="varsToSet">The environment variables to set.</param> public TempEnvironment(Dictionary<string, string> varsToSet) { // Save and clear all known keys @@ -119,6 +124,7 @@ public class StartupValidationTests } } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { foreach (var (key, previousValue) in _previousValues) diff --git a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs index 87c48ee1..6d885e89 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.KpiHistory; namespace ZB.MOM.WW.ScadaBridge.KpiHistory.Tests; /// <summary> -/// K3 unit tests for <see cref="KpiHistoryOptionsValidator"/>: the default +/// Unit tests for <see cref="KpiHistoryOptionsValidator"/>: the default /// options must validate, and each rule (SampleInterval / RetentionDays / /// PurgeInterval / DefaultMaxSeriesPoints) must reject its out-of-range values. /// </summary> @@ -11,6 +11,7 @@ public class KpiHistoryOptionsValidatorTests { private static KpiHistoryOptionsValidator NewValidator() => new(); + /// <summary>Verifies that a zero source timeout fails validation.</summary> [Fact] public void Validate_SourceTimeout_Zero_Fails() { @@ -19,6 +20,7 @@ public class KpiHistoryOptionsValidatorTests Assert.Contains("SourceTimeout", result.FailureMessage); } + /// <summary>Verifies that the default options pass validation.</summary> [Fact] public void Validate_Defaults_Pass() { @@ -30,6 +32,7 @@ public class KpiHistoryOptionsValidatorTests // SampleInterval > TimeSpan.Zero // --------------------------------------------------------------------- + /// <summary>Verifies that a zero sample interval fails validation.</summary> [Fact] public void Validate_SampleInterval_Zero_Fails() { @@ -42,6 +45,7 @@ public class KpiHistoryOptionsValidatorTests f => f.Contains(nameof(KpiHistoryOptions.SampleInterval), StringComparison.Ordinal)); } + /// <summary>Verifies that a negative sample interval fails validation.</summary> [Fact] public void Validate_SampleInterval_Negative_Fails() { @@ -58,6 +62,8 @@ public class KpiHistoryOptionsValidatorTests // RetentionDays in [1, 3650] // --------------------------------------------------------------------- + /// <summary>Verifies that an in-range retention period passes validation.</summary> + /// <param name="value">The option value under test.</param> [Theory] [InlineData(1)] // min [InlineData(90)] // default @@ -71,6 +77,8 @@ public class KpiHistoryOptionsValidatorTests Assert.True(NewValidator().Validate(null, opts).Succeeded); } + /// <summary>Verifies that an out-of-range retention period fails validation.</summary> + /// <param name="value">The option value under test.</param> [Theory] [InlineData(0)] [InlineData(3651)] @@ -90,6 +98,7 @@ public class KpiHistoryOptionsValidatorTests // PurgeInterval > TimeSpan.Zero // --------------------------------------------------------------------- + /// <summary>Verifies that a zero purge interval fails validation.</summary> [Fact] public void Validate_PurgeInterval_Zero_Fails() { @@ -102,6 +111,7 @@ public class KpiHistoryOptionsValidatorTests f => f.Contains(nameof(KpiHistoryOptions.PurgeInterval), StringComparison.Ordinal)); } + /// <summary>Verifies that a negative purge interval fails validation.</summary> [Fact] public void Validate_PurgeInterval_Negative_Fails() { @@ -118,6 +128,8 @@ public class KpiHistoryOptionsValidatorTests // DefaultMaxSeriesPoints in [2, 5000] // --------------------------------------------------------------------- + /// <summary>Verifies that an in-range default maximum series point count passes validation.</summary> + /// <param name="value">The option value under test.</param> [Theory] [InlineData(2)] // min [InlineData(200)] // default @@ -128,6 +140,8 @@ public class KpiHistoryOptionsValidatorTests Assert.True(NewValidator().Validate(null, opts).Succeeded); } + /// <summary>Verifies that an out-of-range default maximum series point count fails validation.</summary> + /// <param name="value">The option value under test.</param> [Theory] [InlineData(1)] [InlineData(5001)] @@ -147,6 +161,8 @@ public class KpiHistoryOptionsValidatorTests // RollupInterval > TimeSpan.Zero // --------------------------------------------------------------------- + /// <summary>Verifies that a non-positive rollup interval fails validation.</summary> + /// <param name="hours">The rollup interval in hours.</param> [Theory] [InlineData(0)] [InlineData(-1)] @@ -165,6 +181,8 @@ public class KpiHistoryOptionsValidatorTests // RollupLookbackHours in [1, 168] // --------------------------------------------------------------------- + /// <summary>Verifies that an in-range rollup lookback passes validation.</summary> + /// <param name="value">The option value under test.</param> [Theory] [InlineData(1)] // min [InlineData(3)] // default @@ -175,6 +193,8 @@ public class KpiHistoryOptionsValidatorTests Assert.True(NewValidator().Validate(null, opts).Succeeded); } + /// <summary>Verifies that an out-of-range rollup lookback fails validation.</summary> + /// <param name="value">The option value under test.</param> [Theory] [InlineData(0)] [InlineData(169)] @@ -194,6 +214,8 @@ public class KpiHistoryOptionsValidatorTests // RollupRetentionDays in [1, 3650] AND >= RetentionDays // --------------------------------------------------------------------- + /// <summary>Verifies that an in-range rollup retention period passes validation.</summary> + /// <param name="value">The option value under test.</param> [Theory] [InlineData(90)] // == RetentionDays default [InlineData(365)] // default @@ -204,6 +226,8 @@ public class KpiHistoryOptionsValidatorTests Assert.True(NewValidator().Validate(null, opts).Succeeded); } + /// <summary>Verifies that an out-of-range rollup retention period fails validation.</summary> + /// <param name="value">The option value under test.</param> [Theory] [InlineData(0)] [InlineData(3651)] @@ -219,6 +243,7 @@ public class KpiHistoryOptionsValidatorTests f => f.Contains(nameof(KpiHistoryOptions.RollupRetentionDays), StringComparison.Ordinal)); } + /// <summary>Verifies that a rollup retention shorter than the raw retention fails validation.</summary> [Fact] public void Validate_RollupRetentionDays_LessThanRetentionDays_Fails() { @@ -237,6 +262,7 @@ public class KpiHistoryOptionsValidatorTests && f.Contains(nameof(KpiHistoryOptions.RetentionDays), StringComparison.Ordinal)); } + /// <summary>Verifies that a rollup retention equal to the raw retention passes validation.</summary> [Fact] public void Validate_RollupRetentionDays_EqualToRetentionDays_Passes() { @@ -249,6 +275,8 @@ public class KpiHistoryOptionsValidatorTests // RollupThresholdHours >= 24 // --------------------------------------------------------------------- + /// <summary>Verifies that a rollup threshold at or above the minimum passes validation.</summary> + /// <param name="value">The option value under test.</param> [Theory] [InlineData(24)] // min [InlineData(168)] // default @@ -259,6 +287,8 @@ public class KpiHistoryOptionsValidatorTests Assert.True(NewValidator().Validate(null, opts).Succeeded); } + /// <summary>Verifies that a rollup threshold below the minimum fails validation.</summary> + /// <param name="value">The option value under test.</param> [Theory] [InlineData(23)] [InlineData(0)] @@ -274,6 +304,7 @@ public class KpiHistoryOptionsValidatorTests f => f.Contains(nameof(KpiHistoryOptions.RollupThresholdHours), StringComparison.Ordinal)); } + /// <summary>Verifies that violating every rule accumulates every failure message.</summary> [Fact] public void Validate_AllRulesViolated_AccumulatesEveryFailure() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs index 3b59f2cc..83a81274 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs @@ -25,8 +25,10 @@ public class KpiHistoryRecorderActorTests : TestKit /// </summary> private sealed class HealthySource : IKpiSampleSource { + /// <inheritdoc /> public string Source => KpiSources.NotificationOutbox; + /// <inheritdoc /> public Task<IReadOnlyList<KpiSample>> CollectAsync( DateTime capturedAtUtc, CancellationToken cancellationToken = default) { @@ -58,8 +60,10 @@ public class KpiHistoryRecorderActorTests : TestKit /// <summary>A source whose <see cref="CollectAsync"/> always throws — must not abort the pass.</summary> private sealed class ThrowingSource : IKpiSampleSource { + /// <inheritdoc /> public string Source => KpiSources.SiteCallAudit; + /// <inheritdoc /> public Task<IReadOnlyList<KpiSample>> CollectAsync( DateTime capturedAtUtc, CancellationToken cancellationToken = default) => throw new InvalidOperationException("simulated source failure"); @@ -74,8 +78,10 @@ public class KpiHistoryRecorderActorTests : TestKit { private int _callCount; + /// <inheritdoc /> public string Source => KpiSources.SiteCallAudit; + /// <inheritdoc /> public Task<IReadOnlyList<KpiSample>> CollectAsync( DateTime capturedAtUtc, CancellationToken cancellationToken = default) { @@ -116,6 +122,7 @@ public class KpiHistoryRecorderActorTests : TestKit private DateTime? _latestRollupHour; private int _latestRollupHourQueryCount; + /// <summary>The samples recorded so far, as a snapshot.</summary> public IReadOnlyList<KpiSample> Recorded { get { lock (_gate) { return _recorded.ToArray(); } } @@ -123,6 +130,7 @@ public class KpiHistoryRecorderActorTests : TestKit // PurgeOlderThanAsync runs on a threadpool thread; guard the field with // the same _gate lock used by _recorded so test-thread reads are race-free. + /// <summary>The cutoff passed to the most recent purge, or <see langword="null"/> if none.</summary> public DateTime? PurgeCutoff { get { lock (_gate) { return _purgeCutoff; } } @@ -158,6 +166,7 @@ public class KpiHistoryRecorderActorTests : TestKit get { lock (_gate) { return _foldWindows.ToArray(); } } } + /// <inheritdoc /> public Task RecordSamplesAsync( IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default) { @@ -168,17 +177,20 @@ public class KpiHistoryRecorderActorTests : TestKit return Task.CompletedTask; } + /// <inheritdoc /> public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync( string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()); + /// <inheritdoc /> public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) { lock (_gate) { _purgeCutoff = before; } return Task.FromResult(0); } + /// <inheritdoc /> public Task FoldHourlyRollupsAsync( DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default) { @@ -192,11 +204,13 @@ public class KpiHistoryRecorderActorTests : TestKit return Task.CompletedTask; } + /// <inheritdoc /> public Task<IReadOnlyList<KpiSeriesPoint>> GetHourlySeriesAsync( string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()); + /// <inheritdoc /> public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) { lock (_gate) { _rollupPurgeCutoff = before; } @@ -216,6 +230,7 @@ public class KpiHistoryRecorderActorTests : TestKit get { lock (_gate) { return _latestRollupHourQueryCount; } } } + /// <inheritdoc /> public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default) { lock (_gate) @@ -242,6 +257,7 @@ public class KpiHistoryRecorderActorTests : TestKit /// <summary>Releases all blocked writes so the gated passes can complete.</summary> public void Release() => _release.Set(); + /// <inheritdoc /> public Task RecordSamplesAsync( IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default) { @@ -251,26 +267,32 @@ public class KpiHistoryRecorderActorTests : TestKit return Task.Run(() => _release.Wait(cancellationToken), cancellationToken); } + /// <inheritdoc /> public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync( string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()); + /// <inheritdoc /> public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.FromResult(0); + /// <inheritdoc /> public Task FoldHourlyRollupsAsync( DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<IReadOnlyList<KpiSeriesPoint>> GetHourlySeriesAsync( string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()); + /// <inheritdoc /> public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default) => Task.FromResult<DateTime?>(null); } @@ -291,18 +313,22 @@ public class KpiHistoryRecorderActorTests : TestKit /// <summary>Releases all blocked folds so the gated passes can complete.</summary> public void Release() => _release.Set(); + /// <inheritdoc /> public Task RecordSamplesAsync( IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync( string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()); + /// <inheritdoc /> public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.FromResult(0); + /// <inheritdoc /> public Task FoldHourlyRollupsAsync( DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default) { @@ -312,14 +338,17 @@ public class KpiHistoryRecorderActorTests : TestKit return Task.Run(() => _release.Wait(cancellationToken), cancellationToken); } + /// <inheritdoc /> public Task<IReadOnlyList<KpiSeriesPoint>> GetHourlySeriesAsync( string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()); + /// <inheritdoc /> public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default) => Task.FromResult<DateTime?>(null); } @@ -347,18 +376,22 @@ public class KpiHistoryRecorderActorTests : TestKit get { lock (_gate) { return _lastFoldToHourUtc; } } } + /// <inheritdoc /> public Task RecordSamplesAsync( IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync( string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()); + /// <inheritdoc /> public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.FromResult(0); + /// <inheritdoc /> public Task FoldHourlyRollupsAsync( DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default) { @@ -372,14 +405,17 @@ public class KpiHistoryRecorderActorTests : TestKit return Task.CompletedTask; } + /// <inheritdoc /> public Task<IReadOnlyList<KpiSeriesPoint>> GetHourlySeriesAsync( string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()); + /// <inheritdoc /> public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default) => Task.FromResult<DateTime?>(null); } @@ -400,6 +436,8 @@ public class KpiHistoryRecorderActorTests : TestKit private int _periodicFoldCount; private int _backfillCountAtFirstPeriodic = -1; + /// <summary>Initializes the repository with the width that distinguishes a periodic fold window from a backfill slice.</summary> + /// <param name="periodicWindowWidth">The width of a periodic fold window.</param> public InterleaveFoldRepository(TimeSpan periodicWindowWidth) => _periodicWindowWidth = periodicWindowWidth; @@ -415,18 +453,22 @@ public class KpiHistoryRecorderActorTests : TestKit /// <summary>Releases the first (held-in-flight) periodic fold so the deferred backfill resumes.</summary> public void ReleaseFirstPeriodic() => _firstPeriodicRelease.Release(); + /// <inheritdoc /> public Task RecordSamplesAsync( IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync( string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()); + /// <inheritdoc /> public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.FromResult(0); + /// <inheritdoc /> public Task FoldHourlyRollupsAsync( DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default) { @@ -455,14 +497,17 @@ public class KpiHistoryRecorderActorTests : TestKit return Task.CompletedTask; } + /// <inheritdoc /> public Task<IReadOnlyList<KpiSeriesPoint>> GetHourlySeriesAsync( string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()); + /// <inheritdoc /> public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default) => Task.FromResult<DateTime?>(null); } @@ -500,7 +545,7 @@ public class KpiHistoryRecorderActorTests : TestKit } /// <summary> - /// Row-40 house pattern for an ABSENCE assertion (mirrors the Medium-wave + /// House pattern for an ABSENCE assertion (mirrors the Medium-wave /// <c>SiteAlarmAggregatorActorTests.AssertHoldsFor</c> treatment): proves /// <paramref name="holds"/> keeps being true for the whole <paramref name="window"/> by /// polling, rather than a single blind <c>Thread.Sleep(window)</c> followed by one @@ -531,19 +576,24 @@ public class KpiHistoryRecorderActorTests : TestKit private sealed class HangingSource(HangingState state) : IKpiSampleSource, IDisposable { private bool _called; + /// <inheritdoc /> public string Source => "hung"; + /// <inheritdoc /> public Task<IReadOnlyList<KpiSample>> CollectAsync(DateTime capturedAtUtc, CancellationToken cancellationToken = default) { _called = true; Interlocked.Increment(ref state.Calls); return state.Completion.Task; // deliberately ignores cancellation } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { if (_called) Interlocked.Increment(ref state.ActiveScopesDisposed); } } + /// <summary>Verifies that a hung source neither blocks healthy sources nor loses scope ownership.</summary> + /// <param name="replaceActor">Whether the recorder actor is replaced while the source is hung.</param> [Theory] [InlineData(false)] [InlineData(true)] @@ -595,6 +645,7 @@ public class KpiHistoryRecorderActorTests : TestKit AwaitAssert(() => Assert.Equal(1, Volatile.Read(ref state.ActiveScopesDisposed)), TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that a sample tick writes the healthy source's samples and a throwing source does not abort the tick.</summary> [Fact] public void SampleTick_WritesHealthySourceSamples_AndThrowingSourceDoesNotAbortTick() { @@ -618,6 +669,7 @@ public class KpiHistoryRecorderActorTests : TestKit interval: TimeSpan.FromMilliseconds(50)); } + /// <summary>Verifies that a purge tick calls purge with a cutoff of UTC now minus the retention days.</summary> [Fact] public void PurgeTick_CallsPurgeWithCutoff_AtUtcNowMinusRetentionDays() { @@ -646,6 +698,7 @@ public class KpiHistoryRecorderActorTests : TestKit interval: TimeSpan.FromMilliseconds(50)); } + /// <summary>Verifies that a faulted tick does not crash the actor and a subsequent tick still runs.</summary> [Fact] public void FaultedTick_DoesNotCrashActor_AndSubsequentTickStillRuns() { @@ -671,8 +724,8 @@ public class KpiHistoryRecorderActorTests : TestKit // in-flight guard after the faulted first pass — a tick that lands before the guard // clears is harmlessly skipped, and the next poll's tick runs. The recovered sample // being recorded proves the actor's message loop is still alive after a faulted pass. - // (>= 1 rather than exactly-1: a later re-sent tick could run an extra recovering pass - // once the guard clears, which is not what this test pins — KH-001's one-pass-per-tick + // (>= 1 rather than exactly one: a later re-sent tick could run an extra recovering pass + // once the guard clears, which is not what this test pins — the one-pass-per-tick // guard is pinned by OverlappingTick_WhileFirstPassInFlight_DoesNotStartSecondPass.) AwaitAssert( () => @@ -684,10 +737,11 @@ public class KpiHistoryRecorderActorTests : TestKit interval: TimeSpan.FromMilliseconds(50)); } + /// <summary>Verifies that an overlapping tick while the first pass is in flight does not start a second pass.</summary> [Fact] public void OverlappingTick_WhileFirstPassInFlight_DoesNotStartSecondPass() { - // KpiHistory-001 regression: the in-flight guard must coalesce a tick that arrives + // Regression: the in-flight guard must coalesce a tick that arrives // while a prior sample pass is still awaiting its DB write. With a gated repository // holding the first write open, a second SampleTick must NOT spawn a second pass — // so RecordSamplesAsync is entered exactly once until the gate is released. @@ -727,9 +781,10 @@ public class KpiHistoryRecorderActorTests : TestKit } // ===================================================================== - // Hourly rollup fold (Task 4) + // Hourly rollup fold // ===================================================================== + /// <summary>Verifies that a rollup tick folds the trailing lookback window with the in-progress hour excluded.</summary> [Fact] public void RollupTick_FoldsTrailingLookback_WithInProgressHourExcluded() { @@ -779,6 +834,7 @@ public class KpiHistoryRecorderActorTests : TestKit interval: TimeSpan.FromMilliseconds(50)); } + /// <summary>Verifies that a purge tick purges both raw samples and rollups with their own cutoffs.</summary> [Fact] public void PurgeTick_PurgesBothRawSamplesAndRollups_WithTheirOwnCutoffs() { @@ -818,6 +874,7 @@ public class KpiHistoryRecorderActorTests : TestKit interval: TimeSpan.FromMilliseconds(50)); } + /// <summary>Verifies that an overlapping rollup tick while the first fold is in flight does not start a second fold.</summary> [Fact] public void OverlappingRollupTick_WhileFirstFoldInFlight_DoesNotStartSecondFold() { @@ -856,6 +913,7 @@ public class KpiHistoryRecorderActorTests : TestKit interval: TimeSpan.FromMilliseconds(50)); } + /// <summary>Verifies that a faulted rollup fold does not crash the actor and a subsequent tick still folds.</summary> [Fact] public void FaultedRollupFold_DoesNotCrashActor_AndSubsequentTickStillFolds() { @@ -886,9 +944,10 @@ public class KpiHistoryRecorderActorTests : TestKit } // ===================================================================== - // One-shot rollup backfill (Task 6) + // One-shot rollup backfill // ===================================================================== + /// <summary>Verifies that the backfill tick folds the full retention window once.</summary> [Fact] public void BackfillTick_FoldsFullRetentionWindow_Once() { @@ -950,6 +1009,7 @@ public class KpiHistoryRecorderActorTests : TestKit Assert.Equal(foldedCount, repository.FoldWindows.Count); } + /// <summary>Verifies that the backfill slices the retention window into bounded day-long folds, oldest first.</summary> [Fact] public void Backfill_SlicesRetentionWindow_IntoBoundedDayFolds_OldestFirst() { @@ -996,6 +1056,8 @@ public class KpiHistoryRecorderActorTests : TestKit interval: TimeSpan.FromMilliseconds(50)); } + /// <summary>Verifies that a periodic rollup tick interleaves between backfill slices.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PeriodicRollupTick_Interleaves_BetweenBackfillSlices() { @@ -1060,6 +1122,7 @@ public class KpiHistoryRecorderActorTests : TestKit interval: TimeSpan.FromMilliseconds(50)); } + /// <summary>Verifies that a backfill in flight causes the periodic rollup tick to be skipped.</summary> [Fact] public void BackfillInFlight_SkipsPeriodicRollupTick() { @@ -1105,6 +1168,7 @@ public class KpiHistoryRecorderActorTests : TestKit interval: TimeSpan.FromMilliseconds(50)); } + /// <summary>Verifies that the backfill is skipped when rollups are already current.</summary> [Fact] public void Backfill_Skips_WhenRollupsAlreadyCurrent() { @@ -1147,6 +1211,7 @@ public class KpiHistoryRecorderActorTests : TestKit "the done-latch must make a second BackfillTick a no-op"); } + /// <summary>Verifies that the backfill shrinks to the watermark instead of the retention floor.</summary> [Fact] public void Backfill_ShrinksToWatermark_InsteadOfRetentionFloor() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/HarnessConfig.cs b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/HarnessConfig.cs index c111fd6e..39e4c34a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/HarnessConfig.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/HarnessConfig.cs @@ -1,20 +1,20 @@ namespace ZB.MOM.WW.ScadaBridge.LoadHarness; /// <summary> -/// Scale and duration knobs for a load-harness run. Defaults are the Phase-8 WP-4 +/// Scale and duration knobs for a load-harness run. Defaults are the /// target scale (10 sites x 500 instances x 75 tags = 375,000 subscriptions). /// Every value is overridable from the command line so the same binary serves both /// the full-scale protocol run and the scaled-down CI smoke. /// </summary> public sealed record HarnessConfig { - /// <summary>Number of simulated sites (WP-4 acceptance criterion <c>[2.5-1]</c>).</summary> + /// <summary>Number of simulated sites (acceptance criterion <c>[2.5-1]</c>).</summary> public int Sites { get; init; } = 10; - /// <summary>Instance Actors per site (WP-4 <c>[2.5-2]</c>).</summary> + /// <summary>Instance Actors per site (acceptance criterion <c>[2.5-2]</c>).</summary> public int InstancesPerSite { get; init; } = 500; - /// <summary>Data-sourced attributes ("live tags") per instance (WP-4 <c>[2.5-3]</c>).</summary> + /// <summary>Data-sourced attributes ("live tags") per instance (acceptance criterion <c>[2.5-3]</c>).</summary> public int TagsPerInstance { get; init; } = 75; /// <summary> diff --git a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/HarnessRun.cs b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/HarnessRun.cs index 51f401e9..68fa277f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/HarnessRun.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/HarnessRun.cs @@ -27,8 +27,8 @@ namespace ZB.MOM.WW.ScadaBridge.LoadHarness; /// <param name="ResourceSamples"> /// Every raw resource sample. Retained because the summary alone cannot distinguish a /// slow leak from a heap that simply sawtooths — especially when the run records zero -/// gen-2 collections, where a positive least-squares slope may be nothing more than -/// gen-2 garbage that was never collected. +/// gen 2 collections, where a positive least-squares slope may be nothing more than +/// gen 2 garbage that was never collected. /// </param> /// <param name="HealthReportLatency">Health report collect+ingest latency.</param> /// <param name="HealthReportsDelivered">Health reports ingested by the central aggregator.</param> @@ -112,7 +112,7 @@ public sealed record EnvironmentInfo( /// <summary> /// Orchestrates a full run: build sites, ramp instances, attach subscribers, drive /// tag updates for the sustained window while sampling resources and probing -/// observability, then run the two register-row-50 scenarios on a dedicated site. +/// observability, then run the two deferred-register scenarios on a dedicated site. /// </summary> public static class HarnessRun { @@ -251,7 +251,7 @@ public static class HarnessRun var probeReceived = sites.Sum(s => s.Probes.Sum(p => p.ReceivedEvents)) - receivedAtWindowStart; var probeDropped = sites.Sum(s => s.Probes.Sum(p => p.DroppedEvents)) - droppedAtWindowStart; - // Wire-path window deltas, captured BEFORE the row-50 scenarios below so + // Wire-path window deltas, captured BEFORE the deferred-register scenarios below so // their publishing cannot contaminate the wire figures. WireStreamResult? wireResult = null; if (batchAggregator is not null) diff --git a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Metrics/BatchSizeAggregator.cs b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Metrics/BatchSizeAggregator.cs index 243261a8..6ee16a0d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Metrics/BatchSizeAggregator.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Metrics/BatchSizeAggregator.cs @@ -61,7 +61,7 @@ public sealed class BatchSizeAggregator : IDisposable return copy; } - /// <inheritdoc /> + /// <summary>Disposes the underlying meter listener.</summary> public void Dispose() => _listener.Dispose(); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Metrics/ResourceSampler.cs b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Metrics/ResourceSampler.cs index a6fdd7fe..9d5d3ecf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Metrics/ResourceSampler.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Metrics/ResourceSampler.cs @@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Metrics; /// <param name="ManagedHeapMb"><see cref="GC.GetTotalMemory(bool)"/> without forcing a collection.</param> /// <param name="CpuPercent">Mean CPU utilization since the previous sample, as a percentage of ONE core.</param> /// <param name="ThreadCount">OS threads in the process.</param> -/// <param name="Gen2Collections">Cumulative gen-2 collections.</param> +/// <param name="Gen2Collections">Cumulative generation 2 collections.</param> public sealed record ResourceSample( double ElapsedSeconds, double WorkingSetMb, @@ -150,7 +150,8 @@ public sealed class ResourceSampler : IAsyncDisposable private int _disposed; - /// <inheritdoc /> + /// <summary>Stops the sampling loop and releases the sampler's resources.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposed, 1) != 0) @@ -184,7 +185,7 @@ public sealed class ResourceSampler : IAsyncDisposable /// <param name="MeanCpuPercentOfOneCore">Mean CPU as a percentage of one core (1400% = 14 cores saturated).</param> /// <param name="PeakCpuPercentOfOneCore">Peak single-sample CPU as a percentage of one core.</param> /// <param name="MeanThreadCount">Mean OS thread count.</param> -/// <param name="Gen2Collections">Gen-2 collections during the window.</param> +/// <param name="Gen2Collections">Generation 2 collections during the window.</param> public sealed record ResourceWindowSummary( int SampleCount, double DurationSeconds, diff --git a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Probes/SiteWireStreamHost.cs b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Probes/SiteWireStreamHost.cs index bda5df43..5f99e229 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Probes/SiteWireStreamHost.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Probes/SiteWireStreamHost.cs @@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Probes; /// <para> /// This is the hop <see cref="StreamSubscriberProbe"/> deliberately substitutes with a /// controllable in-process reader (which the slow-subscriber scenario needs), and -/// therefore the hop the original WP-4 runs never exercised: the +/// therefore the hop the original runs never exercised: the /// <c>SiteStreamEventBatcher</c> pump (R2), protobuf serialization, /// <c>IServerStreamWriter.WriteAsync</c>, and HTTP/2 flow control. The server is built /// through its production DI constructor with default <see cref="CommunicationOptions"/>, @@ -84,7 +84,8 @@ public sealed class SiteWireStreamHost : IAsyncDisposable return new SiteWireStreamHost(app, server, address); } - /// <inheritdoc /> + /// <summary>Cancels active streams and stops and disposes the underlying listener.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async ValueTask DisposeAsync() { // Mirrors the production shutdown ordering: cancel active streams first so diff --git a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Program.cs b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Program.cs index 2f4b76e7..d8d3f9dc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Program.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Program.cs @@ -1,9 +1,9 @@ using System.Text.Json; using ZB.MOM.WW.ScadaBridge.LoadHarness; -// Target-scale load harness (Phase-8 WP-4 / deferred-work register #25 + row 50). +// Target-scale load harness. // -// Full-scale protocol run (the WP-4 numbers): +// Full-scale protocol run (the target-scale numbers): // dotnet run -c Release --project tests/ZB.MOM.WW.ScadaBridge.LoadHarness -- \ // --sustain-minutes 20 --results loadharness-results.json // diff --git a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Scenarios/ObservabilityProbes.cs b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Scenarios/ObservabilityProbes.cs index 16af25fe..e6f0e401 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Scenarios/ObservabilityProbes.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Scenarios/ObservabilityProbes.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.LoadHarness.Metrics; namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Scenarios; /// <summary> -/// The two "does observability still work at scale?" probes WP-4's test protocol +/// The two "does observability still work at scale?" probes the target-scale test protocol /// names — health report delivery timing and debug view latency — run continuously /// alongside the sustained load rather than after it, so both are measured against a /// site that is actually busy. @@ -137,7 +137,8 @@ public sealed class ObservabilityProbes : IAsyncDisposable private int _disposed; - /// <inheritdoc /> + /// <summary>Stops the probes and releases their resources.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async ValueTask DisposeAsync() { // Idempotent: the orchestrator stops the probes early (so the register row 50 diff --git a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Scenarios/StoreAndForwardDrainScenario.cs b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Scenarios/StoreAndForwardDrainScenario.cs index 5370f281..8c35d448 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Scenarios/StoreAndForwardDrainScenario.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Scenarios/StoreAndForwardDrainScenario.cs @@ -49,7 +49,7 @@ public sealed record DrainProgressSample(double ElapsedSeconds, long Delivered, /// Phase 1 buffers <paramref name="messageCount"/> messages with /// <c>attemptImmediateDelivery: false</c>, spread across many origin instance names /// and issued from many concurrent tasks — the "concurrent buffering from multiple -/// instances" WP-4 asks about (<c>[xc-7]</c>). Phase 2 registers a delivery handler +/// instances" case the load test targets. Phase 2 registers a delivery handler /// that always succeeds and drives sweeps to completion, timing the drain. /// </para> /// <para> diff --git a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/SimulatedDataConnection.cs b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/SimulatedDataConnection.cs index d08bdfeb..e61cb558 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/SimulatedDataConnection.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/SimulatedDataConnection.cs @@ -44,10 +44,11 @@ public sealed class SimulatedDataConnection : IDataConnection, IBatchSubscribabl /// <summary>Key under which the connection name travels in the connection details.</summary> public const string ConnectionNameKey = "connectionName"; + // Gets the current health of the simulated connection. /// <inheritdoc /> public ConnectionHealth Status { get; private set; } = ConnectionHealth.Disconnected; - /// <inheritdoc /> + /// <summary>Raised when the simulated connection is disconnected.</summary> public event Action? Disconnected; /// <summary> @@ -60,6 +61,7 @@ public sealed class SimulatedDataConnection : IDataConnection, IBatchSubscribabl /// <summary>Number of tag paths this connection has accepted subscriptions for.</summary> public int SubscribedTagCount => _subscriptionCounter; + // Marks the connection as connected and records the connection name from the details, if present. /// <inheritdoc /> public Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default) { @@ -70,6 +72,7 @@ public sealed class SimulatedDataConnection : IDataConnection, IBatchSubscribabl return Task.CompletedTask; } + // Marks the connection as disconnected and raises the Disconnected event. /// <inheritdoc /> public Task DisconnectAsync(CancellationToken cancellationToken = default) { @@ -78,6 +81,7 @@ public sealed class SimulatedDataConnection : IDataConnection, IBatchSubscribabl return Task.CompletedTask; } + // Captures the callback and returns a new subscription id. /// <inheritdoc /> public Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default) { @@ -85,6 +89,7 @@ public sealed class SimulatedDataConnection : IDataConnection, IBatchSubscribabl return Task.FromResult($"sub-{Interlocked.Increment(ref _subscriptionCounter)}"); } + // Captures the callback and accepts every tag path in the batch. /// <inheritdoc /> public Task<IReadOnlyList<TagSubscribeResult>> SubscribeBatchAsync( IReadOnlyList<string> tagPaths, @@ -102,18 +107,22 @@ public sealed class SimulatedDataConnection : IDataConnection, IBatchSubscribabl return Task.FromResult<IReadOnlyList<TagSubscribeResult>>(results); } + // Does nothing; the simulated connection keeps no per-subscription state. /// <inheritdoc /> public Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default) => Task.CompletedTask; + // Does nothing; the simulated connection keeps no per-subscription state. /// <inheritdoc /> public Task UnsubscribeBatchAsync(IReadOnlyList<string> subscriptionIds, CancellationToken cancellationToken = default) => Task.CompletedTask; + // Returns a good-quality zero value for any tag. /// <inheritdoc /> public Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default) => Task.FromResult(new ReadResult(true, new TagValue(0d, QualityCode.Good, DateTimeOffset.UtcNow), null)); + // Returns a good-quality zero value for every requested tag. /// <inheritdoc /> public Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync( IEnumerable<string> tagPaths, CancellationToken cancellationToken = default) @@ -126,10 +135,12 @@ public sealed class SimulatedDataConnection : IDataConnection, IBatchSubscribabl return Task.FromResult<IReadOnlyDictionary<string, ReadResult>>(results); } + // Accepts the write without storing it. /// <inheritdoc /> public Task<WriteResult> WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default) => Task.FromResult(new WriteResult(true, null)); + // Accepts every write in the batch without storing it. /// <inheritdoc /> public Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync( IDictionary<string, object?> values, CancellationToken cancellationToken = default) @@ -141,6 +152,7 @@ public sealed class SimulatedDataConnection : IDataConnection, IBatchSubscribabl return Task.FromResult<IReadOnlyDictionary<string, WriteResult>>(results); } + // Accepts the batch and reports that the awaited response matched immediately. /// <inheritdoc /> public Task<bool> WriteBatchAndWaitAsync( IDictionary<string, object?> values, string flagPath, object? flagValue, @@ -148,6 +160,7 @@ public sealed class SimulatedDataConnection : IDataConnection, IBatchSubscribabl CancellationToken cancellationToken = default) => Task.FromResult(true); - /// <inheritdoc /> + /// <summary>Releases the connection; the simulated connection holds no resources.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public ValueTask DisposeAsync() => ValueTask.CompletedTask; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/SiteRuntimeFixture.cs b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/SiteRuntimeFixture.cs index 565db7b5..fe408620 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/SiteRuntimeFixture.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/SiteRuntimeFixture.cs @@ -32,7 +32,7 @@ namespace ZB.MOM.WW.ScadaBridge.LoadHarness; /// The sites are separate, non-clustered ActorSystems rather than 10 real two-node /// Akka clusters. Cluster membership, singleton placement and failover timing are /// already measured on a real two-node rig by -/// <c>PerformanceTests/Failover/FailoverTimingTests.cs</c>; what WP-4 asks about is +/// <c>PerformanceTests/Failover/FailoverTimingTests.cs</c>; what matters here is /// the load-bearing hierarchy under each singleton, which is what this builds. /// </para> /// </summary> @@ -50,7 +50,7 @@ public sealed class SiteRuntimeFixture : IAsyncDisposable private SiteStreamGrpcClient? _wireClient; private CancellationTokenSource? _wireCts; - /// <summary>Site identifier, e.g. <c>site-01</c>.</summary> + /// <summary>Site identifier, for example a zero-padded numbered name for the first site.</summary> public string SiteId { get; } /// <summary>This site's actor system.</summary> @@ -393,7 +393,8 @@ public sealed class SiteRuntimeFixture : IAsyncDisposable /// <summary>Total tag paths registered across this site's connections.</summary> public int TotalTagPaths => TagPathsByConnection.Sum(list => list.Count); - /// <inheritdoc /> + /// <summary>Stops the wire path, the actor system and the data directory in production teardown order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async ValueTask DisposeAsync() { // Wire path first: cancel the client streams, wait for their loops to exit, diff --git a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/TagUpdateDriver.cs b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/TagUpdateDriver.cs index 44b3332b..adb8f817 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/TagUpdateDriver.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/TagUpdateDriver.cs @@ -13,7 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.LoadHarness; /// <c>DataConnectionActor</c> has exactly one adapter feeding it, and every update /// for that connection funnels through that actor's single mailbox. Sharding the /// emitters differently would hide the per-connection-actor serialization point, -/// which is one of the things WP-4 needs to characterize. +/// which is one of the things the load test needs to characterize. /// </para> /// <para> /// The emitter walks the connection's tag list in slices sized so that one full pass @@ -154,7 +154,8 @@ public sealed class TagUpdateDriver : IAsyncDisposable private int _disposed; - /// <inheritdoc /> + /// <summary>Cancels the emitter tasks, waits for them to finish and releases the cancellation source.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposed, 1) != 0) diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiKeyCreationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiKeyCreationTests.cs index 7fa6de45..cc6aea59 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiKeyCreationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiKeyCreationTests.cs @@ -26,6 +26,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable private readonly FakeInboundApiKeyAdmin _admin = new(); private readonly IAuditService _auditService = Substitute.For<IAuditService>(); + /// <summary>Initializes the test with a fake key admin and a substituted audit service.</summary> public ApiKeyCreationTests() { _services.AddScoped<IInboundApiKeyAdmin>(_ => _admin); @@ -42,8 +43,10 @@ public class ApiKeyCreationTests : TestKit, IDisposable new(new AuthenticatedUser("admin", "Admin User", roles, Array.Empty<string>()), command, Guid.NewGuid().ToString("N")); + /// <inheritdoc /> void IDisposable.Dispose() => Shutdown(); + /// <summary>Verifies that creating an API key returns the key id and a one-time token.</summary> [Fact] public void CreateApiKey_ReturnsKeyIdAndOneTimeToken() { @@ -69,6 +72,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable Assert.Equal(new[] { "MethodA", "MethodB" }, created.Methods); } + /// <summary>Verifies that creating an API key writes an audit entry.</summary> [Fact] public void CreateApiKey_AuditsTheCreate() { @@ -80,6 +84,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable "admin", "Create", "ApiKey", Arg.Any<string>(), "MES-Production", Arg.Any<object?>()); } + /// <summary>Verifies that the create response does not echo a hash of the key.</summary> [Fact] public void CreateApiKey_ResponseDoesNotEchoAHash() { @@ -93,6 +98,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable Assert.DoesNotContain("hash", response.JsonData, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that listing API keys returns each key with its methods.</summary> [Fact] public void ListApiKeys_ReturnsKeysWithMethods() { @@ -119,6 +125,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable Assert.DoesNotContain("hash", response.JsonData, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that updating an API key toggles its enabled flag and writes an audit entry.</summary> [Fact] public void UpdateApiKey_TogglesEnabled_AndAudits() { @@ -139,6 +146,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable "admin", "Update", "ApiKey", "key-1", Arg.Any<string>(), Arg.Any<object?>()); } + /// <summary>Verifies that deleting an API key removes it and writes an audit entry.</summary> [Fact] public void DeleteApiKey_RemovesKey_AndAudits() { @@ -156,6 +164,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable "admin", "Delete", "ApiKey", "key-1", "key-1", null); } + /// <summary>Verifies that setting API key methods replaces the key's scopes and writes an audit entry.</summary> [Fact] public void SetApiKeyMethods_ReplacesScopes_AndAudits() { @@ -183,6 +192,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable // Fix 1 (review): not-found on mutating ops → ManagementError, no audit // ========================================================================= + /// <summary>Verifies that updating an unknown API key returns a management error and writes no audit entry.</summary> [Fact] public void UpdateApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit() { @@ -198,6 +208,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable Arg.Any<string>(), "Update", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>()); } + /// <summary>Verifies that setting methods on an unknown API key returns a management error and writes no audit entry.</summary> [Fact] public void SetApiKeyMethods_UnknownKey_ReturnsManagementError_AndDoesNotAudit() { @@ -211,6 +222,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable Arg.Any<string>(), "Update", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>()); } + /// <summary>Verifies that deleting an unknown API key returns a management error and writes no audit entry.</summary> [Fact] public void DeleteApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit() { @@ -228,6 +240,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable // Fix 2 (review): empty methods set is rejected before seam + audit // ========================================================================= + /// <summary>Verifies that creating an API key with no methods returns a management error.</summary> [Fact] public void CreateApiKey_EmptyMethods_ReturnsManagementError() { @@ -243,6 +256,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable Arg.Any<string>(), "Create", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>()); } + /// <summary>Verifies that setting an empty method list on an API key returns a management error.</summary> [Fact] public void SetApiKeyMethods_EmptyMethods_ReturnsManagementError() { @@ -260,6 +274,8 @@ public class ApiKeyCreationTests : TestKit, IDisposable Arg.Any<string>(), "Update", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>()); } + /// <summary>Verifies that every API key command is rejected for a caller without the Administrator role.</summary> + /// <param name="command">The API key command under test.</param> [Theory] [MemberData(nameof(AllApiKeyCommands))] public void EveryApiKeyCommand_RequiresAdminRole(object command) @@ -272,6 +288,8 @@ public class ApiKeyCreationTests : TestKit, IDisposable Assert.Contains("Administrator", response.Message); } + /// <summary>Supplies one instance of every API key command as test data.</summary> + /// <returns>The commands, each wrapped as a single-element argument array.</returns> public static IEnumerable<object[]> AllApiKeyCommands() => new[] { new object[] { new ListApiKeysCommand() }, @@ -291,11 +309,18 @@ public class ApiKeyCreationTests : TestKit, IDisposable { public sealed record Entry(string KeyId, string Name, bool Enabled, IReadOnlyList<string> Methods); + /// <summary>Gets the keys currently held by the fake, indexed by key id.</summary> public Dictionary<string, Entry> Keys { get; } = new(StringComparer.Ordinal); + /// <summary>Adds or replaces a key in the fake.</summary> + /// <param name="keyId">The key id.</param> + /// <param name="name">The key name.</param> + /// <param name="enabled">Whether the key is enabled.</param> + /// <param name="methods">The methods the key may call.</param> public void Seed(string keyId, string name, bool enabled, params string[] methods) => Keys[keyId] = new Entry(keyId, name, enabled, methods.ToList()); + /// <inheritdoc /> public Task<InboundApiKeyCreated> CreateAsync( string name, IReadOnlyCollection<string> methods, CancellationToken ct = default) { @@ -304,6 +329,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable return Task.FromResult(new InboundApiKeyCreated(keyId, $"sbk_{keyId}_secret")); } + /// <inheritdoc /> public Task<IReadOnlyList<InboundApiKeyInfo>> ListAsync(CancellationToken ct = default) { IReadOnlyList<InboundApiKeyInfo> result = Keys.Values @@ -314,6 +340,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable return Task.FromResult(result); } + /// <inheritdoc /> public Task<bool> SetEnabledAsync(string keyId, bool enabled, CancellationToken ct = default) { if (!Keys.TryGetValue(keyId, out var e)) return Task.FromResult(false); @@ -321,6 +348,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable return Task.FromResult(true); } + /// <inheritdoc /> public Task<bool> SetMethodsAsync(string keyId, IReadOnlyCollection<string> methods, CancellationToken ct = default) { if (!Keys.TryGetValue(keyId, out var e)) return Task.FromResult(false); @@ -328,15 +356,18 @@ public class ApiKeyCreationTests : TestKit, IDisposable return Task.FromResult(true); } + /// <inheritdoc /> public Task<bool> DeleteAsync(string keyId, CancellationToken ct = default) => Task.FromResult(Keys.Remove(keyId)); + /// <inheritdoc /> public Task<IReadOnlyList<string>> GetMethodsForKeyAsync(string keyId, CancellationToken ct = default) { IReadOnlyList<string> methods = Keys.TryGetValue(keyId, out var e) ? e.Methods : Array.Empty<string>(); return Task.FromResult(methods); } + /// <inheritdoc /> public Task<IReadOnlyList<string>> GetKeysForMethodAsync(string methodName, CancellationToken ct = default) { IReadOnlyList<string> keys = Keys.Values diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiMethodTraceAllCallsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiMethodTraceAllCallsTests.cs index bcfd205d..fe9472a7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiMethodTraceAllCallsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiMethodTraceAllCallsTests.cs @@ -20,6 +20,7 @@ public class ApiMethodTraceAllCallsTests : TestKit, IDisposable private readonly ServiceCollection _services = new ServiceCollection().AddTestConfigurationTransaction(); private readonly IInboundApiRepository _apiRepo = Substitute.For<IInboundApiRepository>(); + /// <summary>Initializes the test class with a stubbed inbound API repository and test services.</summary> public ApiMethodTraceAllCallsTests() { _services.AddScoped(_ => _apiRepo); @@ -35,8 +36,10 @@ public class ApiMethodTraceAllCallsTests : TestKit, IDisposable new(new AuthenticatedUser("designer", "Designer", new[] { "Designer" }, Array.Empty<string>()), command, Guid.NewGuid().ToString("N")); + /// <inheritdoc /> void IDisposable.Dispose() => Shutdown(); + /// <summary>Verifies that commands default to untraced when built with the five-argument constructor.</summary> [Fact] public void Commands_DefaultToUntraced_ForTheFiveArgConstructor() { @@ -45,6 +48,7 @@ public class ApiMethodTraceAllCallsTests : TestKit, IDisposable Assert.False(new ApiMethod("m", "return 1;").TraceAllCalls); } + /// <summary>Verifies that creating a method with the trace-all-calls flag persists it and echoes it in the result.</summary> [Fact] public void Create_WithTraceAllCalls_PersistsTheFlag_AndEchoesItInTheResult() { @@ -60,6 +64,7 @@ public class ApiMethodTraceAllCallsTests : TestKit, IDisposable Assert.Contains("\"traceAllCalls\":true", response.JsonData.Replace(" ", "")); } + /// <summary>Verifies that updating with an explicit false clears the trace-all-calls flag.</summary> [Fact] public void Update_WithExplicitFalse_ClearsTheFlag() { @@ -72,6 +77,7 @@ public class ApiMethodTraceAllCallsTests : TestKit, IDisposable Assert.False(existing.TraceAllCalls); } + /// <summary>Verifies that an update that omits the flag leaves the stored value unchanged.</summary> [Fact] public void Update_OmittingTheFlag_LeavesTheStoredValueUnchanged() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs index cb0a38e0..4992b5d3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs @@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// <summary> -/// HTTP-pipeline tests for the #23 M8 audit endpoints (<see cref="AuditEndpoints"/>). +/// HTTP-pipeline tests for the audit endpoints (<see cref="AuditEndpoints"/>). /// /// <para> /// ManagementService authenticates each request by hand (HTTP Basic → LDAP → @@ -35,7 +35,7 @@ public class AuditEndpointsTests { private const string BasicCredential = "auditor:password"; - // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory; + // Canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory; // the endpoint serializes the canonical record (eventId + occurredAtUtc top-level, // domain fields out of DetailsJson) so these tests assert on the JSON/CSV output. private static AuditEvent SampleEvent(Guid? id = null, DateTime? occurredAt = null) => @@ -74,7 +74,7 @@ public class AuditEndpointsTests .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>())); } - // Substituted LDAP bind — the shared ILdapAuthService is the seam now (Task 1.2). + // Substituted LDAP bind — the shared ILdapAuthService is the seam now. var ldap = Substitute.For<ILdapAuthService>(); ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()) .Returns(ldapSucceeds @@ -127,6 +127,8 @@ public class AuditEndpointsTests // /api/audit/query // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a query with valid parameters returns a JSON page of rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_ValidParams_ReturnsJsonPage() { @@ -152,6 +154,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that a query with a cursor returns the next page.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_WithCursor_ReturnsNextPage() { @@ -196,6 +200,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that a query without the operational-audit permission returns 403.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_WithoutOperationalAudit_Returns403() { @@ -209,6 +215,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that a query without credentials returns 401.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_WithoutCredentials_Returns401() { @@ -220,10 +228,12 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that a query by a viewer is allowed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_ViewerRole_IsAllowed() { - // Viewer (post Task 1.7 home of the former AuditReadOnly role) satisfies + // Viewer (the home of the former AuditReadOnly role) satisfies // OperationalAudit (read) — query must succeed. var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" }); using (host) @@ -237,6 +247,8 @@ public class AuditEndpointsTests // /api/audit/export // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a CSV export streams its content with a CSV content type.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Export_Csv_StreamsContent_WithCsvContentType() { @@ -265,6 +277,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that an export defaults to CSV when the format is omitted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Export_Csv_DefaultsWhenFormatOmitted() { @@ -280,6 +294,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that a JSON Lines export streams one record per line.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Export_Jsonl_StreamsOnePerLine() { @@ -317,9 +333,9 @@ public class AuditEndpointsTests // ───────────────────────────────────────────────────────────────────── // CSV export — formula-injection neutralization + column alignment - // (arch-review 2026-08-19 P6-7) // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that the CSV header has 24 columns in the same order as the export row properties.</summary> [Fact] public void CsvHeader_Has24Columns_SameOrderAsAuditExportRowProperties() { @@ -341,6 +357,7 @@ public class AuditEndpointsTests }, columns); } + /// <summary>Verifies that a formatted CSV row includes the execution id, parent execution id and source node.</summary> [Fact] public void FormatCsvRow_IncludesExecutionParentExecutionAndSourceNode() { @@ -363,6 +380,10 @@ public class AuditEndpointsTests Assert.Equal("site-a-node-a", fields[9]); // SourceNode } + /// <summary>Verifies that a free-text field starting with a formula character is prefixed with a single quote in the CSV export.</summary> + /// <param name="malicious">The free-text value that starts with a formula character.</param> + /// <param name="expectedPrefix">The expected start of the exported cell.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("=HYPERLINK(\"http://evil\",\"click\")", "'=HYPERLINK(")] [InlineData("+cmd|'/c calc'!A1", "'+cmd|")] @@ -371,7 +392,7 @@ public class AuditEndpointsTests [InlineData("\tsneaky", "'\tsneaky")] public async Task Export_Csv_FreeTextLeadingFormulaCharacter_IsPrefixedWithQuote(string malicious, string expectedPrefix) { - // CWE-1236: Target is inbound-API-caller-controlled free text. A raw + // CSV formula injection: Target is inbound-API-caller-controlled free text. A raw // leading =, +, -, @, or TAB must never reach the CSV cell unescaped. var evt = ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiInbound, @@ -400,6 +421,7 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that the error message, response summary and extra columns are neutralized against formula injection.</summary> [Fact] public void FormatCsvRow_ErrorMessageResponseSummaryAndExtra_AreNeutralized() { @@ -422,6 +444,7 @@ public class AuditEndpointsTests Assert.Equal("'@evil", fields[22]); // Extra } + /// <summary>Verifies that negative durations and date columns are never neutralized.</summary> [Fact] public void FormatCsvRow_NegativeDurationAndDateColumns_NeverNeutralized() { @@ -445,6 +468,7 @@ public class AuditEndpointsTests Assert.Equal("200", fields[15]); // HttpStatus, unprefixed } + /// <summary>Verifies that a benign free-text field is not prefixed.</summary> [Fact] public void FormatCsvRow_BenignFreeTextField_IsNotPrefixed() { @@ -462,6 +486,7 @@ public class AuditEndpointsTests Assert.Equal("plant-a-external-system", fields[13]); // Target } + /// <summary>Verifies that the CSV header columns match the property order of the export row.</summary> [Fact] public void CsvHeader_ColumnsMatchAuditExportRowPropertyOrder() { @@ -481,6 +506,8 @@ public class AuditEndpointsTests Assert.Equal(propertyNames, AuditEndpoints.CsvHeader.Split(',')); } + /// <summary>Verifies that a Parquet export returns 501.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Export_Parquet_Returns501() { @@ -497,12 +524,14 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that an export without the audit-export permission returns 403.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Export_WithoutAuditExport_Returns403() { // Viewer (former AuditReadOnly) grants read (OperationalAudit) but NOT // bulk export (AuditExport) — the export endpoint must 403. This is the - // preserved half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse. + // preserved half-SoD after the AuditReadOnly→Viewer collapse. var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" }); using (host) { @@ -511,6 +540,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that an export with an unsupported format returns 400.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Export_UnsupportedFormat_Returns400() { @@ -526,6 +557,7 @@ public class AuditEndpointsTests // Query-string parsing units // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that paging parsing clamps the page size to the maximum.</summary> [Fact] public void ParsePaging_ClampsPageSizeToMax() { @@ -540,6 +572,7 @@ public class AuditEndpointsTests Assert.Equal(AuditEndpoints.MaxPageSize, paging.PageSize); } + /// <summary>Verifies that repeated filter parameters parse into multi-value lists.</summary> [Fact] public void ParseFilter_RepeatedParams_ParseIntoMultiValueLists() { @@ -562,6 +595,7 @@ public class AuditEndpointsTests Assert.Equal(new[] { "plant-a", "plant-b" }, filter.SourceSiteIds); } + /// <summary>Verifies that a single filter parameter parses into a one-element list.</summary> [Fact] public void ParseFilter_SingleParam_ParsesIntoOneElementList() { @@ -594,6 +628,7 @@ public class AuditEndpointsTests // removed. The Guid/date dimensions stay lax — see ParseFilter's remarks. // ======================================================================== + /// <summary>Verifies that an unrecognised enum value in a filter throws.</summary> [Fact] public void ParseFilter_UnrecognisedEnumValue_Throws() { @@ -608,6 +643,7 @@ public class AuditEndpointsTests Assert.Contains("channel", ex.Message); } + /// <summary>Verifies that an unrecognised status throws an error naming the valid values.</summary> [Fact] public void ParseFilter_UnrecognisedStatus_ThrowsNamingTheValidSet() { @@ -624,6 +660,7 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that lowercase enum values are accepted in a filter.</summary> [Fact] public void ParseFilter_LowercaseEnumValues_AreAccepted() { @@ -644,6 +681,7 @@ public class AuditEndpointsTests Assert.Equal(new[] { AuditStatus.Delivered, AuditStatus.Parked }, filter.Statuses); } + /// <summary>Verifies that a numeric enum value in a filter is rejected.</summary> [Fact] public void ParseFilter_NumericEnumValue_IsRejected() { @@ -658,6 +696,7 @@ public class AuditEndpointsTests Assert.Throws<AuditFilterParseException>(() => AuditEndpoints.ParseFilter(query)); } + /// <summary>Verifies that an execution id filter parses into a single GUID value.</summary> [Fact] public void ParseFilter_ExecutionId_ParsesIntoSingleValueGuid() { @@ -674,6 +713,7 @@ public class AuditEndpointsTests Assert.Equal(executionId, filter.ExecutionId); } + /// <summary>Verifies that an unparseable execution id filter is silently dropped.</summary> [Fact] public void ParseFilter_UnparseableExecutionId_IsDroppedSilently() { @@ -690,6 +730,7 @@ public class AuditEndpointsTests Assert.Null(filter.ExecutionId); } + /// <summary>Verifies that a parent execution id filter parses into a single GUID value.</summary> [Fact] public void ParseFilter_ParentExecutionId_ParsesIntoSingleValueGuid() { @@ -706,6 +747,7 @@ public class AuditEndpointsTests Assert.Equal(parentExecutionId, filter.ParentExecutionId); } + /// <summary>Verifies that an unparseable parent execution id filter is silently dropped.</summary> [Fact] public void ParseFilter_UnparseableParentExecutionId_IsDroppedSilently() { @@ -722,6 +764,8 @@ public class AuditEndpointsTests Assert.Null(filter.ParentExecutionId); } + /// <summary>Verifies that repeated channel parameters reach the repository as a multi-value filter.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter() { @@ -744,6 +788,7 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that a half-supplied paging cursor is dropped.</summary> [Fact] public void ParsePaging_HalfSuppliedCursor_IsDropped() { @@ -762,9 +807,10 @@ public class AuditEndpointsTests } // ───────────────────────────────────────────────────────────────────── - // ApplySiteScope (Management-019) + // ApplySiteScope // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a system-wide user's filter is returned unchanged by site scoping.</summary> [Fact] public void ApplySiteScope_SystemWideUser_ReturnsFilterUnchanged() { @@ -781,6 +827,7 @@ public class AuditEndpointsTests Assert.Same(filter, result); } + /// <summary>Verifies that an empty caller filter is restricted to the permitted sites for a scoped user.</summary> [Fact] public void ApplySiteScope_ScopedUser_EmptyCallerFilter_RestrictedToPermittedSet() { @@ -798,6 +845,7 @@ public class AuditEndpointsTests Assert.Equal(new[] { "plant-a", "plant-b" }, result.SourceSiteIds!.OrderBy(s => s).ToArray()); } + /// <summary>Verifies that an explicit in-scope filter is kept unchanged for a scoped user.</summary> [Fact] public void ApplySiteScope_ScopedUser_ExplicitInScopeFilter_KeptVerbatim() { @@ -811,6 +859,7 @@ public class AuditEndpointsTests Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds); } + /// <summary>Verifies that an explicit out-of-scope filter yields null for a scoped user.</summary> [Fact] public void ApplySiteScope_ScopedUser_ExplicitOutOfScopeFilter_ReturnsNull() { @@ -825,6 +874,7 @@ public class AuditEndpointsTests Assert.Null(result); } + /// <summary>Verifies that a mixed filter is reduced to its in-scope sites for a scoped user.</summary> [Fact] public void ApplySiteScope_ScopedUser_MixedInAndOutOfScopeFilter_IntersectedToInScopeOnly() { @@ -907,6 +957,8 @@ public class AuditEndpointsTests FirstOccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), LastOccurredAtUtc: new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc)); + /// <summary>Verifies that a scoped viewer's permitted sites are passed to the repository by the tree endpoint.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Tree_ScopedViewer_ThreadsPermittedSitesToRepository() { @@ -921,6 +973,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that the tree endpoint returns a JSON array for a valid execution id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Tree_ValidExecutionId_ReturnsJsonArray() { @@ -950,6 +1004,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that the tree endpoint returns an empty array when the repository returns nothing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Tree_RepoReturnsEmpty_ReturnsEmptyArray() { @@ -969,6 +1025,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that the tree endpoint returns 400 when the execution id is missing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Tree_MissingExecutionId_Returns400() { @@ -981,6 +1039,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that the tree endpoint returns 400 when the execution id is invalid.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Tree_InvalidExecutionId_Returns400() { @@ -995,6 +1055,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that the tree endpoint returns 403 without the operational-audit permission.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Tree_WithoutOperationalAudit_Returns403() { @@ -1007,6 +1069,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that the tree endpoint returns 401 without credentials.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Tree_WithoutCredentials_Returns401() { @@ -1019,6 +1083,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that the tree endpoint is allowed for a viewer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Tree_ViewerRole_IsAllowed() { @@ -1032,7 +1098,7 @@ public class AuditEndpointsTests } // ───────────────────────────────────────────────────────────────────── - // POST /api/audit/backfill-source-node (M5.6 T5) + // POST /api/audit/backfill-source-node // ───────────────────────────────────────────────────────────────────── private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithBackfillAsync( @@ -1100,6 +1166,8 @@ public class AuditEndpointsTests return request; } + /// <summary>Verifies that an administrator's source-node backfill returns 200 with the updated row count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BackfillSourceNode_AdminRole_Returns200WithRowCount() { @@ -1120,6 +1188,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that a viewer's source-node backfill returns 403.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BackfillSourceNode_ViewerRole_Returns403() { @@ -1135,6 +1205,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that a source-node backfill without credentials returns 401.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BackfillSourceNode_NoCredentials_Returns401() { @@ -1150,6 +1222,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that a source-node backfill without a before date returns 400.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BackfillSourceNode_MissingBefore_Returns400() { @@ -1165,6 +1239,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that a source-node backfill with an invalid before date returns 400.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BackfillSourceNode_InvalidBeforeDate_Returns400() { @@ -1179,6 +1255,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that a custom sentinel and batch size are passed to the repository.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BackfillSourceNode_CustomSentinelAndBatch_PassedToRepo() { @@ -1200,6 +1278,8 @@ public class AuditEndpointsTests } } + /// <summary>Verifies that the sentinel defaults to unknown when it is omitted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BackfillSourceNode_DefaultSentinel_IsUnknown_WhenOmitted() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs index e5157a3c..86ec94e0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs @@ -2,6 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; public class ConfigSecretScrubberTests { + /// <summary>Verifies that password fields are redacted at any nesting depth.</summary> [Fact] public void Scrub_RedactsPasswordFields_AtAnyDepth() { @@ -14,6 +15,7 @@ public class ConfigSecretScrubberTests Assert.Contains("opc.tcp://x", scrubbed); // non-secrets untouched } + /// <summary>Verifies that scrubbing a document with no secrets reports that nothing was redacted.</summary> [Fact] public void Scrub_NoSecrets_ReturnsFalseFlag() { @@ -21,6 +23,8 @@ public class ConfigSecretScrubberTests Assert.False(had); } + /// <summary>Verifies that null, empty or malformed input passes through unchanged.</summary> + /// <param name="input">The raw configuration JSON to scrub.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -32,6 +36,7 @@ public class ConfigSecretScrubberTests Assert.False(had); } + /// <summary>Verifies that merging sentinels restores stored secrets while keeping the operator's other edits.</summary> [Fact] public void MergeSentinels_RestoresStoredSecrets_KeepsEdits() { @@ -60,6 +65,7 @@ public class ConfigSecretScrubberTests [typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections.OpcUaDeadbandConfig)] = [], }; + /// <summary>Verifies that every string property on the config types is either scrubbed or deliberately classified as non-secret.</summary> [Fact] public void EveryConfigStringProperty_IsScrubbedOrDeliberatelyNonSecret() { @@ -79,6 +85,8 @@ public class ConfigSecretScrubberTests } } + /// <summary>Verifies that each known secret property name matches the scrubber's fragment list.</summary> + /// <param name="name">The secret property name.</param> [Theory] [InlineData("Password")] [InlineData("CertificatePassword")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigurationTransactionTestExtensions.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigurationTransactionTestExtensions.cs index d4ef6869..d4ac2259 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigurationTransactionTestExtensions.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigurationTransactionTestExtensions.cs @@ -4,6 +4,9 @@ namespace Microsoft.Extensions.DependencyInjection; internal static class ConfigurationTransactionTestExtensions { + /// <summary>Registers a scope-per-call configuration command transaction and the area service for tests.</summary> + /// <param name="services">The service collection to add the registrations to.</param> + /// <returns>The same service collection, for chaining.</returns> public static ServiceCollection AddTestConfigurationTransaction(this ServiceCollection services) { services.AddSingleton<IConfigurationCommandTransaction, RepositoryTransaction>(); @@ -12,6 +15,7 @@ internal static class ConfigurationTransactionTestExtensions } private sealed class RepositoryTransaction(IServiceScopeFactory scopes) : IConfigurationCommandTransaction { + /// <inheritdoc /> public async Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation) { using var scope = scopes.CreateScope(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubSubscribeRaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubSubscribeRaceTests.cs index 30494038..4d5bc85e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubSubscribeRaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubSubscribeRaceTests.cs @@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// <summary> -/// Regression tests for arch-review 2026-08-19 P6-6 on the hub side. <c>SubscribeInstance</c> +/// Regression tests for the hub-side subscribe race. <c>SubscribeInstance</c> /// awaits <c>StartStreamAsync</c> — up to 30s — and only then stores the session id on the /// connection. A client that disconnects inside that window ran <c>OnDisconnectedAsync</c> /// while there was still no session id to release, so the session stayed registered in the @@ -33,6 +33,7 @@ public class DebugStreamHubSubscribeRaceTests private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly List<string> _stopped = new(); + /// <summary>Creates the gated debug stream service with an unreleased start gate.</summary> public GatedDebugStreamService() : base( new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance), @@ -42,6 +43,7 @@ public class DebugStreamHubSubscribeRaceTests { } + /// <summary>The fixed session id returned by every started stream.</summary> public string SessionId => "session-under-test"; /// <summary>Completes once <see cref="StartStreamAsync"/> has been entered.</summary> @@ -53,8 +55,10 @@ public class DebugStreamHubSubscribeRaceTests get { lock (_stopped) { return _stopped.ToList(); } } } + /// <summary>Releases the start gate so a pending <c>StartStreamAsync</c> call completes.</summary> public void ReleaseStart() => _gate.TrySetResult(); + /// <inheritdoc /> public override async Task<DebugStreamSession> StartStreamAsync( int instanceId, Action<object> onEvent, Action onTerminated, CancellationToken ct = default) { @@ -69,6 +73,7 @@ public class DebugStreamHubSubscribeRaceTests DateTimeOffset.UtcNow)); } + /// <inheritdoc /> public override void StopStream(string sessionId) { lock (_stopped) { _stopped.Add(sessionId); } @@ -80,18 +85,31 @@ public class DebugStreamHubSubscribeRaceTests private readonly FeatureCollection _features = new(); private readonly CancellationTokenSource _aborted = new(); + /// <summary>Creates a hub caller context over the supplied HTTP context.</summary> + /// <param name="http">The HTTP context exposed through the hub context.</param> public TestHubCallerContext(HttpContext http) => _features.Set<IHttpContextFeature>(new TestHttpContextFeature { HttpContext = http }); + /// <inheritdoc /> public override string ConnectionId => "test-conn"; + /// <inheritdoc /> public override string? UserIdentifier => null; + /// <inheritdoc /> public override System.Security.Claims.ClaimsPrincipal? User => null; + /// <inheritdoc /> public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>(); + /// <inheritdoc /> public override IFeatureCollection Features => _features; + /// <inheritdoc /> public override CancellationToken ConnectionAborted => _aborted.Token; + /// <inheritdoc /> public override void Abort() => _aborted.Cancel(); - private sealed class TestHttpContextFeature : IHttpContextFeature { public HttpContext? HttpContext { get; set; } } + private sealed class TestHttpContextFeature : IHttpContextFeature + { + /// <summary>Gets or sets the HTTP context exposed by the feature.</summary> + public HttpContext? HttpContext { get; set; } + } } private const string SessionIdKey = "DebugStreamSessionId"; @@ -119,6 +137,8 @@ public class DebugStreamHubSubscribeRaceTests return (hub, context, stream); } + /// <summary>Verifies that subscribe instance, client disconnects mid start, stops the just created session.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SubscribeInstance_ClientDisconnectsMidStart_StopsTheJustCreatedSession() { @@ -139,6 +159,8 @@ public class DebugStreamHubSubscribeRaceTests Assert.False(context.Items.ContainsKey(SessionIdKey)); } + /// <summary>Verifies that subscribe instance, connection aborted mid start, stops the just created session.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SubscribeInstance_ConnectionAbortedMidStart_StopsTheJustCreatedSession() { @@ -157,6 +179,8 @@ public class DebugStreamHubSubscribeRaceTests Assert.False(context.Items.ContainsKey(SessionIdKey)); } + /// <summary>Verifies that subscribe instance, then disconnect, stops the session exactly once.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SubscribeInstance_ThenDisconnect_StopsTheSessionExactlyOnce() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs index 76957af2..4bfb1268 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs @@ -18,11 +18,11 @@ using ZB.MOM.WW.ScadaBridge.Security.Auth; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// <summary> -/// Tests for <see cref="DebugStreamHub"/> per-instance site-scope authorization -/// (finding ManagementService-003). +/// Tests for <see cref="DebugStreamHub"/> per-instance site-scope authorization. /// </summary> public class DebugStreamHubTests { + /// <summary>Verifies that a site-scoped user is allowed to access an instance within their site scope.</summary> [Fact] public void IsInstanceAccessAllowed_SiteScopedUser_InScopeInstance_Allowed() { @@ -34,6 +34,7 @@ public class DebugStreamHubTests Assert.True(allowed); } + /// <summary>Verifies that a site-scoped user is denied access to an instance outside their site scope.</summary> [Fact] public void IsInstanceAccessAllowed_SiteScopedUser_OutOfScopeInstance_Denied() { @@ -45,6 +46,7 @@ public class DebugStreamHubTests Assert.False(allowed); } + /// <summary>Verifies that a system-wide deployment user is allowed to access instances at any site.</summary> [Fact] public void IsInstanceAccessAllowed_SystemWideDeployment_AnySiteAllowed() { @@ -57,6 +59,7 @@ public class DebugStreamHubTests Assert.True(allowed); } + /// <summary>Verifies that the Administrator role bypasses site scoping.</summary> [Fact] public void IsInstanceAccessAllowed_AdminRole_BypassesSiteScope() { @@ -68,6 +71,7 @@ public class DebugStreamHubTests Assert.True(allowed); } + /// <summary>Verifies that the Administrator role check is case-insensitive.</summary> [Fact] public void IsInstanceAccessAllowed_AdminRoleCheck_IsCaseInsensitive() { @@ -85,6 +89,7 @@ public class DebugStreamHubTests private readonly ServiceProvider _provider; private readonly LoginThrottle _throttle; + /// <summary>Initializes the test with a substituted LDAP service, a login throttle and a service provider.</summary> public DebugStreamHubTests() { var services = new ServiceCollection(); @@ -120,6 +125,8 @@ public class DebugStreamHubTests return ctx; } + /// <summary>Verifies that failed binds on connect feed the shared login throttle.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OnConnectedAsync_FailedBinds_FeedTheSharedLoginThrottle() { @@ -131,6 +138,8 @@ public class DebugStreamHubTests Assert.True(_throttle.IsLockedOut("alice", "10.0.0.1")); } + /// <summary>Verifies that a locked-out key aborts the connection without contacting LDAP.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OnConnectedAsync_LockedOutKey_AbortsWithoutContactingLdap() { @@ -144,17 +153,31 @@ public class DebugStreamHubTests private sealed class TestHubCallerContext : HubCallerContext { private readonly FeatureCollection _features = new(); + /// <summary>Initializes the context so that its HTTP context feature returns the given request context.</summary> + /// <param name="http">The HTTP context exposed through the context feature.</param> public TestHubCallerContext(HttpContext http) => _features.Set<IHttpContextFeature>(new TestHttpContextFeature { HttpContext = http }); + /// <summary>Gets a value indicating whether <see cref="Abort"/> has been called.</summary> public bool Aborted { get; private set; } + /// <inheritdoc /> public override string ConnectionId => "test-conn"; + /// <inheritdoc /> public override string? UserIdentifier => null; + /// <inheritdoc /> public override System.Security.Claims.ClaimsPrincipal? User => null; + /// <inheritdoc /> public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>(); + /// <inheritdoc /> public override IFeatureCollection Features => _features; + /// <inheritdoc /> public override CancellationToken ConnectionAborted => CancellationToken.None; + /// <inheritdoc /> public override void Abort() => Aborted = true; - private sealed class TestHttpContextFeature : IHttpContextFeature { public HttpContext? HttpContext { get; set; } } + private sealed class TestHttpContextFeature : IHttpContextFeature + { + /// <summary>Gets or sets the HTTP context exposed by the feature.</summary> + public HttpContext? HttpContext { get; set; } + } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DeploymentConfigEndpointsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DeploymentConfigEndpointsTests.cs index 8bde3493..5c37c8a6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DeploymentConfigEndpointsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DeploymentConfigEndpointsTests.cs @@ -42,6 +42,7 @@ public class DeploymentConfigEndpointsTests private static int? StatusOf(IResult result) => (result as IStatusCodeHttpResult)?.StatusCode; + /// <summary>Verifies that a null row resolves to not found.</summary> [Fact] public void Resolve_NullRow_ReturnsNotFound() { @@ -51,6 +52,7 @@ public class DeploymentConfigEndpointsTests Assert.Equal(StatusCodes.Status404NotFound, StatusOf(result)); } + /// <summary>Verifies that an expired row with the correct token resolves to not found.</summary> [Fact] public void Resolve_ExpiredRow_WithCorrectToken_ReturnsNotFound() { @@ -63,6 +65,7 @@ public class DeploymentConfigEndpointsTests Assert.Equal(StatusCodes.Status404NotFound, StatusOf(result)); } + /// <summary>Verifies that a row that expired in the past with the correct token resolves to not found.</summary> [Fact] public void Resolve_ExpiredRowInThePast_WithCorrectToken_ReturnsNotFound() { @@ -74,6 +77,7 @@ public class DeploymentConfigEndpointsTests Assert.Equal(StatusCodes.Status404NotFound, StatusOf(result)); } + /// <summary>Verifies that a valid row with the wrong token resolves to unauthorized.</summary> [Fact] public void Resolve_ValidRow_WrongToken_ReturnsUnauthorized() { @@ -85,6 +89,7 @@ public class DeploymentConfigEndpointsTests Assert.Equal(StatusCodes.Status401Unauthorized, StatusOf(result)); } + /// <summary>Verifies that a valid row with an empty token resolves to unauthorized.</summary> [Fact] public void Resolve_ValidRow_EmptyToken_ReturnsUnauthorized() { @@ -96,6 +101,7 @@ public class DeploymentConfigEndpointsTests Assert.Equal(StatusCodes.Status401Unauthorized, StatusOf(result)); } + /// <summary>Verifies that a valid row with a null token resolves to unauthorized.</summary> [Fact] public void Resolve_ValidRow_NullToken_ReturnsUnauthorized() { @@ -107,6 +113,7 @@ public class DeploymentConfigEndpointsTests Assert.Equal(StatusCodes.Status401Unauthorized, StatusOf(result)); } + /// <summary>Verifies that a valid, unexpired row with the correct token resolves to the configuration JSON.</summary> [Fact] public void Resolve_ValidRow_CorrectToken_NotExpired_ReturnsConfigJson() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs index 9b329579..0619e8a4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs @@ -45,8 +45,10 @@ public class DispatchCoverageTests : TestKit, IDisposable && !t.IsAbstract && ManagementCommandRegistry.Resolve(t.Name[..^"Command".Length]) == t); + /// <inheritdoc /> void IDisposable.Dispose() => Shutdown(); + /// <summary>Verifies that every registered command reaches a dispatch arm.</summary> [Fact] public void EveryRegisteredCommand_ReachesADispatchArm() { @@ -117,6 +119,7 @@ public class DispatchCoverageTests : TestKit, IDisposable return Sys.ActorOf(Props.Create(() => new ManagementActor(rootProvider, logger))); } + /// <summary>Verifies that the probe detects a command without a dispatch arm.</summary> [Fact] public void Probe_DetectsACommandWithoutADispatchArm() { @@ -135,7 +138,9 @@ public class DispatchCoverageTests : TestKit, IDisposable // commands before DispatchCommand had a chance to select (or miss) their arm. private sealed class PassthroughTransaction(IServiceProvider provider) : IConfigurationCommandTransaction { + /// <summary>The number of operations dispatched through this transaction.</summary> public int DispatchCount { get; private set; } + /// <inheritdoc /> public Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation) { DispatchCount++; @@ -147,6 +152,7 @@ public class DispatchCoverageTests : TestKit, IDisposable /// "no dispatch arm" <see cref="NotSupportedException"/> signature.</summary> private sealed class SentinelException : Exception { + /// <summary>Initializes the exception with a fixed sentinel message.</summary> public SentinelException() : base("sentinel: scoped provider touched") { } } @@ -157,12 +163,24 @@ public class DispatchCoverageTests : TestKit, IDisposable /// </summary> private sealed class RecordingLogger<T> : ILogger<T> { + /// <summary>The exception logged for each correlation id.</summary> public ConcurrentDictionary<string, Exception> Captured { get; } = new(); + /// <inheritdoc /> IDisposable? ILogger.BeginScope<TState>(TState state) => null; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level being checked.</param> + /// <returns>Always <see langword="true"/>.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Captures the logged exception under its correlation id.</summary> + /// <typeparam name="TState">The type of the log state.</typeparam> + /// <param name="logLevel">The log level.</param> + /// <param name="eventId">The event id.</param> + /// <param name="state">The log state, searched for a correlation id.</param> + /// <param name="exception">The exception being logged, if any.</param> + /// <param name="formatter">The message formatter.</param> public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs index 79ce80a9..ef7168b4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs @@ -39,6 +39,7 @@ public class FlowDebugRunnerTests private readonly InboundScriptExecutor _executor = new(NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>()); private DateTimeOffset _clock = new(2026, 9, 5, 12, 0, 0, TimeSpan.Zero); + /// <summary>Initializes a new instance of the <see cref="FlowDebugRunnerTests"/> class with an active-node gate and a fake audit writer.</summary> public FlowDebugRunnerTests() { _gate.IsActiveNode.Returns(true); @@ -60,6 +61,9 @@ public class FlowDebugRunnerTests private static JsonElement Json(string s) => JsonDocument.Parse(s).RootElement.Clone(); + /// <summary>Verifies that a late completion behind the log cursor is still found despite site clock skew.</summary> + /// <param name="skewSeconds">The offset of the site clock relative to central, in seconds.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(0)] [InlineData(-3600)] @@ -108,6 +112,8 @@ public class FlowDebugRunnerTests Assert.Equal(3, result.Events.Count); } + /// <summary>Verifies that a value-change run pages through ties and queries older descendants beyond 64 execution ids.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunValueChange_PagesTiesAndQueriesOlderDescendantsBeyond64Ids() { @@ -159,6 +165,8 @@ public class FlowDebugRunnerTests Assert.All(grandchildren.Values, id => Assert.Contains(id, queried)); } + /// <summary>Verifies that replay uses the chain anchor even when the child clock precedes the root.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Replay_UsesChainAnchorEvenWhenChildClockPrecedesRoot() { @@ -173,6 +181,8 @@ public class FlowDebugRunnerTests Assert.Equal(root, result.RootExecutionId); } + /// <summary>Verifies that the total deadline of a value-change run includes the initial write.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunValueChange_TotalDeadlineIncludesInitialWrite() { @@ -191,6 +201,8 @@ public class FlowDebugRunnerTests Assert.Empty(puller.ReceivedCalls()); } + /// <summary>Verifies that the total deadline of a value-change run includes the audit write.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunValueChange_TotalDeadlineIncludesAuditWrite() { @@ -207,6 +219,8 @@ public class FlowDebugRunnerTests Assert.Empty(puller.ReceivedCalls()); } + /// <summary>Verifies that a value-change run caps the returned events and reports truncation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunValueChange_CapsReturnedEventsAndReportsTruncation() { @@ -220,6 +234,8 @@ public class FlowDebugRunnerTests Assert.InRange(result.Events.Count, 1, FlowTraceCollector.DefaultMaxEvents); } + /// <summary>Verifies that running a method on a standby node is refused with the standby code before the method is touched.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_OnStandby_RefusesWithStandbyCode_BeforeTouchingTheMethod() { @@ -231,6 +247,8 @@ public class FlowDebugRunnerTests await _apiRepo.DidNotReceiveWithAnyArgs().GetApiMethodByIdAsync(default, default); } + /// <summary>Verifies that invalid method parameters fail before any route call is made.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_InvalidParameters_FailsBeforeAnyRouteCall() { @@ -248,6 +266,8 @@ public class FlowDebugRunnerTests Assert.Empty(_central); } + /// <summary>Verifies that a successful method run returns the inbound row followed by the ordered sink events and writes the inbound row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_HappyPath_ReturnsInboundRow_ThenSinkEvents_Ordered_AndWritesTheInboundRow() { @@ -287,6 +307,8 @@ public class FlowDebugRunnerTests Assert.Contains(_central, e => e.AsRow().Kind == AuditKind.RouteCall); } + /// <summary>Verifies that a site clock behind central still returns the sink events in emission order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_SiteClockBehindCentral_StillReturnsTheSinkEventsInEmissionOrder() { @@ -312,6 +334,8 @@ public class FlowDebugRunnerTests Assert.Same(siteRun, result.Events[2]); } + /// <summary>Verifies that the inbound row carries the error detail when the method script throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_ScriptThrows_InboundRowCarriesErrorDetail() { @@ -336,6 +360,8 @@ public class FlowDebugRunnerTests Assert.StartsWith("System.InvalidOperationException: boom", returnedInbound.ErrorDetail); } + /// <summary>Verifies that running an unknown method reports not found.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_UnknownMethod_IsNotFound() { @@ -359,6 +385,8 @@ public class FlowDebugRunnerTests return (templates, sites, puller); } + /// <summary>Verifies that a traced value-change write is followed by polling until the spawned run settles.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunValueChange_TracedWrite_ThenPollsUntilSpawnedRunSettles() { @@ -396,6 +424,8 @@ public class FlowDebugRunnerTests Assert.True(calls >= 3, $"expected settle polls, got {calls}"); } + /// <summary>Verifies that a value-change run reports the no-trigger code when no run is spawned within the window.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunValueChange_NoSpawnWithinWindow_ReportsNoTriggerCode() { @@ -410,6 +440,8 @@ public class FlowDebugRunnerTests Assert.Single(result.Events); // just the synthesized write row } + /// <summary>Verifies that a value-change run asks the site for its full page of rows rather than the old 500-row cap.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunValueChange_AsksTheSiteForItsFullPage_NotTheOldFiveHundredRowCap() { @@ -431,6 +463,8 @@ public class FlowDebugRunnerTests Assert.Equal(2000, FlowDebugRunner.PullTraceEventsMax); } + /// <summary>Verifies that a value-change run against an out-of-scope site is refused.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunValueChange_OutOfScopeSite_IsRefused() { @@ -441,6 +475,8 @@ public class FlowDebugRunnerTests await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default); } + /// <summary>Verifies that replay threads the site scope through and retains a truncated root that has no rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Replay_ThreadsScopeAndRetainsTruncatedRootWithoutRows() { @@ -453,6 +489,8 @@ public class FlowDebugRunnerTests Assert.True(result.Completed); Assert.True(result.Truncated); Assert.Equal(root, result.RootExecutionId); } + /// <summary>Verifies that replay returns the repository rows with the root taken from the first row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Replay_ReturnsRepositoryRows_WithRootFromTheFirstRow() { @@ -504,6 +542,8 @@ public class FlowDebugRunnerTests return sites; } + /// <summary>Verifies that a site-scoped deployer routing to another site is unauthorized and the inbound row is still written.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_ScopedDeployer_RouteToOtherSite_IsUnauthorized_AndInboundRowIsWritten() { @@ -519,6 +559,8 @@ public class FlowDebugRunnerTests Assert.Contains(_central, e => e.AsRow().Kind == AuditKind.InboundRequest && e.Actor == "ops"); } + /// <summary>Verifies that a site-scoped deployer routing within its scope runs the method.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_ScopedDeployer_RouteWithinScope_Runs() { @@ -533,6 +575,8 @@ public class FlowDebugRunnerTests await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default); } + /// <summary>Verifies that an administrator is never restricted by site scope.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_Administrator_IsNeverScoped() { @@ -546,6 +590,8 @@ public class FlowDebugRunnerTests await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default); } + /// <summary>Verifies that a user without site scoping is never restricted by site scope.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_UnscopedUser_IsNeverScoped() { @@ -559,6 +605,7 @@ public class FlowDebugRunnerTests await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default); } + /// <summary>Verifies that the three run commands are restricted to the Deployer role.</summary> [Fact] public void RoleGate_TheThreeRunCommands_AreDeployerOnly() { @@ -572,6 +619,7 @@ public class FlowDebugRunnerTests Assert.Equal(new[] { "Deployer" }, ManagementActor.GetRequiredRoles(cmd)); } + /// <summary>Verifies that replay also accepts the operational audit roles.</summary> [Fact] public void RoleGate_Replay_AlsoAcceptsTheOperationalAuditRoles() { @@ -612,6 +660,8 @@ public class FlowDebugRunnerTests AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered, executionId: Guid.NewGuid(), target: "work"); + /// <summary>Verifies that sink events are redacted when a redactor is registered.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_SinkEvents_AreRedacted_WhenARedactorIsRegistered() { @@ -632,6 +682,8 @@ public class FlowDebugRunnerTests Assert.NotEqual(written.DetailsJson, result.Events[0].DetailsJson); // the raw row still reaches the writer } + /// <summary>Verifies that the synthesized root row of a value-change run is redacted when a redactor is registered.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunValueChange_SynthesizedRootRow_IsRedacted_WhenARedactorIsRegistered() { @@ -648,6 +700,8 @@ public class FlowDebugRunnerTests Assert.NotEqual(written.DetailsJson, root.DetailsJson); } + /// <summary>Verifies that a failed value-change write returns the redacted root row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunValueChange_FailedWrite_ReturnsTheRedactedRootRow() { @@ -667,6 +721,8 @@ public class FlowDebugRunnerTests Assert.NotEqual(written.DetailsJson, root.DetailsJson); } + /// <summary>Verifies that a method run over the event cap reports truncation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_OverCap_ReportsTruncated() { @@ -680,6 +736,8 @@ public class FlowDebugRunnerTests Assert.False(result.LogsTruncated); // these are full-priority step rows, not log lines } + /// <summary>Verifies that a method run with no log rows does not report truncated logs.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_NoLogRows_LogsTruncatedIsFalse() { @@ -693,6 +751,8 @@ public class FlowDebugRunnerTests Assert.False(result.Truncated); } + /// <summary>Verifies that exceeding the low-priority reserve reports truncated logs but not truncated events.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunMethod_OverLowPriorityReserve_ReportsLogsTruncated_ButNotTruncated() { @@ -736,6 +796,8 @@ public class FlowDebugRunnerTests private static ResolvedScript WriteScript(string name, string kind, string attribute) => new() { CanonicalName = name, Code = "return 1;", TriggerType = kind, TriggerConfiguration = $$"""{"attributeName":"{{attribute}}"}""" }; + /// <summary>Verifies that a write-kind script run writes the trigger attribute and then polls for completion.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_WriteKind_WritesTheTriggerAttribute_ThenPolls() { @@ -773,6 +835,8 @@ public class FlowDebugRunnerTests await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default); } + /// <summary>Verifies that running a script on a standby node is refused with the standby code before the instance is touched.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_OnStandby_RefusesWithStandbyCode_BeforeTouchingTheInstance() { @@ -788,6 +852,8 @@ public class FlowDebugRunnerTests await pipeline.DidNotReceiveWithAnyArgs().FlattenAndValidateAsync(default, default, default, default); } + /// <summary>Verifies that a write-kind script run with an unreadable trigger configuration fails validation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_WriteKind_UnreadableTriggerConfig_FailsValidation() { @@ -806,6 +872,8 @@ public class FlowDebugRunnerTests await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default); } + /// <summary>Verifies that a write-kind script run without a value fails validation before any write.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_WriteKind_WithoutValue_FailsValidation_BeforeAnyWrite() { @@ -816,6 +884,8 @@ public class FlowDebugRunnerTests await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default); } + /// <summary>Verifies that a run of a sibling script does not count as completion of a write-kind script run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_WriteKind_SiblingScriptRun_DoesNotCountAsCompletion() { @@ -838,6 +908,8 @@ public class FlowDebugRunnerTests Assert.Equal("OtherScript", result.Events[1].Target); } + /// <summary>Verifies that a call-kind script run routes a traced call and returns the root route-call row followed by the site rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_CallKind_RoutesATracedCall_AndReturnsRootRouteCallRow_ThenSiteRows() { @@ -875,6 +947,8 @@ public class FlowDebugRunnerTests Assert.Single(_central); // the root row was written centrally } + /// <summary>Verifies that a call-kind script run with invalid parameters fails before routing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_CallKind_InvalidParameters_FailsBeforeRouting() { @@ -886,6 +960,8 @@ public class FlowDebugRunnerTests Assert.Empty(_central); } + /// <summary>Verifies that a site failure in a call-kind script run reports call failed with the root row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_CallKind_SiteFailure_ReportsCallFailed_WithTheRootRow() { @@ -903,6 +979,8 @@ public class FlowDebugRunnerTests Assert.Equal(AuditStatus.Failed, result.Events[0].AsRow().Status); } + /// <summary>Verifies that running an unknown script reports not found.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_UnknownScript_IsNotFound() { @@ -913,6 +991,8 @@ public class FlowDebugRunnerTests Assert.Contains("Nope", ex.Message); } + /// <summary>Verifies that running a script on an out-of-scope site is refused before flattening.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_OutOfScopeSite_IsRefused_BeforeFlattening() { @@ -923,6 +1003,8 @@ public class FlowDebugRunnerTests await pipeline.DidNotReceiveWithAnyArgs().FlattenAndValidateAsync(default, default, default, default); } + /// <summary>Verifies that an unserializable return value in a call-kind script run still writes the root row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_CallKind_UnserializableReturnValue_StillWritesTheRootRow() { @@ -945,6 +1027,8 @@ public class FlowDebugRunnerTests Assert.Equal(AuditKind.RouteCall, written.AsRow().Kind); } + /// <summary>Verifies that a transport fault in a call-kind script run still writes the root row as call failed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_CallKind_TransportFault_StillWritesTheRootRow_AsCallFailed() { @@ -968,6 +1052,8 @@ public class FlowDebugRunnerTests Assert.Equal(AuditKind.RouteCall, written.AsRow().Kind); } + /// <summary>Verifies that an ask timeout in a call-kind script run reports the timeout code.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_CallKind_AskTimeout_ReportsTimeoutCode() { @@ -995,6 +1081,8 @@ public class FlowDebugRunnerTests throw new InvalidOperationException("unreachable"); } + /// <summary>Verifies that a timeout in a call-kind script run reports the timeout code.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_CallKind_Timeout_ReportsTimeoutCode() { @@ -1018,6 +1106,8 @@ public class FlowDebugRunnerTests Assert.Equal(AuditStatus.Failed, written.AsRow().Status); } + /// <summary>Verifies that the root row of a call-kind script run is redacted when a redactor is registered.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunScript_CallKind_RootRow_IsRedacted_WhenARedactorIsRegistered() { @@ -1038,6 +1128,9 @@ public class FlowDebugRunnerTests /// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary> private sealed class UpperRedactor : IAuditRedactor { + /// <summary>Returns a copy of the event with its details upper-cased.</summary> + /// <param name="rawEvent">The event to filter.</param> + /// <returns>The filtered event.</returns> public AuditEvent Apply(AuditEvent rawEvent) => rawEvent with { DetailsJson = rawEvent.DetailsJson?.ToUpperInvariant() }; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/HttpCommandEligibilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/HttpCommandEligibilityTests.cs index 67068b7e..59b817be 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/HttpCommandEligibilityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/HttpCommandEligibilityTests.cs @@ -4,6 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; public class HttpCommandEligibilityTests { + /// <summary>Verifies that parsing rejects commands that are internal contracts and not exposed over HTTP.</summary> + /// <param name="command">The internal command name to parse.</param> [Theory] [InlineData("ReadTagValues")] [InlineData("ResolveRoles")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorSupervisionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorSupervisionTests.cs index de77d998..2b9f1782 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorSupervisionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorSupervisionTests.cs @@ -4,12 +4,12 @@ using ZB.MOM.WW.ScadaBridge.ManagementService; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// <summary> -/// Tests for the <see cref="ManagementActor"/> supervision strategy -/// (finding ManagementService-005). The project convention is that long-lived +/// Tests for the <see cref="ManagementActor"/> supervision strategy. The project convention is that long-lived /// coordinator-style actors declare an explicit Resume-based strategy. /// </summary> public class ManagementActorSupervisionTests { + /// <summary>Verifies that the supervisor strategy is a one-for-one strategy.</summary> [Fact] public void CreateSupervisorStrategy_ReturnsOneForOneStrategy() { @@ -18,6 +18,7 @@ public class ManagementActorSupervisionTests Assert.IsType<OneForOneStrategy>(strategy); } + /// <summary>Verifies that the supervisor strategy resumes on an arbitrary exception.</summary> [Fact] public void CreateSupervisorStrategy_ResumesOnArbitraryException() { @@ -28,6 +29,7 @@ public class ManagementActorSupervisionTests Assert.Equal(Directive.Resume, directive); } + /// <summary>Verifies that the supervisor strategy resumes indefinitely.</summary> [Fact] public void CreateSupervisorStrategy_ResumesIndefinitely() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 25cc6411..66badb49 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -37,6 +37,7 @@ public class ManagementActorTests : TestKit, IDisposable private readonly ServiceCollection _services; private ArtifactDeploymentService _artifactService = null!; + /// <summary>Initializes a new instance of the <c>ManagementActorTests</c> class.</summary> public ManagementActorTests() { _templateRepo = Substitute.For<ITemplateEngineRepository>(); @@ -94,6 +95,7 @@ public class ManagementActorTests : TestKit, IDisposable Result<ArtifactDeploymentSummary>.Success( new ArtifactDeploymentSummary("deploy-1", Array.Empty<SiteArtifactResult>(), 1, 0)); + /// <inheritdoc /> void IDisposable.Dispose() { Shutdown(); @@ -103,6 +105,7 @@ public class ManagementActorTests : TestKit, IDisposable // 1. Authorization test -- admin command with wrong role // ======================================================================== + /// <summary>Verifies that create site command, when with design role, returns unauthorized.</summary> [Fact] public void CreateSiteCommand_WithDesignRole_ReturnsUnauthorized() { @@ -116,6 +119,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that create site command, when with no roles, returns unauthorized.</summary> [Fact] public void CreateSiteCommand_WithNoRoles_ReturnsUnauthorized() { @@ -134,6 +138,7 @@ public class ManagementActorTests : TestKit, IDisposable // to the two-person Secured Write roles + Administrator (any-of). // ======================================================================== + /// <summary>Verifies that list secured writes, when viewer only, unauthorized.</summary> [Fact] public void ListSecuredWrites_ViewerOnly_Unauthorized() { @@ -142,6 +147,8 @@ public class ManagementActorTests : TestKit, IDisposable ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that list secured writes, when secured write roles, allowed.</summary> + /// <param name="role">The role.</param> [Theory] [InlineData("Operator")] [InlineData("Verifier")] @@ -161,6 +168,7 @@ public class ManagementActorTests : TestKit, IDisposable ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that deployment command, when with design role, returns unauthorized.</summary> [Fact] public void DeploymentCommand_WithDesignRole_ReturnsUnauthorized() { @@ -173,10 +181,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Deployer", response.Message); } + /// <summary>Verifies that query audit log command, when with no roles, returns unauthorized.</summary> [Fact] public void QueryAuditLogCommand_WithNoRoles_ReturnsUnauthorized() { - // ManagementService-018: QueryAuditLogCommand used to fall through to the + // QueryAuditLogCommand used to fall through to the // default "any authenticated user" case, allowing a Deployment-only or // no-role caller to read the configuration audit log via /management // even though /api/audit/query enforces OperationalAuditRoles. The fix @@ -191,6 +200,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Administrator", response.Message); } + /// <summary>Verifies that query audit log command, when with deployment role, returns unauthorized.</summary> [Fact] public void QueryAuditLogCommand_WithDeploymentRole_ReturnsUnauthorized() { @@ -207,6 +217,7 @@ public class ManagementActorTests : TestKit, IDisposable // 2. Read-only query passes without special role // ======================================================================== + /// <summary>Verifies that list templates command, when with no roles, returns success.</summary> [Fact] public void ListTemplatesCommand_WithNoRoles_ReturnsSuccess() { @@ -223,6 +234,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that list sites command, when with design role, returns success.</summary> [Fact] public void ListSitesCommand_WithDesignRole_ReturnsSuccess() { @@ -324,6 +336,7 @@ public class ManagementActorTests : TestKit, IDisposable // 3. Template list test -- verify data flows through // ======================================================================== + /// <summary>Verifies that list templates command: returns template data.</summary> [Fact] public void ListTemplatesCommand_ReturnsTemplateData() { @@ -351,6 +364,7 @@ public class ManagementActorTests : TestKit, IDisposable // 4. Instance create test // ======================================================================== + /// <summary>Verifies that create instance command, when with deployment role, returns success.</summary> [Fact] public void CreateInstanceCommand_WithDeploymentRole_ReturnsSuccess() { @@ -389,10 +403,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Pump1", response.JsonData); } + /// <summary>Verifies that set native alarm source override, when on locked source, returns management error.</summary> [Fact] public void SetNativeAlarmSourceOverride_OnLockedSource_ReturnsManagementError() { - // #05-T13: the ManagementActor must reject an override targeting a source + // The ManagementActor must reject an override targeting a source // that is locked at the template level, before persisting anything. var pipeline = Substitute.For<IFlatteningPipeline>(); var flattened = new FlattenedConfiguration @@ -426,6 +441,7 @@ public class ManagementActorTests : TestKit, IDisposable // 5. Error handling test -- service throws exception // ======================================================================== + /// <summary>Verifies that list templates command, when repo throws, returns management error.</summary> [Fact] public void ListTemplatesCommand_WhenRepoThrows_ReturnsManagementError() { @@ -441,11 +457,12 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); Assert.Equal("COMMAND_FAILED", response.ErrorCode); // An unanticipated repository fault must NOT leak its raw message to the - // caller (finding ManagementService-016); a generic message is returned. + // caller; a generic message is returned. Assert.DoesNotContain("Database connection lost", response.Error); Assert.Contains(envelope.CorrelationId, response.Error); } + /// <summary>Verifies that create instance command, when service fails, returns management error.</summary> [Fact] public void CreateInstanceCommand_WhenServiceFails_ReturnsManagementError() { @@ -469,6 +486,7 @@ public class ManagementActorTests : TestKit, IDisposable // Role boundary tests -- verify each category // ======================================================================== + /// <summary>Verifies that design command, when with admin role, returns unauthorized.</summary> [Fact] public void DesignCommand_WithAdminRole_ReturnsUnauthorized() { @@ -484,6 +502,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Designer", response.Message); } + /// <summary>Verifies that admin command, when with admin role, succeeds.</summary> [Fact] public void AdminCommand_WithAdminRole_Succeeds() { @@ -505,6 +524,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that role check: is case insensitive.</summary> [Fact] public void RoleCheck_IsCaseInsensitive() { @@ -531,6 +551,7 @@ public class ManagementActorTests : TestKit, IDisposable // New command authorization tests // ======================================================================== + /// <summary>Verifies that shared script create, when with admin role, returns unauthorized.</summary> [Fact] public void SharedScriptCreate_WithAdminRole_ReturnsUnauthorized() { @@ -543,6 +564,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Designer", response.Message); } + /// <summary>Verifies that database connection create, when with deployment role, returns unauthorized.</summary> [Fact] public void DatabaseConnectionCreate_WithDeploymentRole_ReturnsUnauthorized() { @@ -555,6 +577,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Designer", response.Message); } + /// <summary>Verifies that api method create, when with admin role, returns unauthorized.</summary> [Fact] public void ApiMethodCreate_WithAdminRole_ReturnsUnauthorized() { @@ -567,6 +590,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Designer", response.Message); } + /// <summary>Verifies that update api method, when non compiling script, succeeds but returns compile warning.</summary> [Fact] public void UpdateApiMethod_NonCompilingScript_SucceedsButReturnsCompileWarning() { @@ -599,6 +623,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("failed to compile", response.JsonData); } + /// <summary>Verifies that add template attribute, when with deployment role, returns unauthorized.</summary> [Fact] public void AddTemplateAttribute_WithDeploymentRole_ReturnsUnauthorized() { @@ -612,9 +637,10 @@ public class ManagementActorTests : TestKit, IDisposable } // ======================================================================== - // MV-10: ElementDataType accept + validate on attribute add/update + // ElementDataType accept + validate on attribute add/update // ======================================================================== + /// <summary>Verifies that add list attribute, when with string element type, persists both columns.</summary> [Fact] public void AddListAttribute_WithStringElementType_PersistsBothColumns() { @@ -647,6 +673,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("[\"a\",\"b\"]", saved.Value); } + /// <summary>Verifies that add list attribute, when with no element type, returns management error.</summary> [Fact] public void AddListAttribute_WithNoElementType_ReturnsManagementError() { @@ -663,6 +690,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("requires a valid element type", response.Error); } + /// <summary>Verifies that add list attribute, when with binary element type, returns management error.</summary> [Fact] public void AddListAttribute_WithBinaryElementType_ReturnsManagementError() { @@ -679,6 +707,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("requires a valid element type", response.Error); } + /// <summary>Verifies that add scalar attribute, when with element type, returns management error.</summary> [Fact] public void AddScalarAttribute_WithElementType_ReturnsManagementError() { @@ -695,6 +724,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("only valid on List attributes", response.Error); } + /// <summary>Verifies that add list attribute, when with malformed default, returns management error.</summary> [Fact] public void AddListAttribute_WithMalformedDefault_ReturnsManagementError() { @@ -711,6 +741,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("invalid list value", response.Error); } + /// <summary>Verifies that add list attribute, when with type mismatched default, returns management error.</summary> [Fact] public void AddListAttribute_WithTypeMismatchedDefault_ReturnsManagementError() { @@ -727,6 +758,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("invalid list value", response.Error); } + /// <summary>Verifies that update scalar attribute, when with element type, returns management error.</summary> [Fact] public void UpdateScalarAttribute_WithElementType_ReturnsManagementError() { @@ -745,10 +777,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("only valid on List attributes", response.Error); } + /// <summary>Verifies that add attribute, when with unrecognised data type, returns curated management error.</summary> [Fact] public void AddAttribute_WithUnrecognisedDataType_ReturnsCuratedManagementError() { - // MV-10 review fix: a bogus DataType string used to throw ArgumentException + // A bogus DataType string used to throw ArgumentException // from Enum.Parse, which MapFault masks as a generic "An internal error // occurred" message. TryParse now raises a curated ManagementCommandException // whose message names the offending token. @@ -768,7 +801,7 @@ public class ManagementActorTests : TestKit, IDisposable } // ======================================================================== - // P6-10 (register row-88 class): strict single-value enum parsing in + // Strict single-value enum parsing in // ParseDataType / ParseElementDataType / the AlarmTriggerType parse sites. // Raw Enum.Parse/TryParse admits ordinals ("2"), undefined values ("99" — // a deploy-time bomb that saves fine and only detonates at deploy), and @@ -779,6 +812,8 @@ public class ManagementActorTests : TestKit, IDisposable // ArgumentException) with a curated, field-naming error. // ======================================================================== + /// <summary>Verifies that add attribute, when with non name data type token, returns curated error.</summary> + /// <param name="token">The token.</param> [Theory] [InlineData("2")] // ordinal for a DEFINED member (Float) [InlineData("99")] // undefined ordinal — the deploy-time-bomb case @@ -808,11 +843,12 @@ public class ManagementActorTests : TestKit, IDisposable Assert.DoesNotContain("internal error", response.Error); } + /// <summary>Verifies that add attribute, when with typo data type, returns curated error naming valid values.</summary> [Fact] public void AddAttribute_WithTypoDataType_ReturnsCuratedErrorNamingValidValues() { // The pre-fix ParseDataType already curated a plain typo (that part of - // MV-10 was correct), but its message named no valid set — this asserts + // the earlier fix was correct), but its message named no valid set — this asserts // the new "Valid values:" suffix, which is absent pre-fix. _services.AddScoped<TemplateService>(); var actor = CreateActor(); @@ -829,6 +865,8 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("List", response.Error); } + /// <summary>Verifies that add attribute, when with defined data type name, parses and proceeds past guard.</summary> + /// <param name="name">The name.</param> [Theory] [InlineData("Boolean")] [InlineData("Int32")] @@ -856,6 +894,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Template with ID 1 not found", response.Error); } + /// <summary>Verifies that add attribute, when with defined list data type name and element type, parses and proceeds past guard.</summary> [Fact] public void AddAttribute_WithDefinedListDataTypeNameAndElementType_ParsesAndProceedsPastGuard() { @@ -871,6 +910,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Template with ID 1 not found", response.Error); } + /// <summary>Verifies that update attribute, when with comma joined data type, returns curated error.</summary> [Fact] public void UpdateAttribute_WithCommaJoinedDataType_ReturnsCuratedError() { @@ -887,6 +927,8 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Valid values:", response.Error); } + /// <summary>Verifies that add list attribute, when with non name element type token, returns curated error.</summary> + /// <param name="token">The token.</param> [Theory] [InlineData("2")] // ordinal for a DEFINED, valid element type (Float) [InlineData("99")] // undefined ordinal @@ -912,6 +954,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Valid values:", response.Error); } + /// <summary>Verifies that add list attribute, when with typo element type, returns curated error naming valid values.</summary> [Fact] public void AddListAttribute_WithTypoElementType_ReturnsCuratedErrorNamingValidValues() { @@ -928,6 +971,8 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Valid values:", response.Error); } + /// <summary>Verifies that add alarm, when with non name trigger type token, returns curated error.</summary> + /// <param name="token">The token.</param> [Theory] [InlineData("0")] // ordinal for a DEFINED member (ValueMatch) [InlineData("99")] // undefined ordinal — the deploy-time-bomb case @@ -955,11 +1000,12 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Valid values:", response.Error); } + /// <summary>Verifies that add alarm, when with typo trigger type, returns curated error instead of masked internal error.</summary> [Fact] public void AddAlarm_WithTypoTriggerType_ReturnsCuratedErrorInsteadOfMaskedInternalError() { // PROOF OF FAILURE (pre-fix): unlike ParseDataType (already curated by - // MV-10), the TriggerType sites used raw `Enum.Parse`. A typo's + // the earlier fix), the TriggerType sites used raw `Enum.Parse`. A typo's // ArgumentException is uncaught by any handler-local guard and reaches // MapFault as an unanticipated fault — masked to a generic // "An internal error occurred (CorrelationId=...)" message, exactly the @@ -980,6 +1026,8 @@ public class ManagementActorTests : TestKit, IDisposable Assert.DoesNotContain("internal error", response.Error); } + /// <summary>Verifies that add alarm, when with defined trigger type name, parses and proceeds past guard.</summary> + /// <param name="name">The name.</param> [Theory] [InlineData("ValueMatch")] [InlineData("RangeViolation")] @@ -1000,6 +1048,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Template with ID 1 not found", response.Error); } + /// <summary>Verifies that update alarm, when with ordinal trigger type, returns curated error.</summary> [Fact] public void UpdateAlarm_WithOrdinalTriggerType_ReturnsCuratedError() { @@ -1016,6 +1065,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Valid values:", response.Error); } + /// <summary>Verifies that add template alarm: carries is mes reportable.</summary> [Fact] public void AddTemplateAlarm_CarriesIsMesReportable() { @@ -1048,6 +1098,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.True(added!.IsMesReportable); } + /// <summary>Verifies that update template alarm: carries is mes reportable.</summary> [Fact] public void UpdateTemplateAlarm_CarriesIsMesReportable() { @@ -1083,6 +1134,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.True(updated!.IsMesReportable); } + /// <summary>Verifies that set instance alarm override: passes is mes reportable override.</summary> [Fact] public void SetInstanceAlarmOverride_PassesIsMesReportableOverride() { @@ -1122,10 +1174,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.False(persisted!.IsMesReportableOverride); } + /// <summary>Verifies that update attribute, when valid scalar, persists value and keeps fixed fields.</summary> [Fact] public void UpdateAttribute_ValidScalar_PersistsValueAndKeepsFixedFields() { - // MV-10 review fix: documents the fixed-field contract on update — + // Documents the fixed-field contract on update — // ValidateAttributeOverride(existing, proposed) runs for every update, so // a matching DataType/ElementDataType lets the Value change persist while // the fixed type columns are never copied from the proposed attribute. @@ -1162,6 +1215,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Null(updated.ElementDataType); // fixed } + /// <summary>Verifies that update api key, when with design role, returns unauthorized.</summary> [Fact] public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized() { @@ -1174,6 +1228,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Administrator", response.Message); } + /// <summary>Verifies that add scope rule, when with design role, returns unauthorized.</summary> [Fact] public void AddScopeRule_WithDesignRole_ReturnsUnauthorized() { @@ -1186,6 +1241,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Administrator", response.Message); } + /// <summary>Verifies that update area, when with admin role, returns unauthorized.</summary> [Fact] public void UpdateArea_WithAdminRole_ReturnsUnauthorized() { @@ -1202,6 +1258,7 @@ public class ManagementActorTests : TestKit, IDisposable // New command read-only query tests (no role required) // ======================================================================== + /// <summary>Verifies that list shared scripts, when with no roles, returns success.</summary> [Fact] public void ListSharedScripts_WithNoRoles_ReturnsSuccess() { @@ -1218,6 +1275,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that list database connections, when with no roles, returns success.</summary> [Fact] public void ListDatabaseConnections_WithNoRoles_ReturnsSuccess() { @@ -1235,6 +1293,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that list api methods, when with no roles, returns success.</summary> [Fact] public void ListApiMethods_WithNoRoles_ReturnsSuccess() { @@ -1252,6 +1311,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that list scope rules, when with admin role, returns success.</summary> [Fact] public void ListScopeRules_WithAdminRole_ReturnsSuccess() { @@ -1273,6 +1333,7 @@ public class ManagementActorTests : TestKit, IDisposable // New command error handling tests // ======================================================================== + /// <summary>Verifies that list database connections, when repo throws, returns error.</summary> [Fact] public void ListDatabaseConnections_WhenRepoThrows_ReturnsError() { @@ -1288,13 +1349,13 @@ public class ManagementActorTests : TestKit, IDisposable var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5)); Assert.Equal("COMMAND_FAILED", response.ErrorCode); - // Raw repository fault text is not leaked (finding ManagementService-016). + // Raw repository fault text is not leaked. Assert.DoesNotContain("Connection refused", response.Error); Assert.Contains(envelope.CorrelationId, response.Error); } // ======================================================================== - // Site-scope enforcement tests (findings ManagementService-001 / -002) + // Site-scope enforcement tests // // A site-scoped Deployment user (PermittedSiteIds set, no Admin role) must // not be able to read or act on entities belonging to a site outside their @@ -1312,6 +1373,7 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => siteRepo); } + /// <summary>Verifies that get instance, when out of scope for site scoped user, returns unauthorized.</summary> [Fact] public void GetInstance_OutOfScopeForSiteScopedUser_ReturnsUnauthorized() { @@ -1328,6 +1390,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that get instance, when in scope for site scoped user, returns success.</summary> [Fact] public void GetInstance_InScopeForSiteScopedUser_ReturnsSuccess() { @@ -1353,6 +1416,7 @@ public class ManagementActorTests : TestKit, IDisposable // untouched — they still get the handler's ordinary "not found". // ======================================================================== + /// <summary>Verifies that get instance, when nonexistent id for site scoped user, returns unauthorized.</summary> [Fact] public void GetInstance_NonexistentIdForSiteScopedUser_ReturnsUnauthorized() { @@ -1370,6 +1434,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.DoesNotContain("exist", response.Message, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that get instance, when nonexistent id for site scoped user, is indistinguishable from out of scope.</summary> [Fact] public void GetInstance_NonexistentIdForSiteScopedUser_IsIndistinguishableFromOutOfScope() { @@ -1390,6 +1455,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(outOfScope.GetType(), nonexistent.GetType()); } + /// <summary>Verifies that get instance, when nonexistent id for admin user, is unchanged.</summary> [Fact] public void GetInstance_NonexistentIdForAdminUser_IsUnchanged() { @@ -1404,6 +1470,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.IsNotType<ManagementUnauthorized>(response); } + /// <summary>Verifies that get instance, when nonexistent id for system wide deployer, is unchanged.</summary> [Fact] public void GetInstance_NonexistentIdForSystemWideDeployer_IsUnchanged() { @@ -1418,6 +1485,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.IsNotType<ManagementUnauthorized>(response); } + /// <summary>Verifies that get instance, when in scope id for site scoped user, is still allowed.</summary> [Fact] public void GetInstance_InScopeIdForSiteScopedUser_IsStillAllowed() { @@ -1433,6 +1501,7 @@ public class ManagementActorTests : TestKit, IDisposable ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that get site, when nonexistent id for site scoped user, returns unauthorized.</summary> [Fact] public void GetSite_NonexistentIdForSiteScopedUser_ReturnsUnauthorized() { @@ -1453,6 +1522,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that get site, when nonexistent id for admin user, is unchanged.</summary> [Fact] public void GetSite_NonexistentIdForAdminUser_IsUnchanged() { @@ -1470,6 +1540,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.IsNotType<ManagementUnauthorized>(response); } + /// <summary>Verifies that get data connection, when nonexistent id for site scoped user, returns unauthorized.</summary> [Fact] public void GetDataConnection_NonexistentIdForSiteScopedUser_ReturnsUnauthorized() { @@ -1488,6 +1559,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that query event logs, when nonexistent site identifier for site scoped user, returns unauthorized.</summary> [Fact] public void QueryEventLogs_NonexistentSiteIdentifierForSiteScopedUser_ReturnsUnauthorized() { @@ -1510,6 +1582,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that query event logs, when in scope site identifier for site scoped user, is still allowed.</summary> [Fact] public void QueryEventLogs_InScopeSiteIdentifierForSiteScopedUser_IsStillAllowed() { @@ -1529,6 +1602,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.IsNotType<ManagementUnauthorized>(response); } + /// <summary>Verifies that get site, when out of scope for site scoped user, returns unauthorized.</summary> [Fact] public void GetSite_OutOfScopeForSiteScopedUser_ReturnsUnauthorized() { @@ -1543,6 +1617,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that list areas, when out of scope for site scoped user, returns unauthorized.</summary> [Fact] public void ListAreas_OutOfScopeForSiteScopedUser_ReturnsUnauthorized() { @@ -1561,6 +1636,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that get data connection, when out of scope for site scoped user, returns unauthorized.</summary> [Fact] public void GetDataConnection_OutOfScopeForSiteScopedUser_ReturnsUnauthorized() { @@ -1578,6 +1654,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that get site, when out of scope for admin user, returns success.</summary> [Fact] public void GetSite_OutOfScopeForAdminUser_ReturnsSuccess() { @@ -1592,6 +1669,7 @@ public class ManagementActorTests : TestKit, IDisposable ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that query event logs, when out of scope for site scoped user, returns unauthorized.</summary> [Fact] public void QueryEventLogs_OutOfScopeForSiteScopedUser_ReturnsUnauthorized() { @@ -1607,6 +1685,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that query parked messages, when out of scope for site scoped user, returns unauthorized.</summary> [Fact] public void QueryParkedMessages_OutOfScopeForSiteScopedUser_ReturnsUnauthorized() { @@ -1621,6 +1700,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that retry parked message, when out of scope for site scoped user, returns unauthorized.</summary> [Fact] public void RetryParkedMessage_OutOfScopeForSiteScopedUser_ReturnsUnauthorized() { @@ -1635,6 +1715,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that discard parked message, when out of scope for site scoped user, returns unauthorized.</summary> [Fact] public void DiscardParkedMessage_OutOfScopeForSiteScopedUser_ReturnsUnauthorized() { @@ -1649,6 +1730,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that debug snapshot, when out of scope for site scoped user, returns unauthorized.</summary> [Fact] public void DebugSnapshot_OutOfScopeForSiteScopedUser_ReturnsUnauthorized() { @@ -1668,11 +1750,11 @@ public class ManagementActorTests : TestKit, IDisposable // ======================================================================== // Site scoping: list filtering + resolve-before-scope oracles - // (arch-review 2026-08-19 P6-4 + P6-5) + // (arch-review 2026-08-19) // ======================================================================== /// <summary> - /// P6-4: the unfiltered list used to return EVERY site's connections to any + /// The unfiltered list used to return EVERY site's connections to any /// authenticated caller, handing a scoped caller exactly the rows /// <c>GetDataConnection</c> denies them. /// </summary> @@ -1696,6 +1778,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.DoesNotContain("ConnB", response.JsonData); } + /// <summary>Verifies that list data connections, when system wide user, returns every connection.</summary> [Fact] public void ListDataConnections_SystemWideUser_ReturnsEveryConnection() { @@ -1716,6 +1799,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("ConnB", response.JsonData); } + /// <summary>Verifies that list data connections, when scoped administrator, returns every connection.</summary> [Fact] public void ListDataConnections_ScopedAdministrator_ReturnsEveryConnection() { @@ -1738,6 +1822,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("ConnB", response.JsonData); } + /// <summary>Verifies that list data connections, when explicit out of scope site filter, returns unauthorized.</summary> [Fact] public void ListDataConnections_ExplicitOutOfScopeSiteFilter_ReturnsUnauthorized() { @@ -1756,7 +1841,7 @@ public class ManagementActorTests : TestKit, IDisposable } /// <summary> - /// P6-5: the not-found check used to run before the scope check, so an + /// The not-found check used to run before the scope check, so an /// out-of-scope caller could tell a real instance id from a fake one by the /// error they got back. Both cases must now answer with the same denial. /// </summary> @@ -1775,6 +1860,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + /// <summary>Verifies that debug snapshot, when nonexistent instance for system wide user, still reports not found.</summary> [Fact] public void DebugSnapshot_NonexistentInstanceForSystemWideUser_StillReportsNotFound() { @@ -1790,7 +1876,7 @@ public class ManagementActorTests : TestKit, IDisposable } // ======================================================================== - // Serialization (finding ManagementService-007) + // Serialization // // Command results are serialized with System.Text.Json configured with // ReferenceHandler.IgnoreCycles, so an entity graph with a bidirectional @@ -1800,11 +1886,15 @@ public class ManagementActorTests : TestKit, IDisposable private sealed class CyclicNode { + /// <summary>Gets or sets the name.</summary> public string Name { get; set; } = ""; + /// <summary>Gets or sets the parent.</summary> public CyclicNode? Parent { get; set; } + /// <summary>Gets or sets the children.</summary> public List<CyclicNode> Children { get; set; } = new(); } + /// <summary>Verifies that serialize result, when with cyclic graph, does not throw.</summary> [Fact] public void SerializeResult_WithCyclicGraph_DoesNotThrow() { @@ -1818,6 +1908,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Child", json); } + /// <summary>Verifies that serialize result: uses camel case property names.</summary> [Fact] public void SerializeResult_UsesCamelCasePropertyNames() { @@ -1828,7 +1919,7 @@ public class ManagementActorTests : TestKit, IDisposable } // ======================================================================== - // PipeTo fault mapping (finding ManagementService-004) + // PipeTo fault mapping // // Command processing is piped back via PipeTo; a fault raised inside // DispatchCommand must be mapped to ManagementError by the failure @@ -1836,7 +1927,7 @@ public class ManagementActorTests : TestKit, IDisposable // ======================================================================== // ======================================================================== - // ResolveRolesCommand dead-code removal (finding ManagementService-011 / -008) + // ResolveRolesCommand dead-code removal // // The two-step ResolveRoles + command flow is retired: the HTTP endpoint does // LDAP auth and role resolution itself. The actor must no longer dispatch @@ -1844,6 +1935,7 @@ public class ManagementActorTests : TestKit, IDisposable // ManagementError rather than an unauthenticated role-mapping enumeration. // ======================================================================== + /// <summary>Verifies that resolve roles command, when is no longer dispatched, returns management error.</summary> [Fact] public void ResolveRolesCommand_IsNoLongerDispatched_ReturnsManagementError() { @@ -1862,6 +1954,7 @@ public class ManagementActorTests : TestKit, IDisposable secRepo.DidNotReceiveWithAnyArgs().GetAllMappingsAsync(default); } + /// <summary>Verifies that unknown command type: fault mapped to management error.</summary> [Fact] public void UnknownCommandType_FaultMappedToManagementError() { @@ -1969,6 +2062,7 @@ public class ManagementActorTests : TestKit, IDisposable }); } + /// <summary>Verifies that query deployments, when with design role, returns unauthorized.</summary> [Fact] public void QueryDeployments_WithDesignRole_ReturnsUnauthorized() { @@ -1981,6 +2075,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Deployer", response.Message); } + /// <summary>Verifies that query deployments, when unfiltered with deployment role, returns all records.</summary> [Fact] public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords() { @@ -1998,6 +2093,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("deploy-2", response.JsonData); } + /// <summary>Verifies that query deployments, when filtered by instance id, returns instance records.</summary> [Fact] public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords() { @@ -2014,6 +2110,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("deploy-5", response.JsonData); } + /// <summary>Verifies that query deployments, when filtered by out of scope instance, returns unauthorized.</summary> [Fact] public void QueryDeployments_FilteredByOutOfScopeInstance_ReturnsUnauthorized() { @@ -2034,6 +2131,7 @@ public class ManagementActorTests : TestKit, IDisposable deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentListPageAsync(default!, default, default); } + /// <summary>Verifies that query deployments, when filtered by in scope instance, returns records.</summary> [Fact] public void QueryDeployments_FilteredByInScopeInstance_ReturnsRecords() { @@ -2052,6 +2150,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("deploy-5", response.JsonData); } + /// <summary>Verifies that query deployments, when unfiltered for site scoped user, drops out of scope records.</summary> [Fact] public void QueryDeployments_UnfilteredForSiteScopedUser_DropsOutOfScopeRecords() { @@ -2070,6 +2169,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(new[] { 1 }, CapturedDeploymentFilter(deployRepo).SiteIdScope); } + /// <summary>Verifies that query deployments, when unfiltered for site scoped user, reads no instances at all.</summary> [Fact] public void QueryDeployments_UnfilteredForSiteScopedUser_ReadsNoInstancesAtAll() { @@ -2092,6 +2192,7 @@ public class ManagementActorTests : TestKit, IDisposable _templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default); } + /// <summary>Verifies that query deployments, when site scoped user with unparseable grant, sees nothing.</summary> [Fact] public void QueryDeployments_SiteScopedUserWithUnparseableGrant_SeesNothing() { @@ -2114,6 +2215,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Empty(CapturedDeploymentFilter(deployRepo).SiteIdScope!); } + /// <summary>Verifies that query deployments, when unfiltered for admin user, returns all records.</summary> [Fact] public void QueryDeployments_UnfilteredForAdminUser_ReturnsAllRecords() { @@ -2138,6 +2240,7 @@ public class ManagementActorTests : TestKit, IDisposable // ── Loud status parsing + bounded page size (register rows 87 / 88) ──────── + /// <summary>Verifies that query deployments, when unknown status, fails loudly naming the valid values.</summary> [Fact] public void QueryDeployments_UnknownStatus_FailsLoudlyNamingTheValidValues() { @@ -2158,6 +2261,8 @@ public class ManagementActorTests : TestKit, IDisposable deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentListPageAsync(default!, default, default); } + /// <summary>Verifies that query deployments, when ordinal status, is rejected like any unknown value.</summary> + /// <param name="status">The status.</param> [Theory] [InlineData("2")] [InlineData("+1")] @@ -2179,6 +2284,8 @@ public class ManagementActorTests : TestKit, IDisposable deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentListPageAsync(default!, default, default); } + /// <summary>Verifies that query deployments, when known status, is case insensitive.</summary> + /// <param name="status">The status.</param> [Theory] [InlineData("success")] [InlineData("SUCCESS")] @@ -2195,6 +2302,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(DeploymentStatus.Success, CapturedDeploymentFilter(deployRepo).Status); } + /// <summary>Verifies that query deployments, when numeric status, is rejected.</summary> [Fact] public void QueryDeployments_NumericStatus_IsRejected() { @@ -2210,6 +2318,8 @@ public class ManagementActorTests : TestKit, IDisposable ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that query deployments, when non-positive page size, takes the default not unlimited.</summary> + /// <param name="requested">The requested.</param> [Theory] [InlineData(0)] [InlineData(-1)] @@ -2237,6 +2347,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(DeploymentListPage.DefaultPageSize, result.GetProperty("pageSize").GetInt32()); } + /// <summary>Verifies that query deployments, when oversized page size, is capped at the maximum.</summary> [Fact] public void QueryDeployments_OversizedPageSize_IsCappedAtTheMaximum() { @@ -2253,6 +2364,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(DeploymentListPage.MaxPageSize, (int)call.GetArguments()[2]!); } + /// <summary>Verifies that query deployments: page is floored at one.</summary> [Fact] public void QueryDeployments_PageIsFlooredAtOne() { @@ -2277,6 +2389,7 @@ public class ManagementActorTests : TestKit, IDisposable // Deployer may not deploy fleet-wide, and may not target a site outside scope. // ======================================================================== + /// <summary>Verifies that deploy artifacts, when with site id, calls single site deploy.</summary> [Fact] public void DeployArtifacts_WithSiteId_CallsSingleSiteDeploy() { @@ -2292,6 +2405,7 @@ public class ManagementActorTests : TestKit, IDisposable _artifactService.DidNotReceiveWithAnyArgs().DeployToAllSitesAsync(default!, default); } + /// <summary>Verifies that deploy artifacts, when fleet wide, calls all sites deploy.</summary> [Fact] public void DeployArtifacts_FleetWide_CallsAllSitesDeploy() { @@ -2307,6 +2421,7 @@ public class ManagementActorTests : TestKit, IDisposable _artifactService.DidNotReceiveWithAnyArgs().DeployToSiteAsync(default, default!, default); } + /// <summary>Verifies that deploy artifacts, when site scoped user, out of scope site unauthorized.</summary> [Fact] public void DeployArtifacts_SiteScopedUser_OutOfScopeSite_Unauthorized() { @@ -2319,6 +2434,7 @@ public class ManagementActorTests : TestKit, IDisposable _artifactService.DidNotReceiveWithAnyArgs().DeployToSiteAsync(default, default!, default); } + /// <summary>Verifies that deploy artifacts, when site scoped user, fleet wide unauthorized.</summary> [Fact] public void DeployArtifacts_SiteScopedUser_FleetWide_Unauthorized() { @@ -2340,6 +2456,7 @@ public class ManagementActorTests : TestKit, IDisposable // an out-of-scope entity surface into their page window. // ======================================================================== + /// <summary>Verifies that list templates, when skip take, returns requested window.</summary> [Fact] public void ListTemplates_SkipTake_ReturnsRequestedWindow() { @@ -2360,6 +2477,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.DoesNotContain("T-016", response.JsonData); } + /// <summary>Verifies that list templates, when default take, returns all.</summary> [Fact] public void ListTemplates_DefaultTake_ReturnsAll() { @@ -2373,6 +2491,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("T-030", response.JsonData); } + /// <summary>Verifies that list instances: paging applies after site scope filter.</summary> [Fact] public void ListInstances_PagingAppliesAfterSiteScopeFilter() { @@ -2410,6 +2529,7 @@ public class ManagementActorTests : TestKit, IDisposable // command up front WITHOUT persisting any of the overrides. // ======================================================================== + /// <summary>Verifies that set instance overrides, when with one invalid attribute, persists no overrides.</summary> [Fact] public void SetInstanceOverrides_WithOneInvalidAttribute_PersistsNoOverrides() { @@ -2438,6 +2558,7 @@ public class ManagementActorTests : TestKit, IDisposable .UpdateInstanceAttributeOverrideAsync(default!, default); } + /// <summary>Verifies that set instance overrides, when all valid, persists all overrides.</summary> [Fact] public void SetInstanceOverrides_AllValid_PersistsAllOverrides() { @@ -2471,6 +2592,7 @@ public class ManagementActorTests : TestKit, IDisposable // unanticipated faults (whose raw .Message can disclose internal detail). // ======================================================================== + /// <summary>Verifies that unexpected fault, when returns generic message, not raw exception text.</summary> [Fact] public void UnexpectedFault_ReturnsGenericMessage_NotRawExceptionText() { @@ -2496,6 +2618,7 @@ public class ManagementActorTests : TestKit, IDisposable // UpdateSmtpConfig — TlsMode + Credentials plumbing (preserve-if-null) // ======================================================================== + /// <summary>Verifies that update smtp config, when with tls mode and credentials, persists them.</summary> [Fact] public void UpdateSmtpConfig_WithTlsModeAndCredentials_PersistsThem() { @@ -2526,6 +2649,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("Basic", existing.AuthType); } + /// <summary>Verifies that update smtp config, when with null tls mode and credentials, preserves existing values.</summary> [Fact] public void UpdateSmtpConfig_WithNullTlsModeAndCredentials_PreservesExistingValues() { @@ -2558,6 +2682,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("Basic", existing.AuthType); } + /// <summary>Verifies that update smtp config: applies oauth2 authority and scope.</summary> [Fact] public void UpdateSmtpConfig_AppliesOAuth2AuthorityAndScope() { @@ -2589,6 +2714,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("https://outlook.office365.com/.default", existing.OAuth2Scope); } + /// <summary>Verifies that update smtp config, when with null oauth2 authority and scope, preserves existing values.</summary> [Fact] public void UpdateSmtpConfig_WithNullOAuth2AuthorityAndScope_PreservesExistingValues() { @@ -2621,6 +2747,7 @@ public class ManagementActorTests : TestKit, IDisposable // UpdateSmtpConfig — Transport (EWS) write-path gate // ======================================================================== + /// <summary>Verifies that update smtp config, when with ews transport, persists transport and surfaces it in public shape.</summary> [Fact] public void UpdateSmtpConfig_WithEwsTransport_PersistsTransportAndSurfacesItInPublicShape() { @@ -2653,6 +2780,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("\"transport\":\"Ews\"", response.JsonData, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that update smtp config, when with unknown transport, returns management error.</summary> [Fact] public void UpdateSmtpConfig_WithUnknownTransport_ReturnsManagementError() { @@ -2681,6 +2809,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Graph", response.Error); } + /// <summary>Verifies that update smtp config, when with ews transport and non https host, returns management error.</summary> [Fact] public void UpdateSmtpConfig_WithEwsTransportAndNonHttpsHost_ReturnsManagementError() { @@ -2711,6 +2840,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("http://ews.example.test/ews/exchange.asmx", response.Error); } + /// <summary>Verifies that update smtp config, when with ews transport and oauth2 auth, returns management error.</summary> [Fact] public void UpdateSmtpConfig_WithEwsTransportAndOAuth2Auth_ReturnsManagementError() { @@ -2738,6 +2868,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Basic authentication", response.Error); } + /// <summary>Verifies that update smtp config, when with ews transport and malformed credentials, returns management error.</summary> [Fact] public void UpdateSmtpConfig_WithEwsTransportAndMalformedCredentials_ReturnsManagementError() { @@ -2769,6 +2900,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("username:password", response.Error); } + /// <summary>Verifies that update smtp config, when with ews transport and empty credential half, returns management error.</summary> [Fact] public void UpdateSmtpConfig_WithEwsTransportAndEmptyCredentialHalf_ReturnsManagementError() { @@ -2799,6 +2931,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("username:password", response.Error); } + /// <summary>Verifies that update smtp config, when flipping to ews with stored bad credentials, returns management error.</summary> [Fact] public void UpdateSmtpConfig_FlippingToEwsWithStoredBadCredentials_ReturnsManagementError() { @@ -2829,6 +2962,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("username:password", response.Error); } + /// <summary>Verifies that update smtp config, when with null transport, preserves existing value.</summary> [Fact] public void UpdateSmtpConfig_WithNullTransport_PreservesExistingValue() { @@ -2861,6 +2995,8 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("https://ews2.example.test/ews/exchange.asmx", existing.Host); } + /// <summary>Verifies that update smtp config, when transport acceptance, matches email transport parser.</summary> + /// <param name="transport">The transport.</param> [Theory] [InlineData("Smtp")] [InlineData("Ews")] @@ -2918,6 +3054,7 @@ public class ManagementActorTests : TestKit, IDisposable // SMS Notifications (S5) — list Type discriminator + SMS-config management // ======================================================================== + /// <summary>Verifies that create notification list, when with sms type, persists sms.</summary> [Fact] public void CreateNotificationList_WithSmsType_PersistsSms() { @@ -2942,6 +3079,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(Commons.Types.Enums.NotificationType.Sms, added!.Type); } + /// <summary>Verifies that create notification list, when with sms type and phones, persists for sms recipients.</summary> [Fact] public void CreateNotificationList_WithSmsTypeAndPhones_PersistsForSmsRecipients() { @@ -2979,6 +3117,7 @@ public class ManagementActorTests : TestKit, IDisposable added.Recipients.Select(r => r.PhoneNumber).ToArray()); } + /// <summary>Verifies that create notification list, when default type, is email.</summary> [Fact] public void CreateNotificationList_DefaultType_IsEmail() { @@ -3003,6 +3142,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(Commons.Types.Enums.NotificationType.Email, added!.Type); } + /// <summary>Verifies that update notification list, when with sms type, persists sms.</summary> [Fact] public void UpdateNotificationList_WithSmsType_PersistsSms() { @@ -3032,6 +3172,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(Commons.Types.Enums.NotificationType.Sms, existing.Type); } + /// <summary>Verifies that update notification list, when changing type, is rejected.</summary> [Fact] public void UpdateNotificationList_ChangingType_IsRejected() { @@ -3062,6 +3203,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Type cannot be changed", response.Error); } + /// <summary>Verifies that update notification list, when same email type, succeeds.</summary> [Fact] public void UpdateNotificationList_SameEmailType_Succeeds() { @@ -3092,6 +3234,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(Commons.Types.Enums.NotificationType.Email, existing.Type); } + /// <summary>Verifies that update sms config, when with api base url and auth token, persists them.</summary> [Fact] public void UpdateSmsConfig_WithApiBaseUrlAndAuthToken_PersistsThem() { @@ -3124,6 +3267,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("new-secret", existing.AuthToken); } + /// <summary>Verifies that update sms config, when with null auth token and api base url, preserves existing values.</summary> [Fact] public void UpdateSmsConfig_WithNullAuthTokenAndApiBaseUrl_PreservesExistingValues() { @@ -3156,6 +3300,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("+15551110000", existing.FromNumber); } + /// <summary>Verifies that update sms config, when result and audit, never contain auth token.</summary> [Fact] public void UpdateSmsConfig_ResultAndAudit_NeverContainAuthToken() { @@ -3195,6 +3340,7 @@ public class ManagementActorTests : TestKit, IDisposable ManagementActor.SerializeResult(auditedAfterState)); } + /// <summary>Verifies that list sms configs: projects away auth token.</summary> [Fact] public void ListSmsConfigs_ProjectsAwayAuthToken() { @@ -3217,10 +3363,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("hasAuthToken", response.JsonData); } + /// <summary>Verifies that update sms config, when with viewer role, returns unauthorized.</summary> [Fact] public void UpdateSmsConfig_WithViewerRole_ReturnsUnauthorized() { - // MgmtSvc-021: mutating the SMS provider config rotates the Twilio secret and + // Mutating the SMS provider config rotates the Twilio secret and // is Admin-only (the /notifications/sms page is RequireAdmin). A read-only role // cannot reach it. var actor = CreateActor(); @@ -3234,10 +3381,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Administrator", response.Message); } + /// <summary>Verifies that update sms config, when with designer role, returns unauthorized.</summary> [Fact] public void UpdateSmsConfig_WithDesignerRole_ReturnsUnauthorized() { - // MgmtSvc-021 regression: a Designer is blocked from the /notifications/sms UI + // Regression: a Designer is blocked from the /notifications/sms UI // (RequireAdmin), so the actor gate must reject them too — otherwise a Designer // could rotate a production Twilio Auth Token via the CLI/Management API. var actor = CreateActor(); @@ -3251,10 +3399,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Administrator", response.Message); } + /// <summary>Verifies that update sms config, when with empty auth token, preserves existing token.</summary> [Fact] public void UpdateSmsConfig_WithEmptyAuthToken_PreservesExistingToken() { - // MgmtSvc-021 regression: an explicit empty/whitespace AuthToken must be treated + // Regression: an explicit empty/whitespace AuthToken must be treated // as "omitted" (preserve the stored secret), never as "clear it" — a Twilio token // is always required, so clearing it would 401 every subsequent send. var notifRepo = Substitute.For<INotificationRepository>(); @@ -3282,10 +3431,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", existing.AccountSid); } + /// <summary>Verifies that update sms config, when messaging service sid only, persists null from number.</summary> [Fact] public void UpdateSmsConfig_MessagingServiceSidOnly_PersistsNullFromNumber() { - // UI-Med-2: a Twilio Messaging-Service-only config has no From number. The handler + // A Twilio Messaging-Service-only config has no From number. The handler // must accept and persist a null FromNumber alongside a MessagingServiceSid. var notifRepo = Substitute.For<INotificationRepository>(); var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000") @@ -3310,10 +3460,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("MGonly", existing.MessagingServiceSid); } + /// <summary>Verifies that update sms config, when malformed account sid, rejected.</summary> [Fact] public void UpdateSmsConfig_MalformedAccountSid_Rejected() { - // Task 22: AccountSid is interpolated un-escaped into the Twilio request URI, + // AccountSid is interpolated un-escaped into the Twilio request URI, // so a malformed SID (e.g. a path-traversal / query-injection payload) must be // rejected at save time, closing the URI-injection door before it can be persisted. var notifRepo = Substitute.For<INotificationRepository>(); @@ -3340,10 +3491,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", existing.AccountSid); } + /// <summary>Verifies that update sms config, when valid sid, accepted.</summary> [Fact] public void UpdateSmsConfig_ValidSid_Accepted() { - // Task 22: a well-formed Twilio SID ('AC' + 32 hex chars) passes the guard. + // A well-formed Twilio SID ('AC' + 32 hex chars) passes the guard. var notifRepo = Substitute.For<INotificationRepository>(); var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000") { @@ -3367,6 +3519,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(validSid, existing.AccountSid); } + /// <summary>Verifies that curated handler failure: surfaces the curated message.</summary> [Fact] public void CuratedHandlerFailure_SurfacesTheCuratedMessage() { @@ -3387,7 +3540,7 @@ public class ManagementActorTests : TestKit, IDisposable } // ======================================================================== - // ManagementService-021: Transport (#24) bundle handler coverage + // Transport bundle handler coverage // // The three bundle handlers (HandleExportBundle / HandlePreviewBundle / // HandleImportBundle) at ManagementActor.cs:1717-1897 previously had zero @@ -3418,7 +3571,7 @@ public class ManagementActorTests : TestKit, IDisposable .Returns(new List<Template>()); _templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>()) .Returns(new List<Commons.Entities.Scripts.SharedScript>()); - // M8 (B4): export now also fans out to instances (template repo) and sites + // Export now also fans out to instances (template repo) and sites // (site repo) for the site/instance-scoped selection. _templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>()) .Returns(new List<Instance>()); @@ -3459,6 +3612,7 @@ public class ManagementActorTests : TestKit, IDisposable IncludeDependencies: false, Passphrase: null, SourceEnvironment: "test-env"); + /// <summary>Verifies that export bundle command, when with admin role, returns unauthorized.</summary> [Fact] public void ExportBundleCommand_WithAdminRole_ReturnsUnauthorized() { @@ -3473,6 +3627,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Designer", response.Message); } + /// <summary>Verifies that preview bundle command, when with design role, returns unauthorized.</summary> [Fact] public void PreviewBundleCommand_WithDesignRole_ReturnsUnauthorized() { @@ -3489,6 +3644,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Administrator", response.Message); } + /// <summary>Verifies that import bundle command, when with design role, returns unauthorized.</summary> [Fact] public void ImportBundleCommand_WithDesignRole_ReturnsUnauthorized() { @@ -3502,6 +3658,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Administrator", response.Message); } + /// <summary>Verifies that export bundle command, when with unknown template name, returns management error.</summary> [Fact] public void ExportBundleCommand_WithUnknownTemplateName_ReturnsManagementError() { @@ -3535,10 +3692,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("DoesNotExist", response.Error); } + /// <summary>Verifies that export bundle command, when with site and instance names, resolves to ids.</summary> [Fact] public void ExportBundleCommand_WithSiteAndInstanceNames_ResolvesToIds() { - // M8 (B4): --sites accepts SiteIdentifier (preferred) or Name; --instances + // --sites accepts SiteIdentifier (preferred) or Name; --instances // accepts UniqueName. The handler resolves both to surrogate ids and passes // them on the ExportSelection. var (exporter, _) = AddBundleSubstitutes(); @@ -3586,10 +3744,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(new[] { 101 }, captured.InstanceIds); } + /// <summary>Verifies that export bundle command, when with all, includes every site and instance.</summary> [Fact] public void ExportBundleCommand_WithAll_IncludesEverySiteAndInstance() { - // M8 (B4): All=true ignores per-type name lists and includes every site + + // All=true ignores per-type name lists and includes every site + // instance, mirroring how All already includes every template/etc. var (exporter, _) = AddBundleSubstitutes(); @@ -3624,6 +3783,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(new[] { 101, 909 }, captured.InstanceIds.OrderBy(x => x)); } + /// <summary>Verifies that import bundle command, when with blocker row, aborts before apply.</summary> [Fact] public void ImportBundleCommand_WithBlockerRow_AbortsBeforeApply() { @@ -3674,6 +3834,7 @@ public class ManagementActorTests : TestKit, IDisposable Arg.Any<CancellationToken>()); } + /// <summary>Verifies that import bundle command, when duplicate preview items, dedupe per entity type and name.</summary> [Fact] public void ImportBundleCommand_DuplicatePreviewItems_DedupePerEntityTypeAndName() { @@ -3709,7 +3870,7 @@ public class ManagementActorTests : TestKit, IDisposable })); IReadOnlyList<Commons.Types.Transport.ImportResolution>? captured = null; - // M8 (D3): the handler now calls the 5-arg ApplyAsync overload (trailing + // The handler now calls the 5-arg ApplyAsync overload (trailing // nameMap:), so the arrange must include the BundleNameMap arg matcher or // NSubstitute treats it as an unmatched call and never runs the capture. importer.ApplyAsync( @@ -3746,7 +3907,7 @@ public class ManagementActorTests : TestKit, IDisposable } // ------------------------------------------------------------------------ - // M8 (D3): import name-map plumbing — PreviewBundleResult carries the + // Import name-map plumbing — PreviewBundleResult carries the // required mappings; HandleImportBundle merges explicit specs + auto-match + // create-missing flags into the BundleNameMap passed to ApplyAsync. // ------------------------------------------------------------------------ @@ -3793,6 +3954,7 @@ public class ManagementActorTests : TestKit, IDisposable return (sessionId, () => captured); } + /// <summary>Verifies that import bundle command, when explicit spec, overrides auto match.</summary> [Fact] public void ImportBundleCommand_ExplicitSpec_OverridesAutoMatch() { @@ -3824,6 +3986,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("PLANT-A", site.TargetSiteIdentifier); } + /// <summary>Verifies that import bundle command, when no spec, falls back to auto match.</summary> [Fact] public void ImportBundleCommand_NoSpec_FallsBackToAutoMatch() { @@ -3851,6 +4014,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("AUTO-MATCH", site.TargetSiteIdentifier); } + /// <summary>Verifies that import bundle command, when no auto match with create missing, creates new.</summary> [Fact] public void ImportBundleCommand_NoAutoMatchWithCreateMissing_CreatesNew() { @@ -3879,6 +4043,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Null(site.TargetSiteIdentifier); } + /// <summary>Verifies that import bundle command, when explicit create new spec, creates new.</summary> [Fact] public void ImportBundleCommand_ExplicitCreateNewSpec_CreatesNew() { @@ -3908,6 +4073,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, site.Action); } + /// <summary>Verifies that import bundle command, when unresolved without create missing, fails.</summary> [Fact] public void ImportBundleCommand_UnresolvedWithoutCreateMissing_Fails() { @@ -3942,6 +4108,7 @@ public class ManagementActorTests : TestKit, IDisposable Arg.Any<Commons.Types.Transport.BundleNameMap?>()); } + /// <summary>Verifies that import bundle command, when connection spec and create missing, build map.</summary> [Fact] public void ImportBundleCommand_ConnectionSpecAndCreateMissing_BuildMap() { @@ -3979,6 +4146,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Null(opcB.TargetConnectionName); } + /// <summary>Verifies that preview bundle command: surfaces required mappings.</summary> [Fact] public void PreviewBundleCommand_SurfacesRequiredMappings() { @@ -4024,6 +4192,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("OpcA", success.JsonData); } + /// <summary>Verifies that preview bundle command, when releases the session, and forwards the callers ip.</summary> [Fact] public void PreviewBundleCommand_ReleasesTheSession_AndForwardsTheCallersIp() { @@ -4059,6 +4228,7 @@ public class ManagementActorTests : TestKit, IDisposable importer.Received(1).Abandon(sessionId); } + /// <summary>Verifies that import bundle command, when with blocker row, still releases the session.</summary> [Fact] public void ImportBundleCommand_WithBlockerRow_StillReleasesTheSession() { @@ -4097,6 +4267,7 @@ public class ManagementActorTests : TestKit, IDisposable importer.Received(1).Abandon(sessionId); } + /// <summary>Verifies that import bundle command, when throttled unlock, surfaces the curated reason.</summary> [Fact] public void ImportBundleCommand_ThrottledUnlock_SurfacesTheCuratedReason() { @@ -4117,6 +4288,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Too many bundle unlock attempts", response.Error); } + /// <summary>Verifies that import bundle command, when locked bundle, surfaces the curated reason.</summary> [Fact] public void ImportBundleCommand_LockedBundle_SurfacesTheCuratedReason() { @@ -4141,6 +4313,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.DoesNotContain("An internal error occurred", response.Error); } + /// <summary>Verifies that preview bundle command, when locked bundle, surfaces the curated reason.</summary> [Fact] public void PreviewBundleCommand_LockedBundle_SurfacesTheCuratedReason() { @@ -4163,9 +4336,10 @@ public class ManagementActorTests : TestKit, IDisposable } // ======================================================================== - // Native alarm source CRUD (Task 21) + // Native alarm source CRUD // ======================================================================== + /// <summary>Verifies that add template native alarm source, when with design role, returns success.</summary> [Fact] public void AddTemplateNativeAlarmSource_WithDesignRole_ReturnsSuccess() { @@ -4185,6 +4359,7 @@ public class ManagementActorTests : TestKit, IDisposable _templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default); } + /// <summary>Verifies that add template native alarm source, when with deployment role, returns unauthorized.</summary> [Fact] public void AddTemplateNativeAlarmSource_WithDeploymentRole_ReturnsUnauthorized() { @@ -4199,6 +4374,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Designer", response.Message); } + /// <summary>Verifies that list template native alarm sources: returns data.</summary> [Fact] public void ListTemplateNativeAlarmSources_ReturnsData() { @@ -4217,11 +4393,12 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Pressure", response.JsonData); } + /// <summary>Verifies that set instance native alarm source override, when with deployment role, returns success.</summary> [Fact] public void SetInstanceNativeAlarmSourceOverride_WithDeploymentRole_ReturnsSuccess() { // No prior override → Add path. The source resolves and is UNLOCKED, so the - // lock guard (#05-T13) lets the override through. + // lock guard lets the override through. var pipeline = Substitute.For<IFlatteningPipeline>(); var flattened = new FlattenedConfiguration { @@ -4247,6 +4424,7 @@ public class ManagementActorTests : TestKit, IDisposable _templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default); } + /// <summary>Verifies that set instance native alarm source override, when with design role, returns unauthorized.</summary> [Fact] public void SetInstanceNativeAlarmSourceOverride_WithDesignRole_ReturnsUnauthorized() { @@ -4261,10 +4439,11 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Deployer", response.Message); } + /// <summary>Verifies that set instance native alarm source overrides, when bulk happy path, adds all and saves once.</summary> [Fact] public void SetInstanceNativeAlarmSourceOverrides_BulkHappyPath_AddsAllAndSavesOnce() { - // #12: bulk CSV apply. Both sources resolve and are unlocked → each is added, + // Bulk CSV apply. Both sources resolve and are unlocked → each is added, // and the whole batch commits with a SINGLE SaveChanges (all-or-nothing txn). var pipeline = Substitute.For<IFlatteningPipeline>(); var flattened = new FlattenedConfiguration @@ -4297,10 +4476,11 @@ public class ManagementActorTests : TestKit, IDisposable _templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default); } + /// <summary>Verifies that set instance native alarm source overrides, when one locked source, rejects whole batch.</summary> [Fact] public void SetInstanceNativeAlarmSourceOverrides_OneLockedSource_RejectsWholeBatch() { - // #12: all-or-nothing. If ANY entry targets a locked source, the whole batch + // All-or-nothing. If ANY entry targets a locked source, the whole batch // is rejected before any write — no Add, no SaveChanges. var pipeline = Substitute.For<IFlatteningPipeline>(); var flattened = new FlattenedConfiguration @@ -4333,10 +4513,11 @@ public class ManagementActorTests : TestKit, IDisposable _templateRepo.DidNotReceiveWithAnyArgs().SaveChangesAsync(default); } + /// <summary>Verifies that set instance native alarm source overrides, when unresolved source, rejects whole batch.</summary> [Fact] public void SetInstanceNativeAlarmSourceOverrides_UnresolvedSource_RejectsWholeBatch() { - // #12: a source that does not resolve for the instance is rejected up front. + // A source that does not resolve for the instance is rejected up front. var pipeline = Substitute.For<IFlatteningPipeline>(); var flattened = new FlattenedConfiguration { @@ -4368,9 +4549,10 @@ public class ManagementActorTests : TestKit, IDisposable } // ======================================================================== - // MoveDataConnectionCommand (M9 / T24a) — guarded cross-site move + // MoveDataConnectionCommand — guarded cross-site move // ======================================================================== + /// <summary>Verifies that move data connection, when with viewer role, returns unauthorized.</summary> [Fact] public void MoveDataConnection_WithViewerRole_ReturnsUnauthorized() { @@ -4383,6 +4565,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Designer", response.Message); } + /// <summary>Verifies that move data connection, when happy path, moves site id and audits.</summary> [Fact] public void MoveDataConnection_HappyPath_MovesSiteIdAndAudits() { @@ -4423,6 +4606,7 @@ public class ManagementActorTests : TestKit, IDisposable Arg.Any<object?>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that move data connection, when target site missing, returns error.</summary> [Fact] public void MoveDataConnection_TargetSiteMissing_ReturnsError() { @@ -4447,6 +4631,7 @@ public class ManagementActorTests : TestKit, IDisposable Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that move data connection, when name collision at target, returns error.</summary> [Fact] public void MoveDataConnection_NameCollisionAtTarget_ReturnsError() { @@ -4473,6 +4658,7 @@ public class ManagementActorTests : TestKit, IDisposable Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that move data connection, when blocked by binding, returns error naming instances.</summary> [Fact] public void MoveDataConnection_BlockedByBinding_ReturnsErrorNamingInstances() { @@ -4504,6 +4690,7 @@ public class ManagementActorTests : TestKit, IDisposable Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that move data connection, when same site, returns error no write.</summary> [Fact] public void MoveDataConnection_SameSite_ReturnsErrorNoWrite() { @@ -4529,6 +4716,7 @@ public class ManagementActorTests : TestKit, IDisposable siteRepo.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>()); } + /// <summary>Verifies that move data connection, when blocked by template native alarm source on source site, returns error.</summary> [Fact] public void MoveDataConnection_BlockedByTemplateNativeAlarmSourceOnSourceSite_ReturnsError() { @@ -4574,6 +4762,7 @@ public class ManagementActorTests : TestKit, IDisposable Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that move data connection, when blocked by instance override on source site, returns error.</summary> [Fact] public void MoveDataConnection_BlockedByInstanceOverrideOnSourceSite_ReturnsError() { @@ -4616,6 +4805,7 @@ public class ManagementActorTests : TestKit, IDisposable Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that move data connection, when template referenced only by other site, does not block.</summary> [Fact] public void MoveDataConnection_TemplateReferencedOnlyByOtherSite_DoesNotBlock() { @@ -4674,11 +4864,12 @@ public class ManagementActorTests : TestKit, IDisposable } // ======================================================================== - // #259: design-time ValidateTemplateCommand passes sharedScripts so a + // Design-time ValidateTemplateCommand passes sharedScripts so a // dangling $ref in a SHARED script's schema is caught at design time, not // only on the deploy path. // ======================================================================== + /// <summary>Verifies that validate template, when dangling ref in shared script, returns invalid result.</summary> [Fact] public void ValidateTemplate_DanglingRefInSharedScript_ReturnsInvalidResult() { @@ -4736,9 +4927,10 @@ public class ManagementActorTests : TestKit, IDisposable } // ======================================================================== - // PLAN-06 Task 11: per-system ESG TimeoutSeconds management commands + // Per-system ESG TimeoutSeconds management commands // ======================================================================== + /// <summary>Verifies that create external system: persists timeout seconds.</summary> [Fact] public void CreateExternalSystem_PersistsTimeoutSeconds() { @@ -4759,6 +4951,7 @@ public class ManagementActorTests : TestKit, IDisposable Arg.Any<CancellationToken>()); } + /// <summary>Verifies that update external system, when null timeout, preserves existing.</summary> [Fact] public void UpdateExternalSystem_NullTimeout_PreservesExisting() { @@ -4848,6 +5041,8 @@ public class ManagementActorTests : TestKit, IDisposable // Topology workflow (RequireDeployment), so a pure-Deployer must be allowed. // ======================================================================== + /// <summary>Verifies that create area, when designer or deployer, allowed.</summary> + /// <param name="role">The role.</param> [Theory] [InlineData("Designer")] [InlineData("Deployer")] @@ -4861,6 +5056,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.IsNotType<ManagementUnauthorized>(resp); } + /// <summary>Verifies that create area, when viewer only, unauthorized.</summary> [Fact] public void CreateArea_ViewerOnly_Unauthorized() { @@ -4876,6 +5072,8 @@ public class ManagementActorTests : TestKit, IDisposable // gate, and every validation rule is delegated to AreaService.MoveAreaAsync. // ======================================================================== + /// <summary>Verifies that move area, when designer or deployer, allowed.</summary> + /// <param name="role">The role.</param> [Theory] [InlineData("Designer")] [InlineData("Deployer")] @@ -4887,6 +5085,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.IsNotType<ManagementUnauthorized>(resp); } + /// <summary>Verifies that move area, when viewer only, unauthorized.</summary> [Fact] public void MoveArea_ViewerOnly_Unauthorized() { @@ -4895,6 +5094,7 @@ public class ManagementActorTests : TestKit, IDisposable ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that move area, when to new parent, returns success and reparents area.</summary> [Fact] public void MoveArea_ToNewParent_ReturnsSuccessAndReparentsArea() { @@ -4915,6 +5115,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(2, moving.ParentAreaId); } + /// <summary>Verifies that move area, when with null parent, moves to site root.</summary> [Fact] public void MoveArea_WithNullParent_MovesToSiteRoot() { @@ -4931,6 +5132,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Null(moving.ParentAreaId); } + /// <summary>Verifies that move area, when under own descendant, returns error.</summary> [Fact] public void MoveArea_UnderOwnDescendant_ReturnsError() { @@ -4949,6 +5151,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("descendants", response.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that move area, when area not found, returns error.</summary> [Fact] public void MoveArea_AreaNotFound_ReturnsError() { @@ -4969,6 +5172,7 @@ public class ManagementActorTests : TestKit, IDisposable // SiteIdentifier is REQUIRED (the actor must route to a site); Designer-gated. // ======================================================================== + /// <summary>Verifies that browse node, when without site identifier, returns error.</summary> [Fact] public void BrowseNode_WithoutSiteIdentifier_ReturnsError() { @@ -4978,6 +5182,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("SiteIdentifier", resp.Error); } + /// <summary>Verifies that browse node, when designer, relays to site.</summary> [Fact] public void BrowseNode_Designer_RelaysToSite() { @@ -4990,6 +5195,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("SITE1", stub.LastBrowseSite); } + /// <summary>Verifies that browse node, when viewer only, unauthorized.</summary> [Fact] public void BrowseNode_ViewerOnly_Unauthorized() { @@ -4998,6 +5204,7 @@ public class ManagementActorTests : TestKit, IDisposable ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that search address space, when without site identifier, returns error.</summary> [Fact] public void SearchAddressSpace_WithoutSiteIdentifier_ReturnsError() { @@ -5007,6 +5214,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("SiteIdentifier", resp.Error); } + /// <summary>Verifies that search address space, when designer, relays to site.</summary> [Fact] public void SearchAddressSpace_Designer_RelaysToSite() { @@ -5019,6 +5227,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("SITE1", stub.LastSearchSite); } + /// <summary>Verifies that search address space, when viewer only, unauthorized.</summary> [Fact] public void SearchAddressSpace_ViewerOnly_Unauthorized() { @@ -5027,6 +5236,7 @@ public class ManagementActorTests : TestKit, IDisposable ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that verify endpoint, when without site identifier, returns error.</summary> [Fact] public void VerifyEndpoint_WithoutSiteIdentifier_ReturnsError() { @@ -5036,6 +5246,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("SiteIdentifier", resp.Error); } + /// <summary>Verifies that verify endpoint, when designer, relays to site.</summary> [Fact] public void VerifyEndpoint_Designer_RelaysToSite() { @@ -5048,6 +5259,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("SITE1", stub.LastVerifySite); } + /// <summary>Verifies that verify endpoint, when viewer only, unauthorized.</summary> [Fact] public void VerifyEndpoint_ViewerOnly_Unauthorized() { @@ -5064,6 +5276,7 @@ public class ManagementActorTests : TestKit, IDisposable // is REQUIRED; the handler only relays (site-side reconcile is CertStoreActor). // ======================================================================== + /// <summary>Verifies that trust server cert, when designer only, unauthorized.</summary> [Fact] public void TrustServerCert_DesignerOnly_Unauthorized() { @@ -5072,6 +5285,7 @@ public class ManagementActorTests : TestKit, IDisposable ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that trust server cert, when without site identifier, returns error.</summary> [Fact] public void TrustServerCert_WithoutSiteIdentifier_ReturnsError() { @@ -5081,6 +5295,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("SiteIdentifier", resp.Error); } + /// <summary>Verifies that trust server cert, when admin, relays to site.</summary> [Fact] public void TrustServerCert_Admin_RelaysToSite() { @@ -5093,6 +5308,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("SITE1", stub.LastTrustSite); } + /// <summary>Verifies that remove server cert, when admin, relays to site.</summary> [Fact] public void RemoveServerCert_Admin_RelaysToSite() { @@ -5105,6 +5321,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("SITE1", stub.LastRemoveSite); } + /// <summary>Verifies that remove server cert, when without site identifier, returns error.</summary> [Fact] public void RemoveServerCert_WithoutSiteIdentifier_ReturnsError() { @@ -5114,6 +5331,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("SiteIdentifier", resp.Error); } + /// <summary>Verifies that list server certs, when admin, relays to site.</summary> [Fact] public void ListServerCerts_Admin_RelaysToSite() { @@ -5126,6 +5344,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("SITE1", stub.LastListCertsSite); } + /// <summary>Verifies that list server certs, when designer only, unauthorized.</summary> [Fact] public void ListServerCerts_DesignerOnly_Unauthorized() { @@ -5136,6 +5355,7 @@ public class ManagementActorTests : TestKit, IDisposable // ── R2 T13: site delete disposes the site's cached gRPC channels (N8) ── + /// <summary>Verifies that delete site: disposes the sites cached grpc channels.</summary> [Fact] public void DeleteSite_DisposesTheSitesCachedGrpcChannels() { @@ -5163,9 +5383,14 @@ public class ManagementActorTests : TestKit, IDisposable private sealed class TrackingClient : SiteStreamGrpcClient { + /// <summary>Initializes a new instance of the <c>TrackingClient</c> class.</summary> + /// <param name="endpoint">The endpoint.</param> public TrackingClient(string endpoint) : base(endpoint) { } + /// <summary>Gets or sets the disposed.</summary> public bool Disposed { get; private set; } + /// <inheritdoc /> public override void Dispose() => Disposed = true; + /// <inheritdoc /> public override ValueTask DisposeAsync() { Disposed = true; @@ -5175,7 +5400,9 @@ public class ManagementActorTests : TestKit, IDisposable private sealed class TrackingGrpcFactory : SiteStreamGrpcClientFactory { + /// <summary>Initializes a new instance of the <c>TrackingGrpcFactory</c> class.</summary> public TrackingGrpcFactory() : base(NullLoggerFactory.Instance) { } + /// <inheritdoc /> protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint) => new TrackingClient(grpcEndpoint); } @@ -5187,24 +5414,38 @@ public class ManagementActorTests : TestKit, IDisposable /// </summary> private sealed class StubCommunicationService : CommunicationService { + /// <summary>Initializes a new instance of the <c>StubCommunicationService</c> class.</summary> public StubCommunicationService() : base(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance) { } + /// <summary>Gets or sets the browse count.</summary> public int BrowseCount { get; private set; } + /// <summary>Gets or sets the last browse site.</summary> public string? LastBrowseSite { get; private set; } + /// <summary>Gets or sets the search count.</summary> public int SearchCount { get; private set; } + /// <summary>Gets or sets the last search site.</summary> public string? LastSearchSite { get; private set; } + /// <summary>Gets or sets the verify count.</summary> public int VerifyCount { get; private set; } + /// <summary>Gets or sets the last verify site.</summary> public string? LastVerifySite { get; private set; } + /// <summary>Gets or sets the trust count.</summary> public int TrustCount { get; private set; } + /// <summary>Gets or sets the last trust site.</summary> public string? LastTrustSite { get; private set; } + /// <summary>Gets or sets the remove count.</summary> public int RemoveCount { get; private set; } + /// <summary>Gets or sets the last remove site.</summary> public string? LastRemoveSite { get; private set; } + /// <summary>Gets or sets the list certs count.</summary> public int ListCertsCount { get; private set; } + /// <summary>Gets or sets the last list certs site.</summary> public string? LastListCertsSite { get; private set; } + /// <inheritdoc /> public override Task<CertTrustResult> TrustServerCertAsync( string siteId, TrustServerCertCommand command, CancellationToken ct = default) { @@ -5213,6 +5454,7 @@ public class ManagementActorTests : TestKit, IDisposable return Task.FromResult(new CertTrustResult(true, null, null)); } + /// <inheritdoc /> public override Task<CertTrustResult> RemoveServerCertAsync( string siteId, RemoveServerCertCommand command, CancellationToken ct = default) { @@ -5221,6 +5463,7 @@ public class ManagementActorTests : TestKit, IDisposable return Task.FromResult(new CertTrustResult(true, null, null)); } + /// <inheritdoc /> public override Task<CertTrustResult> ListServerCertsAsync( string siteId, ListServerCertsCommand command, CancellationToken ct = default) { @@ -5229,6 +5472,7 @@ public class ManagementActorTests : TestKit, IDisposable return Task.FromResult(new CertTrustResult(true, null, null)); } + /// <inheritdoc /> public override Task<BrowseNodeResult> BrowseNodeAsync( string siteId, BrowseNodeCommand command, CancellationToken ct = default) { @@ -5237,6 +5481,7 @@ public class ManagementActorTests : TestKit, IDisposable return Task.FromResult(new BrowseNodeResult(Array.Empty<BrowseNode>(), false, null)); } + /// <inheritdoc /> public override Task<SearchAddressSpaceResult> SearchAddressSpaceAsync( string siteId, SearchAddressSpaceCommand command, CancellationToken ct = default) { @@ -5245,6 +5490,7 @@ public class ManagementActorTests : TestKit, IDisposable return Task.FromResult(new SearchAddressSpaceResult(Array.Empty<AddressSpaceMatch>(), false, null)); } + /// <inheritdoc /> public override Task<VerifyEndpointResult> VerifyEndpointAsync( string siteId, VerifyEndpointCommand command, CancellationToken ct = default) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs index c7d1c08b..a60196bd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs @@ -25,6 +25,7 @@ public class ManagementAuthenticatorTests return new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; } + /// <summary>Verifies that no user is returned when the disable-login option is off.</summary> [Fact] public void TryDisableLoginUser_WhenDisabled_ReturnsNull() { @@ -33,6 +34,7 @@ public class ManagementAuthenticatorTests Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context)); } + /// <summary>Verifies that no user is returned when no disable-login options are registered.</summary> [Fact] public void TryDisableLoginUser_WhenNoOptionsRegistered_ReturnsNull() { @@ -44,6 +46,7 @@ public class ManagementAuthenticatorTests Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context)); } + /// <summary>Verifies that an enabled disable-login option returns a dev user holding all roles system-wide.</summary> [Fact] public void TryDisableLoginUser_WhenEnabled_ReturnsDevUserWithAllRolesSystemWide() { @@ -58,6 +61,7 @@ public class ManagementAuthenticatorTests Assert.Empty(user.PermittedSiteIds); // empty == system-wide, mirrors AutoLoginAuthenticationHandler } + /// <summary>Verifies that an enabled option with a blank user falls back to the multi-role user.</summary> [Fact] public void TryDisableLoginUser_WhenEnabledWithBlankUser_FallsBackToMultiRole() { @@ -69,6 +73,8 @@ public class ManagementAuthenticatorTests Assert.Equal("multi-role", user!.Username); } + /// <summary>Verifies that authentication bypasses Basic auth and returns the dev user when login is disabled.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuthenticateAsync_WhenDisabled_BypassesBasicAuthAndReturnsDevUser() { @@ -84,6 +90,8 @@ public class ManagementAuthenticatorTests Assert.Equal(Roles.All, outcome.User.Roles); } + /// <summary>Verifies that authentication returns unauthorized when login is enabled and no header is sent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuthenticateAsync_WhenEnabledWithNoHeader_ReturnsUnauthorized() { @@ -139,6 +147,8 @@ public class ManagementAuthenticatorTests return (context.Response.StatusCode, body); } + /// <summary>Verifies that a locked-out key returns HTTP 429 without attempting an LDAP bind.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuthenticateAsync_LockedOutKey_Returns429WithoutLdapBind() { @@ -156,6 +166,8 @@ public class ManagementAuthenticatorTests await ldap.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default); } + /// <summary>Verifies that a failed LDAP bind records a failure against the lockout tracker.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuthenticateAsync_FailedBind_RecordsFailure() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs index bbd6fcdd..ac73a5ec 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs @@ -5,10 +5,11 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// <summary> /// Tests for <see cref="ManagementEndpoints"/> request-body parsing -/// (findings ManagementService-006 / -013). +/// parsing and Ask-timeout resolution. /// </summary> public class ManagementEndpointsTests { + /// <summary>Verifies that parsing a request with an explicit payload deserializes into the command type.</summary> [Fact] public void ParseCommand_WithExplicitPayload_DeserializesIntoCommandType() { @@ -24,13 +25,15 @@ public class ManagementEndpointsTests } /// <summary> - /// P7-6: <c>MgmtDeploySiteCommand.Force</c> is an ADDITIVE field, so a client + /// <c>MgmtDeploySiteCommand.Force</c> is an ADDITIVE field, so a client /// that predates it — every CLI older than this change, and every scripted /// HTTP caller — must keep working and must land on the SAFE default. Absent /// means <c>false</c> means "skip instances whose config is unchanged", which /// is the non-destructive behaviour; only an explicit <c>true</c> restores the /// unconditional redeploy. /// </summary> + /// <param name="json">The management request body to parse.</param> + /// <param name="expectedForce">The expected value of the command's <c>Force</c> flag.</param> [Theory] [InlineData("""{ "command": "MgmtDeploySite", "payload": { "siteId": 7 } }""", false)] [InlineData("""{ "command": "MgmtDeploySite", "payload": { "siteId": 7, "force": false } }""", false)] @@ -45,6 +48,7 @@ public class ManagementEndpointsTests Assert.Equal(expectedForce, command.Force); } + /// <summary>Verifies that parsing a request with no payload deserializes a parameterless command.</summary> [Fact] public void ParseCommand_WithMissingPayload_DeserializesParameterlessCommand() { @@ -58,6 +62,7 @@ public class ManagementEndpointsTests Assert.IsType<ListTemplatesCommand>(result.Command); } + /// <summary>Verifies that parsing invalid JSON returns a failure.</summary> [Fact] public void ParseCommand_WithInvalidJson_ReturnsFailure() { @@ -67,6 +72,7 @@ public class ManagementEndpointsTests Assert.Equal("BAD_REQUEST", result.ErrorCode); } + /// <summary>Verifies that parsing a request without a command field returns a failure.</summary> [Fact] public void ParseCommand_WithMissingCommandField_ReturnsFailure() { @@ -76,6 +82,7 @@ public class ManagementEndpointsTests Assert.Equal("BAD_REQUEST", result.ErrorCode); } + /// <summary>Verifies that parsing an unknown command returns a failure.</summary> [Fact] public void ParseCommand_WithUnknownCommand_ReturnsFailure() { @@ -86,12 +93,13 @@ public class ManagementEndpointsTests } // ======================================================================== - // Command-timeout configuration (finding ManagementService-010) + // Command-timeout configuration // // ManagementServiceOptions.CommandTimeout must actually drive the Ask // timeout instead of a hard-coded 30s constant. // ======================================================================== + /// <summary>Verifies that the Ask timeout uses the configured command timeout.</summary> [Fact] public void ResolveAskTimeout_UsesConfiguredCommandTimeout() { @@ -102,6 +110,7 @@ public class ManagementEndpointsTests Assert.Equal(TimeSpan.FromSeconds(75), timeout); } + /// <summary>Verifies that null options fall back to the default Ask timeout.</summary> [Fact] public void ResolveAskTimeout_WithNullOptions_FallsBackToDefault() { @@ -110,6 +119,7 @@ public class ManagementEndpointsTests Assert.Equal(TimeSpan.FromSeconds(30), timeout); } + /// <summary>Verifies that a non-positive configured timeout falls back to the default Ask timeout.</summary> [Fact] public void ResolveAskTimeout_WithNonPositiveTimeout_FallsBackToDefault() { @@ -129,6 +139,8 @@ public class ManagementEndpointsTests // LongRunningCommandTimeout (default 5 min) instead of the 30 s default. // ======================================================================== + /// <summary>Verifies that long-running commands use the long Ask timeout.</summary> + /// <param name="cmd">The long-running command type under test.</param> [Theory] [InlineData(typeof(ImportBundleCommand))] [InlineData(typeof(ExportBundleCommand))] @@ -138,15 +150,18 @@ public class ManagementEndpointsTests public void ResolveAskTimeout_LongRunningCommand_UsesLongTimeout(Type cmd) => Assert.Equal(TimeSpan.FromMinutes(5), ManagementEndpoints.ResolveAskTimeout(null, cmd)); + /// <summary>Verifies that an ordinary command uses the default Ask timeout.</summary> [Fact] public void ResolveAskTimeout_OrdinaryCommand_UsesDefault() => Assert.Equal(TimeSpan.FromSeconds(30), ManagementEndpoints.ResolveAskTimeout(null, typeof(ListTemplatesCommand))); + /// <summary>Verifies that a configured long-running timeout is honored.</summary> [Fact] public void ResolveAskTimeout_ConfiguredLongTimeout_Honored() => Assert.Equal(TimeSpan.FromMinutes(10), ManagementEndpoints.ResolveAskTimeout( new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromMinutes(10) }, typeof(ImportBundleCommand))); + /// <summary>Verifies that a bulk Ask timeout leaves a grace period beyond the overall scheduling deadline.</summary> [Fact] public void BulkAsk_LeavesGraceBeyondOverallSchedulingDeadline() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementPagingBoundaryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementPagingBoundaryTests.cs index 61fde6a9..abe277d2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementPagingBoundaryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementPagingBoundaryTests.cs @@ -4,6 +4,9 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; public class ManagementPagingBoundaryTests { + /// <summary>Verifies that a positive take is capped, while an omitted or non-positive take preserves unlimited paging.</summary> + /// <param name="take">The requested page size, or null when omitted.</param> + /// <param name="expected">The number of rows expected back.</param> [Theory] [InlineData(null, 1100)] [InlineData(-1, 1100)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementServiceOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementServiceOptionsValidatorTests.cs index c9dff6b4..2d5580f6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementServiceOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementServiceOptionsValidatorTests.cs @@ -15,6 +15,8 @@ public class ManagementServiceOptionsValidatorTests private static ValidateOptionsResult Validate(ManagementServiceOptions options) => new ManagementServiceOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the long-running command timeout must leave a grace margin for the response.</summary> + /// <param name="seconds">The long-running command timeout, in seconds.</param> [Theory] [InlineData(1)] [InlineData(5)] @@ -23,6 +25,7 @@ public class ManagementServiceOptionsValidatorTests Assert.True(Validate(new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromSeconds(seconds) }).Failed); } + /// <summary>Verifies that the default options are valid.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -30,6 +33,7 @@ public class ManagementServiceOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a non-positive secured-write pending TTL is valid.</summary> [Fact] public void NonPositiveSecuredWritePendingTtl_IsValid() { @@ -38,6 +42,7 @@ public class ManagementServiceOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero command timeout is rejected.</summary> [Fact] public void ZeroCommandTimeout_IsRejected() { @@ -47,6 +52,7 @@ public class ManagementServiceOptionsValidatorTests Assert.Contains("CommandTimeout", result.FailureMessage); } + /// <summary>Verifies that a negative command timeout is rejected.</summary> [Fact] public void NegativeCommandTimeout_IsRejected() { @@ -56,6 +62,7 @@ public class ManagementServiceOptionsValidatorTests Assert.Contains("CommandTimeout", result.FailureMessage); } + /// <summary>Verifies that a zero long-running command timeout is rejected.</summary> [Fact] public void ZeroLongRunningCommandTimeout_IsRejected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/NotificationRecipientPreservationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/NotificationRecipientPreservationTests.cs index 9566c903..34df4380 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/NotificationRecipientPreservationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/NotificationRecipientPreservationTests.cs @@ -11,6 +11,9 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; public class NotificationRecipientPreservationTests { + /// <summary>Verifies that dedicated recipient edits preserve unrelated recipient identity and name for the given channel.</summary> + /// <param name="channel">The notification channel type under test.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(NotificationType.Email)] [InlineData(NotificationType.Sms)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs index 6da79bd0..afad37df 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs @@ -130,7 +130,7 @@ public class RequiredRoleMatrixTests // ---- Deployer-only ---------------------------------------------------------- ["CreateInstance"] = [Roles.Deployer], ["MgmtDeployInstance"] = [Roles.Deployer], - // Bulk site deploy (WP2.5). Same authority as the single-instance deploy + // Bulk site deploy. Same authority as the single-instance deploy // it batches — it is N of those, not a new class of privilege. ["MgmtDeploySite"] = [Roles.Deployer], ["MgmtEnableInstance"] = [Roles.Deployer], @@ -208,12 +208,16 @@ public class RequiredRoleMatrixTests ["ResolveRoles"] = null, }; + /// <summary>Supplies every registered management command type as theory data.</summary> + /// <returns>The theory data rows of command types.</returns> public static IEnumerable<object[]> AllRegisteredCommands() => typeof(ManagementEnvelope).Assembly.GetTypes() .Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace && t.Name.EndsWith("Command", StringComparison.Ordinal) && !t.IsAbstract) .Select(t => new object[] { t }); + /// <summary>Verifies that every registered command is in the frozen authorization table.</summary> + /// <param name="commandType">The registered command type.</param> [Theory, MemberData(nameof(AllRegisteredCommands))] public void EveryRegisteredCommand_IsInTheFrozenTable(Type commandType) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RoleMappingValidationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RoleMappingValidationTests.cs index e15b15f1..ca1ea709 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RoleMappingValidationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RoleMappingValidationTests.cs @@ -12,7 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// <summary> -/// Security-023 (membership-validation half): the LDAP-group-mapping <c>Role</c> arrives +/// Membership validation: the LDAP-group-mapping <c>Role</c> arrives /// as a free string over the CLI/Management API. The single server-side write path /// (<c>ManagementActor.HandleCreateRoleMapping</c> / <c>HandleUpdateRoleMapping</c>) now /// rejects any role that is not in the canonical <see cref="Roles.All"/> set, returning a @@ -21,7 +21,7 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// a silent-misconfiguration footgun rather than changing behaviour. /// </summary> /// <remarks> -/// Per Security-023's scope: only the membership check is added. The pre-existing +/// Scope: only the membership check is added. The pre-existing /// case-sensitivity asymmetry (case-sensitive UI <c>RequireClaim</c> vs case-insensitive /// ManagementActor authz) is a separately-deferred change and is NOT touched — the /// membership check accepts a correctly-cased canonical role and stores it verbatim. @@ -32,6 +32,7 @@ public class RoleMappingValidationTests : TestKit, IDisposable private readonly IAuditService _auditService; private readonly ServiceCollection _services; + /// <summary>Initializes the test with mocked repositories and a configuration transaction.</summary> public RoleMappingValidationTests() { _securityRepo = Substitute.For<ISecurityRepository>(); @@ -55,8 +56,10 @@ public class RoleMappingValidationTests : TestKit, IDisposable new(new AuthenticatedUser("admin", "admin", new[] { Roles.Administrator }, Array.Empty<string>()), command, Guid.NewGuid().ToString("N")); + /// <inheritdoc /> void IDisposable.Dispose() => Shutdown(); + /// <summary>Verifies that creating a role mapping with an unknown role returns an error and inserts no row.</summary> [Fact] public void CreateRoleMapping_UnknownRole_ReturnsError_NoRowInserted() { @@ -69,11 +72,12 @@ public class RoleMappingValidationTests : TestKit, IDisposable _securityRepo.DidNotReceiveWithAnyArgs().AddMappingAsync(default!, default); } + /// <summary>Verifies that creating a role mapping with a misspelled canonical role returns an error and inserts no row.</summary> [Fact] public void CreateRoleMapping_MisspelledCanonicalRole_ReturnsError_NoRowInserted() { // A pure typo of a real role ("Deploer" for "Deployer") is exactly the silent - // footgun Security-023 describes: it would stamp a role claim no policy matches. + // footgun this validation guards against: it would stamp a role claim no policy matches. var actor = CreateActor(); actor.Tell(Envelope(new CreateRoleMappingCommand("SCADA-Deploy", "Deploer"))); @@ -82,6 +86,7 @@ public class RoleMappingValidationTests : TestKit, IDisposable _securityRepo.DidNotReceiveWithAnyArgs().AddMappingAsync(default!, default); } + /// <summary>Verifies that updating a role mapping to an unknown role returns an error and updates no row.</summary> [Fact] public void UpdateRoleMapping_UnknownRole_ReturnsError_NoRowUpdated() { @@ -96,6 +101,7 @@ public class RoleMappingValidationTests : TestKit, IDisposable _securityRepo.DidNotReceiveWithAnyArgs().UpdateMappingAsync(default!, default); } + /// <summary>Verifies that creating a role mapping with a canonical role succeeds and inserts the row.</summary> [Fact] public void CreateRoleMapping_CanonicalRole_Succeeds_RowInserted() { @@ -114,6 +120,7 @@ public class RoleMappingValidationTests : TestKit, IDisposable Assert.Equal("SCADA-Deploy", inserted.LdapGroupName); } + /// <summary>Verifies that creating a role mapping with a lowercase role stores the canonical casing.</summary> [Fact] public void CreateRoleMapping_LowercaseRole_StoresCanonicalCasing() { @@ -135,6 +142,7 @@ public class RoleMappingValidationTests : TestKit, IDisposable Assert.Equal("Deployer", inserted!.Role); // canonical, not the verbatim "deployer" } + /// <summary>Verifies that updating a role mapping with a lowercase role stores the canonical casing.</summary> [Fact] public void UpdateRoleMapping_LowercaseRole_StoresCanonicalCasing() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SchemaLibraryHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SchemaLibraryHandlerTests.cs index e7343f30..ee361f20 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SchemaLibraryHandlerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SchemaLibraryHandlerTests.cs @@ -14,9 +14,9 @@ using ZB.MOM.WW.ScadaBridge.ManagementService; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// <summary> -/// ManagementActor handler tests for the M9-T32c schema-library CRUD commands. The +/// ManagementActor handler tests for the schema-library CRUD commands. The /// reusable named JSON-Schema library (<see cref="SharedSchema"/> + -/// <see cref="ISharedSchemaRepository"/>, T32a) gains its authoring surface here: +/// <see cref="ISharedSchemaRepository"/>) gains its authoring surface here: /// list/get are read-only (any authenticated user), create/update/delete are /// Designer-gated, the schema JSON is validated as a JSON Schema, and the unique /// Name is enforced with a clear duplicate-name error. Mirrors the repo-backed @@ -28,6 +28,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable private readonly IAuditService _auditService; private readonly ServiceCollection _services; + /// <summary>Builds the management actor handler over a mocked schema repository.</summary> public SchemaLibraryHandlerTests() { _schemaRepo = Substitute.For<ISharedSchemaRepository>(); @@ -49,6 +50,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty<string>()), command, Guid.NewGuid().ToString("N")); + /// <inheritdoc /> void IDisposable.Dispose() => Shutdown(); private const string ValidSchema = @@ -56,6 +58,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable // ── Gating ────────────────────────────────────────────────────────────── + /// <summary>Verifies that creating a shared schema with only the Deployer role returns unauthorized.</summary> [Fact] public void CreateSharedSchema_WithDeployerRole_ReturnsUnauthorized() { @@ -68,6 +71,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable Assert.Contains("Designer", response.Message); } + /// <summary>Verifies that updating a shared schema with no roles returns unauthorized.</summary> [Fact] public void UpdateSharedSchema_WithNoRoles_ReturnsUnauthorized() { @@ -80,6 +84,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable Assert.Contains("Designer", response.Message); } + /// <summary>Verifies that deleting a shared schema with only the Deployer role returns unauthorized.</summary> [Fact] public void DeleteSharedSchema_WithDeployerRole_ReturnsUnauthorized() { @@ -94,6 +99,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable // ── List / Get (read-gated) ───────────────────────────────────────────── + /// <summary>Verifies that listing shared schemas succeeds for a user with no roles.</summary> [Fact] public void ListSharedSchemas_WithNoRoles_ReturnsSuccess() { @@ -113,6 +119,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable Assert.Contains("Addr", response.JsonData); } + /// <summary>Verifies that getting a shared schema succeeds for a user with no roles.</summary> [Fact] public void GetSharedSchema_WithNoRoles_ReturnsSuccess() { @@ -130,6 +137,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable // ── Create round-trip + validation ────────────────────────────────────── + /// <summary>Verifies that creating a shared schema with the Designer role persists it and returns the entity.</summary> [Fact] public void CreateSharedSchema_WithDesignerRole_PersistsAndReturnsEntity() { @@ -155,6 +163,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable Assert.Contains("42", response.JsonData); } + /// <summary>Verifies that creating a shared schema with a duplicate name returns a clear error.</summary> [Fact] public void CreateSharedSchema_WithDuplicateName_ReturnsClearError() { @@ -173,6 +182,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable Assert.Contains("already exists", response.Error); } + /// <summary>Verifies that creating a shared schema with an empty name returns a clear error.</summary> [Fact] public void CreateSharedSchema_WithEmptyName_ReturnsClearError() { @@ -187,6 +197,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable Assert.Contains("name", response.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that creating a shared schema with invalid JSON returns a clear error.</summary> [Fact] public void CreateSharedSchema_WithInvalidJson_ReturnsClearError() { @@ -205,6 +216,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable // ── Update round-trip + validation ────────────────────────────────────── + /// <summary>Verifies that updating a shared schema with the Designer role persists the changes.</summary> [Fact] public void UpdateSharedSchema_WithDesignerRole_PersistsChanges() { @@ -229,6 +241,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable Assert.Equal(updatedSchema, existing.SchemaJson); } + /// <summary>Verifies that updating a shared schema that does not exist returns a clear error.</summary> [Fact] public void UpdateSharedSchema_NotFound_ReturnsClearError() { @@ -246,6 +259,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable Assert.Contains("99", response.Error); } + /// <summary>Verifies that renaming a shared schema to an existing name returns a clear error.</summary> [Fact] public void UpdateSharedSchema_RenameToExistingName_ReturnsClearError() { @@ -266,6 +280,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable Assert.Contains("already exists", response.Error); } + /// <summary>Verifies that updating a shared schema while keeping its own name is allowed.</summary> [Fact] public void UpdateSharedSchema_SameName_IsAllowed() { @@ -289,6 +304,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable // ── Delete round-trip ─────────────────────────────────────────────────── + /// <summary>Verifies that deleting a shared schema with the Designer role deletes it.</summary> [Fact] public void DeleteSharedSchema_WithDesignerRole_Deletes() { @@ -302,10 +318,11 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable _schemaRepo.Received(1).DeleteAsync(5, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that deleting a shared schema records its human-readable name in the audit entry.</summary> [Fact] public void DeleteSharedSchema_RecordsHumanReadableNameInAudit() { - // #260: the delete handler pre-fetches the row name BEFORE deleting so the + // The delete handler pre-fetches the row name BEFORE deleting so the // audit EntityName records "Addr" rather than the numeric id (mirroring the // Site delete handler). _schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()) @@ -324,6 +341,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable Arg.Any<object?>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that listing shared schemas returns an error when the repository throws.</summary> [Fact] public void ListSharedSchemas_WhenRepoThrows_ReturnsError() { @@ -340,8 +358,9 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable Assert.DoesNotContain("db down secret detail", response.Error); } - // ── Concurrent-create race (#260) ─────────────────────────────────────── + // ── Concurrent-create race ─────────────────────────────────────── + /// <summary>Verifies that a unique-constraint violation from a racing insert surfaces as a sanitized error.</summary> [Fact] public void CreateSharedSchema_WhenInsertRacesUniqueViolation_SurfacesSanitizedError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecretProjectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecretProjectionTests.cs index 1f9b513e..25653f34 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecretProjectionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecretProjectionTests.cs @@ -23,6 +23,7 @@ public class SecretProjectionTests : TestKit, IDisposable private readonly IAuditService _auditService; private readonly ServiceCollection _services; + /// <summary>Initializes the test class with stubbed repositories and audit service.</summary> public SecretProjectionTests() { _auditService = Substitute.For<IAuditService>(); @@ -41,10 +42,11 @@ public class SecretProjectionTests : TestKit, IDisposable new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty<string>()), command, Guid.NewGuid().ToString("N")); + /// <inheritdoc /> void IDisposable.Dispose() => Shutdown(); // ======================================================================== - // Task 7 — DataConnection config secret elision + // DataConnection config secret elision // ======================================================================== private ISiteRepository AddSiteRepoWithConnection(DataConnection conn) @@ -57,6 +59,7 @@ public class SecretProjectionTests : TestKit, IDisposable return siteRepo; } + /// <summary>Verifies that the get-data-connection response omits passwords.</summary> [Fact] public void GetDataConnection_ResponseOmitsPasswords() { @@ -74,6 +77,7 @@ public class SecretProjectionTests : TestKit, IDisposable Assert.Contains("hasSecrets", resp.JsonData); // camelCase serializer } + /// <summary>Verifies that the list-data-connections response omits passwords.</summary> [Fact] public void ListDataConnections_ResponseOmitsPasswords() { @@ -91,6 +95,8 @@ public class SecretProjectionTests : TestKit, IDisposable Assert.Contains("hasSecrets", resp.JsonData); } + /// <summary>Verifies that updating a data connection with the sentinel config preserves the stored secret.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateDataConnection_SentinelConfig_PreservesStoredSecret() { @@ -112,6 +118,7 @@ public class SecretProjectionTests : TestKit, IDisposable Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the audit after-state of a data connection update omits passwords.</summary> [Fact] public void UpdateDataConnection_AuditAfterState_OmitsPasswords() { @@ -133,7 +140,7 @@ public class SecretProjectionTests : TestKit, IDisposable } // ======================================================================== - // Task 8 — ExternalSystem AuthConfiguration elision + // ExternalSystem AuthConfiguration elision // ======================================================================== private IExternalSystemRepository AddExternalSystemRepo(ExternalSystemDefinition def) @@ -146,6 +153,7 @@ public class SecretProjectionTests : TestKit, IDisposable return repo; } + /// <summary>Verifies that the get-external-system response omits the auth configuration.</summary> [Fact] public void GetExternalSystem_ResponseOmitsAuthConfiguration() { @@ -163,6 +171,7 @@ public class SecretProjectionTests : TestKit, IDisposable Assert.Contains("hasAuthConfiguration", resp.JsonData); } + /// <summary>Verifies that the list-external-systems response omits the auth configuration.</summary> [Fact] public void ListExternalSystems_ResponseOmitsAuthConfiguration() { @@ -180,6 +189,8 @@ public class SecretProjectionTests : TestKit, IDisposable Assert.Contains("hasAuthConfiguration", resp.JsonData); } + /// <summary>Verifies that updating an external system with a null auth configuration preserves the stored secret.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateExternalSystem_NullAuthConfiguration_PreservesStoredSecret() { @@ -199,6 +210,7 @@ public class SecretProjectionTests : TestKit, IDisposable Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the audit after-state of an external system update omits the auth configuration.</summary> [Fact] public void UpdateExternalSystem_AuditAfterState_OmitsAuthConfiguration() { @@ -220,7 +232,7 @@ public class SecretProjectionTests : TestKit, IDisposable } // ======================================================================== - // Task 9 — DatabaseConnection ConnectionString elision + // DatabaseConnection ConnectionString elision // ======================================================================== private IExternalSystemRepository AddDatabaseConnectionRepo(DatabaseConnectionDefinition def) @@ -233,6 +245,7 @@ public class SecretProjectionTests : TestKit, IDisposable return repo; } + /// <summary>Verifies that the get-database-connection response omits the connection string.</summary> [Fact] public void GetDatabaseConnection_ResponseOmitsConnectionString() { @@ -246,6 +259,7 @@ public class SecretProjectionTests : TestKit, IDisposable Assert.Contains("hasConnectionString", resp.JsonData); } + /// <summary>Verifies that the list-database-connections response omits the connection string.</summary> [Fact] public void ListDatabaseConnections_ResponseOmitsConnectionString() { @@ -259,6 +273,8 @@ public class SecretProjectionTests : TestKit, IDisposable Assert.Contains("hasConnectionString", resp.JsonData); } + /// <summary>Verifies that updating a database connection with a null connection string preserves the stored secret.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateDatabaseConnection_NullConnectionString_PreservesStoredSecret() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs index 795e79e6..e08484e2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs @@ -20,15 +20,15 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// <summary> /// Handler tests for the two-person ("secured") write submit/reject/list flow -/// (M7 OPC UA / MxGateway UX, Task T14b). Submit is gated to Operator, reject to +/// Submit is gated to Operator, reject to /// Verifier; the no-self-approval rule and the MxGateway-protocol precondition /// are enforced inside the handlers and surface as ManagementError. /// </summary> public class SecuredWriteHandlerTests : TestKit, IDisposable { /// <summary> - /// The central node the test writer stamps onto every emitted row. #206 routes - /// SecuredWrite audit through <see cref="ICentralAuditWriter"/>, whose + /// The central node the test writer stamps onto every emitted row. SecuredWrite + /// audit is routed through <see cref="ICentralAuditWriter"/>, whose /// <see cref="INodeIdentityProvider"/> stamps <c>SourceNode</c>; the handler tests /// assert each lifecycle row carries this value (proving the stamping actually runs). /// </summary> @@ -40,6 +40,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable private readonly StubCommunicationService _comms; private readonly ServiceCollection _services; + /// <summary>Initializes the test kit with substitute repositories, the real central audit writer and a stub communication service.</summary> public SecuredWriteHandlerTests() { _siteRepo = Substitute.For<ISiteRepository>(); @@ -59,7 +60,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable _services.AddSingleton<IOptions<ManagementServiceOptions>>( Options.Create(new ManagementServiceOptions())); - // #206: route SecuredWrite audit through the REAL CentralAuditWriter (the same + // Route SecuredWrite audit through the REAL CentralAuditWriter (the same // central-direct-write path Notification Outbox dispatch + Inbound API use) so // these tests exercise SourceNode stamping end-to-end — actor → writer → repo. // The writer stamps SourceNode from the INodeIdentityProvider (central-a/central-b) @@ -78,7 +79,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable /// <summary> /// Captures every <see cref="AuditEvent"/> that lands at the substituted /// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — i.e. the row AFTER the - /// real <see cref="CentralAuditWriter"/> has stamped <c>SourceNode</c> (#206), so the + /// real <see cref="CentralAuditWriter"/> has stamped <c>SourceNode</c>, so the /// captured events carry the production node-of-origin value. Audit emission is /// best-effort and asynchronous off the actor thread, so a short await-condition /// poll lets the captured list settle before assertions. @@ -112,20 +113,26 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable /// </summary> private sealed class StubCommunicationService : CommunicationService { + /// <summary>Initializes a new stub with default communication options.</summary> public StubCommunicationService() : base(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance) { } + /// <summary>Gets the last write request relayed through the stub.</summary> public WriteTagRequest? LastRequest { get; private set; } + /// <summary>Gets the number of times the stub was asked to write a tag.</summary> public int CallCount { get; private set; } + /// <summary>Gets or sets the function that produces the canned response for a write request.</summary> public Func<WriteTagRequest, WriteTagResponse> Responder { get; set; } = req => new WriteTagResponse(req.CorrelationId, Success: true, ErrorMessage: null, DateTimeOffset.UtcNow); + /// <summary>Gets or sets an exception to throw when a write is attempted.</summary> public Exception? ThrowOnWrite { get; set; } + /// <inheritdoc /> public override Task<WriteTagResponse> WriteTagAsync( string siteId, WriteTagRequest request, CancellationToken ct = default) { @@ -154,6 +161,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable private static ManagementEnvelope ScopedEnvelope(object command, string username, string[] permittedSiteIds, params string[] roles) => new(new AuthenticatedUser(username, username, roles, permittedSiteIds), command, Guid.NewGuid().ToString("N")); + /// <inheritdoc /> void IDisposable.Dispose() => Shutdown(); /// <summary>Wires a site whose single connection uses the given protocol.</summary> @@ -172,6 +180,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable // Submit // ------------------------------------------------------------------------ + /// <summary>Verifies that submitting on a non-MxGateway connection returns an error and inserts no row.</summary> [Fact] public void Submit_OnNonMxGatewayConnection_ReturnsError_NoRowInserted() { @@ -190,6 +199,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable _securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default); } + /// <summary>Verifies that submitting on an unknown connection returns an error and inserts no row.</summary> [Fact] public void Submit_OnUnknownConnection_ReturnsError_NoRowInserted() { @@ -207,6 +217,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable _securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default); } + /// <summary>Verifies that submitting on an MxGateway connection inserts a pending row stamped with the operator user.</summary> [Fact] public void Submit_OnMxGatewayConnection_InsertsPendingRowWithOperatorUser() { @@ -274,8 +285,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable /// Arms the reject compare-and-swap to succeed (this verifier wins the /// Pending->Rejected transition). NSubstitute's unarmed default for the /// bool-returning CAS is <c>false</c>, i.e. "lost the race", so every reject test - /// that expects the decision to land must arm it explicitly (arch-review - /// 2026-08-19 P6-1). + /// that expects the decision to land must arm it explicitly. /// </summary> private void ArmRejectCasSuccess(long id) => _securedWriteRepo.TryMarkRejectedAsync( @@ -293,6 +303,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Empty(captured); } + /// <summary>Verifies that rejecting as the submitting user returns an error and leaves the status unchanged.</summary> [Fact] public void Reject_BySubmittingUser_ReturnsError_StatusUnchanged() { @@ -311,6 +322,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable default, default!, default, default, default); } + /// <summary>Verifies that rejecting as a different user flips the status to rejected through the compare-and-swap, with no blind update.</summary> [Fact] public void Reject_ByDifferentUser_CasFlipsStatusToRejected_NoBlindUpdate() { @@ -326,7 +338,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); // The decision goes through the conditional Pending->Rejected CAS, exactly once, - // carrying the verifier identity + comment (arch-review 2026-08-19 P6-1). + // carrying the verifier identity + comment. _securedWriteRepo.Received(1).TryMarkRejectedAsync( 7, "bob", "not authorized", Arg.Any<DateTime>(), Arg.Any<CancellationToken>()); // The blind full-row overwrite is gone — it could land mid-relay on a row a @@ -341,6 +353,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Contains("alice", response.JsonData); } + /// <summary>Verifies that rejecting a non-pending row returns an error.</summary> [Fact] public void Reject_NonPendingRow_ReturnsError() { @@ -362,10 +375,11 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable AssertNoAuditRows(captured); } + /// <summary>Verifies that a reject that loses the compare-and-swap race returns a conflict, writes nothing and emits no reject audit row.</summary> [Fact] public void Reject_WhenCasLosesRace_ReturnsConflict_WritesNothing_NoRejectAudit() { - // The P6-1 race, from the reject side: the status read at the top of the handler + // The race, from the reject side: the status read at the top of the handler // saw Pending, but a concurrent approve won the Pending->Approved CAS and entered // its multi-second WriteTagAsync relay before this reject reached the store. The // reject must lose (CAS returns false), record NOTHING — no status write, no @@ -409,6 +423,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable AssertNoAuditRows(captured); } + /// <summary>Verifies that approving an already rejected row returns an error with no compare-and-swap, relay or audit.</summary> [Fact] public void Approve_OnAlreadyRejectedRow_ReturnsError_NoCas_NoRelay_NoAudit() { @@ -431,6 +446,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable AssertNoAuditRows(captured); } + /// <summary>Verifies that rejecting a missing row returns an error.</summary> [Fact] public void Reject_MissingRow_ReturnsError() { @@ -449,6 +465,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable // List // ------------------------------------------------------------------------ + /// <summary>Verifies that listing filters by status and site.</summary> [Fact] public void List_FiltersByStatusAndSite() { @@ -478,6 +495,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable _securedWriteRepo.Received(1).QueryAsync("Pending", "SITE1", 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that listing with no filters passes nulls to the repository.</summary> [Fact] public void List_NoFilters_PassesNullsToRepository() { @@ -494,6 +512,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable _securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a site-scoped reader requesting an out-of-scope site is unauthorized.</summary> [Fact] public void List_SiteScopedReader_ExplicitOutOfScopeSiteFilter_Unauthorized() { @@ -503,6 +522,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that an unfiltered list for a site-scoped reader queries only the permitted site identifiers.</summary> [Fact] public void List_SiteScopedReader_Unfiltered_QueriesOnlyPermittedIdentifiers() { @@ -522,6 +542,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a system-wide reader passes a null permitted-sites filter.</summary> [Fact] public void List_SystemWideReader_PassesNullPermittedFilter() { @@ -537,7 +558,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable } // ------------------------------------------------------------------------ - // Approve (execute) — Task C3 + // Approve (execute) // ------------------------------------------------------------------------ /// <summary>Arms the CAS to succeed (a different verifier wins the race).</summary> @@ -546,6 +567,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable id, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>()) .Returns(true); + /// <summary>Verifies that approval by a different verifier relays the write and flips the status to executed.</summary> [Fact] public void Approve_ByDifferentVerifier_RelaysWrite_FlipsStatusToExecuted() { @@ -581,6 +603,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Null(updated.ExecutionError); } + /// <summary>Verifies that approving as the submitting user returns an error with no compare-and-swap and no relay.</summary> [Fact] public void Approve_BySubmittingUser_ReturnsError_CasNotCalled_NoRelay() { @@ -599,6 +622,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Equal(0, _comms.CallCount); } + /// <summary>Verifies that an approval that loses the compare-and-swap race returns an error and does not relay.</summary> [Fact] public void Approve_WhenCasLosesRace_ReturnsError_NoRelay() { @@ -622,6 +646,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable AssertNoAuditRows(captured); } + /// <summary>Verifies that a failed relay flips the status to failed and records the error.</summary> [Fact] public void Approve_WhenRelayFails_FlipsStatusToFailed_WithError() { @@ -649,6 +674,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.NotNull(updated.ExecutedAtUtc); } + /// <summary>Verifies that a relay that throws flips the status to failed and records the exception message.</summary> [Fact] public void Approve_WhenRelayThrows_FlipsStatusToFailed_WithExceptionMessage() { @@ -672,6 +698,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Contains("transport down", updated.ExecutionError); } + /// <summary>Verifies that an unknown value type flips the status to failed without relaying.</summary> [Fact] public void Approve_UnknownValueType_FlipsStatusToFailed_NoRelay() { @@ -696,6 +723,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Equal("unknown value type", updated.ExecutionError); } + /// <summary>Verifies that approving when the connection is no longer MxGateway flips the status to failed without relaying.</summary> [Fact] public void Approve_WhenConnectionNoLongerMxGateway_FlipsStatusToFailed_NoRelay() { @@ -736,6 +764,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Failure, execute.Outcome); } + /// <summary>Verifies that approving when the connection was deleted flips the status to failed as not found, without relaying.</summary> [Fact] public void Approve_WhenConnectionDeleted_FlipsStatusToFailed_NotFound_NoRelay() { @@ -766,9 +795,10 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable } // ------------------------------------------------------------------------ - // List paging — Task 17 (arch-review P3) + // List paging // ------------------------------------------------------------------------ + /// <summary>Verifies that listing with skip and take forwards paging and returns the total count.</summary> [Fact] public void List_WithSkipTake_ForwardsPagingAndReturnsTotalCount() { @@ -786,9 +816,10 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable } // ------------------------------------------------------------------------ - // Opportunistic expiry sweep on list — Task 16 (arch-review S2) + // Opportunistic expiry sweep on list // ------------------------------------------------------------------------ + /// <summary>Verifies that listing sweeps stale pending rows to expired and calls the expiry compare-and-swap once, for the stale rows only.</summary> [Fact] public void List_SweepsStalePendingRows_MarksExpired_CallsTryMarkExpiredOnceForStaleOnly() { @@ -822,9 +853,10 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable } // ------------------------------------------------------------------------ - // Server-side TTL expiry — Task 15 (arch-review S2) + // Server-side TTL expiry // ------------------------------------------------------------------------ + /// <summary>Verifies that approving a pending row older than the time-to-live expires it instead of executing.</summary> [Fact] public void Approve_PendingRowOlderThanTtl_ExpiresInsteadOfExecuting() { @@ -845,6 +877,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable _securedWriteRepo.Received(1).TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that rejecting a pending row older than the time-to-live expires it instead of rejecting.</summary> [Fact] public void Reject_PendingRowOlderThanTtl_ExpiresInsteadOfRejecting() { @@ -865,6 +898,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable _securedWriteRepo.Received(1).TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that approving a fresh pending row still executes.</summary> [Fact] public void Approve_FreshPendingRow_StillExecutes() { @@ -904,6 +938,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable return new Guid(bytes); } + /// <summary>Verifies that submitting emits exactly one submit audit row carrying the operator and correlation id.</summary> [Fact] public void Submit_EmitsExactlyOneSubmitAuditRow_WithOperatorAndCorrelation() { @@ -927,13 +962,14 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Equal(CorrelationFor(55L), evt.CorrelationId); Assert.Equal("SecuredWrite", evt.Category); Assert.Equal("SITE1/Mx1/Tag.Setpoint", evt.Target); - // #206: the Submit row is stamped with the writing central node's identifier + // The Submit row is stamped with the writing central node's identifier // (it routed through the real CentralAuditWriter), not the legacy null SourceNode. Assert.Equal(ExpectedSourceNode, evt.SourceNode); // Exactly one row — no stray duplicates. Assert.Single(captured); } + /// <summary>Verifies that rejecting emits one reject audit row carrying the verifier.</summary> [Fact] public void Reject_EmitsOneRejectAuditRow_WithVerifier() { @@ -952,11 +988,12 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable var evt = SingleOfKind(captured, AuditKind.SecuredWriteReject); Assert.Equal("bob", evt.Actor); Assert.Equal(CorrelationFor(7L), evt.CorrelationId); - // #206: Reject row carries the writing central node's identifier. + // The Reject row carries the writing central node's identifier. Assert.Equal(ExpectedSourceNode, evt.SourceNode); Assert.Single(captured); } + /// <summary>Verifies that a successful approval emits an approve row followed by an execute-delivered row.</summary> [Fact] public void Approve_Success_EmitsApproveThenExecuteDeliveredAuditRows() { @@ -975,7 +1012,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable var approve = SingleOfKind(captured, AuditKind.SecuredWriteApprove); Assert.Equal("bob", approve.Actor); Assert.Equal(CorrelationFor(7L), approve.CorrelationId); - // #206: Approve + Execute rows both carry the writing central node's identifier. + // Approve + Execute rows both carry the writing central node's identifier. Assert.Equal(ExpectedSourceNode, approve.SourceNode); var execute = SingleOfKind(captured, AuditKind.SecuredWriteExecute); @@ -987,6 +1024,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Equal(2, captured.Count); } + /// <summary>Verifies that a failing audit insert does not fail the submit and the row is still persisted.</summary> [Fact] public void Submit_AuditInsertThrows_StillSucceeds_RowStillPersisted() { @@ -998,7 +1036,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable _securedWriteRepo.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()) .Returns(55L); // Best-effort: a thrown audit insert must NOT abort the secured-write action. - // #206: the throw now happens behind the real CentralAuditWriter (which swallows + // The throw now happens behind the real CentralAuditWriter (which swallows // it), so this still exercises the standing "audit failure never aborts the // action" invariant — now through the central-direct-write path. _auditRepo.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>()) @@ -1019,6 +1057,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Equal("alice", inserted.OperatorUser); } + /// <summary>Verifies that a corrupt list value JSON flips the status to failed with a decode error and no relay.</summary> [Fact] public void Approve_CorruptListValueJson_FlipsStatusToFailed_DecodeError_NoRelay() { @@ -1059,6 +1098,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable // Site scope on submit / approve / reject (arch-review R2 N3) // ------------------------------------------------------------------------ + /// <summary>Verifies that a site-scoped operator submitting for an out-of-scope site is unauthorized and no row is inserted.</summary> [Fact] public void Submit_SiteScopedOperator_OutOfScopeSite_Unauthorized_NoRowInserted() { @@ -1071,6 +1111,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable _securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default); } + /// <summary>Verifies that a site-scoped operator submitting for an in-scope site succeeds.</summary> [Fact] public void Submit_SiteScopedOperator_InScopeSite_Succeeds() { @@ -1082,6 +1123,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that a site-scoped verifier approving for an out-of-scope site is unauthorized and never relays.</summary> [Fact] public void Approve_SiteScopedVerifier_OutOfScopeSite_Unauthorized_NeverRelays() { @@ -1099,6 +1141,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Equal(0, _comms.CallCount); // the device write is NEVER relayed } + /// <summary>Verifies that a site-scoped verifier rejecting for an out-of-scope site is unauthorized.</summary> [Fact] public void Reject_SiteScopedVerifier_OutOfScopeSite_Unauthorized() { @@ -1117,6 +1160,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable default, default!, default, default, default); } + /// <summary>Verifies that a scoped administrator bypasses site scoping on approval.</summary> [Fact] public void Approve_ScopedAdministrator_BypassesSiteScope() { @@ -1140,7 +1184,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable } // ------------------------------------------------------------------------ - // Resolve-before-scope oracles (arch-review 2026-08-19 P6-5) + // Resolve-before-scope oracles // // Submit/approve/reject used to answer "not found" (and, on approve/reject, // "is 'Executed', not Pending") BEFORE the scope gate ran, so an out-of-scope @@ -1158,6 +1202,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow, }); + /// <summary>Verifies that a site-scoped verifier approving a nonexistent id is unauthorized rather than not found.</summary> [Fact] public void Approve_SiteScopedVerifier_NonexistentId_Unauthorized_NotNotFound() { @@ -1170,6 +1215,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that a site-scoped verifier approving an out-of-scope executed row is unauthorized rather than a status error.</summary> [Fact] public void Approve_SiteScopedVerifier_OutOfScopeExecutedRow_Unauthorized_NotStatusError() { @@ -1185,6 +1231,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Equal(0, _comms.CallCount); } + /// <summary>Verifies that a system-wide verifier approving a nonexistent id still gets a not-found error.</summary> [Fact] public void Approve_SystemWideVerifier_NonexistentId_StillReportsNotFound() { @@ -1199,6 +1246,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Contains("not found", response.Error); } + /// <summary>Verifies that a site-scoped verifier rejecting a nonexistent id is unauthorized rather than not found.</summary> [Fact] public void Reject_SiteScopedVerifier_NonexistentId_Unauthorized_NotNotFound() { @@ -1211,6 +1259,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that a site-scoped verifier rejecting an out-of-scope executed row is unauthorized rather than a status error.</summary> [Fact] public void Reject_SiteScopedVerifier_OutOfScopeExecutedRow_Unauthorized_NotStatusError() { @@ -1225,11 +1274,12 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable default, default!, default, default, default); } + /// <summary>Verifies that a system-wide verifier rejecting an executed row still gets a status conflict.</summary> [Fact] public void Reject_SystemWideVerifier_ExecutedRow_StillReportsStatusConflict() { // In-scope/unscoped behavior is unchanged: the status error still names - // the status that blocked the transition (the P6-1 CAS shape is intact). + // the status that blocked the transition (the compare-and-swap shape is intact). SeedExecutedWrite(42); var actor = CreateActor(); @@ -1239,6 +1289,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Contains("Executed", response.Error); } + /// <summary>Verifies that a site-scoped operator submitting for a nonexistent site is unauthorized rather than not found.</summary> [Fact] public void Submit_SiteScopedOperator_NonexistentSite_Unauthorized_NotNotFound() { @@ -1254,6 +1305,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable _securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default); } + /// <summary>Verifies that a system-wide operator submitting for a nonexistent site still gets a not-found error.</summary> [Fact] public void Submit_SystemWideOperator_NonexistentSite_StillReportsNotFound() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs index 6315edf0..e341284b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs @@ -22,6 +22,7 @@ public class TransportGateTests : TestKit, IDisposable private readonly IBundleImporter _importer; private readonly ServiceCollection _services; + /// <summary>Initializes the test with a substituted bundle importer registered in the service collection.</summary> public TransportGateTests() { _importer = Substitute.For<IBundleImporter>(); @@ -40,8 +41,11 @@ public class TransportGateTests : TestKit, IDisposable new(new AuthenticatedUser("admin", "Admin", new[] { "Administrator" }, Array.Empty<string>()), command, Guid.NewGuid().ToString("N")); + /// <inheritdoc /> void IDisposable.Dispose() => Shutdown(); + /// <summary>Verifies that concurrent import-bundle commands are single-flighted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ImportBundle_ConcurrentCommands_AreSingleFlighted() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/DeliveryOutcomeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/DeliveryOutcomeTests.cs index 22b6a0e1..c774a265 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/DeliveryOutcomeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/DeliveryOutcomeTests.cs @@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery; public class DeliveryOutcomeTests { + /// <summary>Verifies that a success outcome carries the resolved targets and no error.</summary> [Fact] public void Success_SetsResolvedTargets_AndNoError() { @@ -14,6 +15,7 @@ public class DeliveryOutcomeTests Assert.Null(outcome.Error); } + /// <summary>Verifies that a transient outcome carries an error and no resolved targets.</summary> [Fact] public void Transient_SetsError_AndNoResolvedTargets() { @@ -24,6 +26,7 @@ public class DeliveryOutcomeTests Assert.Null(outcome.ResolvedTargets); } + /// <summary>Verifies that a permanent outcome carries an error and no resolved targets.</summary> [Fact] public void Permanent_SetsError_AndNoResolvedTargets() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterEwsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterEwsTests.cs index ac7515d4..321e58d5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterEwsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterEwsTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationService.Ews; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery; /// <summary> -/// Task 5: Tests for the EWS transport branch of the Email outbox delivery adapter — +/// Tests for the EWS transport branch of the Email outbox delivery adapter — /// the transport parse, the EWS config-shape validation (each failure permanent and /// short-circuiting before the sender is touched), the request the sender receives, /// the typed transient/permanent outcome mapping, and the proof that an unset or @@ -100,6 +100,8 @@ public class EmailNotificationDeliveryAdapterEwsTests Arg.Any<EwsSendRequest>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that delivery over the EWS transport goes through the EWS sender and returns success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_EwsTransport_SendsThroughEwsSenderAndReturnsSuccess() { @@ -129,6 +131,8 @@ public class EmailNotificationDeliveryAdapterEwsTests Arg.Any<int>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that credentials are split on the first colon only so a password may contain colons.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_PasswordContainsColon_SplitsOnFirstColonOnly() { @@ -143,6 +147,8 @@ public class EmailNotificationDeliveryAdapterEwsTests Assert.Equal(DeliveryResult.Success, outcome.Result); } + /// <summary>Verifies that a transient EWS exception maps to a transient outcome.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_EwsTransientException_ReturnsTransient() { @@ -157,6 +163,8 @@ public class EmailNotificationDeliveryAdapterEwsTests Assert.Contains("server busy", outcome.Error); } + /// <summary>Verifies that a permanent EWS exception maps to a permanent outcome.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_EwsPermanentException_ReturnsPermanent() { @@ -171,6 +179,8 @@ public class EmailNotificationDeliveryAdapterEwsTests Assert.Contains("unauthorized", outcome.Error); } + /// <summary>Verifies that an unclassified sender exception maps to a permanent outcome and redacts the credential.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_UnclassifiedSenderException_ReturnsPermanentAndRedactsCredential() { @@ -186,6 +196,8 @@ public class EmailNotificationDeliveryAdapterEwsTests Assert.DoesNotContain("super-secret-password", outcome.Error); } + /// <summary>Verifies that a cancelled token throws an operation-canceled exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_CancelledToken_ThrowsOperationCanceledException() { @@ -209,6 +221,8 @@ public class EmailNotificationDeliveryAdapterEwsTests () => adapter.DeliverAsync(MakeNotification(), cts.Token)); } + /// <summary>Verifies that a non-HTTPS endpoint returns a permanent failure without calling the sender.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_NonHttpsEndpoint_ReturnsPermanentWithoutCallingSender() { @@ -222,6 +236,8 @@ public class EmailNotificationDeliveryAdapterEwsTests await AssertSenderUntouched(); } + /// <summary>Verifies that a non-absolute endpoint returns a permanent failure without calling the sender.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_NonAbsoluteEndpoint_ReturnsPermanentWithoutCallingSender() { @@ -235,6 +251,8 @@ public class EmailNotificationDeliveryAdapterEwsTests await AssertSenderUntouched(); } + /// <summary>Verifies that a non-basic auth type returns a permanent failure without calling the sender.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_NonBasicAuthType_ReturnsPermanentWithoutCallingSender() { @@ -248,6 +266,9 @@ public class EmailNotificationDeliveryAdapterEwsTests await AssertSenderUntouched(); } + /// <summary>Verifies that malformed credentials return a permanent failure without calling the sender.</summary> + /// <param name="credentials">The malformed credentials string, or null.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(null)] [InlineData("")] @@ -267,6 +288,8 @@ public class EmailNotificationDeliveryAdapterEwsTests await AssertSenderUntouched(); } + /// <summary>Verifies that an unknown transport returns a permanent failure without calling the sender.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_UnknownTransport_ReturnsPermanentWithoutCallingSender() { @@ -283,6 +306,8 @@ public class EmailNotificationDeliveryAdapterEwsTests Arg.Any<int>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that delivery with no EWS sender registered returns a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_NoEwsSenderRegistered_ReturnsPermanent() { @@ -296,6 +321,9 @@ public class EmailNotificationDeliveryAdapterEwsTests Assert.Contains("registered", outcome.Error); } + /// <summary>Verifies that an SMTP or unset transport takes the SMTP path.</summary> + /// <param name="transport">The configured transport, or null when unset.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(null)] [InlineData("Smtp")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs index 7ff63fb1..9bbc8762 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs @@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationService; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery; /// <summary> -/// Task 12: Tests for the Email outbox delivery adapter — list/recipient/SMTP-config +/// Tests for the Email outbox delivery adapter — list/recipient/SMTP-config /// resolution, SMTP send, and the three-way (Success / Permanent / Transient) outcome /// classification. /// </summary> @@ -59,12 +59,15 @@ public class EmailNotificationDeliveryAdapterTests _repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { smtpConfig }); } + /// <summary>Verifies that the adapter reports the Email notification type.</summary> [Fact] public void Type_IsEmail() { Assert.Equal(NotificationType.Email, CreateAdapter().Type); } + /// <summary>Verifies that a successful delivery returns success with the resolved targets.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_HappyPath_ReturnsSuccessWithResolvedTargets() { @@ -80,6 +83,8 @@ public class EmailNotificationDeliveryAdapterTests Assert.Null(outcome.Error); } + /// <summary>Verifies that a missing notification list returns a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_ListNotFound_ReturnsPermanent() { @@ -93,6 +98,8 @@ public class EmailNotificationDeliveryAdapterTests Assert.Contains("not found", outcome.Error); } + /// <summary>Verifies that a list with no recipients returns a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_NoRecipients_ReturnsPermanent() { @@ -107,6 +114,8 @@ public class EmailNotificationDeliveryAdapterTests Assert.Contains("recipient", outcome.Error); } + /// <summary>Verifies that a missing SMTP configuration returns a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_NoSmtpConfig_ReturnsPermanent() { @@ -125,6 +134,8 @@ public class EmailNotificationDeliveryAdapterTests Assert.Contains("SMTP", outcome.Error); } + /// <summary>Verifies that a permanent SMTP exception returns a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_SmtpPermanentException_ReturnsPermanent() { @@ -141,6 +152,8 @@ public class EmailNotificationDeliveryAdapterTests Assert.Contains("mailbox unavailable", outcome.Error); } + /// <summary>Verifies that an SMTP 5xx command exception returns a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_Smtp5xxCommandException_ReturnsPermanent() { @@ -157,6 +170,8 @@ public class EmailNotificationDeliveryAdapterTests Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result); } + /// <summary>Verifies that a socket exception returns a transient failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_SocketException_ReturnsTransient() { @@ -173,6 +188,8 @@ public class EmailNotificationDeliveryAdapterTests Assert.NotNull(outcome.Error); } + /// <summary>Verifies that an SMTP 4xx command exception returns a transient failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_Smtp4xxCommandException_ReturnsTransient() { @@ -189,6 +206,8 @@ public class EmailNotificationDeliveryAdapterTests Assert.Equal(DeliveryResult.TransientFailure, outcome.Result); } + /// <summary>Verifies that an unclassified exception returns a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_UnclassifiedException_ReturnsPermanent() { @@ -205,6 +224,8 @@ public class EmailNotificationDeliveryAdapterTests Assert.NotNull(outcome.Error); } + /// <summary>Verifies that a malformed recipient address returns a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_MalformedRecipientAddress_ReturnsPermanent() { @@ -230,6 +251,8 @@ public class EmailNotificationDeliveryAdapterTests Assert.Contains(malformed, outcome.Error); } + /// <summary>Verifies that a cancelled token throws an <c>OperationCanceledException</c>.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_CancelledToken_ThrowsOperationCanceledException() { @@ -265,6 +288,8 @@ public class EmailNotificationDeliveryAdapterTests () => adapter.DeliverAsync(MakeNotification(), cts.Token)); } + /// <summary>Verifies that when several SMTP configurations exist the one with the lowest id wins.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MultipleSmtpConfigs_LowestIdWins() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/SmsNotificationDeliveryAdapterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/SmsNotificationDeliveryAdapterTests.cs index 538d8ad7..32c2a08c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/SmsNotificationDeliveryAdapterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/SmsNotificationDeliveryAdapterTests.cs @@ -32,7 +32,9 @@ public class SmsNotificationDeliveryAdapterTests { private readonly Queue<Func<HttpRequestMessage, Task<HttpResponseMessage>>> _responders; + /// <summary>The HTTP requests received by the handler, in order.</summary> public List<HttpRequestMessage> Requests { get; } = new(); + /// <summary>The request bodies received by the handler, in order.</summary> public List<string> RequestBodies { get; } = new(); private ScriptedHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, Task<HttpResponseMessage>>> responders) @@ -41,6 +43,8 @@ public class SmsNotificationDeliveryAdapterTests } /// <summary>One queued response per status code (one per recipient POST).</summary> + /// <param name="statusCodes">The status codes to queue, one per expected request.</param> + /// <returns>A handler that answers each request with the next queued status code.</returns> public static ScriptedHttpMessageHandler ForStatuses(params HttpStatusCode[] statusCodes) => new(statusCodes.Select(code => new Func<HttpRequestMessage, Task<HttpResponseMessage>>( @@ -50,9 +54,12 @@ public class SmsNotificationDeliveryAdapterTests })))); /// <summary>Custom per-request responders (e.g. to throw a transport exception).</summary> + /// <param name="responders">The responders to queue, one per expected request.</param> + /// <returns>A handler that answers each request with the next queued responder.</returns> public static ScriptedHttpMessageHandler ForResponders( params Func<HttpRequestMessage, Task<HttpResponseMessage>>[] responders) => new(responders); + /// <inheritdoc /> protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { @@ -116,12 +123,15 @@ public class SmsNotificationDeliveryAdapterTests }); } + /// <summary>Verifies that the adapter reports the SMS notification type.</summary> [Fact] public void Type_IsSms() { Assert.Equal(NotificationType.Sms, CreateAdapter(ScriptedHttpMessageHandler.ForStatuses()).Type); } + /// <summary>Verifies that delivery to a single recipient with a 201 response succeeds and returns the resolved targets.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_SingleRecipient201_ReturnsSuccessWithResolvedTargets() { @@ -154,6 +164,8 @@ public class SmsNotificationDeliveryAdapterTests _httpClientFactory.Received(1).CreateClient(SmsNotificationDeliveryAdapter.HttpClientName); } + /// <summary>Verifies that delivery to several recipients that are all accepted succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_MultiRecipient201_AllAccepted_ReturnsSuccess() { @@ -169,6 +181,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Equal(2, handler.Requests.Count); } + /// <summary>Verifies that delivery uses the messaging service SID when no from number is configured.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_UsesMessagingServiceSid_WhenFromNumberAbsent() { @@ -191,6 +205,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.DoesNotContain("From=", body); } + /// <summary>Verifies that a 500 response is classified as a transient failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_500_ReturnsTransient() { @@ -204,6 +220,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.NotNull(outcome.Error); } + /// <summary>Verifies that a 429 response is classified as a transient failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_429_ReturnsTransient() { @@ -216,6 +234,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Equal(DeliveryResult.TransientFailure, outcome.Result); } + /// <summary>Verifies that an HTTP request exception is classified as a transient failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_HttpRequestException_ReturnsTransient() { @@ -229,6 +249,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Equal(DeliveryResult.TransientFailure, outcome.Result); } + /// <summary>Verifies that a timeout is classified as a transient failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_Timeout_ReturnsTransient() { @@ -244,6 +266,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Equal(DeliveryResult.TransientFailure, outcome.Result); } + /// <summary>Verifies that a 400 response is classified as a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_400_ReturnsPermanent() { @@ -257,6 +281,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.NotNull(outcome.Error); } + /// <summary>Verifies that a 401 response is classified as a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_401_ReturnsPermanent() { @@ -269,6 +295,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result); } + /// <summary>Verifies that a mix of accepted and permanently bad recipients succeeds and notes the bad number.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_MixAcceptedAndPermanentBad_ReturnsSuccessWithBadNumberNoted() { @@ -285,6 +313,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Contains("+1BADNUMBER", outcome.ResolvedTargets); } + /// <summary>Verifies that a mix of accepted and transiently failed recipients is classified as transient.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_MixAcceptedAndTransient_ReturnsTransient() { @@ -299,6 +329,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Equal(DeliveryResult.TransientFailure, outcome.Result); } + /// <summary>Verifies that a transient failure mid-list short-circuits so the remaining recipients are not attempted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MidListTransient_ShortCircuits_RemainingRecipientsNotAttempted() { @@ -317,6 +349,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Single(handler.Requests); } + /// <summary>Verifies that a missing notification list is classified as a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_ListNotFound_ReturnsPermanent() { @@ -331,6 +365,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Contains("not found", outcome.Error); } + /// <summary>Verifies that a list with no phone recipients is classified as a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_NoPhoneRecipients_ReturnsPermanent() { @@ -350,6 +386,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Contains("no SMS recipients", outcome.Error); } + /// <summary>Verifies that a missing SMS configuration is classified as a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_NoSmsConfig_ReturnsPermanent() { @@ -367,6 +405,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Contains("SMS configuration", outcome.Error); } + /// <summary>Verifies that an incomplete configuration missing the auth token is classified as a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_IncompleteConfig_MissingAuthToken_ReturnsPermanent() { @@ -385,6 +425,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Contains("incomplete", outcome.Error); } + /// <summary>Verifies that an incomplete configuration with neither a from number nor a messaging service SID is classified as a permanent failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_IncompleteConfig_NoFromAndNoMessagingServiceSid_ReturnsPermanent() { @@ -407,6 +449,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.Contains("incomplete", outcome.Error); } + /// <summary>Verifies that delivery defaults to the Twilio base URL when no API base URL is configured.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_DefaultsToTwilioBaseUrl_WhenApiBaseUrlAbsent() { @@ -426,6 +470,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.StartsWith("https://api.twilio.com/2010-04-01/Accounts/", request.RequestUri!.ToString()); } + /// <summary>Verifies that the auth token never appears in a returned error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_AuthToken_NeverAppearsInReturnedError() { @@ -457,6 +503,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.DoesNotContain(base64Credential, outcome.Error); } + /// <summary>Verifies that the auth token never appears on a transport exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_AuthToken_NeverAppearsOnTransportException() { @@ -473,6 +521,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.DoesNotContain(AuthToken, outcome.Error); } + /// <summary>Verifies that a long message body is truncated to the maximum message length.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_LongBody_TruncatedToMaxMessageLength() { @@ -495,6 +545,8 @@ public class SmsNotificationDeliveryAdapterTests Assert.DoesNotContain(new string('x', 100), body); } + /// <summary>Verifies that caller cancellation propagates out of delivery.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_CallerCancellation_Propagates() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Ingest/NotificationIngestTypeStampingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Ingest/NotificationIngestTypeStampingTests.cs index b09624d0..3c8c3a68 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Ingest/NotificationIngestTypeStampingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Ingest/NotificationIngestTypeStampingTests.cs @@ -26,6 +26,7 @@ public class NotificationIngestTypeStampingTests : TestKit private readonly INotificationRepository _listRepository = Substitute.For<INotificationRepository>(); + /// <summary>Initializes a new instance of the <c>NotificationIngestTypeStampingTests</c> class.</summary> public NotificationIngestTypeStampingTests() { // Default: a fresh insert succeeds. Individual tests configure the list lookup. @@ -72,6 +73,7 @@ public class NotificationIngestTypeStampingTests : TestKit .Returns(new NotificationList(name) { Type = type }); } + /// <summary>Verifies that ingesting a notification for an email-typed list stamps the type as email.</summary> [Fact] public void Ingest_EmailTypedList_StampsEmail() { @@ -90,6 +92,7 @@ public class NotificationIngestTypeStampingTests : TestKit Arg.Any<CancellationToken>()); } + /// <summary>Verifies that ingesting a notification for an SMS-typed list stamps the type as SMS.</summary> [Fact] public void Ingest_SmsTypedList_StampsSms() { @@ -108,6 +111,7 @@ public class NotificationIngestTypeStampingTests : TestKit Arg.Any<CancellationToken>()); } + /// <summary>Verifies that ingesting a notification for a missing list stamps the default type of email.</summary> [Fact] public void Ingest_MissingList_StampsEmailDefault() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Kpi/NotificationOutboxKpiSampleSourceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Kpi/NotificationOutboxKpiSampleSourceTests.cs index 361b8377..e530914a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Kpi/NotificationOutboxKpiSampleSourceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Kpi/NotificationOutboxKpiSampleSourceTests.cs @@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Kpi; /// <summary> -/// Tests for <see cref="NotificationOutboxKpiSampleSource"/> — the M6 KPI sample source that +/// Tests for <see cref="NotificationOutboxKpiSampleSource"/> — the KPI sample source that /// snapshots the Notification Outbox delivery KPIs (global / per-site / per-node) into the /// central KPI-history store. /// </summary> @@ -46,6 +46,7 @@ public class NotificationOutboxKpiSampleSourceTests Microsoft.Extensions.Options.Options.Create(options), Microsoft.Extensions.Options.Options.Create(kpiHistoryOptions)); + /// <summary>Verifies that the sample source reports the notification outbox as its source.</summary> [Fact] public void Source_IsNotificationOutbox() { @@ -58,6 +59,7 @@ public class NotificationOutboxKpiSampleSourceTests // live in the shared KpiMetrics catalog. Their VALUES are persisted in the KpiSample // store and rendered by the trend UI, so a rename would silently orphan history — // pin the exact historical strings here. + /// <summary>Verifies that the notification outbox metric catalog preserves its historical string values.</summary> [Fact] public void KpiMetricCatalog_NotificationOutbox_PreservesHistoricalStringValues() { @@ -68,6 +70,8 @@ public class NotificationOutboxKpiSampleSourceTests Assert.Equal("oldestPendingAgeSeconds", KpiMetrics.NotificationOutbox.OldestPendingAgeSeconds); } + /// <summary>Verifies that collecting passes cutoffs anchored on the captured-at time.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_PassesCutoffsAnchoredOnCapturedAt() { @@ -88,6 +92,8 @@ public class NotificationOutboxKpiSampleSourceTests expectedStuckCutoff, expectedDeliveredSince, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that collecting emits global, per-site and per-node samples with the expected tuples.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_EmitsGlobalSiteAndNodeSamples_WithExpectedTuples() { @@ -152,15 +158,17 @@ public class NotificationOutboxKpiSampleSourceTests AssertSample(samples, "oldestPendingAgeSeconds", KpiScopes.Node, "node-a", 60); } - // P5-5 (arch-review Low): the KPI-history rollup fold sums every deliveredLastInterval + // The KPI-history rollup fold sums every deliveredLastInterval // sample recorded in an hour, which over-counts unless each sample is a non-overlapping // delta covering exactly one recorder tick (KpiHistoryOptions.SampleInterval). Before the // fix, this class recorded the RAW DeliveredKpiWindow-windowed count verbatim — with a // 15-minute DeliveredKpiWindow sampled every 1-minute SampleInterval, 60 overlapping // 15-minute counts summed to ~15x the true hourly total. Proven against the unfixed // computation: temporarily short-circuiting DeliveredIntervalScaleFactor to always return - // 1.0 (i.e. "record the raw count verbatim", the pre-P5-5 behavior) makes this test fail + // 1.0 (i.e. "record the raw count verbatim", the old behavior) makes this test fail // (30 != 2); restoring the real scale factor passes it. + /// <summary>Verifies that the delivered-last-interval count is rescaled onto the KPI history sample interval.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_RescalesDeliveredLastInterval_OntoKpiHistorySampleInterval() { @@ -191,6 +199,8 @@ public class NotificationOutboxKpiSampleSourceTests Assert.Equal(2.0, delivered.Value, precision: 6); } + /// <summary>Verifies that the oldest pending age is omitted when it is null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_OmitsOldestPendingAge_WhenNull() { @@ -219,6 +229,8 @@ public class NotificationOutboxKpiSampleSourceTests AssertSample(samples, "deliveredLastInterval", KpiScopes.Global, null, 0); } + /// <summary>Verifies that collecting returns an empty list, never null, when there is nothing to report.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_ReturnsEmptyList_NeverNull_WhenNothingToReport() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs index f5bbeb1b..50e4e0ce 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs @@ -15,7 +15,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; /// <summary> -/// M4 Bundle B (B2) — verifies the <see cref="NotificationOutboxActor"/> +/// Verifies the <see cref="NotificationOutboxActor"/> /// dispatcher loop emits exactly ONE /// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/> /// audit row with <see cref="AuditStatus.Attempted"/> per attempt regardless of @@ -39,12 +39,15 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit /// </summary> private sealed class RecordingCentralAuditWriter : ICentralAuditWriter { - // C3 (Task 2.5): store the decomposed row view so assertions keep + // Store the decomposed row view so assertions keep // reading the ScadaBridge domain fields (Channel/Kind/Status/…) as // typed properties; the canonical record carries them in DetailsJson. + /// <summary>Gets the decomposed audit rows captured by the writer.</summary> public List<AuditRowProjection.AuditRowValues> Events { get; } = new(); + /// <summary>Gets or sets an optional callback invoked on every write, used to simulate writer faults.</summary> public Func<AuditEvent, Task>? OnWrite { get; set; } + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) @@ -74,10 +77,14 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit private readonly Func<DeliveryOutcome> _outcome; public int CallCount; + /// <summary>Initializes the adapter with the outcome factory it returns on each delivery.</summary> + /// <param name="outcome">Factory producing the delivery outcome for each call.</param> public StubAdapter(Func<DeliveryOutcome> outcome) { _outcome = outcome; } + /// <inheritdoc /> public NotificationType Type => NotificationType.Email; + /// <inheritdoc /> public Task<DeliveryOutcome> DeliverAsync( Notification notification, CancellationToken cancellationToken = default) { @@ -138,6 +145,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit } } + /// <summary>Verifies that a successful attempt emits one NotifyDeliver audit event with Attempted status.</summary> [Fact] public void Attempt_Success_EmitsOneEvent_KindNotifyDeliver_StatusAttempted() { @@ -170,10 +178,11 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit }); } + /// <summary>Verifies that the attempt event carries the notification's origin execution id as its execution id.</summary> [Fact] public void Attempt_CarriesOriginExecutionId_AsExecutionId() { - // Audit Log #23: the Attempted NotifyDeliver row must echo the + // The Attempted NotifyDeliver row must echo the // notification's OriginExecutionId so all rows for one run share an id. SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); var executionId = Guid.NewGuid(); @@ -193,6 +202,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit }); } + /// <summary>Verifies that a null origin execution id yields a null execution id on the attempt event.</summary> [Fact] public void Attempt_NullOriginExecutionId_HasNullExecutionId() { @@ -213,6 +223,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit }); } + /// <summary>Verifies that the attempt event carries the notification's origin parent execution id as its parent execution id.</summary> [Fact] public void Attempt_CarriesOriginParentExecutionId_AsParentExecutionId() { @@ -237,6 +248,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit }); } + /// <summary>Verifies that a null origin parent execution id yields a null parent execution id on the attempt event.</summary> [Fact] public void Attempt_NullOriginParentExecutionId_HasNullParentExecutionId() { @@ -257,6 +269,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit }); } + /// <summary>Verifies that a transient failure emits an Attempted event with the error message set.</summary> [Fact] public void Attempt_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet() { @@ -278,6 +291,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit }); } + /// <summary>Verifies that a permanent failure emits an Attempted event with the error message set.</summary> [Fact] public void Attempt_PermanentFailure_EmitsEvent_StatusAttempted_ErrorMessageSet() { @@ -299,6 +313,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit }); } + /// <summary>Verifies that a throwing audit writer does not prevent the delivery state update from succeeding.</summary> [Fact] public void AuditWriter_Throws_DeliveryStateUpdate_StillSucceeds() { @@ -323,6 +338,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit }); } + /// <summary>Verifies that the attempt event records its occurrence time in UTC.</summary> [Fact] public void Attempt_RecordsOccurredAtUtc_AsUtc() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs index be39c1f3..6bf5041f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; /// <summary> -/// M4 Bundle B (B1) — verifies <see cref="NotificationOutboxActor"/> accepts an +/// Verifies <see cref="NotificationOutboxActor"/> accepts an /// <see cref="ICentralAuditWriter"/> at construction so subsequent bundle tasks /// (B2/B3) can route attempt + terminal lifecycle events through the central /// direct-write audit path. @@ -32,8 +32,10 @@ public class NotificationOutboxActorAuditInjectionTests : TestKit /// </summary> private sealed class RecordingCentralAuditWriter : ICentralAuditWriter { + /// <summary>Gets the audit events recorded by this writer.</summary> public List<AuditEvent> Events { get; } = new(); + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) @@ -44,6 +46,7 @@ public class NotificationOutboxActorAuditInjectionTests : TestKit } } + /// <summary>Verifies that the actor can be constructed with an audit writer without throwing.</summary> [Fact] public void Actor_ConstructedWith_ICentralAuditWriter_NoException() { @@ -65,6 +68,7 @@ public class NotificationOutboxActorAuditInjectionTests : TestKit } } + /// <summary>Verifies that constructing the actor with a null audit writer throws.</summary> [Fact] public void Actor_NullAuditWriter_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs index b6ca7e79..a2aca4ee 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs @@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; /// <summary> -/// Task 14: Tests for the <see cref="NotificationOutboxActor"/> dispatcher loop — the +/// Tests for the <see cref="NotificationOutboxActor"/> dispatcher loop — the /// periodic sweep that claims due notifications via /// <see cref="INotificationOutboxRepository.GetDueAsync"/>, delivers each through the /// matching <see cref="INotificationDeliveryAdapter"/>, and applies the resulting status @@ -52,6 +52,10 @@ public class NotificationOutboxActorDispatchTests : TestKit private readonly Func<DeliveryOutcome> _outcome; private readonly TimeSpan _delay; + /// <summary>Initializes the adapter with the outcome factory, an optional delivery delay and the notification type it serves.</summary> + /// <param name="outcome">Produces the outcome returned for each delivery.</param> + /// <param name="delay">Optional delay applied to each delivery.</param> + /// <param name="type">The notification type this adapter serves.</param> public StubAdapter(Func<DeliveryOutcome> outcome, TimeSpan? delay = null, NotificationType type = NotificationType.Email) { @@ -62,8 +66,10 @@ public class NotificationOutboxActorDispatchTests : TestKit public int CallCount; + /// <inheritdoc /> public NotificationType Type { get; } + /// <inheritdoc /> public async Task<DeliveryOutcome> DeliverAsync( Notification notification, CancellationToken cancellationToken = default) { @@ -110,6 +116,7 @@ public class NotificationOutboxActorDispatchTests : TestKit .Returns(new[] { config }); } + /// <summary>Verifies that a dispatch tick claims due notifications and invokes the adapter.</summary> [Fact] public void DispatchTick_ClaimsDueNotifications_AndInvokesAdapter() { @@ -130,6 +137,7 @@ public class NotificationOutboxActorDispatchTests : TestKit }); } + /// <summary>Verifies that an SMS notification is routed to the SMS adapter rather than the email adapter.</summary> [Fact] public void Dispatch_SmsTypedNotification_RoutesToSmsAdapter_NotEmailAdapter() { @@ -159,6 +167,7 @@ public class NotificationOutboxActorDispatchTests : TestKit }); } + /// <summary>Verifies that a successful delivery marks the notification delivered with the resolved targets.</summary> [Fact] public void Success_MarksNotificationDelivered_WithResolvedTargets() { @@ -184,6 +193,7 @@ public class NotificationOutboxActorDispatchTests : TestKit }); } + /// <summary>Verifies that a transient failure below the maximum retries marks the notification as retrying and schedules the next attempt.</summary> [Fact] public void TransientFailure_BelowMaxRetries_MarksRetrying_AndSchedulesNextAttempt() { @@ -209,11 +219,12 @@ public class NotificationOutboxActorDispatchTests : TestKit }); } + /// <summary>Verifies that a transient failure that reaches the maximum retries parks the notification.</summary> [Fact] public void TransientFailure_ReachingMaxRetries_MarksParked() { SetupSmtpRetryPolicy(maxRetries: 3, retryDelay: TimeSpan.FromMinutes(1)); - // RetryCount starts at max-1; the failed attempt increments it to max. + // RetryCount starts one below the maximum; the failed attempt increments it to the maximum. var notification = MakeNotification(retryCount: 2); _outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>()) .Returns(new[] { notification }); @@ -232,6 +243,7 @@ public class NotificationOutboxActorDispatchTests : TestKit }); } + /// <summary>Verifies that a permanent failure parks the notification and records the last error.</summary> [Fact] public void PermanentFailure_MarksParked_WithLastError() { @@ -255,6 +267,7 @@ public class NotificationOutboxActorDispatchTests : TestKit }); } + /// <summary>Verifies that a notification with no adapter for its type is parked with an explanatory error.</summary> [Fact] public void NoAdapterForType_MarksParked_WithExplanatoryError() { @@ -279,6 +292,7 @@ public class NotificationOutboxActorDispatchTests : TestKit }); } + /// <summary>Verifies that a faulted dispatch pass clears the in-flight guard so the next tick still runs.</summary> [Fact] public void FaultedDispatchPass_ClearsInFlightGuard_SoNextTickStillRuns() { @@ -329,10 +343,11 @@ public class NotificationOutboxActorDispatchTests : TestKit duration: TimeSpan.FromSeconds(10)); } + /// <summary>Verifies that a transient failure with a maximum retry count of zero retries using the fallback instead of parking immediately.</summary> [Fact] public void TransientFailure_WithZeroMaxRetries_RetriesUsingFallback_DoesNotParkImmediately() { - // NO-002: SmtpConfiguration.MaxRetries=0 used to satisfy 1 >= 0 on the very first + // SmtpConfiguration.MaxRetries=0 used to satisfy 1 >= 0 on the very first // transient failure and park the row without a single retry. ResolveRetryPolicyAsync // now clamps non-positive MaxRetries to the FallbackMaxRetries (10) so transient // failures actually retry before parking. @@ -357,10 +372,11 @@ public class NotificationOutboxActorDispatchTests : TestKit }); } + /// <summary>Verifies that a transient failure with a negative maximum retry count retries using the fallback instead of parking immediately.</summary> [Fact] public void TransientFailure_WithNegativeMaxRetries_RetriesUsingFallback_DoesNotParkImmediately() { - // NO-002: a negative MaxRetries reaches ResolveRetryPolicyAsync just as -1 — same + // A negative MaxRetries reaches ResolveRetryPolicyAsync just as -1 — same // park-immediately bug. Clamp to FallbackMaxRetries. SetupSmtpRetryPolicy(maxRetries: -1, retryDelay: TimeSpan.FromMinutes(1)); var notification = MakeNotification(retryCount: 0); @@ -383,10 +399,11 @@ public class NotificationOutboxActorDispatchTests : TestKit }); } + /// <summary>Verifies that a non-positive retry delay is replaced by the fallback delay rather than zero.</summary> [Fact] public void TransientFailure_WithNonPositiveRetryDelay_UsesFallbackDelay_NotZero() { - // NO-002: a non-positive RetryDelay would burn-loop the dispatcher because + // A non-positive RetryDelay would burn-loop the dispatcher because // NextAttemptAt would equal now. Clamp to FallbackRetryDelay (1 min) so the // schedule actually advances. SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.Zero); @@ -410,10 +427,11 @@ public class NotificationOutboxActorDispatchTests : TestKit }); } + /// <summary>Verifies that stopping the actor cancels an in-flight delivery and leaves the row non-terminal.</summary> [Fact] public void PostStop_CancelsInFlightDelivery_LeavesRowNonTerminal() { - // NO-003: the dispatcher used to drop the CancellationToken on its way into + // The dispatcher used to drop the CancellationToken on its way into // the channel adapter, so a coordinated shutdown had to wait the full SMTP // connect/auth/send timeout per in-flight notification before the sweep // finished. The actor now passes a lifecycle-scoped token; cancelling it on @@ -456,7 +474,7 @@ public class NotificationOutboxActorDispatchTests : TestKit // ── Register row 45: the delivery adapter is resolved PER DELIVERY ──────── // - // Supersedes NotificationOutbox-006 ("resolve the adapter dictionary once for the + // Supersedes an earlier optimisation ("resolve the adapter dictionary once for the // actor's lifetime"). That optimisation cached scoped adapter instances — which hold // a scoped INotificationRepository over an EF DbContext — in an actor-lifetime DI // scope, so MaxParallelDeliveries concurrent deliveries all ran on ONE DbContext and @@ -478,11 +496,14 @@ public class NotificationOutboxActorDispatchTests : TestKit private int _arrived; private int _overlaps; + /// <summary>Initializes the ledger with the number of deliveries expected to reach the rendezvous.</summary> + /// <param name="expectedArrivals">The number of arrivals that completes the rendezvous.</param> public DeliveryConcurrencyLedger(int expectedArrivals) => _expectedArrivals = expectedArrivals; /// <summary>Number of second-operation-on-one-context collisions observed.</summary> public int Overlaps => Volatile.Read(ref _overlaps); + /// <summary>Records one overlapping use of the same simulated context.</summary> public void RecordOverlap() => Interlocked.Increment(ref _overlaps); /// <summary> @@ -491,6 +512,8 @@ public class NotificationOutboxActorDispatchTests : TestKit /// timeout matters for the shared-context (pre-fix) run, where the colliding /// delivery throws instead of arriving and the rendezvous can never complete. /// </summary> + /// <param name="cancellationToken">Token that cancels the wait.</param> + /// <returns>A task that completes once every delivery has arrived or the safety timeout elapses.</returns> public async Task ArriveAndWaitAsync(CancellationToken cancellationToken) { if (Interlocked.Increment(ref _arrived) >= _expectedArrivals) @@ -517,8 +540,13 @@ public class NotificationOutboxActorDispatchTests : TestKit private readonly DeliveryConcurrencyLedger _ledger; private int _inFlight; + /// <summary>Initializes the probe with the ledger it reports to.</summary> + /// <param name="ledger">The ledger that records overlaps and coordinates the rendezvous.</param> public SimulatedDbContextProbe(DeliveryConcurrencyLedger ledger) => _ledger = ledger; + /// <summary>Enters the simulated context, failing if another operation is already in flight, and waits at the rendezvous.</summary> + /// <param name="cancellationToken">Token that cancels the wait.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task EnterAsync(CancellationToken cancellationToken) { if (Interlocked.Increment(ref _inFlight) > 1) @@ -550,10 +578,14 @@ public class NotificationOutboxActorDispatchTests : TestKit { private readonly INotificationRepository _repository; + /// <summary>Initializes the adapter with the scoped repository it works through.</summary> + /// <param name="repository">The scoped notification repository.</param> public RepositoryBackedAdapter(INotificationRepository repository) => _repository = repository; + /// <inheritdoc /> public NotificationType Type => NotificationType.Email; + /// <inheritdoc /> public async Task<DeliveryOutcome> DeliverAsync( Notification notification, CancellationToken cancellationToken = default) { @@ -579,6 +611,7 @@ public class NotificationOutboxActorDispatchTests : TestKit } } + /// <summary>Verifies that parallel deliveries each get their own adapter and DbContext.</summary> [Fact] public void ParallelDeliveries_EachGetTheirOwnAdapterAndDbContext() { @@ -649,6 +682,7 @@ public class NotificationOutboxActorDispatchTests : TestKit Assert.Equal(0, ledger.Overlaps); } + /// <summary>Verifies that the adapter is resolved per delivery rather than once per actor lifetime.</summary> [Fact] public void Dispatch_ResolvesAdapterPerDelivery_NotOncePerActorLifetime() { @@ -703,11 +737,15 @@ public class NotificationOutboxActorDispatchTests : TestKit { private readonly System.Collections.Concurrent.ConcurrentBag<INotificationDeliveryAdapter> _seen; + /// <summary>Initializes the adapter with the bag that records each adapter instance used.</summary> + /// <param name="seen">The bag that collects the adapter instances that performed deliveries.</param> public RecordingStubAdapter( System.Collections.Concurrent.ConcurrentBag<INotificationDeliveryAdapter> seen) => _seen = seen; + /// <inheritdoc /> public NotificationType Type => NotificationType.Email; + /// <inheritdoc /> public Task<DeliveryOutcome> DeliverAsync( Notification notification, CancellationToken cancellationToken = default) { @@ -716,6 +754,7 @@ public class NotificationOutboxActorDispatchTests : TestKit } } + /// <summary>Verifies that overlapping ticks while a dispatch is in flight do not claim notifications concurrently.</summary> [Fact] public void OverlappingTicks_WhileDispatchInFlight_DoNotClaimConcurrently() { @@ -739,14 +778,14 @@ public class NotificationOutboxActorDispatchTests : TestKit duration: TimeSpan.FromSeconds(2)); } - // ── Task 25: bounded parallel delivery ─────────────────────────────────── + // ── Bounded parallel delivery ─────────────────────────────────── /// <summary> /// Adapter that records the concurrent-invocation high-water mark. Each /// <see cref="DeliverAsync"/> increments a live counter, notes the peak, then blocks /// on a shared gate until <paramref name="releaseWhen"/> deliveries are concurrently /// in flight — proving real overlap. A safety timeout releases the gate so a - /// parallelism-1 run (which can never reach <c>releaseWhen</c> concurrently) still + /// run with a parallelism of one (which can never reach <c>releaseWhen</c> concurrently) still /// drains instead of deadlocking. /// </summary> private sealed class ConcurrencyHighWaterAdapter : INotificationDeliveryAdapter @@ -758,12 +797,17 @@ public class NotificationOutboxActorDispatchTests : TestKit private int _highWater; private int _entered; + /// <summary>Initializes the adapter with the concurrency level that releases the gate.</summary> + /// <param name="releaseWhen">The number of concurrent deliveries that releases the gate.</param> public ConcurrencyHighWaterAdapter(int releaseWhen) => _releaseWhen = releaseWhen; + /// <inheritdoc /> public NotificationType Type => NotificationType.Email; + /// <summary>Gets the highest number of deliveries observed in flight at once.</summary> public int HighWater => Volatile.Read(ref _highWater); + /// <inheritdoc /> public async Task<DeliveryOutcome> DeliverAsync( Notification notification, CancellationToken cancellationToken = default) { @@ -800,6 +844,7 @@ public class NotificationOutboxActorDispatchTests : TestKit : Array.Empty<Notification>()); } + /// <summary>Verifies that with a maximum parallelism of four, deliveries run concurrently and all reach a terminal state.</summary> [Fact] public void Dispatch_WithMaxParallelFour_DeliversConcurrently_AndAllReachTerminal() { @@ -830,6 +875,7 @@ public class NotificationOutboxActorDispatchTests : TestKit duration: TimeSpan.FromSeconds(10)); } + /// <summary>Verifies that with a maximum parallelism of one, deliveries run sequentially and all reach a terminal state.</summary> [Fact] public void Dispatch_WithMaxParallelOne_DeliversSequentially_AndAllReachTerminal() { @@ -856,7 +902,7 @@ public class NotificationOutboxActorDispatchTests : TestKit Assert.Equal(1, adapter.HighWater); } - // ── Task 18: per-type retry policy (SMS honors SmsConfiguration) ────────── + // ── Per-type retry policy (SMS honors SmsConfiguration) ────────── private void SetupSmsRetryPolicy(int maxRetries, TimeSpan retryDelay) { @@ -869,10 +915,11 @@ public class NotificationOutboxActorDispatchTests : TestKit .Returns(config); } + /// <summary>Verifies that a transient failure on an SMS notification uses the SMS retry policy.</summary> [Fact] public void SmsNotification_TransientFailure_UsesSmsRetryPolicy() { - // Task 18: an SMS-typed notification resolves its retry policy from the SMS + // An SMS-typed notification resolves its retry policy from the SMS // configuration, NOT the shared SMTP policy. The SMTP policy (MaxRetries=10) // would keep this row Retrying; the SMS policy (MaxRetries=1) parks it after // this attempt (RetryCount 1 -> 2 >= 1). A pre-fix dispatcher (SMTP-for-all) @@ -899,10 +946,11 @@ public class NotificationOutboxActorDispatchTests : TestKit }); } + /// <summary>Verifies that an email notification still uses the SMTP retry policy.</summary> [Fact] public void EmailNotification_StillUsesSmtpPolicy() { - // Task 18: an Email row continues to resolve against the SMTP policy even when a + // An Email row continues to resolve against the SMTP policy even when a // (tighter) SMS policy is present. SMTP MaxRetries=10 keeps it Retrying // (RetryCount 1 -> 2 < 10) and schedules the next attempt with the SMTP delay. SetupSmtpRetryPolicy(maxRetries: 10, retryDelay: TimeSpan.FromMinutes(3)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs index 6eb6b160..3c08ea18 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs @@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; /// <summary> -/// Task 13: Tests for the <see cref="NotificationOutboxActor"/> ingest path — building a +/// Tests for the <see cref="NotificationOutboxActor"/> ingest path — building a /// <see cref="Notification"/> from a <see cref="NotificationSubmit"/>, persisting it via /// <see cref="INotificationOutboxRepository.InsertIfNotExistsAsync"/>, and acking the sender. /// </summary> @@ -59,6 +59,7 @@ public class NotificationOutboxActorIngestTests : TestKit SourceNode: sourceNode); } + /// <summary>Verifies that a notification submit persists the mapped notification and acks it as accepted.</summary> [Fact] public void NotificationSubmit_PersistsMappedNotification_AndAcksAccepted() { @@ -90,10 +91,11 @@ public class NotificationOutboxActorIngestTests : TestKit Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the origin execution id is copied onto the persisted notification.</summary> [Fact] public void NotificationSubmit_CopiesOriginExecutionId_OntoPersistedNotification() { - // Audit Log #23: the originating script execution's id rides on the + // The originating script execution's id rides on the // NotificationSubmit and must be persisted on the Notification row so // the dispatcher can later echo it onto NotifyDeliver audit rows. _repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>()) @@ -110,6 +112,7 @@ public class NotificationOutboxActorIngestTests : TestKit Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a null origin execution id is persisted as null.</summary> [Fact] public void NotificationSubmit_NullOriginExecutionId_PersistsNull() { @@ -126,6 +129,7 @@ public class NotificationOutboxActorIngestTests : TestKit Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the origin parent execution id is copied onto the persisted notification.</summary> [Fact] public void NotificationSubmit_CopiesOriginParentExecutionId_OntoPersistedNotification() { @@ -146,6 +150,7 @@ public class NotificationOutboxActorIngestTests : TestKit Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a null origin parent execution id is persisted as null.</summary> [Fact] public void NotificationSubmit_NullOriginParentExecutionId_PersistsNull() { @@ -162,6 +167,7 @@ public class NotificationOutboxActorIngestTests : TestKit Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a duplicate submit, where the repository returns false, is still acked as accepted.</summary> [Fact] public void DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted() { @@ -178,6 +184,7 @@ public class NotificationOutboxActorIngestTests : TestKit Assert.Null(ack.Error); } + /// <summary>Verifies that a repository exception results in an ack that is not accepted and carries the error.</summary> [Fact] public void RepositoryThrows_AcksNotAcceptedWithError() { @@ -195,10 +202,11 @@ public class NotificationOutboxActorIngestTests : TestKit Assert.Contains("database unavailable", ack.Error); } + /// <summary>Verifies that the source node is copied onto the persisted notification.</summary> [Fact] public void NotificationSubmit_CopiesSourceNode_OntoPersistedNotification() { - // SourceNode-stamping (Task 13): the originating site's node name (node-a/node-b) + // SourceNode stamping: the originating site's node name (node-a/node-b) // rides on the NotificationSubmit and must be persisted on the Notification row so // central observers (KPIs, audit drill-ins, ops dashboards) can see which node // emitted the notification. @@ -215,6 +223,7 @@ public class NotificationOutboxActorIngestTests : TestKit Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a null source node is persisted as null.</summary> [Fact] public void NotificationSubmit_NullSourceNode_PersistsNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs index c397958a..9203ce26 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs @@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; /// <summary> -/// Task 16: Tests for the <see cref="NotificationOutboxActor"/> daily purge job — the +/// Tests for the <see cref="NotificationOutboxActor"/> daily purge job — the /// periodic sweep that bulk-deletes terminal notification rows older than /// <see cref="NotificationOutboxOptions.TerminalRetention"/> via /// <see cref="INotificationOutboxRepository.DeleteTerminalOlderThanAsync"/>. @@ -51,6 +51,7 @@ public class NotificationOutboxActorPurgeTests : TestKit NullLogger<NotificationOutboxActor>.Instance))); } + /// <summary>Verifies that a purge tick deletes terminal rows using a cutoff of the current UTC time minus the terminal retention.</summary> [Fact] public void PurgeTick_DeletesTerminalRows_WithCutoffAtUtcNowMinusTerminalRetention() { @@ -76,6 +77,7 @@ public class NotificationOutboxActorPurgeTests : TestKit Arg.Any<CancellationToken>())); } + /// <summary>Verifies that a faulted purge does not crash the actor and a later purge still runs.</summary> [Fact] public void FaultedPurge_DoesNotCrashActor_AndSubsequentPurgeStillRuns() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs index da4f1d22..a89a2d33 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs @@ -15,7 +15,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; /// <summary> -/// Task 15: Tests for the <see cref="NotificationOutboxActor"/> query surface — the +/// Tests for the <see cref="NotificationOutboxActor"/> query surface — the /// paginated outbox query, single-notification status query, manual retry and discard /// of parked notifications, and the KPI snapshot. /// </summary> @@ -60,6 +60,7 @@ public class NotificationOutboxActorQueryTests : TestKit }; } + /// <summary>Verifies that the query passes the request filter to the repository and maps rows to summaries.</summary> [Fact] public void Query_PassesFilterFromRequest_AndMapsRowsToSummaries() { @@ -115,6 +116,7 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Equal("site-1", staleSummary.SourceSiteId); } + /// <summary>Verifies that a notification id filter is passed to the repository as an exact match.</summary> [Fact] public void Query_WithNotificationIdFilter_PassesExactMatchToRepositoryFilter() { @@ -149,6 +151,7 @@ public class NotificationOutboxActorQueryTests : TestKit 1, 50, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a status filter string is parsed to the status enum on the repository filter.</summary> [Fact] public void Query_WithStatusFilterString_ParsesToEnumOnFilter() { @@ -183,10 +186,11 @@ public class NotificationOutboxActorQueryTests : TestKit 1, 50, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that the source node filter is passed through and the source node is projected onto each summary.</summary> [Fact] public void Query_PassesSourceNodeFilter_AndProjectsSourceNodeOntoSummary() { - // Task 16: the Notifications page's new Node filter input pushes a + // The Notifications page's new Node filter input pushes a // value into NotificationOutboxQueryRequest.SourceNodeFilter; the actor // must thread it onto NotificationOutboxFilter.SourceNode AND mirror // the row's SourceNode column onto the response summaries. @@ -222,6 +226,7 @@ public class NotificationOutboxActorQueryTests : TestKit 1, 50, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a repository failure during a query replies with a failure and an empty list.</summary> [Fact] public void Query_RepositoryThrows_RepliesFailureWithEmptyList() { @@ -243,6 +248,9 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Empty(response.Notifications); } + /// <summary>Verifies that a status query is answered as found only when the verified site owns the stored notification.</summary> + /// <param name="verifiedSite">The site id the caller is verified as.</param> + /// <param name="found">Whether the status query is expected to report the notification as found.</param> [Theory] [InlineData("site-1", true)] [InlineData("site-other", false)] @@ -256,6 +264,7 @@ public class NotificationOutboxActorQueryTests : TestKit if (!found) { Assert.Null(response.LastError); Assert.Empty(response.Status); } } + /// <summary>Verifies that a status query for an existing notification replies with the row detail.</summary> [Fact] public void StatusQuery_Found_RepliesWithRowDetail() { @@ -274,6 +283,7 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Equal("smtp timeout", response.LastError); } + /// <summary>Verifies that a status query for a missing notification replies that it was not found.</summary> [Fact] public void StatusQuery_NotFound_RepliesFoundFalse() { @@ -290,6 +300,7 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Equal(0, response.RetryCount); } + /// <summary>Verifies that retrying a parked notification resets it to Pending and succeeds.</summary> [Fact] public void Retry_ParkedNotification_ResetsToPending_AndSucceeds() { @@ -315,6 +326,7 @@ public class NotificationOutboxActorQueryTests : TestKit Arg.Any<CancellationToken>()); } + /// <summary>Verifies that retrying a notification that is not parked fails.</summary> [Fact] public void Retry_NonParkedNotification_Fails() { @@ -330,6 +342,7 @@ public class NotificationOutboxActorQueryTests : TestKit _repository.DidNotReceive().TryUpdateFromParkedAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that retrying a missing notification fails.</summary> [Fact] public void Retry_MissingNotification_Fails() { @@ -348,7 +361,7 @@ public class NotificationOutboxActorQueryTests : TestKit /// <summary> /// The row is read successfully but the retention purge deletes it before the /// write lands, so the CAS write matches nothing. The re-read that follows a - /// lost CAS (arch-review 2026-08-19 P5-4) finds nothing either, and the + /// lost CAS finds nothing either, and the /// operator must be told the notification is gone — reporting a re-queue /// that never happened is what a blind ExecuteUpdate would silently do (the /// pre-ExecuteUpdate predecessor threw DbUpdateConcurrencyException here). @@ -391,8 +404,8 @@ public class NotificationOutboxActorQueryTests : TestKit } /// <summary> - /// PROOF OF FAILURE for arch-review 2026-08-19 P5-4. Models the race the - /// finding describes: the operator's read sees Parked, but by the time the + /// Proof of failure for the lost-CAS race. Models the race in which + /// the operator's read sees Parked, but by the time the /// write lands the dispatcher has already delivered the row (a detached /// PipeTo task interleaving with the dispatcher's own write). The mechanism /// is proven deterministically via a mocked call sequence, not a wall-clock @@ -459,6 +472,7 @@ public class NotificationOutboxActorQueryTests : TestKit _repository.DidNotReceive().UpdateAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that discarding a parked notification marks it Discarded and succeeds.</summary> [Fact] public void Discard_ParkedNotification_MarksDiscarded_AndSucceeds() { @@ -477,6 +491,7 @@ public class NotificationOutboxActorQueryTests : TestKit Arg.Any<CancellationToken>()); } + /// <summary>Verifies that discarding a notification that is not parked fails.</summary> [Fact] public void Discard_NonParkedNotification_Fails() { @@ -492,6 +507,7 @@ public class NotificationOutboxActorQueryTests : TestKit _repository.DidNotReceive().TryUpdateFromParkedAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that discarding a missing notification fails.</summary> [Fact] public void Discard_MissingNotification_Fails() { @@ -506,6 +522,7 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Contains("not found", response.ErrorMessage); } + /// <summary>Verifies that a detail request for a known id returns the full detail with the body and resolved targets.</summary> [Fact] public void DetailRequest_KnownId_ReturnsFullDetail_WithBodyAndResolvedTargets() { @@ -545,6 +562,7 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Equal("node-a", detail.SourceNode); } + /// <summary>Verifies that a detail request for an unknown id returns not found.</summary> [Fact] public void DetailRequest_UnknownId_ReturnsNotFound() { @@ -562,6 +580,7 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Contains("not found", response.ErrorMessage); } + /// <summary>Verifies that a KPI request computes the KPIs and maps the snapshot.</summary> [Fact] public void KpiRequest_ComputesKpis_AndMapsSnapshot() { @@ -592,6 +611,7 @@ public class NotificationOutboxActorQueryTests : TestKit Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a repository failure during a KPI request replies with a failure response.</summary> [Fact] public void KpiRequest_RepositoryThrows_RepliesFailureResponse() { @@ -615,6 +635,7 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Null(response.OldestPendingAge); } + /// <summary>Verifies that a per-site KPI request replies with per-site snapshots.</summary> [Fact] public void PerSiteKpiRequest_RepliesWithPerSiteSnapshots() { @@ -639,6 +660,7 @@ public class NotificationOutboxActorQueryTests : TestKit Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a repository fault during a per-site KPI request replies unsuccessfully.</summary> [Fact] public void PerSiteKpiRequest_RepositoryFault_RepliesUnsuccessful() { @@ -657,8 +679,9 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Empty(response.Sites); } - // ── Per-node KPI (T6: M5.2 per-node stuck-count KPIs) ────────────────── + // ── Per-node KPI (per-node stuck-count KPIs) ────────────────── + /// <summary>Verifies that a per-node KPI request replies with per-node snapshots.</summary> [Fact] public void PerNodeKpiRequest_RepliesWithPerNodeSnapshots() { @@ -685,6 +708,7 @@ public class NotificationOutboxActorQueryTests : TestKit Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>()); } + /// <summary>Verifies that a repository fault during a per-node KPI request replies unsuccessfully.</summary> [Fact] public void PerNodeKpiRequest_RepositoryFault_RepliesUnsuccessful() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorRetryEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorRetryEmissionTests.cs index 5e35d834..d9a32eb7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorRetryEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorRetryEmissionTests.cs @@ -15,7 +15,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; /// <summary> -/// Task 13 (arch-review 04) — verifies that an operator-initiated Retry on a parked +/// Verifies that an operator-initiated Retry on a parked /// notification emits a <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/> /// audit row with status <see cref="AuditStatus.Submitted"/> and the operator as /// <c>Actor</c>, closing the forensic gap (previously the lifecycle read @@ -31,9 +31,12 @@ public class NotificationOutboxActorRetryEmissionTests : TestKit private sealed class RecordingCentralAuditWriter : ICentralAuditWriter { + /// <summary>Gets the audit rows recorded by the writer.</summary> public List<AuditRowProjection.AuditRowValues> Events { get; } = new(); + /// <summary>Gets or sets a callback invoked on each write, allowing a test to simulate a failing writer.</summary> public Func<AuditEvent, Task>? OnWrite { get; set; } + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) @@ -85,6 +88,7 @@ public class NotificationOutboxActorRetryEmissionTests : TestKit } } + /// <summary>Verifies that retrying a parked notification emits a submitted notify-deliver audit row carrying the operator.</summary> [Fact] public void Retry_OnParked_Emits_Submitted_NotifyDeliver_AuditRow_With_Operator() { @@ -110,6 +114,7 @@ public class NotificationOutboxActorRetryEmissionTests : TestKit }); } + /// <summary>Verifies that discarding a parked notification emits a discarded audit row carrying the operator.</summary> [Fact] public void Discard_OnParked_Emits_Discarded_AuditRow_With_Operator() { @@ -130,6 +135,7 @@ public class NotificationOutboxActorRetryEmissionTests : TestKit }); } + /// <summary>Verifies that an audit write failure does not fail the retry.</summary> [Fact] public void Retry_AuditFailure_DoesNotFail_TheRetry() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs index 887f075b..2c3450c0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs @@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; /// <summary> -/// M4 Bundle B (B3) — verifies the <see cref="NotificationOutboxActor"/> +/// Verifies the <see cref="NotificationOutboxActor"/> /// emits a second /// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/> /// audit row carrying the terminal status (Delivered, Parked, Discarded) on @@ -36,11 +36,14 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit private sealed class RecordingCentralAuditWriter : ICentralAuditWriter { - // C3 (Task 2.5): store the decomposed row view so assertions keep + // Store the decomposed row view so assertions keep // reading the ScadaBridge domain fields as typed properties. + /// <summary>Gets the decomposed audit rows captured by this writer.</summary> public List<AuditRowProjection.AuditRowValues> Events { get; } = new(); + /// <summary>Gets or sets an optional callback invoked on each write.</summary> public Func<AuditEvent, Task>? OnWrite { get; set; } + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) @@ -69,10 +72,14 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit { private readonly Func<DeliveryOutcome> _outcome; + /// <summary>Initializes the adapter with the outcome factory used for each delivery.</summary> + /// <param name="outcome">Produces the outcome returned for each delivery.</param> public StubAdapter(Func<DeliveryOutcome> outcome) { _outcome = outcome; } + /// <inheritdoc /> public NotificationType Type => NotificationType.Email; + /// <inheritdoc /> public Task<DeliveryOutcome> DeliverAsync( Notification notification, CancellationToken cancellationToken = default) => Task.FromResult(_outcome()); @@ -129,6 +136,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit } } + /// <summary>Verifies that a delivered notification emits a terminal event with Delivered status.</summary> [Fact] public void Terminal_Delivered_EmitsEvent_StatusDelivered() { @@ -152,10 +160,11 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + /// <summary>Verifies that the terminal delivered event carries the notification's origin execution id as its execution id.</summary> [Fact] public void Terminal_Delivered_CarriesOriginExecutionId_AsExecutionId() { - // Audit Log #23: the terminal NotifyDeliver row must echo the + // The terminal NotifyDeliver row must echo the // notification's OriginExecutionId so it shares the per-run id with // the site-emitted NotifySend row. SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); @@ -176,6 +185,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + /// <summary>Verifies that a null origin execution id yields a null execution id on the terminal delivered event.</summary> [Fact] public void Terminal_Delivered_NullOriginExecutionId_HasNullExecutionId() { @@ -196,6 +206,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + /// <summary>Verifies that the terminal delivered event carries the notification's origin parent execution id as its parent execution id.</summary> [Fact] public void Terminal_Delivered_CarriesOriginParentExecutionId_AsParentExecutionId() { @@ -220,6 +231,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + /// <summary>Verifies that a null origin parent execution id yields a null parent execution id on the terminal delivered event.</summary> [Fact] public void Terminal_Delivered_NullOriginParentExecutionId_HasNullParentExecutionId() { @@ -240,6 +252,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + /// <summary>Verifies that a permanent failure emits a terminal event with Parked status.</summary> [Fact] public void Terminal_Parked_OnPermanentFailure_EmitsEvent_StatusParked() { @@ -261,10 +274,11 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + /// <summary>Verifies that the terminal parked event carries the notification's origin execution id as its execution id.</summary> [Fact] public void Terminal_Parked_CarriesOriginExecutionId_AsExecutionId() { - // Audit Log #23: the Parked terminal NotifyDeliver row flows through the + // The Parked terminal NotifyDeliver row flows through the // same BuildNotifyDeliverEvent path as the Delivered row, so it must // likewise echo the notification's OriginExecutionId — sharing the // per-run id with the site-emitted NotifySend row. @@ -286,11 +300,12 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + /// <summary>Verifies that a transient failure that reaches the maximum retries emits a terminal event with Parked status.</summary> [Fact] public void Terminal_Parked_OnTransientReachingMaxRetries_EmitsEvent_StatusParked() { SetupSmtpRetryPolicy(maxRetries: 3, retryDelay: TimeSpan.FromMinutes(1)); - // RetryCount starts at max-1; the failed attempt increments it to max + // RetryCount starts one below the maximum; the failed attempt increments it to the maximum // which triggers the Parked terminal transition. var notification = MakeNotification(retryCount: 2); _outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>()) @@ -308,6 +323,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + /// <summary>Verifies that a missing adapter emits a terminal event with Parked status.</summary> [Fact] public void Terminal_Parked_OnMissingAdapter_EmitsEvent_StatusParked() { @@ -329,6 +345,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + /// <summary>Verifies that a transient failure below the maximum retries does not emit a terminal row.</summary> [Fact] public void Transient_BelowMaxRetries_DoesNotEmitTerminalRow() { @@ -353,6 +370,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit Assert.Empty(EventsByStatus(AuditStatus.Discarded)); } + /// <summary>Verifies that a manual discard emits a terminal event with Discarded status.</summary> [Fact] public void Terminal_Discarded_OnManualDiscard_EmitsEvent_StatusDiscarded() { @@ -378,6 +396,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + /// <summary>Verifies that a throwing audit writer does not prevent the terminal status update from succeeding.</summary> [Fact] public void AuditWriter_Throws_TerminalUpdate_StillSucceeds() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxOptionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxOptionsTests.cs index cfc47ff0..f770d602 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxOptionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxOptionsTests.cs @@ -2,6 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; public class NotificationOutboxOptionsTests { + /// <summary>Verifies that the default option values are the expected ones.</summary> [Fact] public void Defaults_AreExpectedValues() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxOptionsValidatorTests.cs index 3563447b..311e026e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxOptionsValidatorTests.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; /// <summary> -/// P5-5 (arch-review Low): <see cref="NotificationOutboxOptions"/> had no +/// Tests for the options validator: <see cref="NotificationOutboxOptions"/> had no /// registered validator before this — the dispatch/purge/KPI cadences and the /// delivered-throughput KPI window (<see cref="NotificationOutboxOptions.DeliveredKpiWindow"/>) /// could be zero/negative with no boot-time signal, which would spin an Akka @@ -19,6 +19,7 @@ public class NotificationOutboxOptionsValidatorTests private static ValidateOptionsResult Validate(NotificationOutboxOptions options) => new NotificationOutboxOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the default options are valid.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -26,6 +27,7 @@ public class NotificationOutboxOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero dispatch interval is rejected.</summary> [Fact] public void ZeroDispatchInterval_IsRejected() { @@ -34,6 +36,7 @@ public class NotificationOutboxOptionsValidatorTests Assert.Contains("DispatchInterval", result.FailureMessage); } + /// <summary>Verifies that a zero dispatch batch size is rejected.</summary> [Fact] public void ZeroDispatchBatchSize_IsRejected() { @@ -42,6 +45,7 @@ public class NotificationOutboxOptionsValidatorTests Assert.Contains("DispatchBatchSize", result.FailureMessage); } + /// <summary>Verifies that a zero stuck-age threshold is rejected.</summary> [Fact] public void ZeroStuckAgeThreshold_IsRejected() { @@ -50,6 +54,7 @@ public class NotificationOutboxOptionsValidatorTests Assert.Contains("StuckAgeThreshold", result.FailureMessage); } + /// <summary>Verifies that a zero terminal-row retention is rejected.</summary> [Fact] public void ZeroTerminalRetention_IsRejected() { @@ -58,6 +63,7 @@ public class NotificationOutboxOptionsValidatorTests Assert.Contains("TerminalRetention", result.FailureMessage); } + /// <summary>Verifies that a zero purge interval is rejected.</summary> [Fact] public void ZeroPurgeInterval_IsRejected() { @@ -66,6 +72,7 @@ public class NotificationOutboxOptionsValidatorTests Assert.Contains("PurgeInterval", result.FailureMessage); } + /// <summary>Verifies that a zero delivered-KPI window is rejected.</summary> [Fact] public void ZeroDeliveredKpiWindow_IsRejected() { @@ -74,6 +81,7 @@ public class NotificationOutboxOptionsValidatorTests Assert.Contains("DeliveredKpiWindow", result.FailureMessage); } + /// <summary>Verifies that a negative delivered-KPI window is rejected.</summary> [Fact] public void NegativeDeliveredKpiWindow_IsRejected() { @@ -82,6 +90,7 @@ public class NotificationOutboxOptionsValidatorTests Assert.Contains("DeliveredKpiWindow", result.FailureMessage); } + /// <summary>Verifies that a zero maximum parallel deliveries value is rejected.</summary> [Fact] public void ZeroMaxParallelDeliveries_IsRejected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/OutboxRepositorySubstitute.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/OutboxRepositorySubstitute.cs index 7403cdb1..d556ff1b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/OutboxRepositorySubstitute.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/OutboxRepositorySubstitute.cs @@ -22,6 +22,7 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; internal static class OutboxRepositorySubstitute { /// <summary>Creates a substitute whose writes report that the row was found.</summary> + /// <returns>A substitute outbox repository whose writes report the row as found.</returns> public static INotificationOutboxRepository Healthy() { var repository = Substitute.For<INotificationOutboxRepository>(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ProjectSmokeTest.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ProjectSmokeTest.cs index ea28201f..e4b3a184 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ProjectSmokeTest.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ProjectSmokeTest.cs @@ -2,6 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; public class ProjectSmokeTest { + /// <summary>Verifies that the test project compiles and runs.</summary> [Fact] public void ProjectCompiles() => Assert.True(true); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs index a9e42359..16b3f1b8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationService; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; /// <summary> -/// Task 17: Tests for <see cref="ServiceCollectionExtensions.AddNotificationOutbox"/> — the +/// Tests for <see cref="ServiceCollectionExtensions.AddNotificationOutbox"/> — the /// DI registration extension for the Notification Outbox component. The extension binds /// <see cref="NotificationOutboxOptions"/> from the <c>ScadaBridge:NotificationOutbox</c> /// configuration section and registers the channel delivery adapter(s). @@ -44,6 +44,7 @@ public class ServiceRegistrationTests return services.BuildServiceProvider(); } + /// <summary>Verifies that registration provides the notification outbox options with their defaults.</summary> [Fact] public void AddNotificationOutbox_RegistersNotificationOutboxOptions_WithDefaults() { @@ -56,6 +57,7 @@ public class ServiceRegistrationTests Assert.Equal(100, options.DispatchBatchSize); } + /// <summary>Verifies that the options section name is the notification outbox configuration path.</summary> [Fact] public void AddNotificationOutbox_OptionsSection_IsTheNotificationOutboxConfigPath() { @@ -64,6 +66,7 @@ public class ServiceRegistrationTests NotificationOutbox.ServiceCollectionExtensions.OptionsSection); } + /// <summary>Verifies that registration binds the notification outbox options from configuration.</summary> [Fact] public void AddNotificationOutbox_BindsNotificationOutboxOptions_FromConfiguration() { @@ -86,6 +89,7 @@ public class ServiceRegistrationTests Assert.Equal(250, options.DispatchBatchSize); } + /// <summary>Verifies that registration provides the email delivery adapter.</summary> [Fact] public void AddNotificationOutbox_RegistersEmailDeliveryAdapter() { @@ -98,6 +102,7 @@ public class ServiceRegistrationTests Assert.Equal(NotificationType.Email, adapter.Type); } + /// <summary>Verifies that registration provides the SMS delivery adapter.</summary> [Fact] public void AddNotificationOutbox_RegistersSmsDeliveryAdapter() { @@ -110,6 +115,7 @@ public class ServiceRegistrationTests Assert.Equal(NotificationType.Sms, adapter.Type); } + /// <summary>Verifies that registration provides both adapters as notification delivery adapters.</summary> [Fact] public void AddNotificationOutbox_RegistersBothAdapters_AsINotificationDeliveryAdapter() { @@ -132,6 +138,7 @@ public class ServiceRegistrationTests Assert.Contains(adapters, a => a is SmsNotificationDeliveryAdapter); } + /// <summary>Verifies that registration provides the SMS options with their defaults.</summary> [Fact] public void AddNotificationOutbox_RegistersSmsOptions_WithDefaults() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs index debadebb..5c2cb6e3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs @@ -5,11 +5,12 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport; /// <summary> /// Test-only no-op <see cref="ICentralAuditWriter"/>. Used by existing -/// NotificationOutboxActor TestKit fixtures whose tests pre-date the M4 Bundle B +/// NotificationOutboxActor TestKit fixtures whose tests pre-date the /// audit-writer injection — they don't care about audit emission, they just /// need a non-null collaborator so the actor's constructor succeeds. /// </summary> internal sealed class NoOpCentralAuditWriter : ICentralAuditWriter { + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/CredentialRedactorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/CredentialRedactorTests.cs index 840780b6..afe99cf1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/CredentialRedactorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/CredentialRedactorTests.cs @@ -1,10 +1,11 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests; /// <summary> -/// NS-009: Tests for scrubbing SMTP credential secrets out of log/result text. +/// Tests for scrubbing SMTP credential secrets out of log/result text. /// </summary> public class CredentialRedactorTests { + /// <summary>Verifies that a Basic auth password is masked.</summary> [Fact] public void Scrub_BasicAuthPassword_IsMasked() { @@ -17,6 +18,7 @@ public class CredentialRedactorTests Assert.DoesNotContain("svc:Hunter2pass!word", result); } + /// <summary>Verifies that an OAuth2 client secret is masked.</summary> [Fact] public void Scrub_OAuth2ClientSecret_IsMasked() { @@ -26,20 +28,23 @@ public class CredentialRedactorTests Assert.DoesNotContain("Sup3rSecretValue", result); } + /// <summary>Verifies that null credentials leave the text unchanged.</summary> [Fact] public void Scrub_NullCredentials_ReturnsTextUnchanged() { Assert.Equal("plain text", CredentialRedactor.Scrub("plain text", null)); } + /// <summary>Verifies that scrubbing null text returns an empty string.</summary> [Fact] public void Scrub_NullText_ReturnsEmpty() { Assert.Equal(string.Empty, CredentialRedactor.Scrub(null, "user:pass")); } - // --- NS-025: don't over-mask short non-secret components --- + // --- Don't over-mask short non-secret components --- + /// <summary>Verifies that a short user name is not masked outside the packed credential string.</summary> [Fact] public void Scrub_ShortUserName_IsNotMaskedOutsidePackedString() { @@ -52,6 +57,7 @@ public class CredentialRedactorTests Assert.Contains("/root/.config", result); } + /// <summary>Verifies that a tenant ID is not masked outside the packed credential string.</summary> [Fact] public void Scrub_TenantId_IsNotMaskedOutsidePackedString() { @@ -63,6 +69,7 @@ public class CredentialRedactorTests Assert.Contains("tnt-1234567890-abcd", result); } + /// <summary>Verifies that a full packed credential is always masked regardless of its length.</summary> [Fact] public void Scrub_FullPackedCredential_IsAlwaysMaskedRegardlessOfLength() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/EmailTransportParserTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/EmailTransportParserTests.cs index e19cc33f..15e530f8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/EmailTransportParserTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/EmailTransportParserTests.cs @@ -5,6 +5,9 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests; /// </summary> public class EmailTransportParserTests { + /// <summary>Verifies that parsing a known transport name returns the expected transport.</summary> + /// <param name="input">The input text to parse.</param> + /// <param name="expected">The expected parsed transport.</param> [Theory] [InlineData("Smtp", EmailTransport.Smtp)] [InlineData("smtp", EmailTransport.Smtp)] @@ -18,6 +21,8 @@ public class EmailTransportParserTests Assert.Equal(expected, EmailTransportParser.Parse(input)); } + /// <summary>Verifies that parsing a null or empty value defaults to SMTP.</summary> + /// <param name="input">The input text to parse.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -28,6 +33,8 @@ public class EmailTransportParserTests Assert.Equal(EmailTransport.Smtp, EmailTransportParser.Parse(input)); } + /// <summary>Verifies that parsing an unknown transport name throws.</summary> + /// <param name="input">The input text to parse.</param> [Theory] [InlineData("Graph")] [InlineData("smtps")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsResponseParserTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsResponseParserTests.cs index 2c5c82da..66cdbb9e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsResponseParserTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsResponseParserTests.cs @@ -73,6 +73,7 @@ public class EwsResponseParserTests </s:Envelope> """; + /// <summary>Verifies that a success response parses to a success result with no error.</summary> [Fact] public void Parse_SuccessResponse_ReturnsSuccessWithNoError() { @@ -82,6 +83,7 @@ public class EwsResponseParserTests Assert.Equal("NoError", result.ResponseCode); } + /// <summary>Verifies that an error response parses to an error result with its code and message.</summary> [Fact] public void Parse_ErrorResponse_ReturnsErrorWithCodeAndMessage() { @@ -92,6 +94,7 @@ public class EwsResponseParserTests Assert.Equal("server busy", result.MessageText); } + /// <summary>Verifies that a SOAP fault with a detail response code parses to a fault carrying that code.</summary> [Fact] public void Parse_SoapFaultWithDetailResponseCode_ReturnsFaultWithCode() { @@ -102,6 +105,7 @@ public class EwsResponseParserTests Assert.Equal("The request failed schema validation.", result.MessageText); } + /// <summary>Verifies that a SOAP fault without a response code parses to a fault with a null code.</summary> [Fact] public void Parse_SoapFaultWithoutResponseCode_ReturnsFaultWithNullCode() { @@ -112,6 +116,7 @@ public class EwsResponseParserTests Assert.Equal("An internal server error occurred.", result.MessageText); } + /// <summary>Verifies that a payload containing a DTD entity declaration is rejected as unparseable.</summary> [Fact] public void Parse_PayloadWithDtdEntityDeclaration_IsRejectedAsUnparseable() { @@ -152,6 +157,8 @@ public class EwsResponseParserTests Assert.Null(result.MessageText); } + /// <summary>Verifies that a garbage or unrelated payload is reported as unparseable.</summary> + /// <param name="body">The response body under test.</param> [Theory] [InlineData("not xml")] [InlineData("")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapEnvelopeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapEnvelopeTests.cs index 37df23ab..d9183544 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapEnvelopeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapEnvelopeTests.cs @@ -21,6 +21,7 @@ public class EwsSoapEnvelopeTests string body = "Body") => XDocument.Parse(EwsSoapEnvelope.BuildCreateItem(from, bcc ?? ["one@example.com"], subject, body)); + /// <summary>Verifies that the envelope sets the send-only disposition and the Exchange 2013 request version.</summary> [Fact] public void BuildCreateItem_SetsSendOnlyDispositionAndExchange2013RequestVersion() { @@ -34,6 +35,7 @@ public class EwsSoapEnvelopeTests Assert.Single(doc.Descendants(Soap + "Header").Single().Elements()); } + /// <summary>Verifies that markup in the subject and body is escaped.</summary> [Fact] public void BuildCreateItem_EscapesMarkupInSubjectAndBody() { @@ -48,6 +50,7 @@ public class EwsSoapEnvelopeTests Assert.Equal("Text", (string?)doc.Descendants(Types + "Body").Single().Attribute("BodyType")); } + /// <summary>Verifies that every recipient is placed in the BCC list and no To recipients are emitted.</summary> [Fact] public void BuildCreateItem_PutsEveryRecipientInBccAndNeverEmitsToRecipients() { @@ -65,6 +68,7 @@ public class EwsSoapEnvelopeTests Assert.DoesNotContain(doc.Descendants(), e => e.Name.LocalName is "ToRecipients" or "CcRecipients"); } + /// <summary>Verifies that the envelope emits the From mailbox.</summary> [Fact] public void BuildCreateItem_EmitsFromMailbox() { @@ -75,6 +79,7 @@ public class EwsSoapEnvelopeTests Assert.Equal("scada-alerts@example.com", mailbox.Element(Types + "EmailAddress")!.Value); } + /// <summary>Verifies that the message child elements follow the EWS schema sequence.</summary> [Fact] public void BuildCreateItem_OrdersMessageChildrenPerEwsSchemaSequence() { @@ -87,6 +92,7 @@ public class EwsSoapEnvelopeTests message.Elements().Select(e => e.Name.LocalName).ToArray()); } + /// <summary>Verifies that the envelope starts with a UTF-8 XML declaration.</summary> [Fact] public void BuildCreateItem_StartsWithUtf8XmlDeclaration() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapMailSenderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapMailSenderTests.cs index 21f13b14..a8b170a3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapMailSenderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapMailSenderTests.cs @@ -72,6 +72,8 @@ public class EwsSoapMailSenderTests </s:Envelope> """; + /// <summary>Verifies that a success response completes the send and the expected request was sent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_SuccessResponse_CompletesAndSendsExpectedRequest() { @@ -88,6 +90,8 @@ public class EwsSoapMailSenderTests Assert.Contains(Recipient, handler.CapturedContent); } + /// <summary>Verifies that a non-HTTPS endpoint throws a permanent error without sending credentials.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_NonHttpsEndpoint_ThrowsPermanentWithoutSendingCredentials() { @@ -104,6 +108,8 @@ public class EwsSoapMailSenderTests AssertNoCredentialLeak(ex); } + /// <summary>Verifies that a server-busy response body throws a transient error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_ServerBusyResponseBody_ThrowsTransient() { @@ -116,6 +122,8 @@ public class EwsSoapMailSenderTests AssertNoCredentialLeak(ex); } + /// <summary>Verifies that an unauthorized response throws a permanent error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_Unauthorized_ThrowsPermanent() { @@ -128,6 +136,8 @@ public class EwsSoapMailSenderTests AssertNoCredentialLeak(ex); } + /// <summary>Verifies that a schema fault on an HTTP 500 response throws a permanent error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_SchemaFaultOnHttp500_ThrowsPermanent() { @@ -142,6 +152,8 @@ public class EwsSoapMailSenderTests AssertNoCredentialLeak(ex); } + /// <summary>Verifies that a bare service-unavailable response throws a transient error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_BareServiceUnavailable_ThrowsTransient() { @@ -154,6 +166,8 @@ public class EwsSoapMailSenderTests AssertNoCredentialLeak(ex); } + /// <summary>Verifies that a transport failure throws a transient error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_TransportFailure_ThrowsTransient() { @@ -166,6 +180,8 @@ public class EwsSoapMailSenderTests AssertNoCredentialLeak(ex); } + /// <summary>Verifies that a request exceeding the timeout throws a transient error rather than a cancellation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_RequestExceedsTimeout_ThrowsTransientNotCancellation() { @@ -178,6 +194,8 @@ public class EwsSoapMailSenderTests AssertNoCredentialLeak(ex); } + /// <summary>Verifies that caller cancellation propagates unwrapped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_CallerCancelled_PropagatesCancellationUnwrapped() { @@ -193,6 +211,8 @@ public class EwsSoapMailSenderTests Assert.IsNotType<EwsPermanentException>(ex); } + /// <summary>Verifies that a success status with an unparseable body throws a permanent error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SendAsync_SuccessStatusWithUnparseableBody_ThrowsPermanent() { @@ -242,6 +262,9 @@ public class EwsSoapMailSenderTests /// <summary>The client name the sender asked for, so tests can pin the named registration.</summary> public string? RequestedClientName { get; private set; } + /// <summary>Creates an HTTP client over the fake message handler.</summary> + /// <param name="name">The logical client name.</param> + /// <returns>An HTTP client backed by the fake handler.</returns> public HttpClient CreateClient(string name) { RequestedClientName = name; @@ -260,26 +283,40 @@ public class EwsSoapMailSenderTests private StubHandler(Func<CancellationToken, Task<HttpResponseMessage>> responder) => _responder = responder; + /// <summary>Gets the authorization scheme of the captured request.</summary> public string? CapturedScheme { get; private set; } + /// <summary>Gets the authorization parameter of the captured request.</summary> public string? CapturedParameter { get; private set; } + /// <summary>Gets the content media type of the captured request.</summary> public string? CapturedMediaType { get; private set; } + /// <summary>Gets the serialized body of the captured request.</summary> public string CapturedContent { get; private set; } = string.Empty; /// <summary>How many times the handler was reached — 0 proves a guard fired first.</summary> public int InvocationCount { get; private set; } + /// <summary>Creates a handler that answers every request with the given status and XML body.</summary> + /// <param name="status">The HTTP status code to return.</param> + /// <param name="body">The XML response body.</param> + /// <returns>A handler that returns the canned response.</returns> public static StubHandler Responding(HttpStatusCode status, string body) => new(_ => Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "text/xml"), })); + /// <summary>Creates a handler that fails every request with the given exception.</summary> + /// <param name="exception">The exception to throw.</param> + /// <returns>A handler that throws.</returns> public static StubHandler Throwing(Exception exception) => new(_ => Task.FromException<HttpResponseMessage>(exception)); + /// <summary>Creates a handler that waits for the given delay before answering, so a timeout can trip.</summary> + /// <param name="delay">How long to wait before responding.</param> + /// <returns>A handler that responds after the delay.</returns> public static StubHandler Delaying(TimeSpan delay) => new(async token => { @@ -287,6 +324,7 @@ public class EwsSoapMailSenderTests return new HttpResponseMessage(HttpStatusCode.OK); }); + /// <inheritdoc /> protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/MailKitSmtpClientWrapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/MailKitSmtpClientWrapperTests.cs index e8b36907..d8da677c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/MailKitSmtpClientWrapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/MailKitSmtpClientWrapperTests.cs @@ -4,15 +4,17 @@ using MailKit.Security; namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests; /// <summary> -/// NS-016: <see cref="MailKitSmtpClientWrapper.AuthenticateAsync"/> must never +/// <see cref="MailKitSmtpClientWrapper.AuthenticateAsync"/> must never /// silently skip authentication for a misconfigured SMTP config — a missing /// credential, an unrecognised auth type, or an unparseable Basic credential /// must be a hard, surfaced error rather than an unauthenticated send. -/// NS-021: the OAuth2 (XOAUTH2) branch must carry a non-empty user identity -/// (the SMTP From address) — an empty user is rejected by M365 with `535 5.7.3`. +/// The OAuth2 (XOAUTH2) branch must carry a non-empty user identity +/// (the SMTP From address) — an empty user is rejected by Microsoft 365 with `535 5.7.3`. /// </summary> public class MailKitSmtpClientWrapperTests { + /// <summary>Verifies that authenticating with empty credentials throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Authenticate_EmptyCredentials_Throws() { @@ -26,6 +28,8 @@ public class MailKitSmtpClientWrapperTests () => wrapper.AuthenticateAsync("oauth2", "")); } + /// <summary>Verifies that authenticating with an unknown auth type throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Authenticate_UnknownAuthType_Throws() { @@ -37,6 +41,8 @@ public class MailKitSmtpClientWrapperTests () => wrapper.AuthenticateAsync("ntlm", "user:pass")); } + /// <summary>Verifies that a basic credential without a colon separator throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Authenticate_BasicCredentialWithoutColon_Throws() { @@ -48,11 +54,13 @@ public class MailKitSmtpClientWrapperTests () => wrapper.AuthenticateAsync("basic", "nocolon")); } + /// <summary>Verifies that OAuth2 authentication without a user name throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Authenticate_OAuth2WithoutUserName_Throws() { - // NS-021: passing an OAuth2 access token but no user identity (FromAddress) - // used to construct `new SaslMechanismOAuth2("", credentials)`, which M365 + // Passing an OAuth2 access token but no user identity (FromAddress) + // used to construct `new SaslMechanismOAuth2("", credentials)`, which Microsoft 365 // rejects with `535 5.7.3`. The wrapper now refuses upfront so the caller // sees a clean configuration error rather than a confusing server reject. var wrapper = new MailKitSmtpClientWrapper(); @@ -63,13 +71,14 @@ public class MailKitSmtpClientWrapperTests () => wrapper.AuthenticateAsync("oauth2", "access-token", oauth2UserName: "")); } + /// <summary>Verifies that the XOAUTH2 initial response carries both the user and the bearer token.</summary> [Fact] public void XOAuth2InitialResponse_CarriesUserAndBearer() { - // NS-021 regression guard: independent of the wrapper, prove that MailKit's + // Regression guard: independent of the wrapper, prove that MailKit's // SaslMechanismOAuth2 puts `user=<userName>` into the initial-response bytes // — i.e. wiring the wrapper to pass `FromAddress` is sufficient to fix the - // M365 handshake. If MailKit ever changes the framing this test will catch it. + // Microsoft 365 handshake. If MailKit ever changes the framing this test will catch it. var sasl = new SaslMechanismOAuth2("noreply@example.com", "tok-xyz"); var initial = sasl.Challenge(string.Empty); diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationOptionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationOptionsTests.cs index 14da4c08..31ec02a8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationOptionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationOptionsTests.cs @@ -1,10 +1,11 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests; /// <summary> -/// WP-11: Tests for NotificationOptions defaults. +/// Tests for NotificationOptions defaults. /// </summary> public class NotificationOptionsTests { + /// <summary>Verifies that the default options have reasonable values.</summary> [Fact] public void DefaultOptions_HasReasonableDefaults() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationOptionsValidatorTests.cs index 059be118..2a271396 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationOptionsValidatorTests.cs @@ -13,6 +13,7 @@ public class NotificationOptionsValidatorTests private static ValidateOptionsResult Validate(NotificationOptions options) => new NotificationOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the default options pass validation.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -20,6 +21,7 @@ public class NotificationOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero connection timeout is rejected.</summary> [Fact] public void ZeroConnectionTimeoutSeconds_IsRejected() { @@ -29,6 +31,7 @@ public class NotificationOptionsValidatorTests Assert.Contains("ConnectionTimeoutSeconds", result.FailureMessage); } + /// <summary>Verifies that a negative connection timeout is rejected.</summary> [Fact] public void NegativeConnectionTimeoutSeconds_IsRejected() { @@ -38,6 +41,7 @@ public class NotificationOptionsValidatorTests Assert.Contains("ConnectionTimeoutSeconds", result.FailureMessage); } + /// <summary>Verifies that a zero maximum concurrent connection count is rejected.</summary> [Fact] public void ZeroMaxConcurrentConnections_IsRejected() { @@ -47,6 +51,7 @@ public class NotificationOptionsValidatorTests Assert.Contains("MaxConcurrentConnections", result.FailureMessage); } + /// <summary>Verifies that a negative maximum concurrent connection count is rejected.</summary> [Fact] public void NegativeMaxConcurrentConnections_IsRejected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/OAuth2TokenServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/OAuth2TokenServiceTests.cs index 49e4beb4..238cf0b4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/OAuth2TokenServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/OAuth2TokenServiceTests.cs @@ -23,6 +23,8 @@ public class OAuth2TokenServiceTests return factory; } + /// <summary>Verifies that get token async returns access token from token endpoint.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTokenAsync_ReturnsAccessToken_FromTokenEndpoint() { @@ -42,6 +44,8 @@ public class OAuth2TokenServiceTests Assert.Equal("mock-access-token-12345", token); } + /// <summary>Verifies that get token async caches token on subsequent calls.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTokenAsync_CachesToken_OnSubsequentCalls() { @@ -65,6 +69,8 @@ public class OAuth2TokenServiceTests Assert.Equal(1, handler.CallCount); // Only one HTTP call should be made } + /// <summary>Verifies that get token async invalid credential format throws invalid operation exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTokenAsync_InvalidCredentialFormat_ThrowsInvalidOperationException() { @@ -76,6 +82,8 @@ public class OAuth2TokenServiceTests () => service.GetTokenAsync("invalid-no-colons")); } + /// <summary>Verifies that get token async http failure throws http request exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTokenAsync_HttpFailure_ThrowsHttpRequestException() { @@ -87,12 +95,14 @@ public class OAuth2TokenServiceTests () => service.GetTokenAsync("tenant:client:secret")); } - // ── NotificationService-006: token cache must be keyed to credential identity ── + // ── Token cache must be keyed to credential identity ── + /// <summary>Verifies that get token async different credentials return per credential tokens.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTokenAsync_DifferentCredentials_ReturnPerCredentialTokens() { - // NS-006: the singleton cached a single token ignoring the credentials + // The singleton cached a single token ignoring the credentials // argument, so a second SMTP config with a different tenant/client got the // first config's token. Each distinct credential must get its own token. var handler = new PerTenantHttpMessageHandler(); @@ -107,10 +117,12 @@ public class OAuth2TokenServiceTests Assert.Equal("token-for-tenantB", tokenB); } + /// <summary>Verifies that get token async same credentials cached per credential.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTokenAsync_SameCredentials_CachedPerCredential() { - // NS-006: caching still works — repeated calls with the same credential + // Caching still works — repeated calls with the same credential // identity make exactly one HTTP call. var handler = new PerTenantHttpMessageHandler(); var client = new HttpClient(handler); @@ -124,12 +136,14 @@ public class OAuth2TokenServiceTests Assert.Equal(2, handler.CallCount); // one per distinct credential, not per call } - // ── NotificationService-012: token expiry/refresh and concurrent acquisition ── + // ── Token expiry/refresh and concurrent acquisition ── + /// <summary>Verifies that get token async expired token refreshes on next call.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTokenAsync_ExpiredToken_RefreshesOnNextCall() { - // NS-012: token expiry/refresh was untested — the cache test used a 3600s + // Token expiry/refresh was untested — the cache test used a 3600s // token so the refresh branch never ran. The service refreshes 60s before // the stated expiry, so an expires_in of 60 makes the token immediately // stale and the next call must fetch a fresh one. @@ -148,10 +162,12 @@ public class OAuth2TokenServiceTests Assert.Equal(2, handler.CallCount); } + /// <summary>Verifies that get token async concurrent calls make exactly one http request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTokenAsync_ConcurrentCalls_MakeExactlyOneHttpRequest() { - // NS-012: the double-checked-locking path was never exercised. Many callers + // The double-checked-locking path was never exercised. Many callers // racing for the same uncached credential must collapse to a single token // fetch, not one HTTP call per caller. var handler = new SlowCountingHttpMessageHandler( @@ -169,12 +185,14 @@ public class OAuth2TokenServiceTests Assert.Equal(1, handler.CallCount); } - // ── Task 19: configurable OAuth2 authority + scope per SMTP configuration ── + // ── Configurable OAuth2 authority + scope per SMTP configuration ── + /// <summary>Verifies that get token async custom authority and scope posts to configured endpoint.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTokenAsync_CustomAuthorityAndScope_PostsToConfiguredEndpoint() { - // A non-M365 IdP: the stored authority template carries a {tenant} placeholder + // An IdP other than Microsoft 365: the stored authority template carries a {tenant} placeholder // and the scope is provider-specific. Both must flow verbatim into the request. var handler = new CapturingHttpMessageHandler( TokenJson("custom-token", expiresIn: 3600)); @@ -192,6 +210,8 @@ public class OAuth2TokenServiceTests Assert.Equal("smtp.send", handler.LastForm!["scope"]); } + /// <summary>Verifies that get token async preserves the Microsoft 365 defaults.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetTokenAsync_DefaultsPreserved_M365() { @@ -223,10 +243,14 @@ public class OAuth2TokenServiceTests private class SequenceHttpMessageHandler : HttpMessageHandler { private readonly string[] _responses; + /// <summary>The number of HTTP requests the handler has served.</summary> public int CallCount { get; private set; } + /// <summary>Initializes a new instance of <c>SequenceHttpMessageHandler</c>.</summary> + /// <param name="responses">The responses.</param> public SequenceHttpMessageHandler(params string[] responses) => _responses = responses; + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { @@ -245,14 +269,19 @@ public class OAuth2TokenServiceTests private readonly string _response; private readonly TimeSpan _delay; private int _callCount; + /// <summary>The number of HTTP requests the handler has served.</summary> public int CallCount => _callCount; + /// <summary>Initializes a new instance of <c>SlowCountingHttpMessageHandler</c>.</summary> + /// <param name="response">The response.</param> + /// <param name="delay">The delay.</param> public SlowCountingHttpMessageHandler(string response, TimeSpan delay) { _response = response; _delay = delay; } + /// <inheritdoc /> protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { @@ -271,8 +300,10 @@ public class OAuth2TokenServiceTests /// </summary> private class PerTenantHttpMessageHandler : HttpMessageHandler { + /// <summary>The number of HTTP requests the handler has served.</summary> public int CallCount { get; private set; } + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { @@ -299,11 +330,16 @@ public class OAuth2TokenServiceTests private class CapturingHttpMessageHandler : HttpMessageHandler { private readonly string _response; + /// <summary>The URI of the most recent request, if any.</summary> public Uri? LastRequestUri { get; private set; } + /// <summary>The form fields posted in the most recent request, if any.</summary> public IReadOnlyDictionary<string, string>? LastForm { get; private set; } + /// <summary>Initializes a new instance of <c>CapturingHttpMessageHandler</c>.</summary> + /// <param name="response">The response.</param> public CapturingHttpMessageHandler(string response) => _response = response; + /// <inheritdoc /> protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { @@ -332,12 +368,16 @@ public class OAuth2TokenServiceTests private readonly HttpStatusCode _statusCode; private readonly string _responseContent; + /// <summary>Initializes a new instance of <c>MockHttpMessageHandler</c>.</summary> + /// <param name="statusCode">The status code.</param> + /// <param name="responseContent">The response content.</param> public MockHttpMessageHandler(HttpStatusCode statusCode, string responseContent) { _statusCode = statusCode; _responseContent = responseContent; } + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { @@ -355,14 +395,19 @@ public class OAuth2TokenServiceTests { private readonly HttpStatusCode _statusCode; private readonly string _responseContent; + /// <summary>The number of HTTP requests the handler has served.</summary> public int CallCount { get; private set; } + /// <summary>Initializes a new instance of <c>CountingHttpMessageHandler</c>.</summary> + /// <param name="statusCode">The status code.</param> + /// <param name="responseContent">The response content.</param> public CountingHttpMessageHandler(HttpStatusCode statusCode, string responseContent) { _statusCode = statusCode; _responseContent = responseContent; } + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/SmtpErrorClassifierTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/SmtpErrorClassifierTests.cs index ab02af3d..78d48090 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/SmtpErrorClassifierTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/SmtpErrorClassifierTests.cs @@ -5,7 +5,7 @@ using MailKit.Net.Smtp; namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests; /// <summary> -/// NS-002/NS-003: Tests for the shared SMTP error classification policy. This +/// Tests for the shared SMTP error classification policy. This /// policy is correctness-relevant — it decides whether a delivery failure is /// retried (transient) or returned to the caller (permanent) — and is shared /// by the central outbox's <c>EmailNotificationDeliveryAdapter</c>, so it @@ -13,6 +13,8 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests; /// </summary> public class SmtpErrorClassifierTests { + /// <summary>Verifies that an SMTP 4xx command error is classified as transient.</summary> + /// <param name="statusCode">The SMTP status code.</param> [Theory] [InlineData(421)] // service not available [InlineData(450)] // mailbox unavailable (busy) @@ -26,6 +28,8 @@ public class SmtpErrorClassifierTests Assert.Equal(SmtpErrorClass.Transient, SmtpErrorClassifier.Classify(ex, CancellationToken.None)); } + /// <summary>Verifies that an SMTP 5xx command error is classified as permanent.</summary> + /// <param name="statusCode">The SMTP status code.</param> [Theory] [InlineData(500)] // syntax error [InlineData(550)] // mailbox unavailable (rejected) @@ -39,6 +43,7 @@ public class SmtpErrorClassifierTests Assert.Equal(SmtpErrorClass.Permanent, SmtpErrorClassifier.Classify(ex, CancellationToken.None)); } + /// <summary>Verifies that an SMTP command error with an unusual status code is classified as unknown.</summary> [Fact] public void Classify_SmtpCommandWithUnusualCode_IsUnknown() { @@ -49,6 +54,7 @@ public class SmtpErrorClassifierTests Assert.Equal(SmtpErrorClass.Unknown, SmtpErrorClassifier.Classify(ex, CancellationToken.None)); } + /// <summary>Verifies that an SMTP protocol exception is classified as transient.</summary> [Fact] public void Classify_SmtpProtocolException_IsTransient() { @@ -57,6 +63,7 @@ public class SmtpErrorClassifierTests SmtpErrorClassifier.Classify(new SmtpProtocolException("protocol error"), CancellationToken.None)); } + /// <summary>Verifies that a service-not-connected exception is classified as transient.</summary> [Fact] public void Classify_ServiceNotConnectedException_IsTransient() { @@ -65,6 +72,7 @@ public class SmtpErrorClassifierTests SmtpErrorClassifier.Classify(new ServiceNotConnectedException(), CancellationToken.None)); } + /// <summary>Verifies that a socket exception is classified as transient.</summary> [Fact] public void Classify_SocketException_IsTransient() { @@ -73,6 +81,7 @@ public class SmtpErrorClassifierTests SmtpErrorClassifier.Classify(new SocketException(), CancellationToken.None)); } + /// <summary>Verifies that a timeout exception is classified as transient.</summary> [Fact] public void Classify_TimeoutException_IsTransient() { @@ -81,6 +90,7 @@ public class SmtpErrorClassifierTests SmtpErrorClassifier.Classify(new TimeoutException(), CancellationToken.None)); } + /// <summary>Verifies that a requested cancellation is classified as unknown.</summary> [Fact] public void Classify_RequestedCancellation_IsUnknown() { @@ -92,6 +102,7 @@ public class SmtpErrorClassifierTests SmtpErrorClassifier.Classify(new OperationCanceledException(), cts.Token)); } + /// <summary>Verifies that an operation cancellation without a requested cancellation is classified as unknown.</summary> [Fact] public void Classify_OperationCanceledWithoutRequestedCancellation_IsUnknown() { @@ -101,6 +112,7 @@ public class SmtpErrorClassifierTests SmtpErrorClassifier.Classify(new OperationCanceledException(), CancellationToken.None)); } + /// <summary>Verifies that an unrecognised exception is classified as unknown.</summary> [Fact] public void Classify_UnrecognisedException_IsUnknown() { @@ -109,6 +121,9 @@ public class SmtpErrorClassifierTests SmtpErrorClassifier.Classify(new InvalidOperationException("bad credential triple"), CancellationToken.None)); } + /// <summary>Verifies that the transient check matches the classification of the status code.</summary> + /// <param name="statusCode">The SMTP status code.</param> + /// <param name="expectedTransient">Whether the status code is expected to be transient.</param> [Theory] [InlineData(450, true)] [InlineData(550, false)] @@ -121,6 +136,7 @@ public class SmtpErrorClassifierTests Assert.Equal(expectedTransient, SmtpErrorClassifier.IsTransient(ex, CancellationToken.None)); } + /// <summary>Verifies that a requested cancellation is not transient.</summary> [Fact] public void IsTransient_RequestedCancellation_IsFalse() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/SmtpTlsModeParserTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/SmtpTlsModeParserTests.cs index 8c303b05..663bf819 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/SmtpTlsModeParserTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/SmtpTlsModeParserTests.cs @@ -1,10 +1,13 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests; /// <summary> -/// NS-005: Tests for parsing the configured SMTP TLS mode into the three-state enum. +/// Tests for parsing the configured SMTP TLS mode into the three-state enum. /// </summary> public class SmtpTlsModeParserTests { + /// <summary>Verifies that a known mode string parses to the expected TLS mode.</summary> + /// <param name="input">The configured mode string.</param> + /// <param name="expected">The expected parsed TLS mode.</param> [Theory] [InlineData("none", SmtpTlsMode.None)] [InlineData("None", SmtpTlsMode.None)] @@ -19,6 +22,8 @@ public class SmtpTlsModeParserTests Assert.Equal(expected, SmtpTlsModeParser.Parse(input)); } + /// <summary>Verifies that a null or empty mode defaults to StartTLS.</summary> + /// <param name="input">The null or empty mode string.</param> [Theory] [InlineData(null)] [InlineData("")] @@ -28,13 +33,15 @@ public class SmtpTlsModeParserTests Assert.Equal(SmtpTlsMode.StartTls, SmtpTlsModeParser.Parse(input)); } + /// <summary>Verifies that an unknown mode string throws.</summary> + /// <param name="input">The unrecognized mode string.</param> [Theory] [InlineData("auto")] [InlineData("tls")] [InlineData("implicit")] public void Parse_UnknownMode_Throws(string input) { - // NS-005: an unknown mode must be rejected, not silently treated as Auto. + // An unknown mode must be rejected, not silently treated as Auto. Assert.Throws<ArgumentException>(() => SmtpTlsModeParser.Parse(input)); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/AuditLog/HotPathLatencyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/AuditLog/HotPathLatencyTests.cs index f033e9fa..01d72b0f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/AuditLog/HotPathLatencyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/AuditLog/HotPathLatencyTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.AuditLog; /// <summary> -/// Bundle D (M5-T9) / C7 (Task 2.5) hot-path latency budget for +/// Hot-path latency budget for /// <see cref="ScadaBridgeAuditRedactor"/>. The redactor sits between event /// construction and persistence on every audit row — site SQLite hot-path and /// central direct-write both — so it MUST stay out of the way of script-thread @@ -47,7 +47,7 @@ public class HotPathLatencyTests private const int WarmupIterations = 200; private const int MeasureIterations = 2_000; - // C3/C7 (Task 2.5): the hot-path redactor is the canonical + // The hot-path redactor is the canonical // ScadaBridgeAuditRedactor (IAuditRedactor) operating on the canonical record. private static ScadaBridgeAuditRedactor Filter(AuditLogOptions opts) => new( new StaticMonitor(opts), @@ -231,9 +231,18 @@ public class HotPathLatencyTests private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions> { private readonly AuditLogOptions _value; + /// <summary>Creates a monitor that always returns the given options.</summary> + /// <param name="value">The options instance to return.</param> public StaticMonitor(AuditLogOptions value) => _value = value; + /// <summary>Gets the fixed options instance.</summary> public AuditLogOptions CurrentValue => _value; + /// <summary>Returns the fixed options instance regardless of name.</summary> + /// <param name="name">The options name, ignored.</param> + /// <returns>The fixed options instance.</returns> public AuditLogOptions Get(string? name) => _value; + /// <summary>Ignores the listener because the options never change.</summary> + /// <param name="listener">The change listener, ignored.</param> + /// <returns>Always <see langword="null"/>.</returns> public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Failover/FailoverTimingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Failover/FailoverTimingTests.cs index 219b547b..5d148254 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Failover/FailoverTimingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Failover/FailoverTimingTests.cs @@ -18,16 +18,18 @@ namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.Failover; /// member REMOVAL (detection + stability window + gossip) with singleton /// continuity asserted on the oldest; the oldest-crash direction is covered /// behaviorally by SbrFailoverTests.AutoDown_HardCrashOfOldestNode_* and by -/// the docker failover drill. Covers overall review P2-10 / report-08 NF2 -/// and report-01 round-2 N1's measurement ask. +/// the docker failover drill. Covers the measurement requested by the overall review. /// </summary> public class FailoverTimingTests(ITestOutputHelper output) { private sealed class EchoActor : ReceiveActor { + /// <summary>Initializes a new instance of the <see cref="EchoActor"/> class.</summary> public EchoActor() => ReceiveAny(msg => Sender.Tell(msg)); } + /// <summary>Verifies that a hard kill of the younger node is followed by removal within the design envelope.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Trait("Category", "Performance")] [Fact] public async Task HardKillOfYoungerNode_SbrRemovalWithinDesignEnvelope() diff --git a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/HealthAggregationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/HealthAggregationTests.cs index 2272f685..389b6032 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/HealthAggregationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/HealthAggregationTests.cs @@ -7,13 +7,14 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.PerformanceTests; /// <summary> -/// WP-4 (Phase 8): Performance test framework for health reporting aggregation. +/// Performance test framework for health reporting aggregation. /// Verifies health reporting from 10 sites can be aggregated correctly. /// </summary> public class HealthAggregationTests { private readonly CentralHealthAggregator _aggregator; + /// <summary>Initializes the test class with a central health aggregator configured for 30-second reports and a 60-second offline timeout.</summary> public HealthAggregationTests() { var options = Options.Create(new HealthMonitoringOptions @@ -26,6 +27,7 @@ public class HealthAggregationTests NullLogger<CentralHealthAggregator>.Instance); } + /// <summary>Verifies that health reports from ten sites are all tracked by the aggregator.</summary> [Trait("Category", "Performance")] [Fact] public void AggregateHealthReports_10Sites_AllTracked() @@ -66,6 +68,7 @@ public class HealthAggregationTests Assert.All(states.Values, s => Assert.True(s.IsOnline)); } + /// <summary>Verifies that the aggregator handles a high volume of rapid report updates.</summary> [Trait("Category", "Performance")] [Fact] public void AggregateHealthReports_RapidUpdates_HandlesVolume() @@ -106,6 +109,7 @@ public class HealthAggregationTests }); } + /// <summary>Verifies that stale health reports are rejected.</summary> [Trait("Category", "Performance")] [Fact] public void AggregateHealthReports_StaleReportsRejected() @@ -133,6 +137,7 @@ public class HealthAggregationTests Assert.Equal(5, state.LatestReport!.ScriptErrorCount); } + /// <summary>Verifies that collecting a report resets the interval counters.</summary> [Trait("Category", "Performance")] [Fact] public void HealthCollector_CollectReport_ResetsIntervalCounters() diff --git a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/StaggeredStartupTests.cs b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/StaggeredStartupTests.cs index 1cf5ed0f..299b186e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/StaggeredStartupTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/StaggeredStartupTests.cs @@ -3,7 +3,7 @@ using System.Diagnostics; namespace ZB.MOM.WW.ScadaBridge.PerformanceTests; /// <summary> -/// WP-4 (Phase 8): Performance test framework for staggered startup. +/// Performance test framework for staggered startup. /// Target scale: 10 sites, 500 machines, 75 tags each. /// These are framework/scaffold tests — actual perf runs are manual. /// </summary> @@ -51,6 +51,7 @@ public class StaggeredStartupTests $"Expected staggered startup {expectedTotalMs}ms exceeds 30s budget"); } + /// <summary>Verifies that staggered startup distributes instance starts across 10 sites.</summary> [Trait("Category", "Performance")] [Fact] public void StaggeredStartup_DistributionAcross10Sites() @@ -76,6 +77,7 @@ public class StaggeredStartupTests Assert.Equal(500, sites.Values.Sum()); } + /// <summary>Verifies that 75 tags on each of 500 machines gives a total capacity of 37,500 tags.</summary> [Trait("Category", "Performance")] [Fact] public void TagCapacity_75TagsPer500Machines_37500Total() diff --git a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Streaming/SiteStreamThroughputTests.cs b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Streaming/SiteStreamThroughputTests.cs index 07993754..193ab961 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Streaming/SiteStreamThroughputTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Streaming/SiteStreamThroughputTests.cs @@ -36,6 +36,7 @@ public class SiteStreamThroughputTests { public static long Received; + /// <summary>Initializes the actor and registers the handlers that count received attribute value changes.</summary> public CountingActor() { // The manager Tells the raw ISiteStreamEvent record (AttributeValueChanged) @@ -45,6 +46,8 @@ public class SiteStreamThroughputTests } } + /// <summary>Verifies that the site stream sustains at least 10,000 events per second to a single subscriber.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Trait("Category", "Performance")] [Fact] public async Task SiteStream_SustainsAtLeast10kEventsPerSecond_ToOneSubscriber() diff --git a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/TargetScale/TargetScaleHarnessSmokeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/TargetScale/TargetScaleHarnessSmokeTests.cs index 4d47f85f..8afb3288 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/TargetScale/TargetScaleHarnessSmokeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/TargetScale/TargetScaleHarnessSmokeTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.LoadHarness; namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.TargetScale; /// <summary> -/// Keeps the target-scale load harness (deferred-work register #25 / Phase-8 WP-4) +/// Keeps the target-scale load harness (a deferred-work register item) /// honest at CI scale. /// /// <para> @@ -13,7 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.TargetScale; /// <c>dotnet test ZB.MOM.WW.ScadaBridge.slnx</c> (the <c>Category=Performance</c> /// trait enables a filter, it does not exclude by default), and a 20-minute test /// would be intolerable there. What this test protects is that the harness still -/// compiles, wires up, and produces coherent measurements — so #25's evidence can be +/// compiles, wires up, and produces coherent measurements — so the harness's evidence can be /// regenerated on demand rather than bit-rotting. /// </para> /// <para> diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs index 2cfc12ce..43a577de 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs @@ -17,8 +17,10 @@ public class CachingScriptMetadataResolverTests { public int MissingCalls; public int ReferenceCalls; + /// <inheritdoc /> public override bool ResolveMissingAssemblies => true; + /// <inheritdoc /> public override PortableExecutableReference? ResolveMissingAssembly( MetadataReference definition, AssemblyIdentity referenceIdentity) { @@ -26,6 +28,7 @@ public class CachingScriptMetadataResolverTests return MetadataReference.CreateFromFile(typeof(object).Assembly.Location); } + /// <inheritdoc /> public override ImmutableArray<PortableExecutableReference> ResolveReference( string reference, string? baseFilePath, MetadataReferenceProperties properties) { @@ -33,7 +36,9 @@ public class CachingScriptMetadataResolverTests return [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]; } + /// <inheritdoc /> public override bool Equals(object? other) => ReferenceEquals(this, other); + /// <inheritdoc /> public override int GetHashCode() => 0; } @@ -41,8 +46,10 @@ public class CachingScriptMetadataResolverTests private sealed class NullReturningResolver : MetadataReferenceResolver { public int MissingCalls; + /// <inheritdoc /> public override bool ResolveMissingAssemblies => true; + /// <inheritdoc /> public override PortableExecutableReference? ResolveMissingAssembly( MetadataReference definition, AssemblyIdentity referenceIdentity) { @@ -50,11 +57,14 @@ public class CachingScriptMetadataResolverTests return null; } + /// <inheritdoc /> public override ImmutableArray<PortableExecutableReference> ResolveReference( string reference, string? baseFilePath, MetadataReferenceProperties properties) => []; + /// <inheritdoc /> public override bool Equals(object? other) => ReferenceEquals(this, other); + /// <inheritdoc /> public override int GetHashCode() => 0; } @@ -62,6 +72,7 @@ public class CachingScriptMetadataResolverTests private static readonly MetadataReference SomeDefinition = MetadataReference.CreateFromFile(typeof(object).Assembly.Location); + /// <summary>Verifies that resolving the same identity twice resolves once and shares the instance.</summary> [Fact] public void ResolveMissingAssembly_SameIdentityTwice_ResolvesOnceAndSharesInstance() { @@ -75,6 +86,7 @@ public class CachingScriptMetadataResolverTests Assert.Same(first, second); // one AssemblyMetadata/PEReader/native block, not two } + /// <summary>Verifies that distinct identities are resolved independently.</summary> [Fact] public void ResolveMissingAssembly_DistinctIdentities_ResolveIndependently() { @@ -133,6 +145,7 @@ public class CachingScriptMetadataResolverTests Assert.Equal(1, inner.MissingCalls); } + /// <summary>Verifies that resolving a reference twice with the same arguments resolves once and shares the instances.</summary> [Fact] public void ResolveReference_SameArgsTwice_ResolvesOnceAndSharesInstances() { @@ -146,10 +159,12 @@ public class CachingScriptMetadataResolverTests Assert.Same(first[0], second[0]); } + /// <summary>Verifies that the shared instance is a process-wide singleton.</summary> [Fact] public void Instance_IsProcessWideSingleton() => Assert.Same(CachingScriptMetadataResolver.Instance, CachingScriptMetadataResolver.Instance); + /// <summary>Verifies that the resolve-missing-assemblies flag is delegated to the inner resolver.</summary> [Fact] public void ResolveMissingAssemblies_DelegatesToInner() { @@ -204,18 +219,25 @@ public class CachingScriptMetadataResolverTests { private readonly MetadataReferenceResolver _inner; public long MissingCalls; + /// <summary>Initializes the probe around an inner resolver.</summary> + /// <param name="inner">The resolver to delegate to.</param> public ProbeResolver(MetadataReferenceResolver inner) => _inner = inner; + /// <inheritdoc /> public override bool ResolveMissingAssemblies => _inner.ResolveMissingAssemblies; + /// <inheritdoc /> public override PortableExecutableReference? ResolveMissingAssembly( MetadataReference definition, AssemblyIdentity referenceIdentity) { Interlocked.Increment(ref MissingCalls); return _inner.ResolveMissingAssembly(definition, referenceIdentity); } + /// <inheritdoc /> public override ImmutableArray<PortableExecutableReference> ResolveReference( string reference, string? baseFilePath, MetadataReferenceProperties properties) => _inner.ResolveReference(reference, baseFilePath, properties); + /// <inheritdoc /> public override bool Equals(object? other) => ReferenceEquals(this, other); + /// <inheritdoc /> public override int GetHashCode() => 0; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs index 3f1622e8..c57a7905 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs @@ -59,6 +59,7 @@ public class FlowShapePredictorTests private static string? AnySiteScript(string instanceCode, string scriptName) => scriptName == "ProcessRecipeDownload" ? DelmiaSiteScript : null; + /// <summary>Verifies that delmia method plus site script predicts the full swimlane shape.</summary> [Fact] public void Delmia_MethodPlusSiteScript_PredictsTheFullSwimlaneShape() { @@ -104,6 +105,7 @@ public class FlowShapePredictorTests Assert.True(flow.Edges[^1].CrossesBoundary); } + /// <summary>Verifies that constant route target is not input dependent and qualifies site titles.</summary> [Fact] public void ConstantRouteTarget_IsNotInputDependent_AndQualifiesSiteTitles() { @@ -124,6 +126,7 @@ public class FlowShapePredictorTests Assert.StartsWith("Z28061Sim.", write.Title); } + /// <summary>Verifies that resolver returning null leaves the site run unexpanded.</summary> [Fact] public void ResolverReturningNull_LeavesTheSiteRunUnexpanded() { @@ -137,6 +140,7 @@ public class FlowShapePredictorTests ], flow.Nodes.Select(n => n.Kind).ToList()); } + /// <summary>Verifies that no recognised call yields only request and response.</summary> [Fact] public void NoRecognisedCall_YieldsOnlyRequestAndResponse() { @@ -150,6 +154,7 @@ public class FlowShapePredictorTests Assert.False(edge.CrossesBoundary); } + /// <summary>Verifies that recursion stops at max depth.</summary> [Fact] public void Recursion_StopsAtMaxDepth() { @@ -168,6 +173,7 @@ public class FlowShapePredictorTests Assert.True(deep.Nodes.Count > shallow.Nodes.Count); } + /// <summary>Verifies that malformed code never throws and notes the parse error.</summary> [Fact] public void MalformedCode_NeverThrows_AndNotesTheParseError() { @@ -182,6 +188,7 @@ public class FlowShapePredictorTests Assert.Equal(FlowShapePredictor.ParseErrorNote, root.Note); } + /// <summary>Verifies that predict site script roots at the script run and recognises attribute ops.</summary> [Fact] public void PredictSiteScript_RootsAtTheScriptRun_AndRecognisesAttributeOps() { @@ -208,6 +215,7 @@ public class FlowShapePredictorTests Assert.Equal(FlowLane.External, flow.Nodes[4].Lane); } + /// <summary>Verifies that outbound calls sit on the external lane sync in chain cached and notify branch async.</summary> [Fact] public void OutboundCalls_SitOnTheExternalLane_SyncInChain_CachedAndNotifyBranchAsync() { @@ -246,6 +254,7 @@ public class FlowShapePredictorTests Assert.All(flow.Edges, e => Assert.False(e.CrossesBoundary)); } + /// <summary>Verifies that cached call is an async api call and notify to resolves the list.</summary> [Fact] public void CachedCall_IsAnAsyncApiCall_AndNotifyTo_ResolvesTheList() { @@ -268,6 +277,7 @@ public class FlowShapePredictorTests Assert.Equal([(flow.Nodes[0].Id, true), (flow.Nodes[0].Id, true)], flow.Edges.Select(e => (e.From, e.Async)).ToList()); } + /// <summary>Verifies that outbound calls with computed targets are input dependent.</summary> [Fact] public void OutboundCalls_WithComputedTargets_AreInputDependent() { @@ -286,6 +296,7 @@ public class FlowShapePredictorTests Assert.Equal("Notify.Send(list)", outbound[2].Title); } + /// <summary>Verifies that inbound method database call crosses from central to external.</summary> [Fact] public void InboundMethod_DatabaseCall_CrossesFromCentralToExternal() { @@ -304,6 +315,7 @@ public class FlowShapePredictorTests Assert.False(into.Async); } + /// <summary>Verifies that input dependent attribute name is flagged.</summary> [Fact] public void InputDependentAttributeName_IsFlagged() { @@ -314,7 +326,7 @@ public class FlowShapePredictorTests Assert.Equal("name", write.Title); } - /// <summary>Gitea #36: the reset-aware overload predicts the reset write + echo wait ahead of the batch.</summary> + /// <summary>The reset-aware overload predicts the reset write + echo wait ahead of the batch.</summary> [Fact] public void ResetAwareWriteBatchAndWait_PredictsResetWriteAndEchoWaitBeforeTheBatch() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs index ad8d986e..9a999d7e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs @@ -3,25 +3,28 @@ using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests; /// <summary> -/// M3.1: parse + compile gate tests. The "representative real script" corpus is +/// Parse + compile gate tests. The "representative real script" corpus is /// the PRIMARY guard that <see cref="ScriptCompileSurface"/> faithfully mirrors /// the runtime <c>ScriptGlobals</c> surface — if a member or signature drifts, /// the corpus stops binding and this test fails. /// </summary> public class RoslynScriptCompilerTests { + /// <summary>Verifies that parse diagnostics non empty for syntax error.</summary> [Fact] public void ParseDiagnostics_NonEmpty_ForSyntaxError() { Assert.NotEmpty(RoslynScriptCompiler.ParseDiagnostics("var x = ;")); } + /// <summary>Verifies that parse diagnostics empty for valid syntax.</summary> [Fact] public void ParseDiagnostics_Empty_ForValidSyntax() { Assert.Empty(RoslynScriptCompiler.ParseDiagnostics("var x = 1;")); } + /// <summary>Verifies that compile non empty for undefined symbol.</summary> [Fact] public void Compile_NonEmpty_ForUndefinedSymbol() { @@ -29,6 +32,7 @@ public class RoslynScriptCompilerTests Assert.NotEmpty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface))); } + /// <summary>Verifies that compile empty for representative real script.</summary> [Fact] public void Compile_Empty_ForRepresentativeRealScript() { @@ -67,6 +71,7 @@ public class RoslynScriptCompilerTests Assert.Empty(diagnostics); } + /// <summary>Verifies that compile empty for a recipe file parsing script.</summary> [Fact] public void Compile_Empty_ForARecipeFileParsingScript() { @@ -89,6 +94,7 @@ public class RoslynScriptCompilerTests Assert.Empty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface))); } + /// <summary>Verifies that compile empty for the mes alarm status script shape.</summary> [Fact] public void Compile_Empty_ForTheMesAlarmStatusScriptShape() { @@ -139,6 +145,7 @@ public class RoslynScriptCompilerTests Assert.Empty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface))); } + /// <summary>Verifies that compile empty for trigger expression.</summary> [Fact] public void Compile_Empty_ForTriggerExpression() { @@ -149,6 +156,7 @@ public class RoslynScriptCompilerTests Assert.Empty(diagnostics); } + /// <summary>Verifies that compile empty for wait async and wait for async.</summary> [Fact] public void Compile_Empty_ForWaitAsyncAndWaitForAsync() { @@ -171,6 +179,7 @@ public class RoslynScriptCompilerTests Assert.Empty(diagnostics); } + /// <summary>Verifies that compiles cleanly for the log global.</summary> [Fact] public void CompilesCleanly_ForTheLogGlobal() { @@ -190,6 +199,7 @@ public class RoslynScriptCompilerTests Assert.Empty(diagnostics); } + /// <summary>Verifies that compile twice with shared resolver still compiles cleanly.</summary> [Fact] public void Compile_TwiceWithSharedResolver_StillCompilesCleanly() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs index 02d3b406..fb18e08e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests; /// <summary> -/// M3.1: adversarial + legitimate cases for the fused trust validator. Reject +/// Adversarial + legitimate cases for the fused trust validator. Reject /// cases exercise the semantic pass (alias / using static / global::), the /// reflection-gateway hardening pass, and the namespace deny-list union; clean /// cases pin the allowed exceptions (Tasks, CancellationToken, Diagnostics @@ -11,6 +11,8 @@ namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests; /// </summary> public class ScriptTrustValidatorTests { + /// <summary>Verifies that runtime escape APIs are rejected.</summary> + /// <param name="code">The script source containing a runtime escape API call.</param> [Theory] [InlineData("object o = new object(); System.Runtime.CompilerServices.Unsafe.As<object, string>(ref o);")] [InlineData("System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(object));")] @@ -22,6 +24,8 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that compiler-support constructs are allowed.</summary> + /// <param name="code">The script source using compiler-support APIs.</param> [Theory] [InlineData("await System.Threading.Tasks.Task.Delay(1);")] [InlineData("string Name([System.Runtime.CompilerServices.CallerMemberName] string name = \"\") => name;")] @@ -32,6 +36,7 @@ public class ScriptTrustValidatorTests // ---- Reject (non-empty violations) -------------------------------------- + /// <summary>Verifies that a <c>using</c> of the System.IO namespace is rejected.</summary> [Fact] public void Rejects_SystemIo_Using() { @@ -39,6 +44,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that a global-qualified System.IO reference is rejected.</summary> [Fact] public void Rejects_SystemIo_GlobalQualified() { @@ -46,6 +52,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that an aliased System.IO reference is rejected.</summary> [Fact] public void Rejects_SystemIo_Aliased() { @@ -53,6 +60,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that a <c>using static</c> of a System.IO type is rejected.</summary> [Fact] public void Rejects_SystemIo_UsingStatic() { @@ -60,6 +68,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that reflection-gateway members are rejected on a permitted type.</summary> [Fact] public void Rejects_ReflectionGateway_OffPermittedType() { @@ -67,6 +76,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that the <c>dynamic</c> keyword is rejected.</summary> [Fact] public void Rejects_Dynamic_Keyword() { @@ -74,6 +84,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that <c>Activator.CreateInstance</c> is rejected.</summary> [Fact] public void Rejects_Activator_CreateInstance() { @@ -81,6 +92,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that System.Runtime.InteropServices usage is rejected.</summary> [Fact] public void Rejects_RuntimeInteropServices() { @@ -88,6 +100,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that Microsoft.Win32 usage is rejected.</summary> [Fact] public void Rejects_MicrosoftWin32() { @@ -95,6 +108,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that <c>Thread.Sleep</c> is rejected.</summary> [Fact] public void Rejects_Threading_Thread_Sleep() { @@ -102,6 +116,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that all System.Net usage is rejected.</summary> [Fact] public void Rejects_All_SystemNet() { @@ -109,6 +124,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that reflection-gateway members are rejected on an allowed Task type.</summary> [Fact] public void Rejects_ReflectionGateway_OffAllowedTaskType() { @@ -119,6 +135,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that forbidden IO nested inside an allowed <c>Task.Run</c> lambda is rejected.</summary> [Fact] public void Rejects_ForbiddenIo_NestedInAllowedTaskRunLambda() { @@ -129,6 +146,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that a forbidden mutex used as a generic argument under an allowed Tasks prefix is rejected.</summary> [Fact] public void Rejects_ForbiddenMutex_AsGenericArg_UnderAllowedTasksPrefix() { @@ -139,6 +157,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that a direct threading mutex is rejected independently of <c>Thread.Sleep</c>.</summary> [Fact] public void Rejects_DirectThreadingMutex_NotThreadSleep() { @@ -148,6 +167,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that <c>FileInfo</c> used as a generic argument is rejected.</summary> [Fact] public void Rejects_ForbiddenFileInfo_AsGenericArg() { @@ -157,6 +177,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that <c>nameof</c> applied to a forbidden type is rejected.</summary> [Fact] public void Rejects_NameOf_ForbiddenType() { @@ -166,6 +187,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that a qualified <c>Process</c> type reference is rejected.</summary> [Fact] public void Rejects_Process_QualifiedType() { @@ -173,6 +195,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that a bare <c>Process</c> identifier introduced via a <c>using</c> is rejected.</summary> [Fact] public void Rejects_Process_BareIdentifier_ViaUsing() { @@ -185,6 +208,7 @@ public class ScriptTrustValidatorTests // ---- Clean (empty violations) ------------------------------------------- + /// <summary>Verifies that <c>Task.Delay</c> is allowed.</summary> [Fact] public void Allows_TasksDelay() { @@ -192,6 +216,7 @@ public class ScriptTrustValidatorTests Assert.Empty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that <c>Stopwatch</c> from System.Diagnostics is allowed while <c>Process</c> is not.</summary> [Fact] public void Allows_DiagnosticsStopwatch_NotProcess() { @@ -199,6 +224,7 @@ public class ScriptTrustValidatorTests Assert.Empty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that <c>CancellationTokenSource</c> is allowed.</summary> [Fact] public void Allows_CancellationTokenSource() { @@ -206,6 +232,7 @@ public class ScriptTrustValidatorTests Assert.Empty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that LINQ and <c>Math</c> usage is allowed.</summary> [Fact] public void Allows_LinqAndMath() { @@ -213,6 +240,7 @@ public class ScriptTrustValidatorTests Assert.Empty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that <c>Alarms.CurrentAsync</c> and the <c>ScriptAlarm</c> type are allowed.</summary> [Fact] public void Allows_AlarmsCurrentAsync_AndTheScriptAlarmType() { @@ -231,6 +259,7 @@ public class ScriptTrustValidatorTests Assert.Empty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that the <c>Log</c> global is allowed.</summary> [Fact] public void Allows_TheLogGlobal() { @@ -249,6 +278,7 @@ public class ScriptTrustValidatorTests Assert.Empty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that the forbidden scopes do not cover the Commons script surface.</summary> [Fact] public void ForbiddenScopes_DoNotCover_TheCommonsScriptSurface() { @@ -261,9 +291,9 @@ public class ScriptTrustValidatorTests .Namespace!.StartsWith(scope, StringComparison.Ordinal)); } - // ---- ScriptAnalysis-003: adversarial bypass-vector coverage -------------- + // ---- Adversarial bypass-vector coverage -------------- - // (a) TPA-FALLBACK DEGRADATION (the SA-001 hole). Forces Pass 1 onto the + // (a) TPA-FALLBACK DEGRADATION (the original bypass hole). Forces Pass 1 onto the // minimal fallback reference set (DefaultAssemblies + ForbiddenAnchorAssemblies) // — the set used on a single-file/AOT/trimmed host with no TPA list — and // proves a BARE forbidden type inside an ALLOWED namespace is STILL caught. @@ -272,18 +302,20 @@ public class ScriptTrustValidatorTests // bare identifier — so `Process.Start` slipped the validator entirely. The // anchor assemblies folded into the fallback close that hole. + /// <summary>Verifies that the minimal fallback reference set still rejects a bare <c>Process</c> introduced via a <c>using</c>.</summary> [Fact] public void TpaFallback_StillRejects_BareProcess_ViaUsing() { // The documented forbidden-type-in-allowed-namespace case: System.Diagnostics // is allowed (Stopwatch/Debug), the `using` is not flagged, and `Process` // is a BARE identifier. Against the minimal fallback set this must still - // be rejected — otherwise the SA-001 fallback hole is open. + // be rejected — otherwise the fallback hole is open. var minimal = ScriptTrustPolicy.BuildMinimalFallbackReferences(); var code = "using System.Diagnostics; var p = Process.Start(\"x\");"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code, minimal)); } + /// <summary>Verifies that the minimal fallback reference set still rejects a bare <c>Socket</c> introduced via a <c>using</c>.</summary> [Fact] public void TpaFallback_StillRejects_BareSocket_ViaUsing() { @@ -295,6 +327,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code, minimal)); } + /// <summary>Verifies that the minimal fallback reference set still allows <c>Stopwatch</c>.</summary> [Fact] public void TpaFallback_StillAllows_DiagnosticsStopwatch() { @@ -305,6 +338,7 @@ public class ScriptTrustValidatorTests Assert.Empty(ScriptTrustValidator.FindViolations(code, minimal)); } + /// <summary>Verifies that the minimal fallback references resolve <c>Process</c> as a forbidden type.</summary> [Fact] public void MinimalFallbackReferences_Resolve_Process_AsForbidden() { @@ -323,6 +357,7 @@ public class ScriptTrustValidatorTests // (a forbidden namespace) even though it is invoked in receiver position — the // semantic pass resolves the reduced extension method's containing type, so the // forbidden namespace is caught through the invocation itself. + /// <summary>Verifies that an extension method from a forbidden namespace is rejected.</summary> [Fact] public void Rejects_ExtensionMethod_InForbiddenNamespace() { @@ -337,6 +372,7 @@ public class ScriptTrustValidatorTests // VisitIdentifierName compares Identifier.ValueText, which decodes both the // verbatim '@' prefix and \uXXXX escapes — so neither spelling evades the // ForbiddenIdentifiers deny-list. + /// <summary>Verifies that a verbatim-identifier <c>Activator</c> reference is rejected.</summary> [Fact] public void Rejects_VerbatimIdentifier_Activator() { @@ -344,6 +380,7 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that a Unicode-escaped <c>Activator</c> identifier is rejected.</summary> [Fact] public void Rejects_UnicodeEscapedIdentifier_Activator() { @@ -356,6 +393,7 @@ public class ScriptTrustValidatorTests // language-feature gate: a benign `unsafe` block reaches no forbidden API, so // it must NOT be a false positive — while a forbidden API used INSIDE an unsafe // block is still caught (the walker descends into the block). + /// <summary>Verifies that an unsafe block without forbidden APIs is allowed.</summary> [Fact] public void Allows_BenignUnsafeBlock_NoForbiddenApi() { @@ -363,6 +401,7 @@ public class ScriptTrustValidatorTests Assert.Empty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that a forbidden API inside an unsafe block is rejected.</summary> [Fact] public void Rejects_ForbiddenApi_InsideUnsafeBlock() { @@ -375,6 +414,7 @@ public class ScriptTrustValidatorTests // must stay clean (the walker inspects name/member nodes, never trivia or // literal text). Reconstructing a forbidden API from runtime strings is outside // the static validator's remit (documented sandbox caveat). + /// <summary>Verifies that a forbidden namespace mentioned only in a comment is allowed.</summary> [Fact] public void Allows_ForbiddenNamespace_InCommentOnly() { @@ -382,6 +422,7 @@ public class ScriptTrustValidatorTests Assert.Empty(ScriptTrustValidator.FindViolations(code)); } + /// <summary>Verifies that a forbidden namespace mentioned only in a string literal is allowed.</summary> [Fact] public void Allows_ForbiddenNamespace_InStringLiteralOnly() { @@ -389,10 +430,12 @@ public class ScriptTrustValidatorTests Assert.Empty(ScriptTrustValidator.FindViolations(code)); } - // ---- PLAN-05 Task 21: hardened deny-list additions ----------------------- + // ---- Hardened deny-list additions ----------------------- // Environment (whole type), GC, and the concrete ADO.NET provider namespaces // (Microsoft.Data / System.Data.SqlClient / .Odbc / .OleDb) are now forbidden. + /// <summary>Verifies that scripts using hardened deny-list APIs are flagged as violations.</summary> + /// <param name="code">The script source expected to be flagged.</param> [Theory] [InlineData("Environment.Exit(0);")] [InlineData("Environment.FailFast(\"x\");")] @@ -402,6 +445,8 @@ public class ScriptTrustValidatorTests // Syntactic gateway closure (matters in TPA-degraded fallback mode): these // reflection-gateway members are rejected regardless of receiver. + /// <summary>Verifies that the syntactic pass flags reflection-gateway members regardless of receiver.</summary> + /// <param name="code">The script source expected to be flagged.</param> [Theory] [InlineData("typeof(string).Assembly.GetTypes()")] [InlineData("asm.EntryPoint")] @@ -409,6 +454,8 @@ public class ScriptTrustValidatorTests // Must NOT regress legitimate scripts: Task.Delay stays allowed and // delegate.Invoke() is deliberately NOT on the reflection-gateway list. + /// <summary>Verifies that legitimate scripts are not flagged as violations.</summary> + /// <param name="code">The script source expected to be allowed.</param> [Theory] [InlineData("await Task.Delay(1);")] [InlineData("Func<int,int> f = x => x; f.Invoke(3);")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs index 33662e13..6ba3cad4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs @@ -21,8 +21,15 @@ public class AutoLoginAuthenticationHandlerTests private sealed class StubOptionsMonitor : IOptionsMonitor<AuthenticationSchemeOptions> { private readonly AuthenticationSchemeOptions _value = new(); + /// <summary>Gets the fixed options value.</summary> public AuthenticationSchemeOptions CurrentValue => _value; + /// <summary>Returns the fixed options value regardless of name.</summary> + /// <param name="name">The options name, ignored.</param> + /// <returns>The fixed options value.</returns> public AuthenticationSchemeOptions Get(string? name) => _value; + /// <summary>Ignores the change listener; the fake never raises change notifications.</summary> + /// <param name="listener">The change listener, ignored.</param> + /// <returns>Always <see langword="null"/>.</returns> public IDisposable? OnChange(Action<AuthenticationSchemeOptions, string?> listener) => null; } @@ -41,6 +48,8 @@ public class AutoLoginAuthenticationHandlerTests return handler; } + /// <summary>Verifies that authenticate, when grants all roles, system wide as configured user.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Authenticate_GrantsAllRoles_SystemWide_AsConfiguredUser() { @@ -56,6 +65,8 @@ public class AutoLoginAuthenticationHandlerTests Assert.Empty(p.FindAll(JwtTokenService.SiteIdClaimType)); // system-wide => no scope claims } + /// <summary>Verifies that authenticate, when blank user, falls back to multi role.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Authenticate_BlankUser_FallsBackToMultiRole() { @@ -64,6 +75,8 @@ public class AutoLoginAuthenticationHandlerTests Assert.Equal("multi-role", result.Principal!.Identity!.Name); } + /// <summary>Verifies that sign in and sign out, when are no ops, do not throw.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SignInAndSignOut_AreNoOps_DoNotThrow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs index c21b1d41..07a24a0b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs @@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.Security.Tests; /// <summary> /// Tests for the cookie <c>OnValidatePrincipal</c> core (<see cref="CookieSessionValidator"/>) -/// and the shared <see cref="SessionClaimBuilder"/>. These pin the always-achievable M2.19 +/// and the shared <see cref="SessionClaimBuilder"/>. These pin the always-achievable /// layers: idle-timeout enforcement, mid-session role-mapping refresh from STORED groups /// WITHOUT any LDAP call, the fail-soft refresh policy (errors keep the session), and claim /// parity between the login path and the refresh path. @@ -29,8 +29,13 @@ public class CookieSessionValidatorTests private sealed class TestTimeProvider : TimeProvider { private DateTimeOffset _now; + /// <summary>Creates a clock fixed at the given start time.</summary> + /// <param name="start">The initial time reported by the clock.</param> public TestTimeProvider(DateTimeOffset start) => _now = start; + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => _now; + /// <summary>Advances the clock by the given amount.</summary> + /// <param name="by">The amount of time to advance.</param> public void Advance(TimeSpan by) => _now = _now.Add(by); } @@ -42,15 +47,27 @@ public class CookieSessionValidatorTests { private RoleMappingResult _result; private readonly Exception? _throw; + /// <summary>The number of times <c>MapAsync</c> has been invoked.</summary> public int CallCount { get; private set; } + /// <summary>The groups passed to the most recent <c>MapAsync</c> call.</summary> public IReadOnlyList<string>? LastGroups { get; private set; } + /// <summary>Creates a mapper that returns the given result.</summary> + /// <param name="result">The mapping result to return.</param> public FakeGroupRoleMapper(RoleMappingResult result) => _result = result; + /// <summary>Creates a mapper that throws the given exception.</summary> + /// <param name="toThrow">The exception to throw from <c>MapAsync</c>.</param> public FakeGroupRoleMapper(Exception toThrow) => (_result, _throw) = (new RoleMappingResult([], [], false), toThrow); + /// <summary>Replaces the result returned by subsequent mapping calls.</summary> + /// <param name="result">The new mapping result.</param> public void SetResult(RoleMappingResult result) => _result = result; + /// <summary>Records the call and returns the canned mapping, or throws the canned exception.</summary> + /// <param name="groups">The directory groups being mapped.</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>A task yielding the canned role mapping.</returns> public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct) { CallCount++; @@ -83,6 +100,8 @@ public class CookieSessionValidatorTests // --- Idle timeout --- + /// <summary>Verifies that validation rejects a session that is past the idle timeout.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ValidateAsync_PastIdleTimeout_Rejects() { @@ -100,6 +119,8 @@ public class CookieSessionValidatorTests Assert.Equal(0, mapper.CallCount); } + /// <summary>Verifies that validation keeps a session that is within the idle window.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ValidateAsync_WithinIdleWindow_KeptAndNotRejected() { @@ -116,6 +137,8 @@ public class CookieSessionValidatorTests Assert.Equal(SessionValidationAction.Keep, result.Action); } + /// <summary>Verifies that a missing last-activity claim is treated as idle timed out.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ValidateAsync_MissingLastActivityClaim_TreatedAsIdleTimedOut() { @@ -137,6 +160,8 @@ public class CookieSessionValidatorTests Assert.Equal(SessionValidationAction.Reject, result.Action); } + /// <summary>Verifies that an unauthenticated principal is kept unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ValidateAsync_UnauthenticatedPrincipal_Kept() { @@ -151,6 +176,8 @@ public class CookieSessionValidatorTests // --- Role-mapping refresh WITHOUT LDAP --- + /// <summary>Verifies that after the role refresh threshold roles are remapped from the stored groups without an LDAP call.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ValidateAsync_AfterRoleRefreshThreshold_RemapsFromStoredGroups_NoLdap() { @@ -188,6 +215,8 @@ public class CookieSessionValidatorTests Assert.Equal(clock.GetUtcNow(), refreshedAt); } + /// <summary>Verifies that a user whose group was revoked loses the roles on refresh.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ValidateAsync_RevokedGroupUser_LosesRolesOnRefresh() { @@ -208,6 +237,8 @@ public class CookieSessionValidatorTests Assert.Empty(result.Principal.FindAll(JwtTokenService.SiteIdClaimType)); } + /// <summary>Verifies that validation does not refresh roles before the refresh threshold.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ValidateAsync_BeforeRoleRefreshThreshold_DoesNotRefresh() { @@ -229,6 +260,8 @@ public class CookieSessionValidatorTests // --- Failure policy: a refresh exception keeps the existing principal --- + /// <summary>Verifies that a throwing refresh keeps the existing principal without signing out or throwing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ValidateAsync_RefreshThrows_KeepsExistingPrincipal_NoSignOut_NoThrow() { @@ -250,6 +283,7 @@ public class CookieSessionValidatorTests // --- IsIdleTimedOut / IsRoleRefreshDue boundaries --- + /// <summary>Verifies that a session exactly at the idle boundary is not timed out.</summary> [Fact] public void IsIdleTimedOut_AtBoundary_NotTimedOut() { @@ -262,6 +296,7 @@ public class CookieSessionValidatorTests Assert.True(sut.IsIdleTimedOut(principal, Start.AddMinutes(30).AddSeconds(1))); } + /// <summary>Verifies that a role refresh exactly at the boundary is not yet due.</summary> [Fact] public void IsRoleRefreshDue_AtBoundary_NotDue() { @@ -275,6 +310,8 @@ public class CookieSessionValidatorTests // --- Missing/unparsable zb:lastrolerefresh anchor treated as refresh-due --- + /// <summary>Verifies that a missing last-role-refresh claim is treated as due, refreshes the roles and restamps the claim.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ValidateAsync_MissingLastRoleRefreshClaim_TreatedAsRefreshDue_RefreshesAndRestamps() { @@ -319,6 +356,7 @@ public class CookieSessionValidatorTests Assert.True(DateTimeOffset.TryParse(refreshClaim!.Value, out _)); } + /// <summary>Verifies that a missing refresh claim makes the role refresh due.</summary> [Fact] public void IsRoleRefreshDue_MissingClaim_ReturnsDue() { @@ -332,6 +370,7 @@ public class CookieSessionValidatorTests Assert.True(sut.IsRoleRefreshDue(principal, Start)); } + /// <summary>Verifies that an unparsable refresh claim makes the role refresh due.</summary> [Fact] public void IsRoleRefreshDue_UnparsableClaim_ReturnsDue() { @@ -351,7 +390,7 @@ public class CookieSessionValidatorTests /// <summary> /// Claim-parity tests: the shared <see cref="SessionClaimBuilder"/> must produce EXACTLY -/// the claim shape the login endpoint historically minted (plus the M2.19 group + +/// the claim shape the login endpoint historically minted (plus the group + /// refresh-anchor additions), and the refresh path — which also goes through the builder — /// must therefore be identical. This is the load-bearing anti-drift guarantee. /// </summary> @@ -359,6 +398,7 @@ public class SessionClaimBuilderParityTests { private static readonly DateTimeOffset At = new(2026, 6, 15, 9, 0, 0, TimeSpan.Zero); + /// <summary>Verifies that the builder produces the canonical identity, role and scope claims like the login path.</summary> [Fact] public void Build_ProducesCanonicalIdentityRoleAndScopeClaims_LikeLogin() { @@ -382,6 +422,7 @@ public class SessionClaimBuilderParityTests Assert.Equal(new[] { "3", "4" }.OrderBy(s => s), sites.OrderBy(s => s)); } + /// <summary>Verifies that a system-wide mapping omits the site id claims.</summary> [Fact] public void Build_SystemWideMapping_OmitsSiteIdClaims() { @@ -394,6 +435,7 @@ public class SessionClaimBuilderParityTests Assert.Contains(Roles.Deployer, principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value)); } + /// <summary>Verifies that the builder stores the groups and the refresh anchor claims.</summary> [Fact] public void Build_StoresGroupsAndRefreshAnchor_M219Additions() { @@ -401,14 +443,15 @@ public class SessionClaimBuilderParityTests var principal = SessionClaimBuilder.Build("janer", "Jane Roe", ["G1", "G2"], mapping, At); - // M2.19: one zb:group claim per LDAP group, round-trippable via ReadGroups. + // One zb:group claim per LDAP group, round-trippable via ReadGroups. Assert.Equal(new[] { "G1", "G2" }, SessionClaimBuilder.ReadGroups(principal)); - // M2.19: refresh anchor AND idle anchor seeded from the same instant. + // Refresh anchor AND idle anchor seeded from the same instant. Assert.Equal(At.ToString("o"), principal.FindFirst(JwtTokenService.LastRoleRefreshClaimType)?.Value); Assert.Equal(At.ToString("o"), principal.FindFirst(JwtTokenService.LastActivityClaimType)?.Value); } + /// <summary>Verifies that the login and refresh paths produce an identical claim shape.</summary> [Fact] public void Build_LoginThenRefresh_ProduceIdenticalClaimShape() { @@ -458,20 +501,46 @@ public class OnValidatePrincipalAdapterTests // the Reject path actually invokes SignOutAsync. private sealed class StubAuthenticationService : IAuthenticationService { + /// <summary>The number of times <c>SignOutAsync</c> has been called.</summary> public int SignOutCallCount { get; private set; } + /// <summary>Returns a no-result authentication outcome.</summary> + /// <param name="context">The HTTP context (ignored).</param> + /// <param name="scheme">The authentication scheme (ignored).</param> + /// <returns>A task yielding a no-result outcome.</returns> public Task<AuthenticateResult> AuthenticateAsync(HttpContext context, string? scheme) => Task.FromResult(AuthenticateResult.NoResult()); + /// <summary>Does nothing.</summary> + /// <param name="context">The HTTP context (ignored).</param> + /// <param name="scheme">The authentication scheme (ignored).</param> + /// <param name="properties">The authentication properties (ignored).</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) => Task.CompletedTask; + /// <summary>Does nothing.</summary> + /// <param name="context">The HTTP context (ignored).</param> + /// <param name="scheme">The authentication scheme (ignored).</param> + /// <param name="properties">The authentication properties (ignored).</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) => Task.CompletedTask; + /// <summary>Does nothing.</summary> + /// <param name="context">The HTTP context (ignored).</param> + /// <param name="scheme">The authentication scheme (ignored).</param> + /// <param name="principal">The principal (ignored).</param> + /// <param name="properties">The authentication properties (ignored).</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties) => Task.CompletedTask; + /// <summary>Counts the sign-out call.</summary> + /// <param name="context">The HTTP context (ignored).</param> + /// <param name="scheme">The authentication scheme (ignored).</param> + /// <param name="properties">The authentication properties (ignored).</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) { SignOutCallCount++; @@ -515,6 +584,8 @@ public class OnValidatePrincipalAdapterTests new RoleMappingResult([Roles.Administrator], [], true), AdapterStart); + /// <summary>Verifies that applying a reject result rejects the principal and signs out.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyResult_Reject_CallsRejectPrincipalAndSignsOut() { @@ -533,6 +604,8 @@ public class OnValidatePrincipalAdapterTests Assert.False(ctx.ShouldRenew); } + /// <summary>Verifies that applying a replace result replaces the principal and requests a renewal.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyResult_Replace_ReplacesAndSetsRenew() { @@ -553,6 +626,8 @@ public class OnValidatePrincipalAdapterTests Assert.Equal(0, authService.SignOutCallCount); } + /// <summary>Verifies that applying a keep result leaves the context unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyResult_Keep_LeavesContextUnchanged() { @@ -587,6 +662,7 @@ public class SecurityOptionsValidatorConfigGuardTests RoleRefreshThresholdMinutes = 15, }; + /// <summary>Verifies that the default options pass validation.</summary> [Fact] public void Validate_DefaultOptions_Succeeds() { @@ -594,6 +670,7 @@ public class SecurityOptionsValidatorConfigGuardTests Assert.True(result.Succeeded); } + /// <summary>Verifies that validation fails when the refresh interval equals the idle timeout.</summary> [Fact] public void Validate_RefreshEqualsIdle_Fails() { @@ -609,6 +686,7 @@ public class SecurityOptionsValidatorConfigGuardTests Assert.Contains(nameof(SecurityOptions.IdleTimeoutMinutes), result.FailureMessage); } + /// <summary>Verifies that validation fails when the refresh interval is greater than the idle timeout.</summary> [Fact] public void Validate_RefreshGreaterThanIdle_Fails() { @@ -623,6 +701,7 @@ public class SecurityOptionsValidatorConfigGuardTests Assert.Contains(nameof(SecurityOptions.RoleRefreshThresholdMinutes), result.FailureMessage); } + /// <summary>Verifies that validation succeeds when the refresh interval is one less than the idle timeout.</summary> [Fact] public void Validate_RefreshOneLessThanIdle_Succeeds() { @@ -636,6 +715,7 @@ public class SecurityOptionsValidatorConfigGuardTests Assert.True(result.Succeeded); } + /// <summary>Verifies that AddSecurity registers the security options validator with validation on start.</summary> [Fact] public void AddSecurity_RegistersSecurityOptionsValidator_WithValidateOnStart() { @@ -652,6 +732,7 @@ public class SecurityOptionsValidatorConfigGuardTests Assert.Contains(validators, v => v is SecurityOptionsValidator); } + /// <summary>Verifies that the registered validator detects a misconfiguration when called directly.</summary> [Fact] public void AddSecurity_RegisteredValidator_DetectsMisconfiguration_WhenCalledDirectly() { @@ -682,8 +763,9 @@ public class SecurityOptionsValidatorConfigGuardTests Assert.Contains(nameof(SecurityOptions.RoleRefreshThresholdMinutes), result.FailureMessage); } - // ── arch-review 2026-08-19 P6-9: PerIpMaxFailures must exceed the per-pair ceiling ── + // ── PerIpMaxFailures must exceed the per-pair ceiling ── + /// <summary>Verifies that the default per-IP maximum failures setting passes validation.</summary> [Fact] public void Validate_DefaultPerIpMaxFailures_Succeeds() { @@ -691,6 +773,7 @@ public class SecurityOptionsValidatorConfigGuardTests Assert.True(result.Succeeded); } + /// <summary>Verifies that validation fails when the per-IP maximum failures equals the per-pair ceiling.</summary> [Fact] public void Validate_PerIpMaxFailuresEqualsPerPairCeiling_Fails() { @@ -704,6 +787,7 @@ public class SecurityOptionsValidatorConfigGuardTests Assert.Contains(nameof(SecurityOptions.MaxLoginFailuresPerWindow), result.FailureMessage); } + /// <summary>Verifies that validation fails when the per-IP maximum failures is below the per-pair ceiling.</summary> [Fact] public void Validate_PerIpMaxFailuresBelowPerPairCeiling_Fails() { @@ -716,6 +800,7 @@ public class SecurityOptionsValidatorConfigGuardTests Assert.Contains(nameof(SecurityOptions.PerIpMaxFailures), result.FailureMessage); } + /// <summary>Verifies that validation succeeds when the per-IP maximum failures is one above the per-pair ceiling.</summary> [Fact] public void Validate_PerIpMaxFailuresOneAbovePerPairCeiling_Succeeds() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginGuardTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginGuardTests.cs index 5a8601f0..e54a6ff6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginGuardTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginGuardTests.cs @@ -5,6 +5,8 @@ namespace ZB.MOM.WW.ScadaBridge.Security.Tests; public class DisableLoginGuardTests { + /// <summary>Verifies that disabling login outside development without an acknowledgement throws.</summary> + /// <param name="env">The hosting environment name.</param> [Theory] [InlineData("Production")] [InlineData("Staging")] @@ -17,6 +19,9 @@ public class DisableLoginGuardTests Assert.Contains("Development", ex.Message); } + /// <summary>Verifies that allowed combinations of environment and acknowledgement do not throw.</summary> + /// <param name="env">The hosting environment name.</param> + /// <param name="ack">Whether the explicit acknowledgement is given.</param> [Theory] [InlineData("Development", false)] // dev env: allowed without ack [InlineData("development", false)] // case-insensitive env name @@ -24,6 +29,7 @@ public class DisableLoginGuardTests public void Validate_Allowed_DoesNotThrow(string env, bool ack) => DisableLoginGuard.Validate(true, env, ack); + /// <summary>Verifies that validation never throws when the disable-login flag is off.</summary> [Fact] public void Validate_FlagOff_NeverThrows() => DisableLoginGuard.Validate(false, "Production", false); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs index c4075ba3..a999af7c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs @@ -19,6 +19,8 @@ public class DisableLoginRegistrationTests return await provider.GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme); } + /// <summary>Verifies that when the disable-login flag is true, the auto-login handler is registered under the cookie scheme.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FlagTrue_RegistersAutoLoginHandlerUnderCookieScheme() { @@ -26,6 +28,8 @@ public class DisableLoginRegistrationTests Assert.Equal(typeof(AutoLoginAuthenticationHandler), scheme!.HandlerType); } + /// <summary>Verifies that when the disable-login flag is false, the cookie handler is registered.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FlagFalse_RegistersCookieHandler() { @@ -34,12 +38,13 @@ public class DisableLoginRegistrationTests } /// <summary> - /// When <c>disableLogin: false</c> (the production path) the M2.19 idle-timeout / + /// When <c>disableLogin: false</c> (the production path) the idle-timeout / /// role-refresh hook MUST be wired on the cookie scheme's /// <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationEvents.OnValidatePrincipal"/>. /// This pin-test ensures a future refactor cannot silently drop the hook without /// a red test. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FlagFalse_CookieScheme_OnValidatePrincipalIsWired() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ForwardedHeadersSetupTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ForwardedHeadersSetupTests.cs index e5107601..173f1e66 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ForwardedHeadersSetupTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ForwardedHeadersSetupTests.cs @@ -6,10 +6,12 @@ namespace ZB.MOM.WW.ScadaBridge.Security.Tests; public class ForwardedHeadersSetupTests { + /// <summary>Verifies that building with forwarded headers disabled returns null.</summary> [Fact] public void Build_Disabled_ReturnsNull() => Assert.Null(ForwardedHeadersSetup.Build(new ForwardedHeadersSecurityOptions { Enabled = false })); + /// <summary>Verifies that enabling forwarded headers parses the proxies and networks and clears the default allow-lists.</summary> [Fact] public void Build_Enabled_ParsesProxiesAndNetworks_AndClearsDefaults() { @@ -27,6 +29,9 @@ public class ForwardedHeadersSetupTests Assert.Single(built.KnownIPNetworks); // net10 successor to the deprecated KnownNetworks } + /// <summary>Verifies that a malformed proxy or network entry throws at startup.</summary> + /// <param name="proxy">The proxy address entry under test.</param> + /// <param name="network">The network CIDR entry under test.</param> [Theory] [InlineData("not-an-ip", "172.16.0.0/12")] [InlineData("10.5.0.9", "not-a-cidr")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/HttpAuditActorAccessorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/HttpAuditActorAccessorTests.cs index 3a470157..038b980e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/HttpAuditActorAccessorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/HttpAuditActorAccessorTests.cs @@ -23,6 +23,7 @@ public class HttpAuditActorAccessorTests /// </summary> private sealed class StubHttpContextAccessor : IHttpContextAccessor { + /// <summary>Gets or sets the http context.</summary> public HttpContext? HttpContext { get; set; } } @@ -35,6 +36,7 @@ public class HttpAuditActorAccessorTests return ctx; } + /// <summary>Verifies that current actor, when authenticated, returns username claim.</summary> [Fact] public void CurrentActor_Authenticated_ReturnsUsernameClaim() { @@ -48,6 +50,7 @@ public class HttpAuditActorAccessorTests Assert.Equal("alice", accessor.CurrentActor); } + /// <summary>Verifies that current actor, when authenticated, no username claim falls back to identity name.</summary> [Fact] public void CurrentActor_Authenticated_NoUsernameClaim_FallsBackToIdentityName() { @@ -61,6 +64,7 @@ public class HttpAuditActorAccessorTests Assert.Equal("bob", accessor.CurrentActor); } + /// <summary>Verifies that current actor, when authenticated, prefers username over zb name.</summary> [Fact] public void CurrentActor_Authenticated_PrefersUsernameOverZbName() { @@ -75,6 +79,7 @@ public class HttpAuditActorAccessorTests Assert.Equal("svc-user", accessor.CurrentActor); } + /// <summary>Verifies that current actor, when unauthenticated, returns null.</summary> [Fact] public void CurrentActor_Unauthenticated_ReturnsNull() { @@ -91,6 +96,7 @@ public class HttpAuditActorAccessorTests Assert.Null(accessor.CurrentActor); } + /// <summary>Verifies that current actor, when no ambient http context, returns null.</summary> [Fact] public void CurrentActor_NoAmbientHttpContext_ReturnsNull() { @@ -100,6 +106,7 @@ public class HttpAuditActorAccessorTests Assert.Null(accessor.CurrentActor); } + /// <summary>Verifies that current actor, when authenticated but no usable name, returns null.</summary> [Fact] public void CurrentActor_AuthenticatedButNoUsableName_ReturnsNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LibraryInboundApiKeyAdminTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LibraryInboundApiKeyAdminTests.cs index 2f277dba..03ad5888 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LibraryInboundApiKeyAdminTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LibraryInboundApiKeyAdminTests.cs @@ -32,6 +32,8 @@ public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime private ApiKeyOptions _options = null!; private IInboundApiKeyAdmin _sut = null!; + /// <summary>Creates the temporary store, migrates it and builds the admin services.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { _factory = new AuthSqliteConnectionFactory(_dbPath); @@ -54,6 +56,9 @@ public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime _sut = new LibraryInboundApiKeyAdmin(commands); } + /// <summary>Verifies that CreateAsync throws for a null or whitespace name.</summary> + /// <param name="name">The key name under test.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(null)] [InlineData("")] @@ -66,6 +71,8 @@ public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime () => _sut.CreateAsync(name!, new[] { "MethodA" }, CancellationToken.None)); } + /// <summary>Verifies that CreateAsync returns a key id and a token that starts with the sbk prefix.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateAsync_ReturnsKeyIdAndToken_TokenStartsWith_sbk() { @@ -83,6 +90,8 @@ public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime Assert.Equal(new[] { "MethodA", "MethodB" }, info.Methods); } + /// <summary>Verifies that disabling then enabling a key toggles the listed enabled flag.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetEnabledAsync_False_ThenTrue_Toggles_ListedEnabledFlag() { @@ -98,12 +107,16 @@ public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime Assert.True((await SingleAsync(created.KeyId)).Enabled); } + /// <summary>Verifies that SetEnabledAsync returns false for an unknown key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetEnabledAsync_UnknownKey_ReturnsFalse() { Assert.False(await _sut.SetEnabledAsync("does-not-exist", enabled: false, CancellationToken.None)); } + /// <summary>Verifies that SetMethodsAsync replaces the key's methods.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetMethodsAsync_ReplacesMethods() { @@ -129,6 +142,8 @@ public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime Assert.DoesNotContain(created.KeyId, outOfScope); } + /// <summary>Verifies that DeleteAsync removes the key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteAsync_RemovesKey() { @@ -148,6 +163,8 @@ public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime k => k.KeyId == created.KeyId); } + /// <summary>Verifies that GetKeysForMethodAsync returns only the keys scoped to that method.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetKeysForMethodAsync_ReturnsOnlyKeysScopedToThatMethod() { @@ -171,6 +188,8 @@ public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime Assert.Empty(await _sut.GetKeysForMethodAsync("Nobody", CancellationToken.None)); } + /// <summary>Verifies that GetMethodsForKeyAsync returns an empty result for an unknown key.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetMethodsForKeyAsync_UnknownKey_ReturnsEmpty() { @@ -183,6 +202,8 @@ public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime return Assert.Single(keys, k => k.KeyId == keyId); } + /// <summary>Clears SQLite connection pools and deletes the temporary database files.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() { // SqliteConnection pooling can hold the file open; clear pools before deleting @@ -202,6 +223,8 @@ public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider { + /// <summary>Returns the fixed pepper.</summary> + /// <returns>The configured pepper value.</returns> public string? GetPepper() => pepper; } @@ -209,6 +232,7 @@ public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime { private readonly DateTimeOffset _now = now; + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => _now; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs index 999ed128..8e680861 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs @@ -17,8 +17,13 @@ public class LoginThrottleTests private sealed class TestTimeProvider : TimeProvider { private DateTimeOffset _now; + /// <summary>Creates a clock fixed at the given start time.</summary> + /// <param name="start">The initial time reported by the clock.</param> public TestTimeProvider(DateTimeOffset start) => _now = start; + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => _now; + /// <summary>Advances the clock by the given amount.</summary> + /// <param name="by">The amount of time to advance.</param> public void Advance(TimeSpan by) => _now = _now.Add(by); } @@ -31,6 +36,7 @@ public class LoginThrottleTests return (Create(clock), clock); } + /// <summary>Verifies that five failures within the window lock the user out until the window expires.</summary> [Fact] public void FiveFailuresInWindow_LocksOut_ThenWindowExpiryUnlocks() { @@ -47,6 +53,7 @@ public class LoginThrottleTests Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); } + /// <summary>Verifies that a successful login resets the failure count.</summary> [Fact] public void SuccessResets() { @@ -63,6 +70,7 @@ public class LoginThrottleTests Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); } + /// <summary>Verifies that the throttle key is case-insensitive on the username.</summary> [Fact] public void KeyIsCaseInsensitiveOnUsername() { @@ -75,6 +83,7 @@ public class LoginThrottleTests Assert.True(throttle.IsLockedOut("ALICE", "10.0.0.1")); } + /// <summary>Verifies that failures outside the window do not accumulate.</summary> [Fact] public void FailuresOutsideWindow_DoNotAccumulate() { @@ -90,6 +99,7 @@ public class LoginThrottleTests Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); } + /// <summary>Verifies that the lockout persists until the configured lockout minutes elapse.</summary> [Fact] public void LockoutPersistsUntilLockoutMinutesElapse() { @@ -106,6 +116,7 @@ public class LoginThrottleTests Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); } + /// <summary>Verifies that a maximum of zero failures disables throttling.</summary> [Fact] public void ZeroMaxFailures_DisablesThrottling() { @@ -119,6 +130,7 @@ public class LoginThrottleTests // ── arch-review R2 N8: amortized prune ───────────────────────────────────── + /// <summary>Verifies that a key spray keeps the tracking map bounded at the hard cap.</summary> [Fact] public void KeySpray_MapStaysBounded_AtTheHardCap() { @@ -130,6 +142,7 @@ public class LoginThrottleTests Assert.True(throttle.TrackedKeyCount <= LoginThrottle.MaxTrackedKeys); } + /// <summary>Verifies that expired entries are eventually swept on the amortized cadence.</summary> [Fact] public void ExpiredEntries_AreEventuallySwept_OnTheAmortizedCadence() { @@ -138,7 +151,7 @@ public class LoginThrottleTests clock.Advance(TimeSpan.FromMinutes(10)); // window fully expired // Distinct IPs (not just distinct usernames) so this stays a pure per-pair prune-cadence // test — hammering PruneEveryNFailures (64) failures from a SINGLE IP would itself trip - // the P6-9 per-IP spray ceiling (default 25) and lock "10.0.0.1" out, which would make + // the per-IP spray ceiling (default 25) and lock "10.0.0.1" out, which would make // the IsLockedOut("stale", "10.0.0.1") assertion below fail for an unrelated reason. for (var i = 0; i < LoginThrottle.PruneEveryNFailures; i++) throttle.RecordFailure($"fresh{i}", $"10.0.1.{i}"); // cadence tick fires within these @@ -146,8 +159,9 @@ public class LoginThrottleTests Assert.True(throttle.TrackedKeyCount <= LoginThrottle.PruneEveryNFailures); // "stale" swept } - // ── arch-review 2026-08-19 P6-9: per-IP spray ceiling ────────────────────── + // ── Per-IP spray ceiling ────────────────────── + /// <summary>Verifies that spraying one password across many usernames from one IP trips the per-IP ceiling.</summary> [Fact] public void Spray_OnePasswordAcrossManyUsernamesFromOneIp_TripsThePerIpCeiling() { @@ -176,6 +190,7 @@ public class LoginThrottleTests Assert.True(throttle.IsLockedOut("brand-new-user", "10.0.0.1")); } + /// <summary>Verifies that distinct IPs are unaffected by one IP's lockout.</summary> [Fact] public void Spray_DistinctIpsAreUnaffectedByOneIpsLockout() { @@ -191,6 +206,7 @@ public class LoginThrottleTests Assert.False(throttle.IsLockedOut("user0", "10.0.0.2")); } + /// <summary>Verifies that the per-pair lockout still fires before the per-IP ceiling so the normal flow is unchanged.</summary> [Fact] public void PerPairLockout_StillFiresBeforeThePerIpCeiling_NormalFlowUnchanged() { @@ -205,6 +221,7 @@ public class LoginThrottleTests Assert.False(throttle.IsLockedOut("bob", "10.0.0.1")); // IP itself is nowhere near its ceiling } + /// <summary>Verifies that a few failures across a few users behind one address stay under the per-IP ceiling.</summary> [Fact] public void NatFriendly_AFewFailuresAcrossAFewUsers_StaysUnderThePerIpCeiling() { @@ -223,6 +240,7 @@ public class LoginThrottleTests Assert.False(throttle.IsLockedOut("eve", "10.0.0.1")); } + /// <summary>Verifies that the per-IP lockout is released when the window expires.</summary> [Fact] public void PerIpLockout_ReleasesOnWindowExpiry() { @@ -238,6 +256,7 @@ public class LoginThrottleTests Assert.False(throttle.IsLockedOut("brand-new-user", "10.0.0.1")); } + /// <summary>Verifies that recording a success does not reset the per-IP counter.</summary> [Fact] public void RecordSuccess_DoesNotResetThePerIpCounter() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/PassiveSessionRegressionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/PassiveSessionRegressionTests.cs index 7b1c3ceb..7513e327 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/PassiveSessionRegressionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/PassiveSessionRegressionTests.cs @@ -13,6 +13,8 @@ public class PassiveSessionRegressionTests { private static readonly DateTimeOffset Start = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + /// <summary>Verifies that a passive ping refreshes roles without advancing the activity timestamp and that the session then expires.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PassivePing_RefreshesRolesWithoutAdvancingActivity_ThenExpires() { @@ -47,20 +49,51 @@ public class PassiveSessionRegressionTests private sealed class Mapper : IGroupRoleMapper<string> { + /// <summary>Maps every group to the Viewer role.</summary> + /// <param name="groups">The directory groups.</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>The canned role mapping.</returns> public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct) => Task.FromResult(new GroupRoleMapping<string>([Roles.Viewer], new RoleMappingResult([Roles.Viewer], ["2"], false))); } private sealed class Auth : IAuthenticationService { + /// <summary>Returns no authentication result.</summary> + /// <param name="c">The HTTP context.</param> + /// <param name="s">The authentication scheme.</param> + /// <returns>An empty authenticate result.</returns> public Task<AuthenticateResult> AuthenticateAsync(HttpContext c, string? s) => Task.FromResult(AuthenticateResult.NoResult()); + /// <summary>Does nothing.</summary> + /// <param name="c">The HTTP context.</param> + /// <param name="s">The authentication scheme.</param> + /// <param name="p">The authentication properties.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task ChallengeAsync(HttpContext c, string? s, AuthenticationProperties? p) => Task.CompletedTask; + /// <summary>Does nothing.</summary> + /// <param name="c">The HTTP context.</param> + /// <param name="s">The authentication scheme.</param> + /// <param name="p">The authentication properties.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task ForbidAsync(HttpContext c, string? s, AuthenticationProperties? p) => Task.CompletedTask; + /// <summary>Does nothing.</summary> + /// <param name="c">The HTTP context.</param> + /// <param name="s">The authentication scheme.</param> + /// <param name="u">The principal to sign in.</param> + /// <param name="p">The authentication properties.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task SignInAsync(HttpContext c, string? s, ClaimsPrincipal u, AuthenticationProperties? p) => Task.CompletedTask; + /// <summary>Does nothing.</summary> + /// <param name="c">The HTTP context.</param> + /// <param name="s">The authentication scheme.</param> + /// <param name="p">The authentication properties.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task SignOutAsync(HttpContext c, string? s, AuthenticationProperties? p) => Task.CompletedTask; } private sealed class Clock : TimeProvider { + /// <summary>Gets or sets the current time the clock reports.</summary> public DateTimeOffset Now { get; set; } + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => Now; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs index 523482bb..7f1c6dfe 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Security.Tests; public class RolesAllTests { + /// <summary>Verifies that the all-roles collection contains every declared role.</summary> [Fact] public void All_ContainsEveryDeclaredRole() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesTests.cs index 28cd4b1c..c4209f57 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesTests.cs @@ -5,23 +5,26 @@ namespace ZB.MOM.WW.ScadaBridge.Security.Tests; /// <summary> /// Pins the role-name string literals and the contents of <see cref="Roles.All"/>. -/// Added for M7-A3 (T14a): the two-person Secured Writes feature introduces the +/// The two-person Secured Writes feature introduces the /// <c>Operator</c> (initiates) and <c>Verifier</c> (approves) global roles. /// </summary> public class RolesTests { + /// <summary>Verifies that the Operator constant has its canonical value.</summary> [Fact] public void OperatorConst_HasCanonicalValue() { Assert.Equal("Operator", Roles.Operator); } + /// <summary>Verifies that the Verifier constant has its canonical value.</summary> [Fact] public void VerifierConst_HasCanonicalValue() { Assert.Equal("Verifier", Roles.Verifier); } + /// <summary>Verifies that the role list still contains the original four roles.</summary> [Fact] public void All_StillContainsOriginalFourRoles() { @@ -31,6 +34,7 @@ public class RolesTests Assert.Contains(Roles.Viewer, Roles.All); } + /// <summary>Verifies that the role list contains the Operator and Verifier roles.</summary> [Fact] public void All_ContainsOperatorAndVerifier() { @@ -38,6 +42,7 @@ public class RolesTests Assert.Contains("Verifier", Roles.All); } + /// <summary>Verifies that the authorization policies declare the Operator and Verifier policy names.</summary> [Fact] public void AuthorizationPolicies_DeclareOperatorAndVerifierPolicyNames() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ScadaBridgeGroupRoleMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ScadaBridgeGroupRoleMapperTests.cs index a5983483..6289b252 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ScadaBridgeGroupRoleMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ScadaBridgeGroupRoleMapperTests.cs @@ -17,6 +17,9 @@ public class ScadaBridgeGroupRoleMapperTests private readonly IReadOnlyList<LdapGroupMapping> _mappings; private readonly IReadOnlyDictionary<int, IReadOnlyList<SiteScopeRule>> _scopeRules; + /// <summary>Initializes the fake repository with fixed mappings and scope rules.</summary> + /// <param name="mappings">The LDAP group mappings to return.</param> + /// <param name="scopeRules">The site scope rules keyed by LDAP group mapping id.</param> public FakeSecurityRepository( IReadOnlyList<LdapGroupMapping> mappings, IReadOnlyDictionary<int, IReadOnlyList<SiteScopeRule>> scopeRules) @@ -25,32 +28,44 @@ public class ScadaBridgeGroupRoleMapperTests _scopeRules = scopeRules; } + /// <inheritdoc /> public Task<IReadOnlyList<LdapGroupMapping>> GetAllMappingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(_mappings); + /// <inheritdoc /> public Task<IReadOnlyList<SiteScopeRule>> GetScopeRulesForMappingAsync(int ldapGroupMappingId, CancellationToken cancellationToken = default) => Task.FromResult(_scopeRules.TryGetValue(ldapGroupMappingId, out var rules) ? rules : (IReadOnlyList<SiteScopeRule>)Array.Empty<SiteScopeRule>()); + /// <inheritdoc /> public Task<LdapGroupMapping?> GetMappingByIdAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task<IReadOnlyList<LdapGroupMapping>> GetMappingsByRoleAsync(string role, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task AddMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task UpdateMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task DeleteMappingAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task<SiteScopeRule?> GetScopeRuleByIdAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task AddScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task UpdateScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task DeleteScopeRuleAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// <inheritdoc /> public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); } @@ -62,6 +77,8 @@ public class ScadaBridgeGroupRoleMapperTests return m; } + /// <summary>Verifies that mapping returns the same roles as the role mapper and carries the result in a site-scoped scope.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MapAsync_ReturnsSameRolesAsRoleMapper_AndCarriesResultInScope_SiteScoped() { @@ -103,6 +120,8 @@ public class ScadaBridgeGroupRoleMapperTests Assert.Equal(expected.IsSystemWideDeployment, scope.IsSystemWideDeployment); } + /// <summary>Verifies that mapping preserves the system-wide deployment flag in the scope.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MapAsync_PreservesSystemWideDeploymentFlagInScope() { @@ -123,10 +142,12 @@ public class ScadaBridgeGroupRoleMapperTests Assert.Empty(scope.PermittedSiteIds); } + /// <summary>Verifies that mapping empty groups returns no roles and a non-null scope.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MapAsync_EmptyGroups_ReturnsNoRoles_AndNonNullScope() { - // Reviewer-requested empty-groups case (Task 1.2): a user that resolves to NO + // Empty-groups case: a user that resolves to NO // groups must map to NO roles and a non-null, not-system-wide Scope with no // permitted sites — so the login endpoint can build a roleless principal (which // every authorization policy then denies) without NRE-ing on Scope. This is the @@ -147,6 +168,8 @@ public class ScadaBridgeGroupRoleMapperTests Assert.Empty(scope.PermittedSiteIds); } + /// <summary>Verifies that mapping groups that match no mapping returns no roles.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MapAsync_GroupsMatchNoMapping_ReturnsNoRoles() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs index c700baf1..25ed4e04 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs @@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.Security.Tests; #region WP-6: LdapAuthService Tests (re-aimed at the shared ZB.MOM.WW.Auth.Ldap service) -// Task 1.2 cutover: ScadaBridge's bespoke LdapAuthService was replaced by the shared +// Cutover: ScadaBridge's bespoke LdapAuthService was replaced by the shared // ZB.MOM.WW.Auth.Ldap.LdapAuthService (ScadaBridge was the donor for its hardened // bind-then-search / RFC-4514+4515 escaping / fail-closed / service-account-bind- // distinction / per-op-timeout semantics). The deep hygiene parity now lives in the @@ -40,6 +40,8 @@ public class LdapAuthServiceTests ServiceAccountDn = "cn=admin,dc=example,dc=com", }; + /// <summary>Verifies the case: authenticate Async, disabled, fails With Disabled, no Network.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuthenticateAsync_Disabled_FailsWithDisabled_NoNetwork() { @@ -52,6 +54,9 @@ public class LdapAuthServiceTests Assert.Equal(LdapAuthFailure.Disabled, result.Failure); } + /// <summary>Verifies the case: authenticate Async, empty Username, fails Bad Credentials, no Network.</summary> + /// <param name="username">The username test input.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("")] [InlineData(" ")] @@ -68,10 +73,12 @@ public class LdapAuthServiceTests Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure); } + /// <summary>Verifies the case: authenticate Async, connection Failure, fails Closed, never Throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuthenticateAsync_ConnectionFailure_FailsClosed_NeverThrows() { - // Security-025: point at a guaranteed-unroutable loopback address (127.0.0.1 on a + // Point at a guaranteed-unroutable loopback address (127.0.0.1 on a // closed high port) rather than DNS-resolving "nonexistent.invalid". The connect is // refused deterministically and immediately, with no external DNS dependency and no // multi-second timeout dead-time, so this stays a fast, network-sandbox-safe unit @@ -114,6 +121,7 @@ public class JwtTokenServiceTests NullLogger<JwtTokenService>.Instance); } + /// <summary>Verifies the case: generate Token, contains Correct Claims.</summary> [Fact] public void GenerateToken_ContainsCorrectClaims() { @@ -140,6 +148,7 @@ public class JwtTokenServiceTests Assert.NotNull(principal.FindFirst(JwtTokenService.LastActivityClaimType)); } + /// <summary>Verifies the case: generate Token, null Site Ids, no Site Id Claims.</summary> [Fact] public void GenerateToken_NullSiteIds_NoSiteIdClaims() { @@ -151,6 +160,7 @@ public class JwtTokenServiceTests Assert.Empty(principal!.FindAll(JwtTokenService.SiteIdClaimType)); } + /// <summary>Verifies the case: validate Token, invalid Token, returns Null.</summary> [Fact] public void ValidateToken_InvalidToken_ReturnsNull() { @@ -159,6 +169,7 @@ public class JwtTokenServiceTests Assert.Null(result); } + /// <summary>Verifies the case: validate Token, wrong Key, returns Null.</summary> [Fact] public void ValidateToken_WrongKey_ReturnsNull() { @@ -176,6 +187,7 @@ public class JwtTokenServiceTests Assert.Null(result); } + /// <summary>Verifies the case: validate Token, uses Hmac Sha256.</summary> [Fact] public void ValidateToken_UsesHmacSha256() { @@ -189,6 +201,7 @@ public class JwtTokenServiceTests Assert.Contains("HS256", headerJson); } + /// <summary>Verifies the case: should Refresh, token Near Expiry, returns True.</summary> [Fact] public void ShouldRefresh_TokenNearExpiry_ReturnsTrue() { @@ -202,6 +215,7 @@ public class JwtTokenServiceTests Assert.True(service.ShouldRefresh(principal!)); } + /// <summary>Verifies the case: should Refresh, token Far From Expiry, returns False.</summary> [Fact] public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse() { @@ -212,6 +226,7 @@ public class JwtTokenServiceTests Assert.False(service.ShouldRefresh(principal!)); } + /// <summary>Verifies the case: is Idle Timed Out, recent Activity, returns False.</summary> [Fact] public void IsIdleTimedOut_RecentActivity_ReturnsFalse() { @@ -222,6 +237,7 @@ public class JwtTokenServiceTests Assert.False(service.IsIdleTimedOut(principal!)); } + /// <summary>Verifies the case: is Idle Timed Out, no Last Activity Claim, returns True.</summary> [Fact] public void IsIdleTimedOut_NoLastActivityClaim_ReturnsTrue() { @@ -234,6 +250,7 @@ public class JwtTokenServiceTests Assert.True(service.IsIdleTimedOut(principal)); } + /// <summary>Verifies the case: refresh Token, returns New Token With Updated Claims.</summary> [Fact] public void RefreshToken_ReturnsNewTokenWithUpdatedClaims() { @@ -249,6 +266,7 @@ public class JwtTokenServiceTests Assert.Contains("Designer", roles); } + /// <summary>Verifies the case: refresh Token, missing Claims, returns Null.</summary> [Fact] public void RefreshToken_MissingClaims_ReturnsNull() { @@ -270,6 +288,7 @@ public class RoleMapperTests : IDisposable private readonly SecurityRepository _securityRepo; private readonly RoleMapper _roleMapper; + /// <summary>Initializes a new instance of the <see cref="RoleMapperTests"/> class.</summary> public RoleMapperTests() { var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>() @@ -283,12 +302,15 @@ public class RoleMapperTests : IDisposable _roleMapper = new RoleMapper(_securityRepo); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } + /// <summary>Verifies the case: map Groups To Roles, multi Role Extraction.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MapGroupsToRoles_MultiRoleExtraction() { @@ -304,6 +326,8 @@ public class RoleMapperTests : IDisposable Assert.DoesNotContain(Roles.Deployer, result.Roles); } + /// <summary>Verifies the case: map Groups To Roles, site Scoped Deployment.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MapGroupsToRoles_SiteScopedDeployment() { @@ -327,10 +351,12 @@ public class RoleMapperTests : IDisposable Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds); } + /// <summary>Verifies the case: map Groups To Roles, user In Both System Wide And Scoped Deployment Group, is System Wide.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide() { - // Security-016: a user in BOTH an unscoped Deployer mapping + // A user in BOTH an unscoped Deployer mapping // (SCADA-Deploy-All, Id=3) AND a scoped Deployer mapping // (SCADA-Deploy-SiteA, Id=4) used to be silently narrowed to the site-A // grant. The union semantics now preserve the broader grant: the @@ -350,6 +376,8 @@ public class RoleMapperTests : IDisposable Assert.Empty(result.PermittedSiteIds); } + /// <summary>Verifies the case: map Groups To Roles, system Wide Deployment, no Scope Rules.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules() { @@ -363,6 +391,8 @@ public class RoleMapperTests : IDisposable Assert.Empty(result.PermittedSiteIds); } + /// <summary>Verifies the case: map Groups To Roles, unrecognized Groups, ignored.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MapGroupsToRoles_UnrecognizedGroups_Ignored() { @@ -373,6 +403,8 @@ public class RoleMapperTests : IDisposable Assert.False(result.IsSystemWideDeployment); } + /// <summary>Verifies the case: map Groups To Roles, no Matching Groups, no Roles.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MapGroupsToRoles_NoMatchingGroups_NoRoles() { @@ -381,10 +413,12 @@ public class RoleMapperTests : IDisposable Assert.Empty(result.Roles); } + /// <summary>Verifies the case: map Groups To Roles, case Insensitive Group Match.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MapGroupsToRoles_CaseInsensitiveGroupMatch() { - // "SCADA-Admins" is seeded (role canonicalized to Administrator, Task 1.7) + // "SCADA-Admins" is seeded (role canonicalized to Administrator) var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "scada-admins" }); Assert.Contains(Roles.Administrator, result.Roles); @@ -396,12 +430,12 @@ public class RoleMapperTests : IDisposable #region Code Review Regression Tests /// <summary> -/// Regression tests for Security-001 (StartTLS dead code), Security-002 (cookie not -/// marked Secure), and Security-003 (JWT signing key length never validated). +/// Regression tests for StartTLS dead code, cookie not +/// marked Secure), and JWT signing key length never validated. /// </summary> public class SecurityReviewRegressionTests { - // --- Security-003: JWT signing key length validation --- + // --- JWT signing key length validation --- private static SecurityOptions JwtOptions(string signingKey) => new() { @@ -411,6 +445,7 @@ public class SecurityReviewRegressionTests JwtRefreshThresholdMinutes = 5 }; + /// <summary>Verifies the case: jwt Token Service, empty Signing Key, throws At Construction.</summary> [Fact] public void JwtTokenService_EmptySigningKey_ThrowsAtConstruction() { @@ -419,6 +454,7 @@ public class SecurityReviewRegressionTests Assert.Contains("JwtSigningKey", ex.Message); } + /// <summary>Verifies the case: jwt Token Service, short Signing Key, throws At Construction.</summary> [Fact] public void JwtTokenService_ShortSigningKey_ThrowsAtConstruction() { @@ -429,6 +465,7 @@ public class SecurityReviewRegressionTests Assert.Contains("32", ex.Message); } + /// <summary>Verifies the case: jwt Token Service, adequate Signing Key, constructs Successfully.</summary> [Fact] public void JwtTokenService_AdequateSigningKey_ConstructsSuccessfully() { @@ -438,8 +475,9 @@ public class SecurityReviewRegressionTests Assert.False(string.IsNullOrEmpty(token)); } - // --- Security-002: authentication cookie must be marked Secure --- + // --- authentication cookie must be marked Secure --- + /// <summary>Verifies the case: add Security, auth Cookie, is Marked Secure Always.</summary> [Fact] public void AddSecurity_AuthCookie_IsMarkedSecureAlways() { @@ -462,11 +500,12 @@ public class SecurityReviewRegressionTests Assert.True(cookieOptions.Cookie.HttpOnly); } - // --- CentralUI-005: cookie auth must use a sliding session window --- + // --- cookie auth must use a sliding session window --- // Documented policy (CLAUDE.md Security & Auth): sliding refresh with a // 30-minute idle timeout. The cookie middleware must enable SlidingExpiration // so an active session is renewed on activity and an idle session expires. + /// <summary>Verifies the case: add Security, auth Cookie, uses Sliding Expiration.</summary> [Fact] public void AddSecurity_AuthCookie_UsesSlidingExpiration() { @@ -483,6 +522,7 @@ public class SecurityReviewRegressionTests Assert.True(cookieOptions.SlidingExpiration); } + /// <summary>Verifies the case: add Security, auth Cookie, expire Time Span Matches Idle Timeout.</summary> [Fact] public void AddSecurity_AuthCookie_ExpireTimeSpanMatchesIdleTimeout() { @@ -501,6 +541,7 @@ public class SecurityReviewRegressionTests Assert.Equal(TimeSpan.FromMinutes(30), cookieOptions.ExpireTimeSpan); } + /// <summary>Verifies the case: add Security, auth Cookie, expire Time Span Is Configurable.</summary> [Fact] public void AddSecurity_AuthCookie_ExpireTimeSpanIsConfigurable() { @@ -523,6 +564,7 @@ public class SecurityReviewRegressionTests // (browser cookies are scoped by host+path, NOT port) must be able to use // distinct cookie names so signing into one does not clobber the other's session. --- + /// <summary>Verifies the case: add Security, auth Cookie, defaults To Canonical Name.</summary> [Fact] public void AddSecurity_AuthCookie_DefaultsToCanonicalName() { @@ -542,6 +584,7 @@ public class SecurityReviewRegressionTests Assert.Equal("ZB.MOM.WW.ScadaBridge.Auth", cookieOptions.Cookie.Name); } + /// <summary>Verifies the case: add Security, auth Cookie, cookie Name Is Configurable.</summary> [Fact] public void AddSecurity_AuthCookie_CookieNameIsConfigurable() { @@ -565,6 +608,8 @@ public class SecurityReviewRegressionTests Assert.True(cookieOptions.SlidingExpiration); } + /// <summary>Verifies the case: add Security, auth Cookie, blank Cookie Name, falls Back To Default.</summary> + /// <param name="blank">The blank test input.</param> [Theory] [InlineData("")] [InlineData(" ")] @@ -585,8 +630,9 @@ public class SecurityReviewRegressionTests Assert.Equal(SecurityOptions.DefaultCookieName, cookieOptions.Cookie.Name); } - // --- Security-001: transport security (now owned by the shared LdapOptions) --- + // --- transport security (now owned by the shared LdapOptions) --- + /// <summary>Verifies the case: ldap Options, transport, defaults To Ldaps.</summary> [Fact] public void LdapOptions_Transport_DefaultsToLdaps() { @@ -595,6 +641,8 @@ public class SecurityReviewRegressionTests Assert.Equal(LdapTransport.Ldaps, options.Transport); } + /// <summary>Verifies the case: authenticate Async, start Tls Transport, not Blocked By Insecure Guard.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuthenticateAsync_StartTlsTransport_NotBlockedByInsecureGuard() { @@ -620,6 +668,7 @@ public class SecurityReviewRegressionTests Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure); } + /// <summary>Verifies the case: ldap Options Validator, no Tls Transport, rejected Without Allow Insecure.</summary> [Fact] public void LdapOptionsValidator_NoTlsTransport_RejectedWithoutAllowInsecure() { @@ -649,12 +698,12 @@ public class SecurityReviewRegressionTests #region Code Review Regression Tests — Security-004/005/006/007 /// <summary> -/// Regression tests for Security-006 (JWT issuer/audience checks disabled) and -/// Security-007 (idle-timeout claim reset on every token refresh). +/// Regression tests for disabled JWT issuer/audience checks and +/// idle-timeout claim reset on every token refresh. /// </summary> /// <remarks> -/// Task 1.2 cutover: the former Security-004 (uid/cn attribute parity) and -/// Security-005 (no-service-account fallback-DN injection) cases tested ScadaBridge's +/// Cutover: the former uid/cn attribute parity and +/// no-service-account fallback-DN injection cases tested ScadaBridge's /// bespoke <c>LdapAuthService.BuildFallbackUserDn</c> / <c>EscapeLdapDn</c> static /// helpers. The shared <c>ZB.MOM.WW.Auth.Ldap</c> service is search-then-bind only (no /// no-service-account fallback DN) and its RFC-4514/4515 escaping parity is covered by @@ -674,8 +723,9 @@ public class SecurityReviewRegressionTests2 private static JwtTokenService CreateJwtService(SecurityOptions? options = null) => new(Options.Create(options ?? JwtOptions()), NullLogger<JwtTokenService>.Instance); - // --- Security-006: JWT issuer/audience must be bound and validated --- + // --- JWT issuer/audience must be bound and validated --- + /// <summary>Verifies the case: generate Token, sets Issuer And Audience.</summary> [Fact] public void GenerateToken_SetsIssuerAndAudience() { @@ -687,6 +737,7 @@ public class SecurityReviewRegressionTests2 Assert.Contains(JwtTokenService.TokenAudience, jwt.Audiences); } + /// <summary>Verifies the case: validate Token, rejects Token With Wrong Issuer.</summary> [Fact] public void ValidateToken_RejectsTokenWithWrongIssuer() { @@ -708,6 +759,7 @@ public class SecurityReviewRegressionTests2 Assert.Null(service.ValidateToken(foreignToken)); } + /// <summary>Verifies the case: validate Token, accepts Own Issuer And Audience.</summary> [Fact] public void ValidateToken_AcceptsOwnIssuerAndAudience() { @@ -716,8 +768,9 @@ public class SecurityReviewRegressionTests2 Assert.NotNull(service.ValidateToken(token)); } - // --- Security-007: refresh must preserve the original LastActivity timestamp --- + // --- refresh must preserve the original LastActivity timestamp --- + /// <summary>Verifies the case: refresh Token, preserves Original Last Activity Claim.</summary> [Fact] public void RefreshToken_PreservesOriginalLastActivityClaim() { @@ -746,6 +799,7 @@ public class SecurityReviewRegressionTests2 $"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}"); } + /// <summary>Verifies the case: refresh Token, does Not Reset Idle Timeout When User Is Actually Idle.</summary> [Fact] public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle() { @@ -771,6 +825,7 @@ public class SecurityReviewRegressionTests2 "Refresh wrongly reset the idle clock to ~now"); } + /// <summary>Verifies the case: record Activity, updates Last Activity To Now.</summary> [Fact] public void RecordActivity_UpdatesLastActivityToNow() { @@ -801,31 +856,34 @@ public class SecurityReviewRegressionTests2 #region Code Review Regression Tests — Security-009/011 /// <summary> -/// Regression tests for Security-009 (no LDAP connection timeout — a hung server can +/// Regression tests for the missing LDAP connection timeout (a hung server can /// pin a thread-pool thread indefinitely because <c>ct</c> only guards work-item -/// scheduling) and the remaining Security-011 coverage gaps. +/// scheduling) and the remaining coverage gaps. /// </summary> public class SecurityReviewRegressionTests3 { - // --- Security-009: LDAP connection timeout must be configurable and bounded --- + // --- LDAP connection timeout must be configurable and bounded --- + /// <summary>Verifies the case: ldap Options, connection Timeout, has Sane Default.</summary> [Fact] public void LdapOptions_ConnectionTimeout_HasSaneDefault() { var options = new LdapOptions(); // A positive, finite default so a hung LDAP server cannot pin a thread forever - // (the donor's Security-009 safeguard, preserved on the shared LdapOptions). + // (the donor's connection-timeout safeguard, preserved on the shared LdapOptions). Assert.True(options.ConnectionTimeoutMs > 0); Assert.True(options.ConnectionTimeoutMs <= 60_000, "Default LDAP connection timeout should be bounded (<= 60s)."); } + /// <summary>Verifies the case: authenticate Async, unreachable Host, fails Within Configured Timeout.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuthenticateAsync_UnreachableHost_FailsWithinConfiguredTimeout() { // A routable-but-non-responsive address would otherwise hang for the OS default // (often minutes). With ConnectionTimeoutMs applied to the LdapConnection the call - // must give up promptly. 198.51.100.0/24 (TEST-NET-2, RFC 5737) is reserved and not + // must give up promptly. 198.51.100.0/24 (the second documentation test network, RFC 5737) is reserved and not // routed, so the connect attempt stalls until the timeout fires. var options = new LdapOptions { @@ -851,7 +909,7 @@ public class SecurityReviewRegressionTests3 $"Authentication did not honour the LDAP connection timeout: took {sw.Elapsed}."); } - // Note: the former Security-011 no-service-account fallback-DN / DN-escape static-helper + // Note: the former no-service-account fallback-DN / DN-escape static-helper // cases (BuildFallbackUserDn / EscapeLdapDn) were removed: the shared service is // search-then-bind only (no fallback DN), and its escaping parity is covered by the // library's own LdapEscapingTests. @@ -862,12 +920,12 @@ public class SecurityReviewRegressionTests3 #region Code Review Regression Tests — Security-012/013/014/015 /// <summary> -/// Regression tests for Security-014 (<c>RefreshToken</c> re-issues a token without +/// Regression tests for <c>RefreshToken</c> re-issuing a token without /// checking the idle timeout, so an idle-expired session can be renewed indefinitely). /// </summary> /// <remarks> -/// Task 1.2 cutover: the former Security-013 (<c>ExtractFirstRdnValue</c> escaped-comma -/// parsing) and Security-015 (username trim before use) cases tested ScadaBridge's +/// Cutover: the former <c>ExtractFirstRdnValue</c> escaped-comma +/// parsing) and username trim before use cases tested ScadaBridge's /// bespoke <c>LdapAuthService.ExtractFirstRdnValue</c> / <c>NormalizeUsername</c> / /// <c>BuildFallbackUserDn</c> statics. Those behaviours are now owned by the shared /// library and covered by its own tests — <c>LdapEscapingTests.FirstRdnValue_IsEscapeAware</c> @@ -887,8 +945,9 @@ public class SecurityReviewRegressionTests4 private static JwtTokenService CreateJwtService(SecurityOptions? options = null) => new(Options.Create(options ?? JwtOptions()), NullLogger<JwtTokenService>.Instance); - // --- Security-014: RefreshToken must reject an idle-expired principal --- + // --- RefreshToken must reject an idle-expired principal --- + /// <summary>Verifies the case: refresh Token, idle Expired Principal, returns Null.</summary> [Fact] public void RefreshToken_IdleExpiredPrincipal_ReturnsNull() { @@ -909,6 +968,7 @@ public class SecurityReviewRegressionTests4 Assert.Null(refreshed); } + /// <summary>Verifies the case: refresh Token, active Session Within Idle Window, still Refreshes.</summary> [Fact] public void RefreshToken_ActiveSessionWithinIdleWindow_StillRefreshes() { @@ -928,6 +988,7 @@ public class SecurityReviewRegressionTests4 Assert.NotNull(refreshed); } + /// <summary>Verifies the case: refresh Token, missing Last Activity Claim, returns Null.</summary> [Fact] public void RefreshToken_MissingLastActivityClaim_ReturnsNull() { @@ -951,12 +1012,12 @@ public class SecurityReviewRegressionTests4 #region Code Review Regression Tests — Security-012 (partial LDAP outage) /// <summary> -/// Security-012 (fail-closed group lookup) + the ScadaBridge-side +/// Fail-closed group lookup + the ScadaBridge-side /// <see cref="LdapAuthFailureMessages"/> failure-code→message adapter. /// </summary> /// <remarks> /// <para> -/// Task 1.2 cutover: the donor's fail-closed group-lookup rule (a post-bind group +/// Cutover: the donor's fail-closed group-lookup rule (a post-bind group /// search failure FAILS the login rather than admitting a roleless session) is now /// owned by the shared <c>ZB.MOM.WW.Auth.Ldap</c> service and asserted by its own /// <c>LdapAuthServiceFailureTests.GroupLookupFailed_WhenUserHasNoGroups</c> / @@ -981,6 +1042,7 @@ public class SecurityReviewRegressionTests4 /// </remarks> public class Security012GroupLookupFailureTests { + /// <summary>Verifies the case: ldap Auth Result, fail, group Lookup Failed, is Failed Login With No Groups.</summary> [Fact] public void LdapAuthResult_Fail_GroupLookupFailed_IsFailedLoginWithNoGroups() { @@ -993,6 +1055,7 @@ public class Security012GroupLookupFailureTests Assert.Empty(result.Groups); } + /// <summary>Verifies the case: ldap Auth Result, success, carries Groups.</summary> [Fact] public void LdapAuthResult_Success_CarriesGroups() { @@ -1003,6 +1066,7 @@ public class Security012GroupLookupFailureTests Assert.Null(result.Failure); } + /// <summary>Verifies the case: empty Groups, is Treated As Group Lookup Failed, library Is Stricter.</summary> [Fact] public void EmptyGroups_IsTreatedAsGroupLookupFailed_LibraryIsStricter() { @@ -1013,12 +1077,15 @@ public class Security012GroupLookupFailureTests var failClosed = LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed); Assert.False(failClosed.Succeeded); - // And the donor's "Security-012 directory-unavailable" message is preserved for it. + // And the donor's "directory-unavailable" message is preserved for it. Assert.Equal( LdapAuthFailureMessages.DirectoryUnavailable, LdapAuthFailureMessages.ToMessage(LdapAuthFailure.GroupLookupFailed)); } + /// <summary>Verifies the case: ldap Auth Failure Messages, maps Each Failure, to Donor Equivalent Message.</summary> + /// <param name="failure">The failure test input.</param> + /// <param name="expected">The expected test input.</param> [Theory] [InlineData(LdapAuthFailure.BadCredentials, LdapAuthFailureMessages.InvalidCredentials)] [InlineData(LdapAuthFailure.UserNotFound, LdapAuthFailureMessages.InvalidCredentials)] @@ -1029,12 +1096,13 @@ public class Security012GroupLookupFailureTests public void LdapAuthFailureMessages_MapsEachFailure_ToDonorEquivalentMessage( LdapAuthFailure failure, string expected) { - // Security-019 framing preserved: user-credential failures are enumeration-safe + // Framing preserved: user-credential failures are enumeration-safe // ("Invalid username or password."), system-side faults point the OPERATOR at the // cause ("...misconfigured...") rather than blaming user input. Assert.Equal(expected, LdapAuthFailureMessages.ToMessage(failure)); } + /// <summary>Verifies the case: ldap Auth Failure Messages, null Failure, falls Back To Generic.</summary> [Fact] public void LdapAuthFailureMessages_NullFailure_FallsBackToGeneric() { @@ -1048,6 +1116,8 @@ public class Security012GroupLookupFailureTests public class AuthorizationPolicyTests { + /// <summary>Verifies the case: admin Policy, administrator Role, succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AdminPolicy_AdministratorRole_Succeeds() { @@ -1056,6 +1126,8 @@ public class AuthorizationPolicyTests Assert.True(result); } + /// <summary>Verifies the case: admin Policy, designer Role, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AdminPolicy_DesignerRole_Fails() { @@ -1064,6 +1136,8 @@ public class AuthorizationPolicyTests Assert.False(result); } + /// <summary>Verifies the case: design Policy, designer Role, succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DesignPolicy_DesignerRole_Succeeds() { @@ -1072,6 +1146,8 @@ public class AuthorizationPolicyTests Assert.True(result); } + /// <summary>Verifies the case: deployment Policy, deployer Role, succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploymentPolicy_DeployerRole_Succeeds() { @@ -1080,6 +1156,8 @@ public class AuthorizationPolicyTests Assert.True(result); } + /// <summary>Verifies the case: no Roles, denied All.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoRoles_DeniedAll() { @@ -1092,8 +1170,8 @@ public class AuthorizationPolicyTests } // ───────────────────────────────────────────────────────────────────── - // Audit Log #23 — OperationalAudit + AuditExport policies (M7-T15), - // post Task 1.7 canonicalization + SoD collapse. Default mapping + // OperationalAudit + AuditExport policies, + // post-canonicalization + SoD collapse. Default mapping // (see AuthorizationPolicies XML doc): // Administrator → OperationalAudit + AuditExport // Viewer → OperationalAudit only (former AuditReadOnly home) @@ -1104,6 +1182,9 @@ public class AuthorizationPolicyTests // AuditReadOnly → collapsed into Viewer // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies the case: operational Audit Policy, granted Roles, succeed.</summary> + /// <param name="role">The role test input.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("Administrator")] [InlineData("Viewer")] @@ -1113,6 +1194,9 @@ public class AuthorizationPolicyTests Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal)); } + /// <summary>Verifies the case: operational Audit Policy, ungranted Roles, fail.</summary> + /// <param name="role">The role test input.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("Designer")] [InlineData("Deployer")] @@ -1122,6 +1206,9 @@ public class AuthorizationPolicyTests Assert.False(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal)); } + /// <summary>Verifies the case: audit Export Policy, granted Roles, succeed.</summary> + /// <param name="role">The role test input.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("Administrator")] public async Task AuditExportPolicy_GrantedRoles_Succeed(string role) @@ -1130,6 +1217,9 @@ public class AuthorizationPolicyTests Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); } + /// <summary>Verifies the case: audit Export Policy, ungranted Roles, fail.</summary> + /// <param name="role">The role test input.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("Viewer")] [InlineData("Designer")] @@ -1140,6 +1230,8 @@ public class AuthorizationPolicyTests Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); } + /// <summary>Verifies the case: viewer, reads Audit, but Cannot Export, preserved Half So D.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Viewer_ReadsAudit_ButCannotExport_PreservedHalfSoD() { @@ -1151,6 +1243,8 @@ public class AuthorizationPolicyTests Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); } + /// <summary>Verifies the case: former Audit User, now Administrator, gains Export And Full Admin, documented Escalation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FormerAuditUser_NowAdministrator_GainsExportAndFullAdmin_DocumentedEscalation() { @@ -1164,7 +1258,7 @@ public class AuthorizationPolicyTests } // ───────────────────────────────────────────────────────────────────── - // Security-024 — M7 two-person Secured Write separation-of-duties (SoD) + // Two-person Secured Write separation-of-duties (SoD) // policies. The whole safety argument of Secured Writes is that the role // that INITIATES (Operator → RequireOperator) is distinct from the role // that APPROVES (Verifier → RequireVerifier). These functional @@ -1174,6 +1268,8 @@ public class AuthorizationPolicyTests // SoD (the prior coverage only asserted the constant string values). // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies the case: operator Policy, operator Role, succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OperatorPolicy_OperatorRole_Succeeds() { @@ -1181,6 +1277,9 @@ public class AuthorizationPolicyTests Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireOperator, principal)); } + /// <summary>Verifies the case: operator Policy, non Operator Roles, fail.</summary> + /// <param name="role">The role test input.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("Verifier")] [InlineData("Administrator")] @@ -1193,6 +1292,8 @@ public class AuthorizationPolicyTests Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireOperator, principal)); } + /// <summary>Verifies the case: operator Policy, no Roles, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OperatorPolicy_NoRoles_Fails() { @@ -1200,6 +1301,8 @@ public class AuthorizationPolicyTests Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireOperator, principal)); } + /// <summary>Verifies the case: verifier Policy, verifier Role, succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task VerifierPolicy_VerifierRole_Succeeds() { @@ -1207,6 +1310,9 @@ public class AuthorizationPolicyTests Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireVerifier, principal)); } + /// <summary>Verifies the case: verifier Policy, non Verifier Roles, fail.</summary> + /// <param name="role">The role test input.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("Operator")] [InlineData("Administrator")] @@ -1219,6 +1325,8 @@ public class AuthorizationPolicyTests Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireVerifier, principal)); } + /// <summary>Verifies the case: verifier Policy, no Roles, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task VerifierPolicy_NoRoles_Fails() { @@ -1226,6 +1334,8 @@ public class AuthorizationPolicyTests Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireVerifier, principal)); } + /// <summary>Verifies the case: secured Write So D, operator Principal Cannot Satisfy Verifier, and Vice Versa.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SecuredWriteSoD_OperatorPrincipalCannotSatisfyVerifier_AndViceVersa() { @@ -1242,6 +1352,9 @@ public class AuthorizationPolicyTests Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireOperator, verifierOnly)); } + /// <summary>Verifies the case: require Secured Write Access, secured Write Roles, satisfy.</summary> + /// <param name="role">The role test input.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("Operator")] [InlineData("Verifier")] @@ -1254,6 +1367,9 @@ public class AuthorizationPolicyTests Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireSecuredWriteAccess, principal)); } + /// <summary>Verifies the case: require Secured Write Access, other Roles, denied.</summary> + /// <param name="role">The role test input.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("Viewer")] [InlineData("Designer")] @@ -1272,6 +1388,9 @@ public class AuthorizationPolicyTests // by the ManagementActor role matrix; Designer gets neither. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies the case: require Flow Debugger, replay Capable Roles, satisfy.</summary> + /// <param name="role">The role test input.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("Deployer")] [InlineData("Administrator")] @@ -1282,6 +1401,9 @@ public class AuthorizationPolicyTests Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireFlowDebugger, principal)); } + /// <summary>Verifies the case: require Flow Debugger, other Roles, denied.</summary> + /// <param name="role">The role test input.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("Designer")] [InlineData("Operator")] @@ -1292,6 +1414,8 @@ public class AuthorizationPolicyTests Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireFlowDebugger, principal)); } + /// <summary>Verifies the case: require Flow Debugger, does Not Imply Deployment.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RequireFlowDebugger_DoesNotImplyDeployment() { @@ -1333,12 +1457,12 @@ public class AuthorizationPolicyTests #region Code Review Regression Tests — Security-020 (LDAP fail-fast validation) /// <summary> -/// Security-020: a missing/empty required LDAP field must fail fast at startup with a +/// A missing/empty required LDAP field must fail fast at startup with a /// clear, field-naming message, so a typo'd appsettings section fails at boot instead /// of surfacing minutes/hours later as a generic LDAP error on the first real login. /// </summary> /// <remarks> -/// Task 1.2/1.4 cutover: the donor's <c>SecurityOptionsValidator</c> (which checked +/// Cutover: the donor's <c>SecurityOptionsValidator</c> (which checked /// <c>Security:LdapServer</c> + <c>Security:LdapSearchBase</c> non-empty) was replaced by /// the shared <c>ZB.MOM.WW.Auth.Ldap.LdapOptionsValidator</c>, registered with /// <c>ValidateOnStart()</c> by <c>AddZbLdapAuth</c> inside <c>AddSecurity</c>. The shared @@ -1357,6 +1481,7 @@ public class SecurityOptionsValidatorTests Transport = LdapTransport.Ldaps, }; + /// <summary>Verifies the case: validate, all Required Fields Set, succeeds.</summary> [Fact] public void Validate_AllRequiredFieldsSet_Succeeds() { @@ -1364,6 +1489,8 @@ public class SecurityOptionsValidatorTests Assert.True(result.Succeeded); } + /// <summary>Verifies the case: validate, empty Or Whitespace Server, fails.</summary> + /// <param name="server">The server test input.</param> [Theory] [InlineData("")] [InlineData(" ")] @@ -1377,6 +1504,8 @@ public class SecurityOptionsValidatorTests Assert.Contains(nameof(LdapOptions.Server), result.FailureMessage); } + /// <summary>Verifies the case: validate, empty Or Whitespace Search Base, fails.</summary> + /// <param name="searchBase">The searchBase test input.</param> [Theory] [InlineData("")] [InlineData(" ")] @@ -1390,6 +1519,7 @@ public class SecurityOptionsValidatorTests Assert.Contains(nameof(LdapOptions.SearchBase), result.FailureMessage); } + /// <summary>Verifies the case: validate, empty Service Account Dn, fails.</summary> [Fact] public void Validate_EmptyServiceAccountDn_Fails() { @@ -1439,7 +1569,7 @@ public class SecurityOptionsValidatorTests #region Task 1.5: Canonical claim vocabulary (ZbClaimTypes) — role/scope migration /// <summary> -/// Task 1.5 (full canonical adoption): the Security module's claim-type constants now +/// Full canonical adoption: the Security module's claim-type constants now /// alias the shared <see cref="ZbClaimTypes"/> vocabulary. These tests pin the contract /// that the MINTED claim type is exactly the type the policies + token validation CONSUME, /// across both the JWT bearer and the cookie principal, so a future drift fails loudly. @@ -1457,6 +1587,7 @@ public class CanonicalClaimVocabularyTests private static JwtTokenService CreateService() => new(Options.Create(CreateOptions()), NullLogger<JwtTokenService>.Instance); + /// <summary>Verifies the case: claim Type Constants, are Canonical Zb Claim Types.</summary> [Fact] public void ClaimTypeConstants_AreCanonicalZbClaimTypes() { @@ -1477,6 +1608,7 @@ public class CanonicalClaimVocabularyTests Assert.Equal("LastActivity", JwtTokenService.LastActivityClaimType); } + /// <summary>Verifies the case: minted Jwt, round Trips, canonical Claim Types Verbatim.</summary> [Fact] public void MintedJwt_RoundTrips_CanonicalClaimTypesVerbatim() { @@ -1497,11 +1629,13 @@ public class CanonicalClaimVocabularyTests Assert.Equal("7", principal.FindFirst(ZbClaimTypes.ScopeId)?.Value); } + /// <summary>Verifies the case: minted Jwt, role Claim, satisfies Operational Audit Policy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MintedJwt_RoleClaim_SatisfiesOperationalAuditPolicy() { // The load-bearing round-trip: a JWT minted with RoleClaimType=Administrator - // (post Task 1.7, the home of the former full-audit Audit role) must satisfy + // (the home of the former full-audit Audit role) must satisfy // a RequireClaim(RoleClaimType, OperationalAuditRoles) policy after validation. var service = CreateService(); var token = service.GenerateToken("Jane Roe", "janer", new[] { Roles.Administrator }, null); @@ -1512,7 +1646,7 @@ public class CanonicalClaimVocabularyTests Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal!)); // Administrator grants AuditExport too (it absorbed the former Audit role): Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal!)); - // Viewer (post Task 1.7 home of the former AuditReadOnly role) is read-only — + // Viewer (home of the former AuditReadOnly role) is read-only — // separate assertion that the read-not-export half-SoD survives the type // migration AND the role collapse. var roPrincipal = service.ValidateToken( @@ -1521,6 +1655,8 @@ public class CanonicalClaimVocabularyTests Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, roPrincipal!)); } + /// <summary>Verifies the case: cookie Principal, built Like Login, authorizes And Exposes Canonical Types.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CookiePrincipal_BuiltLikeLogin_AuthorizesAndExposesCanonicalTypes() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/Kpi/SiteCallAuditKpiSampleSourceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/Kpi/SiteCallAuditKpiSampleSourceTests.cs index 2554b915..d809439a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/Kpi/SiteCallAuditKpiSampleSourceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/Kpi/SiteCallAuditKpiSampleSourceTests.cs @@ -11,8 +11,8 @@ using ZB.MOM.WW.ScadaBridge.SiteCallAudit.Kpi; namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests.Kpi; /// <summary> -/// Unit tests for <see cref="SiteCallAuditKpiSampleSource"/> (M6 KPI History, -/// #K7). A hand-rolled <see cref="ISiteCallAuditRepository"/> fake (matching the +/// Unit tests for <see cref="SiteCallAuditKpiSampleSource"/> (KPI history). +/// A hand-rolled <see cref="ISiteCallAuditRepository"/> fake (matching the /// project's existing test-double style — no mocking library) returns known /// global + per-site + per-node snapshots; the assertions pin the exact /// <c>(Metric, Scope, ScopeKey, Value)</c> tuples the recorder will persist, the @@ -54,6 +54,7 @@ public class SiteCallAuditKpiSampleSourceTests // 1. Source identifier is the canonical SiteCallAudit constant. // --------------------------------------------------------------------- + /// <summary>Verifies that the source identifier is the SiteCallAudit constant.</summary> [Fact] public void Source_IsSiteCallAuditConstant() { @@ -65,6 +66,8 @@ public class SiteCallAuditKpiSampleSourceTests // 2. Full snapshot: global + per-site + per-node, exact tuples. // --------------------------------------------------------------------- + /// <summary>Verifies that the expected tuples are emitted for the global, per-site and per-node KPIs.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_EmitsExpectedTuples_ForGlobalSiteAndNode() { @@ -142,6 +145,8 @@ public class SiteCallAuditKpiSampleSourceTests // 3. Null oldest-pending-age is omitted (not written as zero). // --------------------------------------------------------------------- + /// <summary>Verifies that the oldest pending age is omitted when it is null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_OmitsOldestPendingAge_WhenNull() { @@ -173,6 +178,8 @@ public class SiteCallAuditKpiSampleSourceTests // 4. Empty per-site/per-node: only the global six metrics emitted. // --------------------------------------------------------------------- + /// <summary>Verifies that only global tuples are emitted when there are no per-site or per-node rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_GlobalOnly_WhenNoPerSiteOrPerNodeRows() { @@ -193,6 +200,8 @@ public class SiteCallAuditKpiSampleSourceTests // 5. Cutoffs are anchored on capturedAtUtc using the options windows. // --------------------------------------------------------------------- + /// <summary>Verifies that the cutoffs are derived from the options, anchored on the captured-at time.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_DerivesCutoffs_FromOptionsAnchoredOnCapturedAt() { @@ -219,17 +228,19 @@ public class SiteCallAuditKpiSampleSourceTests } // --------------------------------------------------------------------- - // P5-5 (arch-review Low): failedLastInterval/deliveredLastInterval are rescaled onto + // Rescaling: failedLastInterval/deliveredLastInterval are rescaled onto // KpiHistoryOptions.SampleInterval before recording, so the central hourly rollup fold's // per-hour Sum stays correct regardless of how KpiInterval is tuned. Before the fix, this // class recorded the RAW KpiInterval-windowed counts verbatim; with a 20-minute // KpiInterval sampled every 1-minute SampleInterval, summing 60 overlapping 20-minute // counts across an hour would over-count ~20x. Proven against the unfixed computation: // temporarily short-circuiting IntervalScaleFactor to always return 1.0 (i.e. "record the - // raw count verbatim", the pre-P5-5 behavior) makes this test fail (40/1 != 2/0.05); + // raw count verbatim", the pre-fix behavior) makes this test fail (40/1 != 2/0.05); // restoring the real scale factor passes it. // --------------------------------------------------------------------- + /// <summary>Verifies that the failed and delivered last-interval counts are rescaled onto the KPI history sample interval.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_RescalesFailedAndDeliveredLastInterval_OntoKpiHistorySampleInterval() { @@ -264,6 +275,8 @@ public class SiteCallAuditKpiSampleSourceTests // 6. Multiple sites/nodes are all emitted with distinct ScopeKeys. // --------------------------------------------------------------------- + /// <summary>Verifies that tuples are emitted for all sites and nodes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CollectAsync_EmitsAllSitesAndNodes() { @@ -316,15 +329,20 @@ public class SiteCallAuditKpiSampleSourceTests /// </summary> private sealed class StubRepo : ISiteCallAuditRepository { + /// <summary>Gets or sets the global KPI snapshot returned by the stub.</summary> public SiteCallKpiSnapshot Global { get; set; } = new(0, 0, 0, 0, null, 0); + /// <summary>Gets or sets the per-site KPI snapshots returned by the stub.</summary> public IReadOnlyList<SiteCallSiteKpiSnapshot> PerSite { get; set; } = Array.Empty<SiteCallSiteKpiSnapshot>(); + /// <summary>Gets or sets the per-node KPI snapshots returned by the stub.</summary> public IReadOnlyList<SiteCallNodeKpiSnapshot> PerNode { get; set; } = Array.Empty<SiteCallNodeKpiSnapshot>(); + /// <summary>Gets the recorded KPI calls in the order they were made.</summary> public List<KpiCall> Calls { get; } = new(); + /// <inheritdoc /> public Task<SiteCallKpiSnapshot> ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) { @@ -332,6 +350,7 @@ public class SiteCallAuditKpiSampleSourceTests return Task.FromResult(Global); } + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) { @@ -339,6 +358,7 @@ public class SiteCallAuditKpiSampleSourceTests return Task.FromResult(PerSite); } + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) { @@ -347,16 +367,20 @@ public class SiteCallAuditKpiSampleSourceTests } // ── Inert non-KPI members ── + /// <inheritdoc /> public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => Task.FromResult<SiteCall?>(null); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCall>> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>()); + /// <inheritdoc /> public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => Task.FromResult(0); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs index c001ebf9..ab1db9bf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs @@ -14,8 +14,8 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests; /// <summary> -/// Bundle C1 (#22, #23 M3) tests for <see cref="SiteCallAuditActor"/>. Uses the -/// same <see cref="MsSqlMigrationFixture"/> as the Bundle B3 repository tests +/// Tests for <see cref="SiteCallAuditActor"/>. Uses the +/// same <see cref="MsSqlMigrationFixture"/> as the repository tests /// so the actor exercises the real monotonic-upsert SQL end to end against the /// <c>SiteCalls</c> schema. Each test scopes its data by minting a fresh /// <see cref="TrackedOperationId"/> (and a per-test <c>SourceSite</c> suffix) @@ -25,6 +25,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt { private readonly MsSqlMigrationFixture _fixture; + /// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary> + /// <param name="fixture">The shared MSSQL migration fixture.</param> public SiteCallAuditActorTests(MsSqlMigrationFixture fixture) { _fixture = fixture; @@ -87,6 +89,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt NullLogger<SiteCallAuditActor>.Instance))); } + /// <summary>Verifies that an upsert command is persisted and acknowledged as accepted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task Receive_UpsertSiteCallCommand_Persists_Replies_Accepted() { @@ -116,6 +120,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal("Submitted", rows[0].Status); } + /// <summary>Verifies that a duplicate upsert with an older status changes nothing yet is still acknowledged as accepted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task Receive_DuplicateUpsert_OlderStatus_NoOp_StillRepliesAccepted() { @@ -143,7 +149,7 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt var secondReply = ExpectMsg<UpsertSiteCallReply>(TimeSpan.FromSeconds(10)); Assert.True(secondReply.Accepted, "Stale upsert must still be acked (idempotent contract)."); - // Storage must still show the rank-2 row, not rolled back. + // Storage must still show the rank 2 row, not rolled back. await using var readContext = CreateContext(); var stored = await readContext.Set<SiteCall>() .Where(s => s.TrackedOperationId == id) @@ -154,6 +160,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal("first", stored[0].LastError); } + /// <summary>Verifies that a transient repository failure replies not-accepted and leaves the actor alive.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task Receive_RepoThrowsTransient_RepliesAccepted_False_ActorStaysAlive() { @@ -194,8 +202,10 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal(healthyId, rows[0].TrackedOperationId); } - // ── Task 4: read-side (query / detail / KPI) handlers ── + // ── Read-side (query / detail / KPI) handlers ── + /// <summary>Verifies that filtering a query by source site returns only the matching summaries.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SiteCallQueryRequest_FilterBySourceSite_ReturnsMatchingSummaries() { @@ -231,10 +241,12 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal(response.SiteCalls[^1].TrackedOperationId, response.NextAfterId); } + /// <summary>Verifies that filtering a query by source node returns only the matching summaries.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SiteCallQueryRequest_FilterBySourceNode_ReturnsMatchingSummaries() { - // Task 17: the new Node filter input pushes a string into + // The new Node filter input pushes a string into // SiteCallQueryRequest.SourceNodeFilter — the actor must thread it // onto SiteCallQueryFilter.SourceNode and the response summaries must // mirror the row's SourceNode column. @@ -268,10 +280,12 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal("Attempted", response.SiteCalls[0].Status); } + /// <summary>Verifies that filtering a query by tracked operation id returns only that row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SiteCallQueryRequest_FilterByTrackedOperationId_ReturnsOnlyThatRow() { - // Flow Debugger follow-ups Task 2: the ?correlationId= deep link pushes + // Flow Debugger follow-up: the ?correlationId= deep link pushes // one operation id into SiteCallQueryRequest.TrackedOperationIdFilter — // the actor must thread it onto SiteCallQueryFilter.TrackedOperationId // so the page returns exactly that row and nothing else from the site. @@ -307,6 +321,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal(rowA.TrackedOperationId.Value, response.NextAfterId); } + /// <summary>Verifies that keyset paging advances through the results via the cursor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SiteCallQueryRequest_KeysetPaging_AdvancesViaCursor() { @@ -344,6 +360,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal(3, allIds.Count); } + /// <summary>Verifies that the stuck-only filter returns only old, non-terminal rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SiteCallQueryRequest_StuckOnly_ReturnsOnlyOldNonTerminalRows() { @@ -379,6 +397,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.True(response.SiteCalls[0].IsStuck); } + /// <summary>Verifies that stuck-only pages are full and never come back under-filled with a next cursor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SiteCallQueryRequest_StuckOnly_PagesAreFull_NoEmptyPagesWithCursor() { @@ -392,7 +412,7 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt var now = DateTime.UtcNow; // Three stuck rows interleaved (by CreatedAtUtc) with three non-stuck // rows: recent non-terminal and old-but-terminal. With the StuckOnly - // filter pushed into SQL, a page-size-2 query must return exactly the + // filter pushed into SQL, a page-size-of-two query must return exactly the // stuck rows two-per-page — never an under-filled page with a non-null // next cursor caused by post-filtering. for (var i = 0; i < 3; i++) @@ -438,13 +458,15 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal(3, allIds.Count); } + /// <summary>Verifies that a stuck-only cursor sitting on a non-stuck row skips to the next stuck row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow() { - // SiteCallAudit-006 boundary regression. The earlier paging test - // interleaves stuck/non-stuck rows but the cursor between page-1 and - // page-2 always lands on a stuck row. This test forces the cursor to - // sit on a NON-stuck row (page-size=1 over a strict + // Boundary regression. The earlier paging test + // interleaves stuck/non-stuck rows but the cursor between page 1 and + // page 2 always lands on a stuck row. This test forces the cursor to + // sit on a NON-stuck row (a page size of 1 over a strict // stuck-not_stuck-stuck-not_stuck-stuck-not_stuck pattern oldest-first) // so the SQL-side composition of the stuck predicate AND the keyset // cursor predicate (CreatedAtUtc < cursor OR =cursor AND id < ...) must @@ -470,7 +492,7 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt // t=-35m (stuck — Attempted, 35m > 10m: stuckC), // t=-40m (non-stuck — terminal Delivered, oldest of all). // The non-stuck rows at -20m and -30m sit between consecutive stuck - // rows, so the page-size-1 cursor lands ON a non-stuck row between + // rows, so a page-size-of-one cursor lands ON a non-stuck row between // pages — exactly the boundary the predicate composition must skip. var stuckA = TrackedOperationId.New(); var stuckB = TrackedOperationId.New(); @@ -502,7 +524,7 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt TrackedOperationId.New(), siteId, status: "Attempted", createdAtUtc: now.AddMinutes(-1))); - // Page-1: page-size=1, expect stuckA (newest stuck row). + // Page 1: page size of 1, expect stuckA (newest stuck row). actor.Tell( new SiteCallQueryRequest( "corr-stuck-b1", null, siteId, null, null, StuckOnly: true, @@ -515,7 +537,7 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal(stuckA.Value, page1.SiteCalls[0].TrackedOperationId); Assert.NotNull(page1.NextAfterCreatedAtUtc); - // Page-2: between stuckA and stuckB the non-stuck terminal row at -20m + // Page 2: between stuckA and stuckB the non-stuck terminal row at -20m // sits at the cursor — the SQL must skip it, NOT return it. actor.Tell( new SiteCallQueryRequest( @@ -530,7 +552,7 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal(stuckB.Value, page2.SiteCalls[0].TrackedOperationId); Assert.NotNull(page2.NextAfterCreatedAtUtc); - // Page-3: between stuckB and stuckC the non-stuck row at -30m sits at + // Page 3: between stuckB and stuckC the non-stuck row at -30m sits at // the cursor — again must be skipped. actor.Tell( new SiteCallQueryRequest( @@ -553,6 +575,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal(stuckIds, visited); } + /// <summary>Verifies that a detail request for a known id returns the full detail.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SiteCallDetailRequest_KnownId_ReturnsFullDetail() { @@ -582,6 +606,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal("node-a", response.Detail.SourceNode); } + /// <summary>Verifies that a detail request for an unknown id replies not found.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SiteCallDetailRequest_UnknownId_RepliesNotFound() { @@ -599,6 +625,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.NotNull(response.ErrorMessage); } + /// <summary>Verifies that a KPI request computes point-in-time counts.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SiteCallKpiRequest_ComputesPointInTimeCounts() { @@ -641,8 +669,10 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.NotNull(response.OldestPendingAge); } - // ── Per-node KPI (T6: M5.2 per-node stuck-count KPIs) ────────────────── + // ── Per-node KPI (per-node stuck-count KPIs) ────────────────── + /// <summary>Verifies that the per-node KPI request scopes the counts to each node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task PerNodeSiteCallKpiRequest_ScopesCountsToEachNode() { @@ -678,6 +708,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.NotNull(myNode.OldestPendingAge); } + /// <summary>Verifies that the per-site KPI request scopes the counts to each site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task PerSiteSiteCallKpiRequest_ScopesCountsToEachSite() { @@ -712,6 +744,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.NotNull(mySite.OldestPendingAge); } + /// <summary>Verifies that a repository failure during a query replies with a failure and leaves the actor alive.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task SiteCallQueryRequest_RepoThrows_RepliesFailure_ActorStaysAlive() { @@ -734,14 +768,16 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal("corr-fault", response.CorrelationId); } - // ── SourceNode-stamping (Task 14): end-to-end actor → repo persistence ── + // ── SourceNode-stamping: end-to-end actor → repo persistence ── + /// <summary>Verifies that an upsert command persists its source node end to end.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertSiteCallCommand_PersistsSourceNode_EndToEnd() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); - // SourceNode-stamping (Task 14): an UpsertSiteCallCommand carrying + // SourceNode-stamping: an UpsertSiteCallCommand carrying // SourceNode "node-a" must land in the SiteCalls row's SourceNode // column unchanged — verifies the actor's mapping path does not // strip the column AND the repository INSERT writes it. @@ -765,6 +801,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt Assert.Equal("node-a", stored[0].SourceNode); } + /// <summary>Verifies that an upsert command with a null source node persists null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [SkippableFact] public async Task UpsertSiteCallCommand_NullSourceNode_PersistsNull() { @@ -804,32 +842,41 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt { private readonly ISiteCallAuditRepository _inner; + /// <summary>Initializes the repository wrapper.</summary> + /// <param name="inner">The real repository that every call except <c>QueryAsync</c> delegates to.</param> public QueryThrowingRepository(ISiteCallAuditRepository inner) { _inner = inner; } + /// <inheritdoc /> public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => _inner.UpsertAsync(siteCall, ct); + /// <inheritdoc /> public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => _inner.GetAsync(id, ct); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCall>> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => throw new InvalidOperationException("simulated query failure"); + /// <inheritdoc /> public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => _inner.PurgeTerminalAsync(olderThanUtc, ct); + /// <inheritdoc /> public Task<SiteCallKpiSnapshot> ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct); @@ -846,12 +893,16 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt private readonly ISiteCallAuditRepository _inner; private readonly TrackedOperationId _poisonId; + /// <summary>Initializes the repository wrapper.</summary> + /// <param name="inner">The real repository that non-poisoned calls delegate to.</param> + /// <param name="poisonId">The tracked operation id whose upsert throws.</param> public ThrowingRepository(ISiteCallAuditRepository inner, TrackedOperationId poisonId) { _inner = inner; _poisonId = poisonId; } + /// <inheritdoc /> public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) { if (siteCall.TrackedOperationId == _poisonId) @@ -861,24 +912,30 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt return _inner.UpsertAsync(siteCall, ct); } + /// <inheritdoc /> public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => _inner.GetAsync(id, ct); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCall>> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => _inner.QueryAsync(filter, paging, ct); + /// <inheritdoc /> public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => _inner.PurgeTerminalAsync(olderThanUtc, ct); + /// <inheritdoc /> public Task<SiteCallKpiSnapshot> ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditOptionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditOptionsTests.cs index 703b366c..2cbbc416 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditOptionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditOptionsTests.cs @@ -2,6 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests; public class SiteCallAuditOptionsTests { + /// <summary>Verifies that the defaults are the expected values.</summary> [Fact] public void Defaults_AreExpectedValues() { @@ -12,7 +13,7 @@ public class SiteCallAuditOptionsTests // KPI interval mirrors NotificationOutboxOptions.DeliveredKpiWindow. Assert.Equal(TimeSpan.FromMinutes(1), options.KpiInterval); - // Reconciliation tick cadence mirrors SiteAuditReconciliationOptions (#23). + // Reconciliation tick cadence mirrors SiteAuditReconciliationOptions. Assert.Equal(TimeSpan.FromMinutes(5), options.ReconciliationInterval); // Purge tick cadence mirrors AuditLogPurgeOptions. Assert.Equal(TimeSpan.FromHours(24), options.PurgeInterval); @@ -20,6 +21,7 @@ public class SiteCallAuditOptionsTests Assert.Equal(365, options.RetentionDays); } + /// <summary>Verifies that the resolved reconciliation interval defaults to the configured value.</summary> [Fact] public void ResolvedReconciliationInterval_DefaultsToConfiguredValue() { @@ -28,6 +30,8 @@ public class SiteCallAuditOptionsTests Assert.Equal(options.ReconciliationInterval, options.ResolvedReconciliationInterval); } + /// <summary>Verifies that the resolved reconciliation interval clamps a zero or negative value to the minimum.</summary> + /// <param name="configuredSeconds">The configured interval in seconds.</param> [Theory] [InlineData(0)] [InlineData(-5)] @@ -47,6 +51,7 @@ public class SiteCallAuditOptionsTests Assert.Equal(TimeSpan.FromSeconds(1), options.ResolvedReconciliationInterval); } + /// <summary>Verifies that an override of the reconciliation interval bypasses the clamp.</summary> [Fact] public void ResolvedReconciliationInterval_OverrideBypassesClamp() { @@ -62,6 +67,7 @@ public class SiteCallAuditOptionsTests Assert.Equal(sub1Second, options.ResolvedReconciliationInterval); } + /// <summary>Verifies that the resolved purge interval defaults to the configured value.</summary> [Fact] public void ResolvedPurgeInterval_DefaultsToConfiguredValue() { @@ -70,6 +76,8 @@ public class SiteCallAuditOptionsTests Assert.Equal(options.PurgeInterval, options.ResolvedPurgeInterval); } + /// <summary>Verifies that the resolved purge interval clamps a zero or negative value to the minimum.</summary> + /// <param name="configuredSeconds">The configured interval in seconds.</param> [Theory] [InlineData(0)] [InlineData(-30)] @@ -89,6 +97,7 @@ public class SiteCallAuditOptionsTests Assert.Equal(TimeSpan.FromMinutes(1), options.ResolvedPurgeInterval); } + /// <summary>Verifies that a purge interval below the one-minute floor is clamped to the minimum.</summary> [Fact] public void ResolvedPurgeInterval_BelowMinuteFloorClampsToMinimum() { @@ -101,6 +110,7 @@ public class SiteCallAuditOptionsTests Assert.Equal(TimeSpan.FromMinutes(1), options.ResolvedPurgeInterval); } + /// <summary>Verifies that an override of the purge interval bypasses the clamp.</summary> [Fact] public void ResolvedPurgeInterval_OverrideBypassesClamp() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditOptionsValidatorTests.cs index 1a7fcd32..bdef6283 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditOptionsValidatorTests.cs @@ -16,6 +16,9 @@ public class SiteCallAuditOptionsValidatorTests private static ValidateOptionsResult Validate(SiteCallAuditOptions options) => new SiteCallAuditOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the reconciliation batch size is valid only up to the RPC ceiling.</summary> + /// <param name="size">The reconciliation batch size under test.</param> + /// <param name="valid">Whether the batch size is expected to pass validation.</param> [Theory] [InlineData(1, true)] [InlineData(2000, true)] @@ -24,6 +27,7 @@ public class SiteCallAuditOptionsValidatorTests public void ReconciliationBatchSize_MatchesRpcCeiling(int size, bool valid) => Assert.Equal(valid, Validate(new SiteCallAuditOptions { ReconciliationBatchSize = size }).Succeeded); + /// <summary>Verifies that the default options are valid.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -31,6 +35,7 @@ public class SiteCallAuditOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero stuck-age threshold is rejected.</summary> [Fact] public void ZeroStuckAgeThreshold_IsRejected() { @@ -39,6 +44,7 @@ public class SiteCallAuditOptionsValidatorTests Assert.Contains("StuckAgeThreshold", result.FailureMessage); } + /// <summary>Verifies that a zero KPI interval is rejected.</summary> [Fact] public void ZeroKpiInterval_IsRejected() { @@ -47,6 +53,7 @@ public class SiteCallAuditOptionsValidatorTests Assert.Contains("KpiInterval", result.FailureMessage); } + /// <summary>Verifies that a zero relay timeout is rejected.</summary> [Fact] public void ZeroRelayTimeout_IsRejected() { @@ -55,6 +62,7 @@ public class SiteCallAuditOptionsValidatorTests Assert.Contains("RelayTimeout", result.FailureMessage); } + /// <summary>Verifies that a zero reconciliation interval is rejected.</summary> [Fact] public void ZeroReconciliationInterval_IsRejected() { @@ -63,6 +71,7 @@ public class SiteCallAuditOptionsValidatorTests Assert.Contains("ReconciliationInterval", result.FailureMessage); } + /// <summary>Verifies that a zero reconciliation batch size is rejected.</summary> [Fact] public void ZeroReconciliationBatchSize_IsRejected() { @@ -71,6 +80,7 @@ public class SiteCallAuditOptionsValidatorTests Assert.Contains("ReconciliationBatchSize", result.FailureMessage); } + /// <summary>Verifies that a zero purge interval is rejected.</summary> [Fact] public void ZeroPurgeInterval_IsRejected() { @@ -79,6 +89,7 @@ public class SiteCallAuditOptionsValidatorTests Assert.Contains("PurgeInterval", result.FailureMessage); } + /// <summary>Verifies that zero retention days is rejected.</summary> [Fact] public void ZeroRetentionDays_IsRejected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditPurgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditPurgeTests.cs index 3cfede59..a11af472 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditPurgeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditPurgeTests.cs @@ -12,7 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests; /// <summary> -/// Purge-scheduler tests for <see cref="SiteCallAuditActor"/> (#22, Piece B). +/// Purge-scheduler tests for <see cref="SiteCallAuditActor"/>. /// Exercises the daily terminal-row purge tick in-memory — a recording /// <see cref="ISiteCallAuditRepository"/> captures the /// <see cref="ISiteCallAuditRepository.PurgeTerminalAsync"/> threshold the actor @@ -34,6 +34,7 @@ public class SiteCallAuditPurgeTests : TestKit /// <summary>Empty enumerator — the purge path never touches it, but it must be present to arm the scheduler.</summary> private sealed class EmptyEnumerator : ISiteEnumerator { + /// <inheritdoc /> public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteEntry>>(Array.Empty<SiteEntry>()); } @@ -41,6 +42,7 @@ public class SiteCallAuditPurgeTests : TestKit /// <summary>No-op pull client — present only to arm the scheduler.</summary> private sealed class NoOpPullClient : IPullSiteCallsClient { + /// <inheritdoc /> public Task<PullSiteCallsResponse> PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) => Task.FromResult(new PullSiteCallsResponse(Array.Empty<SiteCall>(), MoreAvailable: false)); @@ -52,32 +54,41 @@ public class SiteCallAuditPurgeTests : TestKit /// </summary> private sealed class RecordingRepo : ISiteCallAuditRepository { + /// <summary>The cutoff thresholds passed to each purge call, in call order.</summary> public List<DateTime> PurgeThresholds { get; } = new(); + /// <summary>The row count each purge call reports as deleted.</summary> public int RowsDeletedPerCall { get; set; } + /// <inheritdoc /> public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) { PurgeThresholds.Add(olderThanUtc); return Task.FromResult(RowsDeletedPerCall); } + /// <inheritdoc /> public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => Task.FromResult<SiteCall?>(null); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCall>> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>()); + /// <inheritdoc /> public Task<SiteCallKpiSnapshot> ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0)); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>()); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>()); @@ -88,17 +99,24 @@ public class SiteCallAuditPurgeTests : TestKit { public int PurgeCallCount; + /// <inheritdoc /> public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) { Interlocked.Increment(ref PurgeCallCount); throw new InvalidOperationException("simulated purge failure"); } + /// <inheritdoc /> public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => Task.CompletedTask; + /// <inheritdoc /> public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => Task.FromResult<SiteCall?>(null); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCall>> QueryAsync(SiteCallQueryFilter f, SiteCallPaging p, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>()); + /// <inheritdoc /> public Task<SiteCallKpiSnapshot> ComputeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0)); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>()); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>()); } @@ -115,7 +133,7 @@ public class SiteCallAuditPurgeTests : TestKit } /// <summary> - /// Row-82/row-95 house pattern: every test in this file arms a + /// House pattern: every test in this file arms a /// <c>PurgeIntervalOverride = 100ms</c> ticker. Left running past the test's own /// assertions it keeps firing against the recording repo — and, worse, against a /// scope-per-tick DI container whose lifetime the test no longer controls — for the @@ -132,6 +150,7 @@ public class SiteCallAuditPurgeTests : TestKit // 1. PurgeTick_CallsPurgeTerminal_WithRetentionThreshold // --------------------------------------------------------------------- + /// <summary>Verifies that purge tick, calls purge terminal async, with retention threshold.</summary> [Fact] public void PurgeTick_CallsPurgeTerminalAsync_WithRetentionThreshold() { @@ -165,6 +184,7 @@ public class SiteCallAuditPurgeTests : TestKit // 2. PurgeTick_UsesDefaultRetention_365Days // --------------------------------------------------------------------- + /// <summary>Verifies that purge tick, default retention, uses365 day threshold.</summary> [Fact] public void PurgeTick_DefaultRetention_Uses365DayThreshold() { @@ -193,6 +213,7 @@ public class SiteCallAuditPurgeTests : TestKit // 3. PurgeTick_RepoThrows_ActorStaysAlive_RetriesNextTick (continue-on-error) // --------------------------------------------------------------------- + /// <summary>Verifies that purge tick, purge throws, actor stays alive, retries next tick.</summary> [Fact] public void PurgeTick_PurgeThrows_ActorStaysAlive_RetriesNextTick() { @@ -216,13 +237,14 @@ public class SiteCallAuditPurgeTests : TestKit } // --------------------------------------------------------------------- - // 4. SiteCallAudit-007: purge timer arms even when the reconciliation + // 4. Purge timer arms even when the reconciliation // collaborators are ABSENT (production ctor, no IPullSiteCallsClient / // ISiteEnumerator registered). Proves the decoupling — a host that omits // the reconciliation client still purges, so the central SiteCalls table // cannot grow unbounded. // --------------------------------------------------------------------- + /// <summary>Verifies that purge tick, production ctor, no reconciliation collaborators, still purges.</summary> [Fact] public void PurgeTick_ProductionCtor_NoReconciliationCollaborators_StillPurges() { @@ -232,7 +254,7 @@ public class SiteCallAuditPurgeTests : TestKit // ctor resolves per-tick, but deliberately registers NEITHER // IPullSiteCallsClient NOR ISiteEnumerator. GetService returns null for // both, so the actor's reconciliation tick is gated off — but the purge - // tick must still arm (SiteCallAudit-007). + // tick must still arm. var provider = new ServiceCollection() .AddScoped<ISiteCallAuditRepository>(_ => repo) .BuildServiceProvider(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs index cc054a25..8be1677d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs @@ -12,7 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests; /// <summary> -/// Reconciliation-tick tests for <see cref="SiteCallAuditActor"/> (#22, Piece A). +/// Reconciliation-tick tests for <see cref="SiteCallAuditActor"/> (Piece A). /// These exercise the periodic per-site self-heal pull entirely in-memory — /// fake <see cref="IPullSiteCallsClient"/> + <see cref="ISiteEnumerator"/> + a /// recording <see cref="ISiteCallAuditRepository"/> — so they run in @@ -63,7 +63,10 @@ public class SiteCallAuditReconciliationTests : TestKit private sealed class StaticEnumerator : ISiteEnumerator { private readonly IReadOnlyList<SiteEntry> _sites; + /// <summary>Creates an enumerator that returns the given sites.</summary> + /// <param name="sites">The sites to return.</param> public StaticEnumerator(params SiteEntry[] sites) => _sites = sites; + /// <inheritdoc /> public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) => Task.FromResult(_sites); } @@ -75,22 +78,32 @@ public class SiteCallAuditReconciliationTests : TestKit /// </summary> private sealed class ScriptedPullClient : IPullSiteCallsClient { + /// <summary>Gets every pull invocation recorded so far.</summary> public List<(string SiteId, DateTime SinceUtc, string? AfterId, int BatchSize)> Calls { get; } = new(); private readonly Dictionary<string, Queue<PullSiteCallsResponse>> _scripted = new(); private readonly Dictionary<string, Exception> _throwOnSite = new(); + /// <summary>Queues the responses to return for the given site.</summary> + /// <param name="siteId">The site the responses are for.</param> + /// <param name="responses">The responses to return, in order.</param> + /// <returns>This client, for chaining.</returns> public ScriptedPullClient Script(string siteId, params PullSiteCallsResponse[] responses) { _scripted[siteId] = new Queue<PullSiteCallsResponse>(responses); return this; } + /// <summary>Makes every pull for the given site throw.</summary> + /// <param name="siteId">The site whose pulls should fail.</param> + /// <param name="ex">The exception to throw.</param> + /// <returns>This client, for chaining.</returns> public ScriptedPullClient ThrowFor(string siteId, Exception ex) { _throwOnSite[siteId] = ex; return this; } + /// <inheritdoc /> public Task<PullSiteCallsResponse> PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) { @@ -109,7 +122,7 @@ public class SiteCallAuditReconciliationTests : TestKit } /// <summary> - /// Keyset-honouring in-memory pull client (Task 16). Holds one static set of + /// Keyset-honouring in-memory pull client. Holds one static set of /// rows and answers each pull as a real site would: returns the next /// <c>batchSize</c> rows strictly greater than the composite /// <c>(sinceUtc, afterId)</c> cursor under the deterministic @@ -122,14 +135,18 @@ public class SiteCallAuditReconciliationTests : TestKit private sealed class KeysetHonoringPullClient : IPullSiteCallsClient { private readonly IReadOnlyList<SiteCall> _rows; + /// <summary>Gets the number of pulls made so far.</summary> public int CallCount { get; private set; } + /// <summary>Creates a client holding the given rows in keyset order.</summary> + /// <param name="rows">The rows the simulated site holds.</param> public KeysetHonoringPullClient(IEnumerable<SiteCall> rows) => _rows = rows .OrderBy(r => r.UpdatedAtUtc) .ThenBy(r => r.TrackedOperationId.ToString(), StringComparer.Ordinal) .ToList(); + /// <inheritdoc /> public Task<PullSiteCallsResponse> PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) { @@ -148,7 +165,7 @@ public class SiteCallAuditReconciliationTests : TestKit } /// <summary> - /// Legacy pull client (Task 16) that IGNORES <c>afterId</c> and always + /// Legacy pull client that IGNORES <c>afterId</c> and always /// returns the same saturated page (<c>MoreAvailable=true</c>) — models a /// pre-keyset site the central actor cannot drain past. The composite cursor /// cannot advance, so the actor must latch and publish @@ -157,10 +174,14 @@ public class SiteCallAuditReconciliationTests : TestKit private sealed class LegacyIgnoresAfterIdPullClient : IPullSiteCallsClient { private readonly IReadOnlyList<SiteCall> _rows; + /// <summary>Gets the number of pulls made so far.</summary> public int CallCount { get; private set; } + /// <summary>Creates a client that always returns the given rows.</summary> + /// <param name="rows">The rows returned on every pull.</param> public LegacyIgnoresAfterIdPullClient(IReadOnlyList<SiteCall> rows) => _rows = rows; + /// <inheritdoc /> public Task<PullSiteCallsResponse> PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) { @@ -172,7 +193,7 @@ public class SiteCallAuditReconciliationTests : TestKit /// <summary> /// Pull client that ALWAYS returns the same saturated response /// (<c>MoreAvailable=true</c>) regardless of the <c>since</c> cursor — - /// simulates the SiteCallAudit-009 single-timestamp no-progress pin: a backlog + /// simulates the single-timestamp no-progress pin: a backlog /// larger than the batch size all sharing one exact <c>UpdatedAtUtc</c>, so /// the inclusive max-timestamp cursor never advances. Records every call so /// the test can assert the within-tick drain is BOUNDED (the actor must not @@ -181,10 +202,14 @@ public class SiteCallAuditReconciliationTests : TestKit private sealed class SaturatedPinPullClient : IPullSiteCallsClient { private readonly IReadOnlyList<SiteCall> _rows; + /// <summary>Gets the number of pulls made so far.</summary> public int CallCount { get; private set; } + /// <summary>Creates a client that always returns the given rows.</summary> + /// <param name="rows">The rows returned on every pull.</param> public SaturatedPinPullClient(IReadOnlyList<SiteCall> rows) => _rows = rows; + /// <inheritdoc /> public Task<PullSiteCallsResponse> PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) { @@ -201,9 +226,12 @@ public class SiteCallAuditReconciliationTests : TestKit /// </summary> private sealed class RecordingRepo : ISiteCallAuditRepository { + /// <summary>Gets the upserted rows keyed by tracked operation id.</summary> public Dictionary<TrackedOperationId, SiteCall> Upserted { get; } = new(); + /// <summary>Gets the number of upsert calls received.</summary> public int UpsertCallCount { get; private set; } + /// <inheritdoc /> public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) { UpsertCallCount++; @@ -211,24 +239,30 @@ public class SiteCallAuditReconciliationTests : TestKit return Task.CompletedTask; } + /// <inheritdoc /> public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => Task.FromResult(Upserted.TryGetValue(id, out var row) ? row : null); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCall>> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>()); + /// <inheritdoc /> public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => Task.FromResult(0); + /// <inheritdoc /> public Task<SiteCallKpiSnapshot> ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0)); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>()); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>()); @@ -252,7 +286,7 @@ public class SiteCallAuditReconciliationTests : TestKit } /// <summary> - /// Row-82/row-95 house pattern: every actor built above arms a fast-repeating + /// House pattern: every actor built above arms a fast-repeating /// reconciliation (and, via <see cref="SiteCallAuditActor"/>, purge) ticker — as fast /// as 100ms in most of the tests below. Left running past the owning test it keeps /// firing against test doubles the test has stopped controlling, stranding later @@ -269,6 +303,7 @@ public class SiteCallAuditReconciliationTests : TestKit // 1. AbsentRow_PulledFromSite_IsUpserted // --------------------------------------------------------------------- + /// <summary>Verifies that a row absent centrally is upserted from the site pull.</summary> [Fact] public void ReconciliationTick_AbsentRow_IsUpsertedFromSitePull() { @@ -305,6 +340,7 @@ public class SiteCallAuditReconciliationTests : TestKit // 2. Cursor_Advances_ToMaxUpdatedAtUtc_NoRePullOfOldRows // --------------------------------------------------------------------- + /// <summary>Verifies that a second tick advances the cursor past the rows already pulled.</summary> [Fact] public void ReconciliationTick_SecondTick_AdvancesCursorPastAlreadyPulledRows() { @@ -348,6 +384,7 @@ public class SiteCallAuditReconciliationTests : TestKit // 3. OneSiteThrows_OtherSitesStillProcessed (failure isolation) // --------------------------------------------------------------------- + /// <summary>Verifies that other sites are still reconciled when the pull for one site throws.</summary> [Fact] public void ReconciliationTick_OneSiteThrows_OtherSitesStillReconciled() { @@ -389,6 +426,7 @@ public class SiteCallAuditReconciliationTests : TestKit // start the reconciliation tick // --------------------------------------------------------------------- + /// <summary>Verifies that no reconciliation tick starts when the collaborators are not registered.</summary> [Fact] public void NoReconciliationCollaboratorsRegistered_DoesNotStartReconciliationTick() { @@ -436,11 +474,12 @@ public class SiteCallAuditReconciliationTests : TestKit } // --------------------------------------------------------------------- - // 5. SiteCallAudit-009: MoreAvailable drives a within-tick continuation + // 5. MoreAvailable drives a within-tick continuation // drain — a multi-page backlog whose timestamps advance is fully drained // in ONE tick rather than one page per tick. // --------------------------------------------------------------------- + /// <summary>Verifies that a multi-page backlog is drained within a single tick when more rows are available.</summary> [Fact] public void ReconciliationTick_MoreAvailable_DrainsMultiplePagesWithinOneTick() { @@ -455,7 +494,7 @@ public class SiteCallAuditReconciliationTests : TestKit var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteA:8083")); // Page 1 saturates (MoreAvailable: true) → the actor continues pulling // within the SAME tick; page 2 is the final page (MoreAvailable: false). - // The continuation pull's `since` must be t2 (page-1 max), proving the + // The continuation pull's `since` must be t2 (page 1 max), proving the // cursor advanced page-to-page inside one tick rather than across ticks. var client = new ScriptedPullClient().Script(siteId, new PullSiteCallsResponse(new[] { p1a, p1b }, MoreAvailable: true), @@ -477,7 +516,7 @@ public class SiteCallAuditReconciliationTests : TestKit AwaitAssert( () => { - // All three rows reconciled — including the page-2 row that only a + // All three rows reconciled — including the page 2 row that only a // within-tick continuation pull could have fetched. Assert.True(repo.Upserted.ContainsKey(p1a.TrackedOperationId)); Assert.True(repo.Upserted.ContainsKey(p1b.TrackedOperationId)); @@ -488,7 +527,7 @@ public class SiteCallAuditReconciliationTests : TestKit interval: TimeSpan.FromMilliseconds(50)); // Exactly two pulls happened (page 1 + the continuation page 2) and the - // second pull's `since` cursor advanced to the page-1 max (t2). + // second pull's `since` cursor advanced to the page 1 max (t2). Assert.True(client.Calls.Count >= 2, $"expected >= 2 pulls within the tick, got {client.Calls.Count}"); Assert.Equal(DateTime.MinValue, client.Calls[0].SinceUtc); Assert.Equal(t2, client.Calls[1].SinceUtc); @@ -500,12 +539,13 @@ public class SiteCallAuditReconciliationTests : TestKit } // --------------------------------------------------------------------- - // 6. SiteCallAudit-009: single-timestamp saturation pin does NOT spin — + // 6. Single-timestamp saturation pin does NOT spin — // a saturated batch whose max UpdatedAtUtc never advances past `since` // breaks the within-tick drain after one page (no unbounded re-pull), // and still upserts the rows it saw. // --------------------------------------------------------------------- + /// <summary>Verifies that a single-timestamp saturation does not spin the drain and makes no progress gracefully.</summary> [Fact] public void ReconciliationTick_SingleTimestampSaturation_DoesNotSpin_MakesNoProgressGracefully() { @@ -551,7 +591,7 @@ public class SiteCallAuditReconciliationTests : TestKit duration: TimeSpan.FromSeconds(3), interval: TimeSpan.FromMilliseconds(50)); - // Critical SiteCallAudit-009 invariant: the within-tick drain BROKE on the + // Critical invariant: the within-tick drain BROKE on the // no-progress pin rather than looping to the 50-page ceiling. With a 2s // tick interval, only the first tick has fired in the window, so the pull // count reflects ONE tick's within-loop behaviour. A correct break yields @@ -568,12 +608,13 @@ public class SiteCallAuditReconciliationTests : TestKit } // --------------------------------------------------------------------- - // 7. Task 16: a keyset-honouring site drains a single-timestamp burst + // 7. A keyset-honouring site drains a single-timestamp burst // LARGER than the batch size in ONE tick — the composite (UpdatedAtUtc, // TrackedOperationId) cursor advances by id even when every row shares // one exact timestamp, so the tail no longer pins forever. // --------------------------------------------------------------------- + /// <summary>Verifies that the keyset cursor drains a single-timestamp burst larger than the batch size.</summary> [Fact] public void ReconciliationTick_KeysetCursor_DrainsSingleTimestampBurstBeyondBatchSize() { @@ -625,11 +666,12 @@ public class SiteCallAuditReconciliationTests : TestKit } // --------------------------------------------------------------------- - // 8. Task 16: a LEGACY site that ignores after_id can never be drained, so + // 8. A LEGACY site that ignores after_id can never be drained, so // the actor latches and publishes SiteCallReconciliationPinnedChanged // instead of silently logging (the dead-end is now health-observable). // --------------------------------------------------------------------- + /// <summary>Verifies that a legacy site ignoring the after-id cursor causes a pinned-changed event to be published.</summary> [Fact] public void ReconciliationTick_LegacySiteIgnoresAfterId_PublishesPinnedChanged() { @@ -665,7 +707,7 @@ public class SiteCallAuditReconciliationTests : TestKit } // --------------------------------------------------------------------- - // 9. WP2.2: the reconciliation drain runs OFF the mailbox, so ingest, + // 9. The reconciliation drain runs OFF the mailbox, so ingest, // query and KPI messages are answered while a long post-outage // catch-up is still pulling. // --------------------------------------------------------------------- @@ -684,8 +726,10 @@ public class SiteCallAuditReconciliationTests : TestKit /// <summary>Completes once the drain has actually started pulling.</summary> public Task Entered => _entered.Task; + /// <summary>Releases the blocked pull.</summary> public void Release() => _release.TrySetResult(); + /// <inheritdoc /> public async Task<PullSiteCallsResponse> PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) { @@ -695,6 +739,8 @@ public class SiteCallAuditReconciliationTests : TestKit } } + /// <summary>Verifies that an in-flight reconciliation drain does not block an ingest upsert.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReconciliationDrain_InFlight_DoesNotBlockIngestUpsert() { @@ -732,12 +778,14 @@ public class SiteCallAuditReconciliationTests : TestKit { // Unblock the drain FIRST so the actor is free to process the Stop rather // than parked inside the blocked pull, then stop it deterministically - // rather than leaving its 100ms ticker running (row-95). + // rather than leaving its 100ms ticker running. client.Release(); StopTicker(actor); } } + /// <summary>Verifies that reconciliation ticks do not overlap while a drain is in flight.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReconciliationTicks_DoNotOverlap_WhileADrainIsInFlight() { @@ -764,8 +812,7 @@ public class SiteCallAuditReconciliationTests : TestKit finally { // Unblock the drain FIRST so the actor is free to process the Stop, then - // stop it deterministically rather than leaving its 100ms ticker running - // (row-95). + // stop it deterministically rather than leaving its 100ms ticker running. client.Release(); StopTicker(actor); } @@ -780,6 +827,7 @@ public class SiteCallAuditReconciliationTests : TestKit /// upsert arriving on the mailbox. (Production is unaffected: each message and /// each pass resolves its own scope, hence its own context.) /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InjectedRepository_IsNeverCalledConcurrently_ByDrainAndMailbox() { @@ -835,10 +883,14 @@ public class SiteCallAuditReconciliationTests : TestKit new(TaskCreationOptions.RunContinuationsAsynchronously); private int _callCount; + /// <summary>Creates a client that returns the given rows on its first pull only.</summary> + /// <param name="rows">The rows returned by the first pull.</param> public OneBatchThenEmptyPullClient(SiteCall[] rows) => _rows = rows; + /// <summary>Gets a task that completes once the first pull has started.</summary> public Task Entered => _entered.Task; + /// <inheritdoc /> public Task<PullSiteCallsResponse> PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) { @@ -861,24 +913,31 @@ public class SiteCallAuditReconciliationTests : TestKit private int _maxObserved; private int _upsertCount; + /// <summary>Creates a repository that holds each call open for the given time.</summary> + /// <param name="hold">How long each repository call stays in flight.</param> public ConcurrencyDetectingRepo(TimeSpan hold) => _hold = hold; + /// <summary>Gets the peak number of overlapping repository calls.</summary> public int MaxObservedConcurrency => Volatile.Read(ref _maxObserved); + /// <summary>Gets the number of upserts received.</summary> public int UpsertCount => Volatile.Read(ref _upsertCount); + /// <inheritdoc /> public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) { Interlocked.Increment(ref _upsertCount); await TrackAsync().ConfigureAwait(false); } + /// <inheritdoc /> public async Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) { await TrackAsync().ConfigureAwait(false); return null; } + /// <inheritdoc /> public async Task<IReadOnlyList<SiteCall>> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) { @@ -886,12 +945,14 @@ public class SiteCallAuditReconciliationTests : TestKit return Array.Empty<SiteCall>(); } + /// <inheritdoc /> public async Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) { await TrackAsync().ConfigureAwait(false); return 0; } + /// <inheritdoc /> public async Task<SiteCallKpiSnapshot> ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) { @@ -899,6 +960,7 @@ public class SiteCallAuditReconciliationTests : TestKit return new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0); } + /// <inheritdoc /> public async Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) { @@ -906,6 +968,7 @@ public class SiteCallAuditReconciliationTests : TestKit return Array.Empty<SiteCallSiteKpiSnapshot>(); } + /// <inheritdoc /> public async Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) { @@ -948,12 +1011,16 @@ public class SiteCallAuditReconciliationTests : TestKit new(TaskCreationOptions.RunContinuationsAsynchronously); private int _callCount; + /// <summary>Gets a task that completes once the first pull has started.</summary> public Task Entered => _entered.Task; + /// <summary>Gets the number of pulls started so far.</summary> public int CallCount => Volatile.Read(ref _callCount); + /// <summary>Releases the blocked pull.</summary> public void Release() => _release.TrySetResult(); + /// <inheritdoc /> public async Task<PullSiteCallsResponse> PullAsync( string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallRelayTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallRelayTests.cs index a5fe773f..78f9b228 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallRelayTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallRelayTests.cs @@ -12,7 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Communication; namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests; /// <summary> -/// Task 5 (#22 Retry/Discard relay): tests for <see cref="SiteCallAuditActor"/> +/// Tests for <see cref="SiteCallAuditActor"/> /// relaying operator Retry/Discard on a parked Site Call down to the owning /// site. The relay routes a <see cref="RetryParkedOperation"/> / /// <see cref="DiscardParkedOperation"/> command via a <see cref="SiteEnvelope"/> @@ -30,27 +30,34 @@ public class SiteCallRelayTests : TestKit /// </summary> private sealed class ThrowingRepository : ISiteCallAuditRepository { + /// <inheritdoc /> public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => throw new InvalidOperationException("relay must not write the SiteCalls row"); + /// <inheritdoc /> public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => throw new InvalidOperationException("relay must not read the SiteCalls row"); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCall>> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => throw new InvalidOperationException("relay must not query the SiteCalls table"); + /// <inheritdoc /> public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => throw new InvalidOperationException("relay must not purge"); + /// <inheritdoc /> public Task<SiteCallKpiSnapshot> ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute KPIs"); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute per-site KPIs"); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute per-node KPIs"); @@ -74,6 +81,7 @@ public class SiteCallRelayTests : TestKit return actor; } + /// <summary>Verifies that retrying a site call routes a RetryParkedOperation command to the owning site.</summary> [Fact] public void RetrySiteCall_RoutesRetryParkedOperation_ToOwningSite() { @@ -101,6 +109,7 @@ public class SiteCallRelayTests : TestKit Assert.Null(response.ErrorMessage); } + /// <summary>Verifies that discarding a site call routes a DiscardParkedOperation command to the owning site.</summary> [Fact] public void DiscardSiteCall_RoutesDiscardParkedOperation_ToOwningSite() { @@ -122,6 +131,7 @@ public class SiteCallRelayTests : TestKit Assert.True(response.Success); } + /// <summary>Verifies that a site reply of not applied is reported as the call not being parked.</summary> [Fact] public void RetrySiteCall_SiteRepliesNotApplied_ReportsNotParked() { @@ -141,6 +151,7 @@ public class SiteCallRelayTests : TestKit Assert.True(response.SiteReachable); } + /// <summary>Verifies that a site error reply is reported as a failed operation.</summary> [Fact] public void RetrySiteCall_SiteRepliesError_ReportsOperationFailed() { @@ -162,6 +173,7 @@ public class SiteCallRelayTests : TestKit Assert.NotNull(response.ErrorMessage); } + /// <summary>Verifies that a retry to a site that never replies is reported as site unreachable.</summary> [Fact] public void RetrySiteCall_SiteNeverReplies_ReportsSiteUnreachable() { @@ -183,6 +195,7 @@ public class SiteCallRelayTests : TestKit Assert.NotNull(response.ErrorMessage); } + /// <summary>Verifies that a discard to a site that never replies is reported as site unreachable.</summary> [Fact] public void DiscardSiteCall_SiteNeverReplies_ReportsSiteUnreachable() { @@ -198,6 +211,7 @@ public class SiteCallRelayTests : TestKit Assert.False(response.SiteReachable); } + /// <summary>Verifies that a retry issued before central communication is registered is reported as site unreachable.</summary> [Fact] public void RetrySiteCall_BeforeCentralCommunicationRegistered_ReportsSiteUnreachable() { @@ -217,13 +231,18 @@ public class SiteCallRelayTests : TestKit Assert.False(response.SiteReachable); } - // ── Task 14: operator-identity audit on a successful relay ── + // ── Operator-identity audit on a successful relay ── /// <summary>Captures the central direct-write audit events emitted by the relay.</summary> private sealed class RecordingCentralAuditWriter : ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter { + /// <summary>The central direct-write audit rows captured so far.</summary> public List<AuditRowProjection.AuditRowValues> Events { get; } = new(); + /// <summary>Records the audit event for later assertions.</summary> + /// <param name="evt">The audit event to record.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent evt, CancellationToken ct = default) { lock (Events) @@ -243,29 +262,38 @@ public class SiteCallRelayTests : TestKit private sealed class RowReturningRepository : ISiteCallAuditRepository { private readonly SiteCall _row; + /// <summary>Creates the repository around the fixed row it returns from <c>GetAsync</c>.</summary> + /// <param name="row">The site call row returned for its own tracked operation ID.</param> public RowReturningRepository(SiteCall row) { _row = row; } + /// <inheritdoc /> public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => Task.FromResult<SiteCall?>(_row.TrackedOperationId == id ? _row : null); + /// <inheritdoc /> public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => throw new InvalidOperationException("relay must not write the SiteCalls row"); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCall>> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => throw new InvalidOperationException("relay must not query the SiteCalls table"); + /// <inheritdoc /> public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => throw new InvalidOperationException("relay must not purge"); + /// <inheritdoc /> public Task<SiteCallKpiSnapshot> ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute KPIs"); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute per-site KPIs"); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute per-node KPIs"); @@ -302,6 +330,7 @@ public class SiteCallRelayTests : TestKit return (actor, writer); } + /// <summary>Verifies that a retry that the site applied emits a cached-resolve submitted audit row that records the operator.</summary> [Fact] public void RetrySiteCall_WhenApplied_EmitsCachedResolveSubmitted_WithOperator() { @@ -330,6 +359,7 @@ public class SiteCallRelayTests : TestKit }); } + /// <summary>Verifies that a retry that the site did not apply emits no audit row.</summary> [Fact] public void RetrySiteCall_WhenNotApplied_EmitsNoAuditRow() { @@ -350,6 +380,7 @@ public class SiteCallRelayTests : TestKit lock (writer.Events) { Assert.Empty(writer.Events); } } + /// <summary>Verifies that a discard that the site applied emits a cached-resolve discarded audit row that records the operator.</summary> [Fact] public void DiscardSiteCall_WhenApplied_EmitsCachedResolveDiscarded_WithOperator() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/TestSupport/StubRepositoryProvider.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/TestSupport/StubRepositoryProvider.cs index 83e1269e..3da3b70b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/TestSupport/StubRepositoryProvider.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/TestSupport/StubRepositoryProvider.cs @@ -41,6 +41,7 @@ internal static class StubRepositoryProvider /// False registers the repository unwrapped — for fixtures asserting on call ordering or /// identity where the decorator would get in the way. /// </param> + /// <returns>A service provider whose scopes resolve the configured repository and collaborators.</returns> public static ServiceProvider Build( ISiteCallAuditRepository repository, ISiteEnumerator? siteEnumerator = null, @@ -107,29 +108,38 @@ internal sealed class SerializingSiteCallAuditRepository : ISiteCallAuditReposit private readonly ISiteCallAuditRepository _inner; private readonly SemaphoreSlim _gate = new(1, 1); + /// <summary>Wraps the inner repository so every call is serialized.</summary> + /// <param name="inner">The repository to wrap.</param> public SerializingSiteCallAuditRepository(ISiteCallAuditRepository inner) => _inner = inner; + /// <inheritdoc /> public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => RunAsync(() => _inner.UpsertAsync(siteCall, ct)); + /// <inheritdoc /> public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => RunAsync(() => _inner.GetAsync(id, ct)); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCall>> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => RunAsync(() => _inner.QueryAsync(filter, paging, ct)); + /// <inheritdoc /> public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => RunAsync(() => _inner.PurgeTerminalAsync(olderThanUtc, ct)); + /// <inheritdoc /> public Task<SiteCallKpiSnapshot> ComputeKpisAsync( DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) => RunAsync(() => _inner.ComputeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct)); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync( DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) => RunAsync(() => _inner.ComputePerSiteKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct)); + /// <inheritdoc /> public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync( DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) => RunAsync(() => _inner.ComputePerNodeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogCoverageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogCoverageTests.cs index b2a6e9b1..6fc32b1d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogCoverageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogCoverageTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests; /// <summary> -/// Regression tests for SiteEventLogging-010: previously untested behaviours — +/// Regression tests for Previously untested behaviours — /// the query service error path and the recorder's disposed-state semantics. /// </summary> public class EventLogCoverageTests : IDisposable @@ -13,12 +13,14 @@ public class EventLogCoverageTests : IDisposable private readonly string _dbPath; private readonly TestLocalDb _localDb; + /// <summary>Initializes a new instance of the <c>EventLogCoverageTests</c> class.</summary> public EventLogCoverageTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"test_coverage_{Guid.NewGuid()}.db"); _localDb = TestLocalDb.Create(_dbPath); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _localDb.Dispose(); @@ -46,6 +48,7 @@ public class EventLogCoverageTests : IDisposable PageSize: 500, Timestamp: DateTimeOffset.UtcNow); + /// <summary>Verifies that execute query returns failure response when database unavailable.</summary> [Fact] public void ExecuteQuery_ReturnsFailureResponse_WhenDatabaseUnavailable() { @@ -70,10 +73,12 @@ public class EventLogCoverageTests : IDisposable Assert.Equal("corr-err", response.CorrelationId); } + /// <summary>Verifies that log event async after dispose faults task not reports success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_AfterDispose_FaultsTask_NotReportsSuccess() { - // SiteEventLogging-012: when the logger has been disposed the event cannot + // When the logger has been disposed the event cannot // be persisted. The returned Task must FAULT (not complete successfully) so // an awaiting caller can distinguish a dropped audit event from a written // one. Per the XML doc contract, the Task "faults if the write fails". @@ -86,6 +91,7 @@ public class EventLogCoverageTests : IDisposable () => task.WaitAsync(TimeSpan.FromSeconds(5))); } + /// <summary>Verifies that dispose is idempotent.</summary> [Fact] public void Dispose_IsIdempotent() { @@ -95,10 +101,12 @@ public class EventLogCoverageTests : IDisposable logger.Dispose(); } + /// <summary>Verifies that log event async enqueued then disposed faults task when write cannot complete.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_EnqueuedThenDisposed_FaultsTask_WhenWriteCannotComplete() { - // SiteEventLogging-012, second path: an event enqueued onto the background + // Second path: an event enqueued onto the background // writer just before disposal must NOT be reported as persisted if the // writer's WithConnection returns false (logger disposed mid-drain). We // flood the queue and dispose immediately; any event whose write did not diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogHandlerActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogHandlerActorTests.cs index ea267425..afdd9052 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogHandlerActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogHandlerActorTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests; /// <summary> -/// Regression tests for SiteEventLogging-010: the actor message contract of +/// Regression tests for the actor message contract of /// <see cref="EventLogHandlerActor"/> was previously untested. /// </summary> public class EventLogHandlerActorTests : TestKit @@ -14,11 +14,15 @@ public class EventLogHandlerActorTests : TestKit private sealed class FakeQueryService : IEventLogQueryService { private readonly Func<EventLogQueryRequest, EventLogQueryResponse> _handler; + /// <summary>Gets the most recent query request received.</summary> public EventLogQueryRequest? LastRequest { get; private set; } + /// <summary>Initializes the fake with a handler that produces query responses.</summary> + /// <param name="handler">The function that answers each query request.</param> public FakeQueryService(Func<EventLogQueryRequest, EventLogQueryResponse> handler) => _handler = handler; + /// <inheritdoc /> public EventLogQueryResponse ExecuteQuery(EventLogQueryRequest request) { LastRequest = request; @@ -49,6 +53,7 @@ public class EventLogHandlerActorTests : TestKit ErrorMessage: success ? null : "boom", Timestamp: DateTimeOffset.UtcNow); + /// <summary>Verifies that the actor replies to the sender with the query response.</summary> [Fact] public void Actor_RepliesToSender_WithQueryResponse() { @@ -65,6 +70,7 @@ public class EventLogHandlerActorTests : TestKit Assert.Same(request, fake.LastRequest); } + /// <summary>Verifies that the actor passes a query-service error response on to the sender.</summary> [Fact] public void Actor_PropagatesQueryServiceErrorResponse_ToSender() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs index 920ed4ad..1dc24236 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs @@ -13,7 +13,7 @@ public class EventLogPurgeServiceTests : IDisposable private readonly SiteEventLogOptions _options; /// <summary> - /// SiteEventLogging-023: stop flag for the concurrent stress test. Declared as + /// Stop flag for the concurrent stress test. Declared as /// a <c>volatile</c> field so every writer thread observes the main thread's /// `_stop = true` write without depending on JIT/runtime quirks. A plain /// <c>bool</c> local would be legal-cached in a register inside the tight @@ -21,6 +21,7 @@ public class EventLogPurgeServiceTests : IDisposable /// </summary> private volatile bool _stop; + /// <summary>Initializes a new instance of the <see cref="EventLogPurgeServiceTests"/> class with a temporary LocalDb file.</summary> public EventLogPurgeServiceTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"test_purge_{Guid.NewGuid()}.db"); @@ -37,6 +38,7 @@ public class EventLogPurgeServiceTests : IDisposable _localDb.Db); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _eventLogger.Dispose(); @@ -85,6 +87,7 @@ public class EventLogPurgeServiceTests : IDisposable }); } + /// <summary>Verifies that the retention purge deletes events older than the retention window.</summary> [Fact] public void PurgeByRetention_DeletesOldEvents() { @@ -98,6 +101,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(1, GetEventCount()); } + /// <summary>Verifies that the retention purge keeps recent events.</summary> [Fact] public void PurgeByRetention_KeepsRecentEvents() { @@ -111,10 +115,11 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(3, GetEventCount()); } + /// <summary>Verifies that the retention purge deletes every expired row across multiple batch slices when the backlog exceeds one batch.</summary> [Fact] public void PurgeByRetention_OverBatchSize_DeletesAllExpiredRowsAcrossSlices() { - // WP1.7: the retention purge must slice a large expired backlog into + // The retention purge must slice a large expired backlog into // bounded DELETE batches (1000 rows/iteration, mirroring the storage-cap // purge a few lines below in the same file) rather than one unbounded // DELETE. Seed more than one slice's worth of expired rows plus a @@ -145,6 +150,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(2, GetEventCount()); } + /// <summary>Verifies that the storage-cap purge deletes the oldest events when the database is over the cap.</summary> [Fact] public void PurgeByStorageCap_DeletesOldestWhenOverCap() { @@ -169,6 +175,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(0, GetEventCount()); } + /// <summary>Verifies that the database size query returns a positive value.</summary> [Fact] public void GetDatabaseSizeBytes_ReturnsPositiveValue() { @@ -241,11 +248,11 @@ public class EventLogPurgeServiceTests : IDisposable }); } + /// <summary>Verifies that the storage-cap purge stops once under the cap and does not empty the table.</summary> [Fact] public void PurgeByStorageCap_StopsWhenUnderCap_DoesNotEmptyTable() { - // Regression test for SiteEventLogging-001 / -002: - // a realistic non-zero cap must trim the oldest events to the budget, + // Regression test: a realistic non-zero cap must trim the oldest events to the budget, // not delete the entire table. InsertBulkEvents(3000); @@ -275,10 +282,11 @@ public class EventLogPurgeServiceTests : IDisposable $"Database size {finalSize} must be at or below cap {finalCapBytes} after purge."); } + /// <summary>Verifies that the storage-cap purge removes the oldest events first.</summary> [Fact] public void PurgeByStorageCap_RemovesOldestEventsFirst() { - // Regression test for SiteEventLogging-002: only the oldest events should be + // Regression test: only the oldest events should be // removed when trimming to the cap. // // "Oldest" used to mean "lowest autoincrement id". Since LocalDb Phase 1 the id @@ -320,10 +328,12 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(1L, newestPresent); } + /// <summary>Verifies that starting the service does not block on the initial purge.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_DoesNotBlock_OnTheInitialPurge() { - // SiteEventLogging-014 (re-triaged): on .NET 8+ BackgroundService runs + // On .NET 8+ BackgroundService runs // ExecuteAsync on a thread-pool thread — the synchronous prelude (the // initial RunPurge()) does NOT execute on the host startup thread, so // StartAsync returns promptly and host startup / the /health/ready gate is @@ -356,10 +366,12 @@ public class EventLogPurgeServiceTests : IDisposable await purge.StopAsync(CancellationToken.None); } + /// <summary>Verifies that ExecuteAsync yields before running the initial purge synchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteAsync_YieldsBeforeRunningTheInitialPurgeSynchronously() { - // arch-review 2026-08-19 P4-5 — timing-independent companion to + // Timing-independent companion to // StartAsync_DoesNotBlock_OnTheInitialPurge above. // // That test times BackgroundService.StartAsync as a whole and passes even on the @@ -368,7 +380,7 @@ public class EventLogPurgeServiceTests : IDisposable // never blocks regardless of whether ExecuteAsync yields first. Verified empirically // against this repo's actual net10.0 target (a BackgroundService whose ExecuteAsync // does Thread.Sleep(1500) before its first await still returns from StartAsync in - // ~2 ms). So P4-5's literal "StartAsync blocks" symptom does not reproduce here, and + // ~2 ms). So the literal "StartAsync blocks" symptom does not reproduce here, and // a wall-clock test cannot prove the fix. // // What CAN be proven without any timing is the ExecuteAsync-level contract itself: @@ -432,12 +444,13 @@ public class EventLogPurgeServiceTests : IDisposable } } + /// <summary>Verifies that a throwing active-node check is logged as inactive rather than active.</summary> [Fact] public void RunPurge_ActiveNodeCheckThrows_TickLogsAsInactive_NotActive() { - // arch-review 2026-08-19 P4-5: a throwing isActive predicate must fail CLOSED + // A throwing isActive predicate must fail CLOSED // (logged as inactive) rather than silently claiming "active". The predicate no - // longer gates any purge work (P4-4 — every node purges its own file every tick + // longer gates any purge work (every node purges its own file every tick // regardless), so a wrong value here cannot skip work; but an operator correlating // this line against the real active-node state must not be misled into thinking the // check actually succeeded and reported active. @@ -459,10 +472,12 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Contains("active=False", tickLine.Message, StringComparison.Ordinal); } + /// <summary>Verifies that concurrent writes during a storage-cap purge do not corrupt the connection.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PurgeByStorageCap_ConcurrentWritesDoNotCorruptConnection() { - // Regression test for SiteEventLogging-003: purge running on a background + // Regression test: purge running on a background // thread while events are recorded on other threads must not throw // "DataReader already open" / "connection busy" from a shared connection. InsertBulkEvents(2000); @@ -477,7 +492,7 @@ public class EventLogPurgeServiceTests : IDisposable }; var exceptions = new System.Collections.Concurrent.ConcurrentBag<Exception>(); - // SiteEventLogging-023: must be volatile so the writer threads observe the + // Must be volatile so the writer threads observe the // main thread's `stop = true` flip after the purge task completes. Without // it, a release-mode JIT is permitted to cache the `stop = false` read in // a register inside the tight `while (!stop)` loop and never see the flip, @@ -514,15 +529,16 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Empty(exceptions); } - // ── arch-review 2026-08-19 P4-4: purge runs on every node, not just the active one ── - // (supersedes the old SiteEventLogging-019 "active-node-only gate" — DataConnectionActor + // ── Purge runs on every node, not just the active one ── + // (supersedes the old "active-node-only gate" — DataConnectionActor // writes site_events on both site nodes, so a standby that skipped its own purge would // grow its file unbounded on an unreplicated site.) + /// <summary>Verifies that a standby node still runs the purge.</summary> [Fact] public void RunPurge_OnStandbyNode_StillRunsThePurge() { - // P4-4 regression: before the fix, a standby (isActiveNode returning false) early-exited + // Regression: before the fix, a standby (isActiveNode returning false) early-exited // the purge tick entirely, leaving this well-past-retention row in place forever on an // unreplicated standby. The gate is gone — the standby purges its own file exactly like // the active node. @@ -535,6 +551,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(0, GetEventCount()); } + /// <summary>Verifies that an active node runs the retention purge.</summary> [Fact] public void RunPurge_OnActiveNode_RunsTheRetentionPurge() { @@ -549,11 +566,12 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(1, GetEventCount()); } + /// <summary>Verifies that the purge still runs when no active-node check is supplied.</summary> [Fact] public void RunPurge_WithNullCheck_StillRuns() { // When no active-node check is supplied (the default for non-clustered hosts and - // pre-existing tests), the service runs the purge — unaffected by P4-4 since the + // pre-existing tests), the service runs the purge — unaffected by the every-node purge since the // check was never gating anything for this caller either. InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31)); @@ -573,6 +591,7 @@ public class EventLogPurgeServiceTests : IDisposable /// </summary> private const string BacklogNoteFragment = "transient LocalDb oplog backlog"; + /// <summary>Verifies that the retention purge on a replicated node logs the oplog backlog note.</summary> [Fact] public void PurgeByRetention_OnReplicatedNode_LogsTheOplogBacklogNote() { @@ -592,6 +611,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(LogLevel.Information, note.Level); } + /// <summary>Verifies that the retention purge on an unreplicated node does not log the backlog note.</summary> [Fact] public void PurgeByRetention_OnUnreplicatedNode_DoesNotLogTheNote() { @@ -608,6 +628,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.DoesNotContain(logger.Entries, e => e.Message.Contains(BacklogNoteFragment, StringComparison.Ordinal)); } + /// <summary>Verifies that the retention purge does not log the backlog note when no rows were deleted.</summary> [Fact] public void PurgeByRetention_WithNoRowsDeleted_DoesNotLogTheNote() { @@ -624,6 +645,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.DoesNotContain(logger.Entries, e => e.Message.Contains(BacklogNoteFragment, StringComparison.Ordinal)); } + /// <summary>Verifies that the storage-cap purge on a replicated node logs the oplog backlog note.</summary> [Fact] public void PurgeByStorageCap_OnReplicatedNode_LogsTheOplogBacklogNote() { @@ -650,6 +672,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(LogLevel.Information, note.Level); } + /// <summary>Verifies that the backlog note is logged once per tick when both purge phases delete rows.</summary> [Fact] public void RunPurge_WhenBothPhasesDeleteRows_LogsTheBacklogNoteOnceForTheWholeTick() { @@ -691,6 +714,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Contains($"Purged {expiredRows + recentRows} site_events rows", note.Message, StringComparison.Ordinal); } + /// <summary>Verifies that the purge still runs and swallows the error when the replication check throws.</summary> [Fact] public void RunPurge_WhenReplicationCheckThrows_StillPurgesAndSwallows() { @@ -757,6 +781,7 @@ public class EventLogPurgeServiceTests : IDisposable PurgeCatchUpInterval = TimeSpan.FromSeconds(1), }; + /// <summary>Verifies that the retention purge stops at the per-tick row ceiling.</summary> [Fact] public void PurgeByRetention_StopsAtThePerTickCeiling() { @@ -773,6 +798,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(1500, GetEventCount()); } + /// <summary>Verifies that the retention purge with a ceiling converges across successive ticks.</summary> [Fact] public void PurgeByRetention_WithCeiling_ConvergesAcrossSuccessiveTicks() { @@ -792,6 +818,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(1, GetEventCount()); } + /// <summary>Verifies that an expired set that lands exactly on the ceiling is not counted as a ceiling hit.</summary> [Fact] public void PurgeByRetention_ExpiredSetLandingExactlyOnTheCeiling_IsNotACeilingHit() { @@ -806,10 +833,11 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(0, GetEventCount()); } + /// <summary>Verifies that a zero MaxPurgedRowsPerTick restores unbounded purges.</summary> [Fact] public void MaxPurgedRowsPerTick_Zero_RestoresUnboundedPurges() { - // The documented escape hatch: 0 means unlimited, i.e. byte-for-byte the pre-row-34 + // The documented escape hatch: 0 means unlimited, i.e. byte-for-byte the pre-ceiling // behaviour, for an operator who would rather take one long burst than a paced one. InsertExpiredEvents(2500); @@ -819,6 +847,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(0, GetEventCount()); } + /// <summary>Verifies that hitting the ceiling logs the ceiling line and increments the counter.</summary> [Fact] public void RunPurge_HittingTheCeiling_LogsTheCeilingLineAndCountsIt() { @@ -838,6 +867,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(1, probe.Total); } + /// <summary>Verifies that repeated ceiling hits rate-limit the log line but not the counter.</summary> [Fact] public void RunPurge_RepeatedCeilingHits_RateLimitTheLogLineButNotTheCounter() { @@ -855,6 +885,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.Equal(2, probe.Total); } + /// <summary>Verifies that the storage-cap purge shares the per-tick budget with the retention purge.</summary> [Fact] public void PurgeByStorageCap_SharesTheTickBudgetWithRetention() { @@ -875,6 +906,7 @@ public class EventLogPurgeServiceTests : IDisposable Assert.DoesNotContain(logger.Entries, e => e.Message.Contains("exceeds cap", StringComparison.Ordinal)); } + /// <summary>Verifies that the storage-cap purge with a ceiling stops at the budget and asks for a catch-up tick.</summary> [Fact] public void PurgeByStorageCap_WithCeiling_StopsAtTheBudgetAndAsksForCatchUp() { @@ -904,6 +936,8 @@ public class EventLogPurgeServiceTests : IDisposable private readonly System.Diagnostics.Metrics.MeterListener _listener = new(); private long _total; + /// <summary>Initializes a new instance of the <see cref="CounterProbe"/> class listening to the named instrument.</summary> + /// <param name="instrumentName">The name of the counter instrument to listen to.</param> public CounterProbe(string instrumentName) { _listener.InstrumentPublished = (instrument, listener) => @@ -919,6 +953,7 @@ public class EventLogPurgeServiceTests : IDisposable /// <summary>Measurements recorded since construction.</summary> public long Total => Interlocked.Read(ref _total); + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() => _listener.Dispose(); } @@ -929,12 +964,27 @@ public class EventLogPurgeServiceTests : IDisposable /// </summary> private sealed class CapturingLogger : ILogger<EventLogPurgeService> { + /// <summary>Gets the captured log entries in the order they were written.</summary> public List<(LogLevel Level, string Message)> Entries { get; } = []; + /// <summary>Starts no scope; the fake does not track scopes.</summary> + /// <typeparam name="TState">The type of the scope state.</typeparam> + /// <param name="state">The scope state, ignored.</param> + /// <returns>No scope token; the fake does not track scopes.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level being queried.</param> + /// <returns>Always <see langword="true"/>.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The type of the log state.</typeparam> + /// <param name="logLevel">The severity of the entry.</param> + /// <param name="eventId">The event id of the entry.</param> + /// <param name="state">The state object being logged.</param> + /// <param name="exception">The exception associated with the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message text.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogQueryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogQueryServiceTests.cs index a7b55f96..b4ffadbf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogQueryServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogQueryServiceTests.cs @@ -11,6 +11,7 @@ public class EventLogQueryServiceTests : IDisposable private readonly TestLocalDb _localDb; private readonly string _dbPath; + /// <summary>Initializes the test class with a temporary LocalDb-backed event logger and query service.</summary> public EventLogQueryServiceTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"test_query_{Guid.NewGuid()}.db"); @@ -27,6 +28,7 @@ public class EventLogQueryServiceTests : IDisposable NullLogger<EventLogQueryService>.Instance); } + /// <summary>Releases the resources used by this instance.</summary> public void Dispose() { _eventLogger.Dispose(); @@ -66,6 +68,8 @@ public class EventLogQueryServiceTests : IDisposable PageSize: pageSize, Timestamp: DateTimeOffset.UtcNow); + /// <summary>Verifies that a query with no filters returns all events.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_ReturnsAllEvents_WhenNoFilters() { @@ -78,6 +82,8 @@ public class EventLogQueryServiceTests : IDisposable Assert.False(response.HasMore); } + /// <summary>Verifies that a query filters events by event type.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_FiltersByEventType() { @@ -90,6 +96,8 @@ public class EventLogQueryServiceTests : IDisposable Assert.All(response.Entries, e => Assert.Equal("script", e.EventType)); } + /// <summary>Verifies that a query filters events by severity.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_FiltersBySeverity() { @@ -102,6 +110,8 @@ public class EventLogQueryServiceTests : IDisposable Assert.All(response.Entries, e => Assert.Equal("Error", e.Severity)); } + /// <summary>Verifies that a query filters events by instance id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_FiltersByInstanceId() { @@ -114,6 +124,8 @@ public class EventLogQueryServiceTests : IDisposable Assert.All(response.Entries, e => Assert.Equal("inst-1", e.InstanceId)); } + /// <summary>Verifies that a keyword search matches the event message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_KeywordSearch_MatchesMessage() { @@ -126,6 +138,8 @@ public class EventLogQueryServiceTests : IDisposable Assert.Contains("timeout", response.Entries[0].Message, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that a keyword search matches the event source.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_KeywordSearch_MatchesSource() { @@ -138,6 +152,8 @@ public class EventLogQueryServiceTests : IDisposable Assert.Contains("AlarmActor", response.Entries[0].Source); } + /// <summary>Verifies that a query combines multiple filters.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_CombinesMultipleFilters() { @@ -153,6 +169,8 @@ public class EventLogQueryServiceTests : IDisposable Assert.Equal("Script timeout", response.Entries[0].Message); } + /// <summary>Verifies that pagination returns the requested page size.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_Pagination_ReturnsCorrectPageSize() { @@ -166,6 +184,8 @@ public class EventLogQueryServiceTests : IDisposable Assert.NotNull(response.ContinuationToken); } + /// <summary>Verifies that the continuation token retrieves the next page correctly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_Pagination_ContinuationTokenWorksCorrectly() { @@ -198,6 +218,8 @@ public class EventLogQueryServiceTests : IDisposable Assert.Equal(5, allIds.Distinct().Count()); } + /// <summary>Verifies that a query filters events by time range.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_FiltersByTimeRange() { @@ -217,10 +239,12 @@ public class EventLogQueryServiceTests : IDisposable Assert.Equal(2, response.Entries.Count); } + /// <summary>Verifies that a time range filter normalises non-UTC offsets correctly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_FiltersByTimeRange_HandlesNonUtcOffset() { - // SiteEventLogging-024 (re-opens -016): the store holds UTC ISO-8601 "o" + // The store holds UTC ISO-8601 "o" // strings (always +00:00) and compares them lexicographically. If the // From/To bounds are stringified verbatim without UTC normalisation, a // non-UTC DateTimeOffset from a central client sorts wrongly against the @@ -253,6 +277,8 @@ public class EventLogQueryServiceTests : IDisposable Assert.Equal(new[] { "MIDDLE" }, response.Entries.Select(e => e.Source).ToArray()); } + /// <summary>Verifies that a query with no matches returns an empty result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_EmptyResult_WhenNoMatches() { @@ -266,6 +292,7 @@ public class EventLogQueryServiceTests : IDisposable Assert.Null(response.ContinuationToken); } + /// <summary>Verifies that a query returns the correlation id of each event.</summary> [Fact] public void Query_ReturnsCorrelationId() { @@ -276,6 +303,8 @@ public class EventLogQueryServiceTests : IDisposable Assert.Equal("site-1", response.SiteId); } + /// <summary>Verifies that a query returns every field of the event log entry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_ReturnsAllEventLogEntryFields() { @@ -294,10 +323,12 @@ public class EventLogQueryServiceTests : IDisposable Assert.Equal("{\"stack\":\"trace\"}", entry.Details); } + /// <summary>Verifies that a keyword search treats an underscore as a literal rather than a wildcard.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_KeywordSearch_TreatsUnderscoreAsLiteral_NotWildcard() { - // SiteEventLogging-013: a SQL LIKE '_' matches any single character. A + // A SQL LIKE '_' matches any single character. A // keyword search for a literal underscore (common in identifiers such as // "store_and_forward") must NOT match strings that merely have any // character in that position. @@ -311,10 +342,12 @@ public class EventLogQueryServiceTests : IDisposable Assert.Equal("store_and_forward", response.Entries[0].Source); } + /// <summary>Verifies that a keyword search treats a percent sign as a literal rather than a wildcard.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_KeywordSearch_TreatsPercentAsLiteral_NotWildcard() { - // SiteEventLogging-013: a SQL LIKE '%' matches any run of characters. A + // A SQL LIKE '%' matches any run of characters. A // keyword search containing a literal '%' must match only the literal. await _eventLogger.LogEventAsync("script", "Warning", null, "ScriptActor:Run", "CPU at 90% utilisation"); await _eventLogger.LogEventAsync("script", "Warning", null, "ScriptActor:Run", "CPU at 90 then 0 utilisation"); @@ -326,6 +359,8 @@ public class EventLogQueryServiceTests : IDisposable Assert.Contains("90% utilisation", response.Entries[0].Message); } + /// <summary>Verifies that a keyword search still matches a plain substring.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_KeywordSearch_StillMatchesPlainSubstring() { @@ -358,8 +393,10 @@ public class EventLogQueryServiceTests : IDisposable }); } - // --- SiteEventLogging-017: PageSize hard upper bound --- + // --- PageSize hard upper bound --- + /// <summary>Verifies that a requested page size is clamped to the maximum query page size.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_PageSize_IsClampedToMaxQueryPageSize() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogStorageCapScopeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogStorageCapScopeTests.cs index 308be8d3..497de571 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogStorageCapScopeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogStorageCapScopeTests.cs @@ -33,6 +33,8 @@ public class EventLogStorageCapScopeTests : IDisposable private readonly TestLocalDb _localDb; private readonly string _dbPath; + /// <summary>Initializes a new instance of the <c>EventLogStorageCapScopeTests</c> class.</summary> + /// <param name="output">The output.</param> public EventLogStorageCapScopeTests(ITestOutputHelper output) { _output = output; @@ -44,6 +46,7 @@ public class EventLogStorageCapScopeTests : IDisposable _localDb.Db); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { _eventLogger.Dispose(); @@ -128,6 +131,7 @@ public class EventLogStorageCapScopeTests : IDisposable return (long)cmd.ExecuteScalar()!; }); + /// <summary>Verifies that the estimated event-log size excludes other tables in the same database file.</summary> [Fact] public void EstimatedSize_ExcludesOtherTablesInTheSameFile() { @@ -154,6 +158,7 @@ public class EventLogStorageCapScopeTests : IDisposable Assert.Equal(eventsOnly, eventsAfterBallast); } + /// <summary>Verifies that the storage-cap purge does not trim an under-cap event log when other tables in the same file are large.</summary> [Fact] public void PurgeByStorageCap_WithLargeBallastInTheSameFile_DoesNotTrimAnUnderCapEventLog() { @@ -179,6 +184,7 @@ public class EventLogStorageCapScopeTests : IDisposable Assert.Equal(4000, EventCount()); } + /// <summary>Verifies that the storage-cap purge still enforces the cap on events without emptying the table when other tables in the same file are large.</summary> [Fact] public void PurgeByStorageCap_WithLargeBallastInTheSameFile_StillEnforcesTheCapOnEventsAndDoesNotEmptyTheTable() { @@ -217,6 +223,7 @@ public class EventLogStorageCapScopeTests : IDisposable // ── size-model accuracy ── + /// <summary>Gets the workloads value.</summary> public static TheoryData<string, int, int, double> Workloads => new() { // name, rows, details length (0 = no details column), details on every row @@ -225,6 +232,11 @@ public class EventLogStorageCapScopeTests : IDisposable { "wide rows, 2KB details", 3000, 2048, 1.0 }, }; + /// <summary>Verifies that the site-events size estimate tracks the real on-disk footprint across workloads.</summary> + /// <param name="name">The name.</param> + /// <param name="rows">The rows.</param> + /// <param name="detailsLength">The details length.</param> + /// <param name="detailsFraction">The details fraction.</param> [Theory] [MemberData(nameof(Workloads))] public void EstimateSiteEventsSize_TracksTheRealFootprint(string name, int rows, int detailsLength, double detailsFraction) @@ -292,13 +304,16 @@ public class EventLogStorageCapScopeTests : IDisposable long? frozenRowCount) : EventLogPurgeService(eventLogger, options, logger) { + /// <inheritdoc /> internal override EventLogPurgeService.SiteEventsSizeEstimate EstimateSiteEventsSize() => estimate ?? base.EstimateSiteEventsSize(); + /// <inheritdoc /> internal override long GetSiteEventsRowCount() => frozenRowCount ?? base.GetSiteEventsRowCount(); } + /// <summary>Verifies that the storage-cap purge deletes nothing and logs a warning when the row width cannot be sampled.</summary> [Fact] public void PurgeByStorageCap_WhenRowWidthCannotBeSampled_DeletesNothingAndWarns() { @@ -322,6 +337,7 @@ public class EventLogStorageCapScopeTests : IDisposable e.Level == LogLevel.Warning && e.Message.Contains("row width could not be sampled", StringComparison.Ordinal)); } + /// <summary>Verifies that the storage-cap purge stops rather than emptying the log when the table stops shrinking.</summary> [Fact] public void PurgeByStorageCap_WhenTheTableStopsShrinking_StopsRatherThanEmptyingTheLog() { @@ -351,12 +367,27 @@ public class EventLogStorageCapScopeTests : IDisposable private sealed class CapturingLogger : ILogger<EventLogPurgeService> { + /// <summary>Gets the entries value.</summary> public List<LogEntry> Entries { get; } = []; + /// <summary>Returns no scope; the fake does not track scopes.</summary> + /// <typeparam name="TState">The type of the scope state.</typeparam> + /// <param name="state">The scope state (ignored).</param> + /// <returns>Always null.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level to check.</param> + /// <returns>Always true.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The type of the log state.</typeparam> + /// <param name="logLevel">The severity of the entry.</param> + /// <param name="eventId">The event identifier.</param> + /// <param name="state">The state object to format.</param> + /// <param name="exception">The exception associated with the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) => diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SchemaIndexTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SchemaIndexTests.cs index 7f70a4cb..92111d1c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SchemaIndexTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SchemaIndexTests.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests; /// <summary> -/// Regression tests for SiteEventLogging-006: the schema must index the columns the +/// Regression tests: the schema must index the columns the /// query service filters on so common queries do not full-scan a 1 GB database. /// </summary> public class SchemaIndexTests : IDisposable @@ -15,6 +15,7 @@ public class SchemaIndexTests : IDisposable private readonly string _dbPath; private readonly TestLocalDb _localDb; + /// <summary>Initializes a new instance of the <c>SchemaIndexTests</c> class.</summary> public SchemaIndexTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"test_index_{Guid.NewGuid()}.db"); @@ -26,6 +27,7 @@ public class SchemaIndexTests : IDisposable _verifyConnection.Open(); } + /// <summary>Releases the resources used by this instance.</summary> public void Dispose() { _verifyConnection.Dispose(); @@ -34,6 +36,7 @@ public class SchemaIndexTests : IDisposable TestLocalDb.DeleteFiles(_dbPath); } + /// <summary>Verifies that schema: has index on severity.</summary> [Fact] public void Schema_HasIndexOnSeverity() { @@ -47,6 +50,8 @@ public class SchemaIndexTests : IDisposable Assert.Contains("idx_events_severity", indexes); } + /// <summary>Verifies that severity filtered query, when uses index, not full scan.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SeverityFilteredQuery_UsesIndex_NotFullScan() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/ServiceWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/ServiceWiringTests.cs index 9068edfb..554c5766 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/ServiceWiringTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/ServiceWiringTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.LocalDb; namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests; /// <summary> -/// Regression tests for SiteEventLogging-007: the purge and query services must not +/// Regression tests: the purge and query services must not /// downcast an injected <see cref="ISiteEventLogger"/> to the concrete type. They now /// depend on the concrete <see cref="SiteEventLogger"/> directly, and DI must resolve /// the recorder, query service, and purge service to a single shared instance. @@ -17,17 +17,20 @@ public class ServiceWiringTests : IDisposable private readonly string _dbPath; private ServiceProvider? _provider; + /// <summary>Initializes the test class with a service provider backed by a temporary database.</summary> public ServiceWiringTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"test_wiring_{Guid.NewGuid()}.db"); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _provider?.Dispose(); TestLocalDb.DeleteFiles(_dbPath); } + /// <summary>Verifies that registering site event logging resolves all services and shares one recorder instance.</summary> [Fact] public void AddSiteEventLogging_ResolvesAllServices_SharingOneRecorderInstance() { @@ -61,6 +64,7 @@ public class ServiceWiringTests : IDisposable Assert.Contains(hosted, h => h is EventLogPurgeService); } + /// <summary>Verifies that the purge and query services accept the concrete recorder without downcasting.</summary> [Fact] public void PurgeAndQueryServices_AcceptConcreteRecorder_WithoutDowncast() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLogOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLogOptionsValidatorTests.cs index 7cd7db96..ba16ec1a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLogOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLogOptionsValidatorTests.cs @@ -14,6 +14,7 @@ public class SiteEventLogOptionsValidatorTests private static ValidateOptionsResult Validate(SiteEventLogOptions options) => new SiteEventLogOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the default options are valid.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -21,6 +22,7 @@ public class SiteEventLogOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that zero retention days is rejected.</summary> [Fact] public void ZeroRetentionDays_IsRejected() { @@ -30,6 +32,7 @@ public class SiteEventLogOptionsValidatorTests Assert.Contains("RetentionDays", result.FailureMessage); } + /// <summary>Verifies that a zero purge interval is rejected.</summary> [Fact] public void ZeroPurgeInterval_IsRejected() { @@ -39,6 +42,7 @@ public class SiteEventLogOptionsValidatorTests Assert.Contains("PurgeInterval", result.FailureMessage); } + /// <summary>Verifies that a maximum query page size below the default page size is rejected.</summary> [Fact] public void MaxQueryPageSize_BelowQueryPageSize_IsRejected() { @@ -50,6 +54,7 @@ public class SiteEventLogOptionsValidatorTests // ── Register row 34: the per-tick purge ceiling ── + /// <summary>Verifies that a negative maximum of purged rows per tick is rejected.</summary> [Fact] public void NegativeMaxPurgedRowsPerTick_IsRejected() { @@ -61,6 +66,7 @@ public class SiteEventLogOptionsValidatorTests Assert.Contains("MaxPurgedRowsPerTick", result.FailureMessage); } + /// <summary>Verifies that a zero maximum of purged rows per tick (unlimited) is accepted.</summary> [Fact] public void ZeroMaxPurgedRowsPerTick_IsAccepted() { @@ -70,6 +76,7 @@ public class SiteEventLogOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero purge catch-up interval is rejected.</summary> [Fact] public void ZeroPurgeCatchUpInterval_IsRejected() { @@ -80,6 +87,7 @@ public class SiteEventLogOptionsValidatorTests Assert.Contains("PurgeCatchUpInterval", result.FailureMessage); } + /// <summary>Verifies that a purge catch-up interval longer than the purge interval is rejected.</summary> [Fact] public void PurgeCatchUpInterval_LongerThanPurgeInterval_IsRejected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLogSchemaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLogSchemaTests.cs index daed0b52..18e54d12 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLogSchemaTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLogSchemaTests.cs @@ -3,7 +3,7 @@ using Microsoft.Data.Sqlite; namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests; /// <summary> -/// LocalDb Phase 1 (Task 2) — the <c>site_events</c> DDL is extracted out of +/// The <c>site_events</c> DDL is extracted out of /// <see cref="SiteEventLogger"/> so the Host's <c>AddZbLocalDb</c> onReady callback can /// create it in the consolidated site database. /// <para> @@ -39,6 +39,7 @@ public class SiteEventLogSchemaTests return cols; } + /// <summary>Verifies that applying the schema creates the table with the expected columns.</summary> [Fact] public void Apply_CreatesTableWithExpectedColumns() { @@ -51,6 +52,7 @@ public class SiteEventLogSchemaTests TableInfo(conn, "site_events").Select(c => c.Name)); } + /// <summary>Verifies that the id column is a text primary key rather than an autoincrement integer.</summary> [Fact] public void Apply_IdIsTextPrimaryKey_NotAutoincrement() { @@ -71,6 +73,7 @@ public class SiteEventLogSchemaTests Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar())); } + /// <summary>Verifies that inserts that omit the text id are rejected.</summary> [Fact] public void Apply_RejectsIntegerIdInserts() { @@ -79,7 +82,7 @@ public class SiteEventLogSchemaTests SiteEventLogSchema.Apply(conn); // A TEXT PK with no AUTOINCREMENT means an INSERT omitting id is an error, - // which is what forces the writer to mint one (Task 5a). + // which is what forces the writer to mint one. using var cmd = conn.CreateCommand(); cmd.CommandText = """ INSERT INTO site_events (timestamp, event_type, severity, source, message) @@ -88,6 +91,7 @@ public class SiteEventLogSchemaTests Assert.Throws<SqliteException>(() => cmd.ExecuteNonQuery()); } + /// <summary>Verifies that the schema has no blob columns.</summary> [Fact] public void Apply_HasNoBlobColumns() { @@ -100,6 +104,7 @@ public class SiteEventLogSchemaTests c => c.Type.Contains("BLOB", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that applying the schema creates the expected indexes.</summary> [Fact] public void Apply_CreatesExpectedIndexes() { @@ -120,6 +125,7 @@ public class SiteEventLogSchemaTests indexes); } + /// <summary>Verifies that applying the schema repeatedly is safe.</summary> [Fact] public void Apply_IsIdempotent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerAsyncTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerAsyncTests.cs index e469a4ec..a6fb6da6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerAsyncTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerAsyncTests.cs @@ -6,8 +6,8 @@ using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests; /// <summary> -/// Regression tests for SiteEventLogging-005 (synchronous I/O on caller thread) -/// and SiteEventLogging-008 (write failures silently swallowed). +/// Regression tests for non-blocking logging (synchronous I/O on the caller thread) +/// and surfaced write failures (failures silently swallowed). /// </summary> public class SiteEventLoggerAsyncTests : IDisposable { @@ -15,6 +15,7 @@ public class SiteEventLoggerAsyncTests : IDisposable private readonly string _dbPath; private readonly TestLocalDb _localDb; + /// <summary>Initializes the test class with a temporary LocalDb-backed event logger.</summary> public SiteEventLoggerAsyncTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"test_async_{Guid.NewGuid()}.db"); @@ -23,6 +24,7 @@ public class SiteEventLoggerAsyncTests : IDisposable _logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, _localDb.Db); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _logger.Dispose(); @@ -30,10 +32,12 @@ public class SiteEventLoggerAsyncTests : IDisposable TestLocalDb.DeleteFiles(_dbPath); } + /// <summary>Verifies that logging an event does not block the caller when the write is slow.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_DoesNotBlockCaller_WhenWriteIsSlow() { - // SiteEventLogging-005: LogEventAsync must not perform the SQLite write + // LogEventAsync must not perform the SQLite write // inline on the caller's thread. We hold the recorder busy on a long // database operation from one thread, then time how long it takes the // caller of LogEventAsync to get control back. If recording is offloaded @@ -71,6 +75,8 @@ public class SiteEventLoggerAsyncTests : IDisposable await recordTask.WaitAsync(TimeSpan.FromSeconds(10)); } + /// <summary>Verifies that the logging task completes only after the event is persisted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_TaskCompletes_AfterEventIsPersisted() { @@ -86,10 +92,12 @@ public class SiteEventLoggerAsyncTests : IDisposable Assert.Equal(1, count); } + /// <summary>Verifies that a write error faults the logging task and increments the failure count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_FaultsTask_AndCountsFailure_OnWriteError() { - // SiteEventLogging-008: a write failure must not be silently swallowed. + // A write failure must not be silently swallowed. // The returned Task must fault and the failure counter must increment so // Health Monitoring can surface a logging outage. using var failingConnection = new SqliteConnection("Data Source=:memory:"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerBatchingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerBatchingTests.cs index 150749c3..fade25a7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerBatchingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerBatchingTests.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests; /// <summary> -/// WP1.7 (arch-review remediation, 2026-08-14): <see cref="SiteEventLogger"/>'s +/// The <see cref="SiteEventLogger"/>'s /// background writer loop must drain its bounded channel into batched transactions /// (up to <see cref="SiteEventLogger.WriteBatchSize"/> events per commit) instead of /// committing one transaction per event — mirroring the reference shape in @@ -17,6 +17,7 @@ public class SiteEventLoggerBatchingTests : IDisposable private readonly string _dbPath; private readonly TestLocalDb _localDb; + /// <summary>Initializes a new instance of the <see cref="SiteEventLoggerBatchingTests"/> class with a temporary LocalDb file.</summary> public SiteEventLoggerBatchingTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"test_batch_{Guid.NewGuid()}.db"); @@ -25,6 +26,7 @@ public class SiteEventLoggerBatchingTests : IDisposable _logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, _localDb.Db); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _logger.Dispose(); @@ -32,6 +34,8 @@ public class SiteEventLoggerBatchingTests : IDisposable TestLocalDb.DeleteFiles(_dbPath); } + /// <summary>Verifies that logging a single event commits exactly one batch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_SingleEvent_CommitsOneBatch() { @@ -40,6 +44,8 @@ public class SiteEventLoggerBatchingTests : IDisposable Assert.Equal(1, _logger.CommittedBatchCount); } + /// <summary>Verifies that a burst of queued events commits far fewer batches than events.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ProcessWriteQueue_BurstOfQueuedEvents_CommitsFarFewerBatchesThanEvents() { @@ -102,6 +108,8 @@ public class SiteEventLoggerBatchingTests : IDisposable Assert.Equal(burstCount, count); } + /// <summary>Verifies that a batch failure faults every queued event so none is silently dropped.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ProcessWriteQueue_BatchFailure_FaultsEveryQueuedEvent_NoneSilentlyDropped() { @@ -156,6 +164,8 @@ public class SiteEventLoggerBatchingTests : IDisposable $"Expected FailedWriteCount to account for all {burstCount} events in the rolled-back batch, got {_logger.FailedWriteCount}."); } + /// <summary>Verifies that a poison row in the middle of a batch fails alone while the other rows are written.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ProcessWriteQueue_PoisonRowMidBatch_OtherRowsLand_AndOnlyThePoisonRowIsCountedFailed() { @@ -223,6 +233,8 @@ public class SiteEventLoggerBatchingTests : IDisposable Assert.Equal(burstCount - 1, landed); } + /// <summary>Verifies that disposing the logger persists events that are still queued at shutdown.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Dispose_PersistsEventsStillQueuedAtShutdown() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerTests.cs index c2921663..58eae82a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLoggerTests.cs @@ -11,6 +11,7 @@ public class SiteEventLoggerTests : IDisposable private readonly string _dbPath; private readonly TestLocalDb _localDb; + /// <summary>Initializes the test class with a temporary LocalDb-backed logger.</summary> public SiteEventLoggerTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"test_events_{Guid.NewGuid()}.db"); @@ -23,6 +24,7 @@ public class SiteEventLoggerTests : IDisposable _verifyConnection.Open(); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { _verifyConnection.Dispose(); @@ -31,6 +33,8 @@ public class SiteEventLoggerTests : IDisposable TestLocalDb.DeleteFiles(_dbPath); } + /// <summary>Verifies that logging an event inserts a record.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_InsertsRecord() { @@ -42,6 +46,8 @@ public class SiteEventLoggerTests : IDisposable Assert.Equal(1, count); } + /// <summary>Verifies that logging an event stores all of its fields.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_StoresAllFields() { @@ -59,6 +65,8 @@ public class SiteEventLoggerTests : IDisposable Assert.Equal("{\"value\":95}", reader.GetString(5)); } + /// <summary>Verifies that the nullable fields may be left empty.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_NullableFieldsAllowed() { @@ -72,6 +80,8 @@ public class SiteEventLoggerTests : IDisposable Assert.True(reader.IsDBNull(1)); } + /// <summary>Verifies that the stored timestamp is an ISO 8601 UTC value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_StoresIso8601UtcTimestamp() { @@ -84,6 +94,8 @@ public class SiteEventLoggerTests : IDisposable Assert.Equal(TimeSpan.Zero, parsed.Offset); } + /// <summary>Verifies that an empty event type throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_ThrowsOnEmptyEventType() { @@ -91,6 +103,8 @@ public class SiteEventLoggerTests : IDisposable _logger.LogEventAsync("", "Info", null, "Source", "Message")); } + /// <summary>Verifies that an empty severity throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_ThrowsOnEmptySeverity() { @@ -98,6 +112,8 @@ public class SiteEventLoggerTests : IDisposable _logger.LogEventAsync("script", "", null, "Source", "Message")); } + /// <summary>Verifies that an empty source throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_ThrowsOnEmptySource() { @@ -105,6 +121,8 @@ public class SiteEventLoggerTests : IDisposable _logger.LogEventAsync("script", "Info", null, "", "Message")); } + /// <summary>Verifies that an empty message throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_ThrowsOnEmptyMessage() { @@ -112,6 +130,8 @@ public class SiteEventLoggerTests : IDisposable _logger.LogEventAsync("script", "Info", null, "Source", "")); } + /// <summary>Verifies that multiple events each get a distinct application-minted id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogEventAsync_MultipleEvents_GetDistinctApplicationMintedIds() { @@ -139,6 +159,8 @@ public class SiteEventLoggerTests : IDisposable $"Event id '{id}' is not a GUID in 'N' format.")); } + /// <summary>Verifies that all event types are accepted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AllEventTypes_Accepted() { @@ -154,8 +176,11 @@ public class SiteEventLoggerTests : IDisposable Assert.Equal(6, count); } - // --- SiteEventLogging-020: severity validation against the closed set --- + // --- Severity validation against the closed set --- + /// <summary>Verifies that an unknown severity throws.</summary> + /// <param name="badSeverity">The bad severity.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("info")] // wrong casing [InlineData("warn")] // abbreviation @@ -170,6 +195,9 @@ public class SiteEventLoggerTests : IDisposable Assert.Contains("Info, Warning, Error", ex.Message); } + /// <summary>Verifies that every documented severity is accepted.</summary> + /// <param name="severity">The severity.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("Info")] [InlineData("Warning")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventsSchemaCalibrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventsSchemaCalibrationTests.cs index 2ac76c7c..f926a0b7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventsSchemaCalibrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventsSchemaCalibrationTests.cs @@ -43,6 +43,7 @@ public class SiteEventsSchemaCalibrationTests return values; } + /// <summary>Verifies that the modelled columns exactly match the sampled octet-length sum.</summary> [Fact] public void Columns_MatchTheSampledOctetLengthSum_Exactly() { @@ -59,6 +60,7 @@ public class SiteEventsSchemaCalibrationTests $"{RecalibrationNotice} Actual columns: {string.Join(", ", columns)}"); } + /// <summary>Verifies that the table has exactly the four indexes the model charges for, plus the primary-key autoindex.</summary> [Fact] public void Indexes_AreExactlyTheFourTheModelCharges_PlusThePkAutoindex() { @@ -82,6 +84,9 @@ public class SiteEventsSchemaCalibrationTests $"{RecalibrationNotice} Actual indexes: {string.Join(", ", indexes)}"); } + /// <summary>Verifies that each index covers exactly the column the sampler sums.</summary> + /// <param name="indexName">The index name.</param> + /// <param name="expectedColumn">The column the index is expected to cover.</param> [Theory] [InlineData("idx_events_timestamp", "timestamp")] [InlineData("idx_events_type", "event_type")] diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SqliteCapabilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SqliteCapabilityTests.cs index c46dc380..a44ce489 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SqliteCapabilityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SqliteCapabilityTests.cs @@ -23,6 +23,7 @@ public class SqliteCapabilityTests return connection; } + /// <summary>Verifies that the bundled SQLite build has no dbstat table, so the per-table page count has to be modelled.</summary> [Fact] public void DbStat_IsNotAvailable_SoThePerTablePageCountMustBeModelled() { @@ -64,6 +65,7 @@ public class SqliteCapabilityTests Assert.Contains("dbstat", ex.Message, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that octet_length is available, so row width is sampled in bytes rather than characters.</summary> [Fact] public void OctetLength_IsAvailable_SoRowWidthIsSampledInBytesNotCharacters() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/TestLocalDb.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/TestLocalDb.cs index a5df1b4a..320e9625 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/TestLocalDb.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/TestLocalDb.cs @@ -38,6 +38,8 @@ public sealed class TestLocalDb : IDisposable public ILocalDb Db { get; } /// <summary>Opens a local database at <paramref name="databasePath"/>.</summary> + /// <param name="databasePath">The filesystem path of the SQLite database file.</param> + /// <returns>A wrapper owning the opened database and its service provider.</returns> public static TestLocalDb Create(string databasePath) { var configuration = new ConfigurationBuilder() @@ -58,6 +60,7 @@ public sealed class TestLocalDb : IDisposable /// Deletes <paramref name="databasePath"/> and its WAL sidecars. Call only after the /// owning <see cref="TestLocalDb"/> is disposed — the master connection anchors the WAL. /// </summary> + /// <param name="databasePath">The filesystem path of the SQLite database file whose files are deleted.</param> public static void DeleteFiles(string databasePath) { foreach (var suffix in new[] { "", "-wal", "-shm" }) @@ -66,5 +69,6 @@ public sealed class TestLocalDb : IDisposable } } + /// <summary>Disposes the underlying service provider.</summary> public void Dispose() => _provider.Dispose(); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmActorTests.cs index 66341fd6..0a73ce81 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmActorTests.cs @@ -14,8 +14,8 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// WP-16: Alarm Actor tests — value match, range violation, rate of change. -/// WP-21: Alarm on-trigger call direction tests. +/// Alarm Actor tests — value match, range violation, rate of change. +/// Alarm on-trigger call direction tests. /// </summary> public class AlarmActorTests : TestKit, IDisposable { @@ -23,6 +23,7 @@ public class AlarmActorTests : TestKit, IDisposable private readonly SiteRuntimeOptions _options; private readonly ScriptCompilationService _compilationService; + /// <summary>Initializes the test with a compilation service, shared script library and default runtime options.</summary> public AlarmActorTests() { _compilationService = new ScriptCompilationService( @@ -32,11 +33,13 @@ public class AlarmActorTests : TestKit, IDisposable _options = new SiteRuntimeOptions(); } + /// <inheritdoc /> void IDisposable.Dispose() { Shutdown(); } + /// <summary>Verifies that a value-match alarm activates when the value matches.</summary> [Fact] public void AlarmActor_ValueMatch_ActivatesOnMatch() { @@ -64,6 +67,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal("HighTemp", msg.AlarmName); } + /// <summary>Verifies that a value-match alarm clears when the value no longer matches.</summary> [Fact] public void AlarmActor_ValueMatch_ClearsOnNonMatch() { @@ -94,6 +98,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmState.Normal, clearMsg.State); } + /// <summary>Verifies that a computed alarm carries the unified condition state.</summary> [Fact] public void AlarmActor_ComputedAlarm_CarriesUnifiedConditionState() { @@ -123,6 +128,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(string.Empty, msg.SourceReference); // no native source reference } + /// <summary>Verifies that a range-violation alarm activates when the value is outside the range.</summary> [Fact] public void AlarmActor_RangeViolation_ActivatesOutsideRange() { @@ -152,6 +158,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmState.Active, msg.State); } + /// <summary>Verifies that a range-violation alarm clears when the value is back in range.</summary> [Fact] public void AlarmActor_RangeViolation_ClearsWhenBackInRange() { @@ -181,6 +188,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmState.Normal, clearMsg.State); } + /// <summary>Verifies that the alarm actor ignores changes to attributes it does not monitor.</summary> [Fact] public void AlarmActor_IgnoresUnmonitoredAttributes() { @@ -205,6 +213,7 @@ public class AlarmActorTests : TestKit, IDisposable instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that an alarm that is already active does not trigger again.</summary> [Fact] public void AlarmActor_DoesNotReTrigger_WhenAlreadyActive() { @@ -233,6 +242,7 @@ public class AlarmActorTests : TestKit, IDisposable instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that the alarm actor starts in the normal state after a restart.</summary> [Fact] public void AlarmActor_StartsNormal_OnRestart() { @@ -257,10 +267,11 @@ public class AlarmActorTests : TestKit, IDisposable instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that no on-clear script runs when the alarm deactivates.</summary> [Fact] public void AlarmActor_NoClearScript_OnDeactivation() { - // WP-16: On clear, NO script is executed. Only on activate. + // On clear, NO script is executed. Only on activate. var alarmConfig = new ResolvedAlarm { CanonicalName = "ClearTest", @@ -320,6 +331,7 @@ public class AlarmActorTests : TestKit, IDisposable value.ToString(System.Globalization.CultureInfo.InvariantCulture), "Good", ts); + /// <summary>Verifies that an either-direction rate-of-change alarm activates on a rapid rise.</summary> [Fact] public void AlarmActor_RateOfChange_Either_ActivatesOnRapidRise() { @@ -339,6 +351,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmState.Active, msg.State); } + /// <summary>Verifies that an either-direction rate-of-change alarm activates on a rapid fall.</summary> [Fact] public void AlarmActor_RateOfChange_Either_ActivatesOnRapidFall() { @@ -356,6 +369,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmState.Active, msg.State); } + /// <summary>Verifies that an either-direction rate-of-change alarm does not activate when the rate is below the threshold.</summary> [Fact] public void AlarmActor_RateOfChange_Either_DoesNotActivateWhenBelowThreshold() { @@ -370,6 +384,7 @@ public class AlarmActorTests : TestKit, IDisposable instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that a rising rate-of-change alarm ignores falling spikes.</summary> [Fact] public void AlarmActor_RateOfChange_Rising_IgnoresFallingSpikes() { @@ -384,6 +399,7 @@ public class AlarmActorTests : TestKit, IDisposable instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that a falling rate-of-change alarm ignores rising spikes.</summary> [Fact] public void AlarmActor_RateOfChange_Falling_IgnoresRisingSpikes() { @@ -397,6 +413,7 @@ public class AlarmActorTests : TestKit, IDisposable instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that a falling rate-of-change alarm activates on a falling rate.</summary> [Fact] public void AlarmActor_RateOfChange_Falling_ActivatesOnFallingRate() { @@ -411,6 +428,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmState.Active, msg.State); } + /// <summary>Verifies that a rate-of-change alarm does not activate on a single sample.</summary> [Fact] public void AlarmActor_RateOfChange_SingleSample_DoesNotActivate() { @@ -424,6 +442,7 @@ public class AlarmActorTests : TestKit, IDisposable instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that rate-of-change samples that roll out of the window are discarded.</summary> [Fact] public void AlarmActor_RateOfChange_WindowRollsOff_OldSamplesDiscarded() { @@ -442,6 +461,7 @@ public class AlarmActorTests : TestKit, IDisposable instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that a rate-of-change alarm clears when the rate drops back.</summary> [Fact] public void AlarmActor_RateOfChange_ClearsWhenRateDropsBack() { @@ -465,6 +485,7 @@ public class AlarmActorTests : TestKit, IDisposable // ── Legacy JSON aliases & not-equals prefix ──────────────────────────── + /// <summary>Verifies that a value-match alarm using the legacy attribute and value keys still fires.</summary> [Fact] public void AlarmActor_ValueMatch_LegacyAttributeAndValueKeys_StillFire() { @@ -490,6 +511,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmState.Active, msg.State); } + /// <summary>Verifies that a value-match alarm with a not-equals prefix fires when the value differs.</summary> [Fact] public void AlarmActor_ValueMatch_NotEqualsPrefix_FiresWhenValueDiffers() { @@ -521,6 +543,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmState.Normal, clear.State); } + /// <summary>Verifies that a range-violation alarm using the legacy low and high keys still fires.</summary> [Fact] public void AlarmActor_RangeViolation_LegacyLowHighKeys_StillFire() { @@ -574,6 +597,7 @@ public class AlarmActorTests : TestKit, IDisposable value.ToString(System.Globalization.CultureInfo.InvariantCulture), "Good", DateTimeOffset.UtcNow); + /// <summary>Verifies that a HiLo alarm enters the High level when the value crosses the Hi setpoint.</summary> [Fact] public void AlarmActor_HiLo_EntersHigh_WhenValueCrossesHi() { @@ -591,6 +615,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmLevel.High, msg.Level); } + /// <summary>Verifies that a HiLo alarm escalates to HighHigh when the value climbs past the HiHi setpoint.</summary> [Fact] public void AlarmActor_HiLo_EscalatesToHighHigh_WhenValueClimbsPastHiHi() { @@ -609,6 +634,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmLevel.HighHigh, second.Level); } + /// <summary>Verifies that a HiLo alarm descalates from HighHigh to High when the value drops.</summary> [Fact] public void AlarmActor_HiLo_DescalatesFromHighHighToHigh_WhenValueDrops() { @@ -626,6 +652,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmLevel.High, msg.Level); } + /// <summary>Verifies that a HiLo alarm clears to Normal when the value returns to the normal band.</summary> [Fact] public void AlarmActor_HiLo_ClearsToNormal_WhenValueReturnsToNormalBand() { @@ -643,6 +670,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmLevel.None, clear.Level); } + /// <summary>Verifies that a HiLo alarm enters Low when the value crosses the Lo setpoint.</summary> [Fact] public void AlarmActor_HiLo_EntersLow_WhenValueCrossesLo() { @@ -656,6 +684,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmLevel.Low, msg.Level); } + /// <summary>Verifies that a HiLo alarm enters LowLow when the value crosses the LoLo setpoint.</summary> [Fact] public void AlarmActor_HiLo_EntersLowLow_WhenValueCrossesLoLo() { @@ -669,6 +698,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmLevel.LowLow, msg.Level); } + /// <summary>Verifies that a per-setpoint priority overrides the alarm-level priority.</summary> [Fact] public void AlarmActor_HiLo_PerSetpointPriority_OverridesAlarmLevelPriority() { @@ -688,6 +718,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(900, hiHi.Priority); } + /// <summary>Verifies that a setpoint without its own priority falls back to the alarm-level priority.</summary> [Fact] public void AlarmActor_HiLo_MissingPerSetpointPriority_FallsBackToAlarmLevel() { @@ -701,6 +732,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(432, msg.Priority); } + /// <summary>Verifies that with only HiHi configured a HiLo alarm has no effect in the low range.</summary> [Fact] public void AlarmActor_HiLo_PartialConfig_OnlyHiHiSet_NoEffectInLowRange() { @@ -720,6 +752,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmLevel.HighHigh, msg.Level); } + /// <summary>Verifies that a value exactly at the HiHi setpoint resolves to HighHigh.</summary> [Fact] public void AlarmActor_HiLo_BoundaryValue_AtHiHi_ResolvesToHighHigh() { @@ -735,6 +768,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmLevel.HighHigh, msg.Level); } + /// <summary>Verifies that a HiLo alarm that stays at the same level emits no redundant state change.</summary> [Fact] public void AlarmActor_HiLo_StaysAtSameLevel_NoRedundantEmission() { @@ -752,6 +786,7 @@ public class AlarmActorTests : TestKit, IDisposable instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400)); } + /// <summary>Verifies that a HiLo alarm with no setpoints configured never fires.</summary> [Fact] public void AlarmActor_HiLo_NoSetpointsConfigured_NeverFires() { @@ -769,6 +804,7 @@ public class AlarmActorTests : TestKit, IDisposable // ── HiLo hysteresis ──────────────────────────────────────────────────── + /// <summary>Verifies that with hysteresis a HiLo alarm stays at HighHigh until the value drops below the deadband.</summary> [Fact] public void AlarmActor_HiLo_Hysteresis_StaysAtHighHigh_UntilDropsBelowDeadband() { @@ -787,6 +823,7 @@ public class AlarmActorTests : TestKit, IDisposable instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } + /// <summary>Verifies that with hysteresis a HiLo alarm drops to High only after the deadband is cleared.</summary> [Fact] public void AlarmActor_HiLo_Hysteresis_DropsToHigh_OnlyAfterDeadbandCleared() { @@ -803,6 +840,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmLevel.High, msg.Level); } + /// <summary>Verifies that the Hi deadband prevents a HiLo alarm from flapping.</summary> [Fact] public void AlarmActor_HiLo_Hysteresis_HiDeadband_PreventsFlapping() { @@ -822,6 +860,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmLevel.None, clear.Level); } + /// <summary>Verifies that hysteresis on the low side behaves symmetrically to the high side.</summary> [Fact] public void AlarmActor_HiLo_Hysteresis_LowSide_Symmetric() { @@ -842,6 +881,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmLevel.None, clear.Level); } + /// <summary>Verifies that a per-band message flows through to the alarm state change.</summary> [Fact] public void AlarmActor_HiLo_PerBandMessage_FlowsToAlarmStateChanged() { @@ -863,6 +903,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(string.Empty, clear.Message); } + /// <summary>Verifies that hysteresis does not delay escalation to a higher level.</summary> [Fact] public void AlarmActor_HiLo_Hysteresis_DoesNotDelayEscalation() { @@ -880,8 +921,9 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmLevel.HighHigh, escalated.Level); } - // ── M1.5: site event log `alarm` category ────────────────────────────── + // ── Site event log `alarm` category ────────────────────────────── + /// <summary>Verifies that raising an alarm emits an alarm site event.</summary> [Fact] public void AlarmActor_Raise_EmitsAlarmSiteEvent() { @@ -918,6 +960,7 @@ public class AlarmActorTests : TestKit, IDisposable }, TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that raising a low-priority alarm emits a warning-severity alarm site event.</summary> [Fact] public void AlarmActor_RaiseLowPriority_EmitsWarningAlarmSiteEvent() { @@ -950,6 +993,7 @@ public class AlarmActorTests : TestKit, IDisposable }, TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that clearing an alarm emits an info-severity alarm site event.</summary> [Fact] public void AlarmActor_Clear_EmitsInfoAlarmSiteEvent() { @@ -986,6 +1030,7 @@ public class AlarmActorTests : TestKit, IDisposable }, TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that a malformed trigger configuration does not crash the alarm actor.</summary> [Fact] public void AlarmActor_MalformedTriggerConfig_DoesNotCrash() { @@ -1027,13 +1072,14 @@ public class AlarmActorTests : TestKit, IDisposable return s; } + /// <summary>Verifies that an expression alarm evaluates off the blocking script pool and activates.</summary> [Fact] public void ExpressionAlarm_EvaluatesOffTheBlockingScriptPool_AndActivates() { - // WP3.1 retarget of the former P1 assertion, whose sense is deliberately INVERTED. + // Retarget of the former assertion, whose sense is deliberately INVERTED. // - // P1 moved the evaluation off the actor's dispatcher and onto the dedicated - // script-execution scheduler, and this test asserted exactly that. WP3.1 (finding #4) + // An earlier change moved the evaluation off the actor's dispatcher and onto the dedicated + // script-execution scheduler, and this test asserted exactly that. Later analysis // proved that destination wrong for alarms in particular: sharing the blocking pool // meant an alarm that should raise in milliseconds queued behind blocked script bodies // for an unbounded time, and its 2 s evaluation timeout did not even start ticking @@ -1063,6 +1109,7 @@ public class AlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmState.Active, change.State); } + /// <summary>Verifies that a fault in the expression evaluation task does not kill the alarm trigger.</summary> [Fact] public void ExpressionEvalTaskFault_DoesNotKillTheAlarmTrigger() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmCascadeParentExecutionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmCascadeParentExecutionTests.cs index c5b59ce7..b67becd7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmCascadeParentExecutionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmCascadeParentExecutionTests.cs @@ -17,7 +17,7 @@ using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// Audit Log #23 (M5.4 T4 — <c>ParentExecutionId</c> tag-cascade, alarm leg): +/// <c>ParentExecutionId</c> tag-cascade, alarm leg: /// an alarm on-trigger script run must chain under the execution whose attribute /// write fired the alarm, and must stay a tree ROOT when the firing value had no /// audited origin. @@ -52,6 +52,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable private readonly TestLocalDb _localDb; private readonly SiteStorageService _storage; + /// <summary>Initializes the test class with a compilation service, shared script library and a temporary LocalDb-backed site storage.</summary> public AlarmCascadeParentExecutionTests() { _compilationService = new ScriptCompilationService( @@ -64,6 +65,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable _storage.InitializeAsync().GetAwaiter().GetResult(); } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -127,6 +129,8 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable // Hop 3 — Alarm Actor → AlarmExecutionActor // ------------------------------------------------------------------------- + /// <summary>Verifies that a script-written value firing an alarm parents the on-trigger run only for observed (not inferred) writes.</summary> + /// <param name="inferred">Whether the writer was inferred rather than observed.</param> [Theory] [InlineData(false)] [InlineData(true)] @@ -143,6 +147,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable Assert.Equal(inferred ? (Guid?)null : writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId); } + /// <summary>Verifies that a value originating from the data connection layer leaves the on-trigger run as a tree root.</summary> [Fact] public void DclOriginatedValue_FiringAlarm_LeavesOnTriggerRunAsATreeRoot() { @@ -159,6 +164,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable Assert.Null(alarm.UnderlyingActor.LastOnTriggerParentExecutionId); } + /// <summary>Verifies that a hi/lo alarm entering a band parents its on-trigger run to the writing execution.</summary> [Fact] public void HiLoAlarm_EnteringABand_ParentsOnTriggerRunToTheWritingExecution() { @@ -181,6 +187,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId); } + /// <summary>Verifies that an expression alarm carries the writer captured with the evaluated snapshot.</summary> [Fact] public void ExpressionAlarm_CarriesTheWriterCapturedWithTheEvaluatedSnapshot() { @@ -212,6 +219,8 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable // Hop 1 — ScriptRuntimeContext.SetAttribute stamps the running execution // ------------------------------------------------------------------------- + /// <summary>Verifies that <c>SetAttribute</c> stamps the running execution id on the write command.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttribute_StampsTheRunningExecutionIdOnTheWriteCommand() { @@ -241,6 +250,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable // Hop 2 — Instance Actor carries the writer onto the published change // ------------------------------------------------------------------------- + /// <summary>Verifies that a static write publishes the writing execution id on the resulting change.</summary> [Fact] public void InstanceActor_StaticWrite_PublishesTheWritingExecutionOnTheChange() { @@ -281,6 +291,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable Assert.Equal(writerExecutionId, published.SourceExecutionId); } + /// <summary>Verifies that a write with no audited origin publishes no execution id.</summary> [Fact] public void InstanceActor_WriteWithNoAuditedOrigin_PublishesNoExecutionId() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmScriptLogTargetTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmScriptLogTargetTests.cs index c1ef7967..07cb8d41 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmScriptLogTargetTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmScriptLogTargetTests.cs @@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// Final review M6: an alarm on-trigger run's <c>ScriptRuntimeContext</c> was built without a +/// Regression: an alarm on-trigger run's <c>ScriptRuntimeContext</c> was built without a /// <c>sourceScript</c>, so the context's <c>_logTarget</c> — which names the script on every /// <c>Log.*</c> line and on the run's trace rows — fell back to the INSTANCE name. The launcher now /// passes the same <c>AlarmActor:{alarmName}</c> source key it already uses for this alarm's site @@ -26,12 +26,27 @@ public class AlarmScriptLogTargetTests : TestKit, IDisposable /// <summary>A minimal recording logger — the alarm path hands its own ILogger down to the script logger.</summary> private sealed class RecordingLogger<T> : ILogger<T> { + /// <summary>Gets the log entries recorded so far.</summary> public List<Entry> Entries { get; } = []; + /// <summary>Returns no scope.</summary> + /// <typeparam name="TState">The scope state type.</typeparam> + /// <param name="state">The scope state.</param> + /// <returns>Always null.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level.</param> + /// <returns>Always true.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The log state type.</typeparam> + /// <param name="level">The log level.</param> + /// <param name="eventId">The event id.</param> + /// <param name="state">The log state.</param> + /// <param name="exception">The associated exception, if any.</param> + /// <param name="formatter">Formats the state and exception into a message.</param> public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { lock (Entries) Entries.Add(new Entry(level, formatter(state, exception))); @@ -42,8 +57,10 @@ public class AlarmScriptLogTargetTests : TestKit, IDisposable new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance), NullLogger<SharedScriptLibrary>.Instance); + /// <inheritdoc /> void IDisposable.Dispose() => Shutdown(); + /// <summary>Verifies that an alarm on-trigger run attributes its log lines to the alarm, not just the instance.</summary> [Fact] public void AlarmOnTriggerRun_AttributesItsLogLinesToTheALARM_NotJustTheInstance() { @@ -87,6 +104,7 @@ public class AlarmScriptLogTargetTests : TestKit, IDisposable }, TimeSpan.FromSeconds(15)); } + /// <summary>Verifies that an alarm run resolves and disposes its scoped integration services.</summary> [Fact] public void SR2_AlarmRunResolvesAndDisposesScopedIntegrationServices() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmScriptWatchdogTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmScriptWatchdogTests.cs index c38de82a..2ee7bfff 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmScriptWatchdogTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmScriptWatchdogTests.cs @@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// arch-review 2026-08-19 P1-3 — alarm on-trigger runs used to bypass the stuck-script +/// Alarm on-trigger runs used to bypass the stuck-script /// watchdog, the script-error health counter, and <see cref="ScriptRunSummaryRecorder"/> /// entirely: <c>ScriptRunLauncher.RunAlarmScriptAsync</c> carried none of the machinery its /// sibling <c>RunScriptAsync</c> (the instance-script path) carries, on the SAME bounded @@ -32,6 +32,7 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable { private readonly SharedScriptLibrary _sharedLibrary; + /// <summary>Initializes the test class with a shared script library and a closed watchdog gate.</summary> public AlarmScriptWatchdogTests() { var compilationService = new ScriptCompilationService( @@ -41,6 +42,7 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable AlarmWatchdogHooks.Gate = new SemaphoreSlim(0); } + /// <inheritdoc /> void IDisposable.Dispose() { // Free any still-wedged worker before tearing down, so no test leaves a blocked thread. @@ -99,6 +101,7 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable MaxScriptCallDepth = 10 }; + /// <summary>Verifies that a wedged alarm script has its worker detached, a replacement started, and the queue drains once it is freed.</summary> [Fact] public void WedgedAlarmScript_DetachesItsWorker_StartsAReplacement_AndDrainsWhenFreed() { @@ -142,6 +145,7 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable Assert.Equal(1, scheduler.MaximumConcurrencyLevel); } + /// <summary>Verifies that an alarm run that timed out at dequeue increments the health collector's script error count.</summary> [Fact] public void AlarmRunTimedOutAtDequeue_IncrementsHealthCollectorScriptErrorCount() { @@ -196,6 +200,8 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable TimeSpan.FromSeconds(20), TimeSpan.FromMilliseconds(25)); } + /// <summary>Verifies that an alarm run completion is recorded by the script run summary recorder.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AlarmRunCompletion_IsRecordedByScriptRunSummaryRecorder() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs index 15ab81f9..e6706ee4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs @@ -21,6 +21,7 @@ public class CertStoreActorTests : TestKit, IDisposable private readonly string _rejectedDir; private readonly OpcUaGlobalOptions _options; + /// <summary>Initializes a new instance of the <see cref="CertStoreActorTests"/> class.</summary> public CertStoreActorTests() { var root = Path.Combine(Path.GetTempPath(), "ScadaBridge-CertStoreTests", Guid.NewGuid().ToString("N")); @@ -33,6 +34,7 @@ public class CertStoreActorTests : TestKit, IDisposable }; } + /// <inheritdoc /> void IDisposable.Dispose() { Shutdown(); @@ -69,6 +71,7 @@ public class CertStoreActorTests : TestKit, IDisposable return (Convert.ToBase64String(der), cert.Thumbprint); } + /// <summary>Verifies the case: write, then List, then Remove, round Trips.</summary> [Fact] public void Write_ThenList_ThenRemove_RoundTrips() { @@ -106,6 +109,7 @@ public class CertStoreActorTests : TestKit, IDisposable emptyAck.Certs!.Should().NotContain(c => c.Thumbprint == thumbprint); } + /// <summary>Verifies the case: export Local Trusted Certs, returns Thumbprint And Der Bytes.</summary> [Fact] public void ExportLocalTrustedCerts_ReturnsThumbprintAndDerBytes() { @@ -127,6 +131,7 @@ public class CertStoreActorTests : TestKit, IDisposable entry.DerBytes.Should().Equal(expectedDer); } + /// <summary>Verifies the case: write, invalid Base64, returns Failure Ack.</summary> [Fact] public void Write_InvalidBase64_ReturnsFailureAck() { @@ -138,6 +143,7 @@ public class CertStoreActorTests : TestKit, IDisposable ack.Error.Should().NotBeNullOrEmpty(); } + /// <summary>Verifies the case: remove, missing Cert, is No Op Success.</summary> [Fact] public void Remove_MissingCert_IsNoOpSuccess() { @@ -148,6 +154,7 @@ public class CertStoreActorTests : TestKit, IDisposable ack.Success.Should().BeTrue("removing an absent cert is an idempotent no-op"); } + /// <summary>Verifies the case: write, path Traversal Thumbprint, rejected Without Touching Filesystem.</summary> [Fact] public void Write_PathTraversalThumbprint_RejectedWithoutTouchingFilesystem() { @@ -167,6 +174,7 @@ public class CertStoreActorTests : TestKit, IDisposable "the actor must not write outside the trusted-store directory"); } + /// <summary>Verifies the case: remove, path Traversal Thumbprint, rejected Without Touching Filesystem.</summary> [Fact] public void Remove_PathTraversalThumbprint_RejectedWithoutTouchingFilesystem() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DclConnectionArbitrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DclConnectionArbitrationTests.cs index 42706887..c8065fa5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DclConnectionArbitrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DclConnectionArbitrationTests.cs @@ -67,6 +67,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable private readonly ConcurrentQueue<ReconfigureConnectionCommand> _reconfigures = new(); private readonly ConcurrentQueue<RemoveConnectionCommand> _removes = new(); + /// <summary>Creates a temporary LocalDb-backed site storage and the script services the actors need.</summary> public DclConnectionArbitrationTests() { _localDb = TestLocalDb.CreateTemp("dcl-arbitration-test"); @@ -78,6 +79,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { // Shut the actors down BEFORE the ILocalDb they write through is disposed. @@ -190,7 +192,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable // ── Rule 1: a definition row outranks an older flattened snapshot ── /// <summary> - /// <b>THE row-89 reproduction, in its real (failover / restart) shape.</b> The store holds a + /// <b>THE reproduction, in its real (failover / restart) shape.</b> The store holds a /// definition row pointing at the new endpoint — put there by a connection-only artifact /// deploy and, on a replicated pair, delivered to this node by LocalDb CDC — while the /// instance's own flattened config still carries the endpoint frozen in at ITS deploy time. @@ -204,6 +206,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable /// that applied it. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DefinitionRow_WinsOverAnOlderFlattenedSnapshot_WhenTheSingletonBoots() { @@ -233,6 +236,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable /// guarding the flip after the fact. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DisagreeingSources_DoNotPingPongTheConnection_AcrossManySweeps() { @@ -265,6 +269,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable /// /// <para>Passes before and after: a deploy-only regression guard.</para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoDefinitionRow_LeavesTheFlattenedConfigAuthoritative_AtBoot() { @@ -287,6 +292,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable /// /// <para>Passes before and after: a deploy-only regression guard for the sweep path.</para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoDefinitionRow_LeavesTheFlattenedConfigAuthoritative_OnTheSweepPath() { @@ -322,6 +328,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable /// manager's create-only guard and change nothing. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DefinitionArrivingAfterBoot_IsAdoptedByTheNextSweepTick() { @@ -352,7 +359,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable // ── Row 97: a changed configuration reaches the LIVE connection ── /// <summary> - /// <b>THE row-97 reproduction on the sender side.</b> A connection the DCL already holds gets + /// <b>THE reproduction on the sender side.</b> A connection the DCL already holds gets /// a new endpoint. What must go out is a <see cref="ReconfigureConnectionCommand"/>, which the /// live connection actor applies in place by swapping its adapter and re-issuing its /// subscriptions. @@ -366,6 +373,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable /// failover, or a remove-then-add. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ChangedConfigurationForALiveConnection_IsSentAsAReconfigure_NotASecondCreate() { @@ -397,6 +405,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable /// a reconfigure — which drops the link and re-subscribes every tag — is safe to send on the /// change edge. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UnchangedConfiguration_SendsNeitherACreateNorAReconfigure() { @@ -423,7 +432,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable // ── Row 90: removal, and not being resurrected by a flattened snapshot ── /// <summary> - /// <b>THE row-90 reproduction.</b> A definition row this incarnation had adopted disappears + /// <b>THE reproduction.</b> A definition row this incarnation had adopted disappears /// from the store — a central delete, reconciled by an artifact deploy on the other node and /// replicated here as a row deletion. The sweep can PROVE that removal (the store owned the /// name a tick ago and does not now), so it stops the live connection actor; and the name is @@ -435,6 +444,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable /// connection actor stayed live until the node restarted. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DefinitionRowDisappearing_StopsTheConnection_AndItIsNotResurrected() { @@ -483,6 +493,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable /// tick. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ArtifactReconcileRemoval_IsNotResurrectedByALaterInstanceStart() { @@ -533,6 +544,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable /// stay bad quality until the referencing instance is redeployed. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReAddedDefinition_ClearsTheTombstone_SoTheNextInstanceStartRecreatesIt() { @@ -581,6 +593,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable /// without the generation guard the actor removes the connection it created a moment ago. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaleSweepSnapshot_RacingAnArtifactApply_DoesNotRemoveJustAppliedConnections() { @@ -603,10 +616,11 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable } /// <summary> - /// The row-101 guard must not blunt row 90's legitimate disappearance detection: the same + /// The stale-snapshot guard must not blunt the legitimate disappearance detection: the same /// empty snapshot stamped with the CURRENT write generation is a real answer (the store /// genuinely holds no rows any more) and still stops the connection. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FreshEmptySweepSnapshot_StillRemovesDisappearedConnections() { @@ -640,6 +654,10 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable /// </summary> private sealed class DclConnectionSpy : ReceiveActor { + /// <summary>Creates the spy that records the connection commands it receives.</summary> + /// <param name="creates">Queue that receives create-connection commands.</param> + /// <param name="reconfigures">Queue that receives reconfigure-connection commands.</param> + /// <param name="removes">Queue that receives remove-connection commands.</param> public DclConnectionSpy( ConcurrentQueue<CreateConnectionCommand> creates, ConcurrentQueue<ReconfigureConnectionCommand> reconfigures, diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorArtifactSecretsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorArtifactSecretsTests.cs index f6a99134..be38caa6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorArtifactSecretsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorArtifactSecretsTests.cs @@ -15,7 +15,7 @@ using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// Task 10, site-secrets artifact deploy: <see cref="DeploymentManagerActor.HandleDeployArtifacts"/> +/// Site-secrets artifact deploy: <see cref="DeploymentManagerActor.HandleDeployArtifacts"/> /// writes shipped secrets to <c>deployed_secrets</c>, runs <see cref="DeployedSecretsMirror.ReconcileAsync"/> /// synchronously, and answers with <see cref="ArtifactDeploymentResponse.SecretsApplied"/> or a failure. /// Follows the shape of the existing artifact-apply tests in <c>DeploymentManagerActorTests</c>. @@ -27,6 +27,7 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Initializes a new instance of <c>DeploymentManagerActorArtifactSecretsTests</c>.</summary> public DeploymentManagerActorArtifactSecretsTests() { _localDb = TestLocalDb.CreateTemp("dm-artifact-secrets-test"); @@ -40,6 +41,7 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { Shutdown(); @@ -120,6 +122,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable UpdatedUtc: new DateTimeOffset(2026, 9, 28, 1, 0, 0, TimeSpan.Zero), Description: "test secret"); + /// <summary>Verifies that secrets null leaves table untouched and secrets applied null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Secrets_Null_LeavesTableUntouched_AndSecretsAppliedNull() { @@ -138,6 +142,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable Assert.Empty(rows); } + /// <summary>Verifies that secrets present full set write sync reconcile secrets applied equals shipped count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Secrets_Present_FullSetWrite_SyncReconcile_SecretsAppliedEqualsShippedCount() { @@ -158,6 +164,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable Assert.True(store.Contains("b")); } + /// <summary>Verifies that secrets empty with mirror secrets applied zero success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Secrets_Empty_WithMirror_SecretsAppliedZero_Success() { @@ -173,6 +181,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable Assert.Equal(0, response.SecretsApplied); } + /// <summary>Verifies that secrets dropped reply names the retired secrets.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Secrets_Dropped_ReplyNamesTheRetiredSecrets() { @@ -202,6 +212,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable await Task.CompletedTask; } + /// <summary>Verifies that secrets mirror fails one success false kek id in message secrets applied excludes failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Secrets_MirrorFailsOne_SuccessFalse_KekIdInMessage_SecretsAppliedExcludesFailure() { @@ -222,6 +234,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable Assert.Equal(1, response.SecretsApplied); // "good" applied+verified; "bad" failed } + /// <summary>Verifies that secrets non empty no mirror registered fails deploy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Secrets_NonEmpty_NoMirrorRegistered_FailsDeploy() { @@ -234,6 +248,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable Assert.Equal("Secret deployment is not enabled on this site node", response.ErrorMessage); } + /// <summary>Verifies that secrets empty no mirror registered treated as zero success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Secrets_Empty_NoMirrorRegistered_TreatedAsZeroSuccess() { @@ -246,6 +262,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable Assert.Equal(0, response.SecretsApplied); } + /// <summary>Verifies that secrets removal failure does not fail deploy and excluded from error message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Secrets_RemovalFailure_DoesNotFailDeploy_AndExcludedFromErrorMessage() { @@ -275,10 +293,11 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable Assert.Null(r2.ErrorMessage); } + /// <summary>Verifies that data connections pushed to dcl only after shipped secrets are mirrored.</summary> [Fact] public void DataConnections_PushedToDcl_OnlyAfterShippedSecretsAreMirrored() { - // M2 (final review): a connection whose config references a secret shipped by THIS + // A connection whose config references a secret shipped by THIS // deploy must find it on its first connect, so the DCL create must not reach the // DCL manager before the secret is in this node's store. var store = new FakeSecretStore(); @@ -310,6 +329,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable Assert.True(secretPresentWhenDclSawCreate); } + /// <summary>Verifies that data connections still stored and pushed when secrets step fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DataConnections_StillStoredAndPushed_WhenSecretsStepFails() { @@ -336,19 +357,36 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable { private readonly Dictionary<string, StoredSecret> _rows = new(StringComparer.Ordinal); + /// <summary>When set, deleting any secret throws.</summary> public bool ThrowOnDelete { get; set; } + /// <summary>Returns whether the store currently holds a live secret with the given name.</summary> + /// <param name="name">The secret name.</param> + /// <returns>True when a live secret with that name exists.</returns> public bool Contains(string name) => _rows.TryGetValue(name, out var row) && !row.IsDeleted; + /// <summary>Returns the stored or configured secret.</summary> + /// <param name="name">The secret name.</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>The secret, or null when absent.</returns> public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(_rows.TryGetValue(name.Value, out var row) ? row : null); + /// <summary>Stores the row, replacing any existing row of the same name.</summary> + /// <param name="row">The secret row.</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task UpsertAsync(StoredSecret row, CancellationToken ct) { _rows[row.Name.Value] = row; return Task.CompletedTask; } + /// <summary>Soft-deletes the named secret.</summary> + /// <param name="name">The secret name.</param> + /// <param name="actor">The acting user.</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>True when a live secret was deleted; otherwise false.</returns> public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) { if (ThrowOnDelete) @@ -372,12 +410,23 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable return Task.FromResult(true); } + /// <summary>Returns an empty metadata list.</summary> + /// <param name="includeDeleted">Whether deleted secrets are included.</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>An empty list.</returns> public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) => Task.FromResult<IReadOnlyList<SecretMetadata>>([]); + /// <summary>Returns an empty manifest.</summary> + /// <param name="ct">The cancellation token.</param> + /// <returns>An empty manifest.</returns> public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]); + /// <summary>Applies a replicated row when it is newer than the stored one.</summary> + /// <param name="row">The replicated row.</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) { if (_rows.TryGetValue(row.Name.Value, out var existing) @@ -390,6 +439,11 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable return Task.CompletedTask; } + /// <summary>Reports that no rewrap was applied.</summary> + /// <param name="rewrappedRow">The rewrapped row.</param> + /// <param name="expectedCurrentWrappedDek">The wrapped key the row is expected to hold.</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>Always false.</returns> public Task<bool> ApplyRewrapAsync( StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => Task.FromResult(false); @@ -399,8 +453,14 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable { private readonly HashSet<string> _throwing = new(StringComparer.Ordinal); + /// <summary>Throws the configured fault when the named secret is requested.</summary> + /// <param name="name">The secret name that should fail.</param> public void ThrowFor(string name) => _throwing.Add(name); + /// <summary>Returns the stored or configured secret.</summary> + /// <param name="name">The secret name.</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>The secret, or null when absent.</returns> public Task<string?> GetAsync(SecretName name, CancellationToken ct) { if (_throwing.Contains(name.Value)) diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs index c8fae91c..82f8d753 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs @@ -32,6 +32,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Initializes the test with a temporary SQLite storage and an in-memory shared script library.</summary> public DeploymentManagerActorTests() { _localDb = TestLocalDb.CreateTemp("dm-test"); @@ -45,6 +46,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -94,7 +96,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable /// Builds a config carrying a single STATIC List attribute whose canonical /// JSON-array default the Instance Actor decodes into a typed <c>List<int></c> /// in memory (InstanceActor.PreStart / DecodeAttributeValue). Used to drive a - /// routed wait whose matched value is a collection — the shape WS-4 normalizes + /// routed wait whose matched value is a collection — the shape that is normalized /// for cross-process transport. /// </summary> private static string MakeConfigWithListAttributeJson(string instanceName) @@ -118,7 +120,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable /// Builds a config carrying BOTH a static List attribute (whose JSON-array default /// the Instance Actor decodes into a typed <c>List<int></c>) and a static scalar /// attribute. Used to drive a routed <see cref="RouteToGetAttributesRequest"/> that - /// exercises #162: the List value must be normalized for cross-process transport, + /// exercises list normalization: the List value must be normalized for cross-process transport, /// while the scalar must pass through the normalizer unchanged. /// </summary> private static string MakeConfigWithListAndScalarAttributesJson(string instanceName) @@ -161,6 +163,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable return JsonSerializer.Serialize(config); } + /// <summary>Verifies that the deployment manager creates instance actors from the stored configurations.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploymentManager_CreatesInstanceActors_FromStoredConfigs() { @@ -182,6 +186,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal("Pump1", response.InstanceUniqueName); } + /// <summary>Verifies that the deployment manager skips disabled instances on startup.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploymentManager_SkipsDisabledInstances_OnStartup() { @@ -200,6 +206,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.True(response.Success); } + /// <summary>Verifies that instance actors are created in staggered batches.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploymentManager_StaggeredBatchCreation() { @@ -226,6 +234,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable } } + /// <summary>Verifies that a deploy creates a new instance.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploymentManager_Deploy_CreatesNewInstance() { @@ -251,6 +261,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Scripts = [new ResolvedScript { CanonicalName = "S1", Code = "this is not C# ~~~" }] }); + /// <summary>Verifies that a deploy with a non-compiling script reports failure and creates no instance actor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deploy_WithNonCompilingScript_ReportsFailed_AndCreatesNoInstanceActor() { @@ -277,6 +289,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable // ── S5: startup deployed-config load is retried on transient failure ── + /// <summary>Verifies that a startup load failure is retried until it succeeds.</summary> [Fact] public void StartupLoadFailure_IsRetried_UntilSuccess() { @@ -318,8 +331,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable }, TimeSpan.FromSeconds(10)); } - // ── M1.6: site event log `deployment` category ───────────────────────── + // ── Site event log `deployment` category ───────────────────────── + /// <summary>Verifies that a successful deploy emits a deployment site event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploymentManager_DeploySuccess_EmitsDeploymentSiteEvent() { @@ -345,6 +360,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable }, TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that disable, enable and delete each emit a deployment site event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploymentManager_DisableEnableDelete_EmitDeploymentSiteEvents() { @@ -382,6 +399,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that an instance can be disabled, enabled and deleted through its lifecycle.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploymentManager_Lifecycle_DisableEnableDelete() { @@ -421,8 +440,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "LifecyclePump"); } - // ── DeploymentManager-006: query-the-site-before-redeploy ── + // ── Query-the-site-before-redeploy ── + /// <summary>Verifies that a state query for a deployed instance returns its applied identity.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploymentStateQuery_DeployedInstance_ReturnsAppliedIdentity() { @@ -444,6 +465,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal("sha256:applied", response.AppliedRevisionHash); } + /// <summary>Verifies that a state query for an unknown instance reports it as not deployed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploymentStateQuery_UnknownInstance_ReturnsNotDeployed() { @@ -461,6 +484,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Null(response.AppliedRevisionHash); } + /// <summary>Verifies that the supervision strategy resumes a child on an exception.</summary> [Fact] public void DeploymentManager_SupervisionStrategy_ResumesOnException() { @@ -475,8 +499,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal(DeploymentStatus.Success, response.Status); } - // ── Audit Log #23 (ParentExecutionId, Task 4): inbound-API routing ── + // ── ParentExecutionId: inbound-API routing ── + /// <summary>Verifies that an inbound API call carrying a parent execution id is routed to the script successfully.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteInboundApiCall_WithParentExecutionId_RoutesToScriptSuccessfully() { @@ -504,6 +530,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal(7, Convert.ToInt32(response.ReturnValue)); } + /// <summary>Verifies that an inbound API call without a parent execution id is still routed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteInboundApiCall_WithoutParentExecutionId_StillRoutes() { @@ -530,6 +558,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable // ── Spec §6 (WD-2b): routed RouteToWaitForAttributeRequest → InstanceActor ── + /// <summary>Verifies that a routed wait replies matched when the attribute is already at the target value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteInboundApiWaitForAttribute_AttributeAlreadyAtTarget_RepliesMatched() { @@ -562,10 +592,12 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal("Good", response.Quality); } + /// <summary>Verifies that the require-good-quality flag is passed through to the instance actor.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteInboundApiWaitForAttribute_RequireGoodQuality_IsThreadedToInstanceActor() { - // #14 (full fidelity): the additive RouteToWaitForAttributeRequest.RequireGoodQuality + // Full fidelity: the additive RouteToWaitForAttributeRequest.RequireGoodQuality // flag must reach the InstanceActor's wait. A static attribute is Good quality, so a // quality-gated wait whose target equals the current value still matches — proving the // flag threads through (and does not spuriously reject a Good value). @@ -591,6 +623,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal("Good", response.Quality); } + /// <summary>Verifies that a routed wait for an unknown instance replies not found.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteInboundApiWaitForAttribute_UnknownInstance_RepliesNotFound() { @@ -612,10 +646,12 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that a routed wait matching a list value returns a serializer-safe value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteInboundApiWaitForAttribute_ListValuedMatch_ReturnsSerializerSafeValue() { - // WS-4: a routed wait whose matched attribute is a List-typed value comes + // A routed wait whose matched attribute is a List-typed value comes // back from the Instance Actor as a concrete generic List<int> — a shape the // cross-process (Newtonsoft) serializer cannot reliably round-trip, which would // otherwise silently drop the reply and hang the caller's Ask. The handler must @@ -658,15 +694,17 @@ public class DeploymentManagerActorTests : TestKit, IDisposable list.Select(e => Convert.ToDouble(e, System.Globalization.CultureInfo.InvariantCulture))); } - // ── #162: routed RouteToGetAttributesRequest → InstanceActor (List normalization) ── + // ── Routed RouteToGetAttributesRequest → InstanceActor (List normalization) ── + /// <summary>Verifies that a routed get-attributes read returns a serializer-safe list value and leaves scalar values unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteInboundApiGetAttributes_ListValuedAttribute_ReturnsSerializerSafeValueAndScalarUnchanged() { - // #162: a routed GetAttributes read of a List-typed static attribute comes back + // A routed GetAttributes read of a List-typed static attribute comes back // from the Instance Actor as a concrete generic List<int> — the SAME non-primitive // shape the cross-process (Newtonsoft) serializer cannot reliably round-trip that - // WS-4 fixed for WaitForAttribute. Un-normalized, the reply is silently dropped and + // was already fixed for WaitForAttribute. Un-normalized, the reply is silently dropped and // the caller's Route.To().GetAttributes() Ask hangs to timeout. The handler must // project EACH value through NormalizeRoutedReturnValue so the List survives the // wire — while leaving scalar/string/null values unchanged (no regression). @@ -714,6 +752,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Null(response.Values["DoesNotExist"]); } + /// <summary>Verifies that a routed get-attributes read for an unknown instance replies not found.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteInboundApiGetAttributes_UnknownInstance_RepliesNotFound() { @@ -734,8 +774,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase); } - // ── M2.11: Debug-view routing — unknown-instance not-found signal ── + // ── Debug-view routing — unknown-instance not-found signal ── + /// <summary>Verifies that a debug snapshot request for an unknown instance sets the instance-not-found flag.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteDebugSnapshot_UnknownInstance_SetsInstanceNotFound() { @@ -755,6 +797,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Empty(snapshot.AlarmStates); } + /// <summary>Verifies that a debug view subscribe for an unknown instance sets the instance-not-found flag.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteDebugViewSubscribe_UnknownInstance_SetsInstanceNotFound() { @@ -772,6 +816,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Empty(snapshot.AlarmStates); } + /// <summary>Verifies that a debug snapshot for a known but empty instance does not set the instance-not-found flag.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteDebugSnapshot_KnownButEmptyInstance_DoesNotSetInstanceNotFound() { @@ -794,8 +840,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable "A deployed (but empty) instance must NOT set InstanceNotFound."); } - // ── Task 10: notify-and-fetch — RefreshDeploymentCommand → fetch → apply ── + // ── Notify-and-fetch — RefreshDeploymentCommand → fetch → apply ── + /// <summary>Verifies that a refresh whose fetch succeeds applies the configuration and replies success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RefreshDeployment_FetchSucceeds_AppliesConfigAndRepliesSuccess() { @@ -825,6 +873,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Contains(configs, c => c.InstanceUniqueName == "FetchedPump"); } + /// <summary>Verifies that a refresh whose fetch fails replies failed and applies nothing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RefreshDeployment_FetchFails_RepliesFailedAndDoesNotApply() { @@ -852,6 +902,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "UnfetchedPump"); } + /// <summary>Verifies that a refresh with no fetcher replies failed.</summary> [Fact] public void RefreshDeployment_NullFetcher_RepliesFailed() { @@ -868,6 +919,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal("Pump", response.InstanceUniqueName); } + /// <summary>Verifies that the refresh reply goes to the original sender across the asynchronous fetch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RefreshDeployment_ReplyGoesToOriginalSender_AcrossAsyncFetch() { @@ -889,13 +942,15 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal("SenderPump", response.InstanceUniqueName); } - // ── LocalDb Phase 2 / Task 12: the active-node central-only purge ── + // ── LocalDb Phase 2: the active-node central-only purge ── + /// <summary>Verifies that applying artifacts purges the central-only notification configuration.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyingArtifacts_PurgesCentralOnlyNotificationConfig() { // Security cleanup. notification_lists and smtp_configurations can hold plaintext - // SMTP passwords written by a pre-2026-07-10 build, and the ACTIVE node's artifact + // SMTP passwords written by a build older than 2026-07-10, and the ACTIVE node's artifact // apply is what clears them (DeploymentManagerActor.HandleDeployArtifacts). The // standby used to hold a second copy of this call in SiteReplicationActor; LocalDb // Phase 2 deleted that actor, so this is now the ONLY call site that keeps the @@ -1004,6 +1059,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable /// policy was dropped on the floor here and every cached call fell back to the engine's /// 50 x 30 s default. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyingArtifacts_PersistsExternalSystemRetryPolicy() { @@ -1026,6 +1082,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal(45_000, reader.GetInt64(1)); } + /// <summary>Verifies that applying artifacts deletes external systems that are absent from the set.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyingArtifacts_DeletesExternalSystemsAbsentFromTheSet() { @@ -1049,6 +1107,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal(1, await RowCountAsync("external_systems")); } + /// <summary>Verifies that applying artifacts with a null set touches nothing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyingArtifacts_NullSet_TouchesNothing() { @@ -1067,6 +1127,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Equal(1, await RowCountAsync("external_systems")); } + /// <summary>Verifies that a removed shared script is unregistered from the library.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyingArtifacts_RemovedSharedScript_IsUnregisteredFromLibrary() { @@ -1093,6 +1155,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.False(_sharedScriptLibrary.Contains("GoneScript")); } + /// <summary>Verifies that a removed data connection stops its live connection in the data connection layer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyingArtifacts_RemovedDataConnection_StopsLiveDclConnection() { @@ -1134,13 +1198,26 @@ public class DeploymentManagerActorTests : TestKit, IDisposable private readonly string? _result; private readonly Exception? _error; + /// <summary>Initializes the fetcher to return the given configuration JSON.</summary> + /// <param name="result">The configuration JSON returned from every fetch.</param> public FakeConfigFetcher(string result) => _result = result; + /// <summary>Initializes the fetcher to throw the given exception.</summary> + /// <param name="error">The exception thrown from every fetch.</param> public FakeConfigFetcher(Exception error) => _error = error; + /// <summary>Gets the central base URL passed to the most recent fetch.</summary> public string? LastBaseUrl { get; private set; } + /// <summary>Gets the deployment id passed to the most recent fetch.</summary> public string? LastDeploymentId { get; private set; } + /// <summary>Gets the token passed to the most recent fetch.</summary> public string? LastToken { get; private set; } + /// <summary>Records the request arguments and returns the canned JSON or throws the canned exception.</summary> + /// <param name="centralFetchBaseUrl">The central base URL being fetched from.</param> + /// <param name="deploymentId">The deployment id being fetched.</param> + /// <param name="token">The deployment token.</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>A task yielding the canned configuration JSON.</returns> public async Task<string> FetchAsync( string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerCertReconcileTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerCertReconcileTests.cs index 11c6a0b4..8fc909c8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerCertReconcileTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerCertReconcileTests.cs @@ -49,6 +49,7 @@ akka { private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Creates the temporary local database, storage service and script services the deployment manager needs.</summary> public DeploymentManagerCertReconcileTests() : base(ClusterConfig, "dm-cert-reconcile") { _localDb = TestLocalDb.CreateTemp("dm-cert-test"); @@ -61,6 +62,7 @@ akka { _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -74,6 +76,8 @@ akka { /// <summary>Forwards every message it receives to a probe, preserving the original sender.</summary> private sealed class ForwardingActor : ReceiveActor { + /// <summary>Creates a forwarder that sends every message it receives to the given target.</summary> + /// <param name="target">The actor that receives the forwarded messages.</param> public ForwardingActor(IActorRef target) => ReceiveAny(msg => target.Forward(msg)); } @@ -82,6 +86,7 @@ akka { _storage, _compilationService, _sharedScriptLibrary, null, new SiteRuntimeOptions(), NullLogger<DeploymentManagerActor>.Instance))); + /// <summary>Verifies that when a site node joins, the local trusted certificates are pushed to the joined node.</summary> [Fact] public void SiteNodeJoined_PushesLocalTrustedCertsToJoinedNode() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerLoggerFactoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerLoggerFactoryTests.cs index fcf6da18..3b33ad8e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerLoggerFactoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerLoggerFactoryTests.cs @@ -14,7 +14,7 @@ using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// Regression test for SiteRuntime-015 — <see cref="DeploymentManagerActor"/> must +/// Regression test: <see cref="DeploymentManagerActor"/> must /// reuse a single, injected <see cref="ILoggerFactory"/> for every Instance Actor it /// creates rather than newing (and leaking) a fresh <see cref="LoggerFactory"/> per /// instance. @@ -26,6 +26,7 @@ public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Initializes the test class with an actor system.</summary> public DeploymentManagerLoggerFactoryTests() { _localDb = TestLocalDb.CreateTemp("dm-loggerfactory-test"); @@ -39,6 +40,7 @@ public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -72,16 +74,24 @@ public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable public int CreateLoggerCalls; public bool Disposed; + /// <summary>Counts the call and returns a null logger.</summary> + /// <param name="categoryName">The logger category name.</param> + /// <returns>A logger that discards entries.</returns> public ILogger CreateLogger(string categoryName) { Interlocked.Increment(ref CreateLoggerCalls); return NullLogger.Instance; } + /// <summary>Ignores the provider.</summary> + /// <param name="provider">The provider (ignored).</param> public void AddProvider(ILoggerProvider provider) { } + /// <summary>Records that the factory was disposed.</summary> public void Dispose() => Disposed = true; } + /// <summary>Verifies that creating instance actors reuses the injected logger factory for every instance.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerMediumFindingsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerMediumFindingsTests.cs index c1c132da..7b84d6e0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerMediumFindingsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerMediumFindingsTests.cs @@ -15,8 +15,8 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> /// Regression tests for the Medium-severity DeploymentManagerActor findings: -/// SiteRuntime-005 (Success reported before persistence completes) and -/// SiteRuntime-008 (blocking shared-script load on the actor thread). +/// success reported before persistence completes, and +/// blocking shared-script load on the actor thread. /// </summary> public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable { @@ -24,6 +24,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Initializes a new instance of the <c>DeploymentManagerMediumFindingsTests</c> class.</summary> public DeploymentManagerMediumFindingsTests() { _localDb = TestLocalDb.CreateTemp("dm-medium-test"); @@ -33,6 +34,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -98,11 +100,12 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable } /// <summary> - /// SiteRuntime-005: when SQLite persistence of the deployed config fails, the + /// When SQLite persistence of the deployed config fails, the /// Deployment Manager must report <see cref="DeploymentStatus.Failed"/> to central, /// not <see cref="DeploymentStatus.Success"/>. Reporting Success on a persistence /// failure silently loses the deployment on the next restart/failover. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deploy_PersistenceFailure_ReportsFailedNotSuccess() { @@ -140,10 +143,11 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable } /// <summary> - /// SiteRuntime-005: a successful deployment must still report + /// A successful deployment must still report /// <see cref="DeploymentStatus.Success"/>, and only after the config row is /// committed to SQLite (so a restart re-creates the instance). /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deploy_Success_ReportsSuccessAndPersistsConfig() { @@ -165,7 +169,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable } /// <summary> - /// SiteRuntime-010: when a redeployment changes a connection's configuration + /// When a redeployment changes a connection's configuration /// (here the failover retry count and endpoint), the Deployment Manager must push the new /// configuration to the DCL rather than leaving it serving the stale one. /// @@ -177,6 +181,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable /// which the live connection actor actually applies in place. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnsureDclConnections_ConnectionConfigChanged_ReissuesReconfigureCommand() { @@ -214,9 +219,10 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable } /// <summary> - /// SiteRuntime-010: an unchanged connection configuration must still be skipped — + /// An unchanged connection configuration must still be skipped — /// re-sending an identical create command on every deploy is wasteful. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnsureDclConnections_UnchangedConfig_DoesNotReissueCreateCommand() { @@ -245,10 +251,11 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable } /// <summary> - /// SiteRuntime-008: startup must not block the Deployment Manager mailbox on a + /// Startup must not block the Deployment Manager mailbox on a /// synchronous shared-script load. With shared scripts present, the actor must /// still load deployed configs, create Instance Actors, and remain responsive. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Startup_WithSharedScripts_LoadsConfigsAndStaysResponsive() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs index 663243a3..8db764a2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs @@ -17,7 +17,7 @@ using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// Regression tests for SiteRuntime-003: redeployment of an existing instance must +/// Regression tests: redeployment of an existing instance must /// wait for the terminating Instance Actor before recreating the child, instead of /// relying on a fixed 500 ms reschedule that can collide on the child actor name. /// </summary> @@ -28,6 +28,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Initializes a temporary LocalDb, site storage and script compilation services for the tests.</summary> public DeploymentManagerRedeployTests() { _localDb = TestLocalDb.CreateTemp("dm-redeploy-test"); @@ -41,6 +42,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -95,25 +97,45 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable /// </summary> private sealed class CountCapturingHealthCollector : ISiteHealthCollector { + /// <summary>Gets the most recently reported deployed-instance count.</summary> public int LastDeployedCount { get; private set; } + /// <inheritdoc /> public void IncrementScriptError() { } + /// <inheritdoc /> public void IncrementAlarmError() { } + /// <inheritdoc /> public void IncrementDeadLetter() { } + /// <inheritdoc /> public void IncrementSiteAuditWriteFailures() { } + /// <inheritdoc /> public void IncrementAuditRedactionFailure() { } + /// <inheritdoc /> public void UpdateSiteAuditBacklog(ZB.MOM.WW.ScadaBridge.Commons.Types.SiteAuditBacklogSnapshot snapshot) { } + /// <inheritdoc /> public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) { } + /// <inheritdoc /> public void RemoveConnection(string connectionName) { } + /// <inheritdoc /> public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) { } + /// <inheritdoc /> public void UpdateConnectionEndpoint(string connectionName, string endpoint) { } + /// <inheritdoc /> public void UpdateTagQuality(string connectionName, int good, int bad, int uncertain) { } + /// <inheritdoc /> public void SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths) { } + /// <inheritdoc /> public void SetInstanceCounts(int deployed, int enabled, int disabled) => LastDeployedCount = deployed; + /// <inheritdoc /> public void SetParkedMessageCount(int count) { } + /// <inheritdoc /> public void SetNodeHostname(string hostname) { } + /// <inheritdoc /> public void SetClusterNodes(IReadOnlyList<NodeStatus> nodes) { } + /// <inheritdoc /> public void SetActiveNode(bool isActive) { } + /// <inheritdoc /> public bool IsActiveNode => true; + /// <inheritdoc /> public SiteHealthReport CollectReport(string siteId) => throw new NotSupportedException(); } @@ -130,6 +152,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable return JsonSerializer.Serialize(config); } + /// <summary>Verifies that redeploying an existing instance succeeds without a child actor name collision.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Redeploy_ExistingInstance_SucceedsWithoutNameCollision() { @@ -155,10 +179,12 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable Assert.True(disable.Success); } + /// <summary>Verifies that three rapid deploys do not throw an invalid-actor-name exception and the latest deploy wins.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SR020_ThreeRapidDeploys_DoNotThrowInvalidActorNameException_LatestWins() { - // Regression test for SiteRuntime-020. The previous implementation tracked + // Regression test: the previous implementation tracked // pending redeploys by IActorRef (_pendingRedeploys) but had no // name-keyed shadow, so a third DeployInstanceCommand arriving WHILE the // first redeploy's predecessor was still terminating saw @@ -184,7 +210,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable // Two rapid redeploys before the predecessor has time to fully terminate. // The second deploy stops the actor (watching it) and buffers itself. // The third deploy arrives almost immediately and must NOT crash — it - // overwrites the buffered pending command and tells dep-2 it was superseded. + // overwrites the buffered pending command and tells the first deployment it was superseded. var probe2 = CreateTestProbe(); var probe3 = CreateTestProbe(); @@ -195,7 +221,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable "dep-3", "RapidPump", "h3", MakeConfigJson("RapidPump"), "admin", DateTimeOffset.UtcNow), probe3.Ref); - // dep-2 must be told it was superseded; dep-3 must succeed once the + // The first deployment must be told it was superseded; the second must succeed once the // predecessor finishes terminating. var superseded = probe2.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10)); Assert.Equal("dep-2", superseded.DeploymentId); @@ -214,10 +240,12 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable Assert.True(disable.Success); } + /// <summary>Verifies that a delete during a pending redeploy leaves the instance deleted and the deployed count correct.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SR029_DeleteDuringPendingRedeploy_InstanceStaysDeleted_AndCounterIsCorrect() { - // Regression test for SiteRuntime-029. A delete arriving WHILE a redeploy is + // Regression test: a delete arriving WHILE a redeploy is // still terminating used to: (1) over-decrement _totalDeployedCount, and // (2) leave the buffered _pendingRedeploys entry intact — so when Terminated // fired, HandleTerminated called ApplyDeployment(isRedeploy: true) and @@ -241,7 +269,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable // singleton mailbox: HandleDeploy runs first (removes from _instanceActors, // watches + stops the predecessor, buffers the redeploy, sets the terminating // shadow), then HandleDelete runs while the predecessor is still terminating - // (Terminated has not fired) — exactly the SiteRuntime-029 window. + // (Terminated has not fired) — exactly the delete-during-redeploy window. var redeployProbe = CreateTestProbe(); actor.Tell(new DeployInstanceCommand( "dep-2", "RaceTarget", "h2", MakeConfigJson("RaceTarget"), "admin", DateTimeOffset.UtcNow), @@ -270,10 +298,12 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable Assert.Equal(0, health.LastDeployedCount); } + /// <summary>Verifies that deleting a disabled instance decrements the deployed count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SR032_DeleteDisabledInstance_DecrementsDeployedCount() { - // Regression test for SiteRuntime-032. SiteRuntime-029 gated the deployed-count + // Regression test: the earlier delete-during-redeploy fix gated the deployed-count // decrement on the instance being present in _instanceActors OR mid-redeploy in // _terminatingActorsByName. A DISABLED instance is in NEITHER map (disable removes // it from _instanceActors and never adds it to the terminating shadow) yet still has @@ -300,7 +330,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable Assert.Equal(1, health.LastDeployedCount); // Delete the DISABLED instance → the deployed count must return to 0. - // (The SiteRuntime-029 regression left it stuck at 1.) + // (The delete-during-redeploy regression left it stuck at 1.) actor.Tell(new DeleteInstanceCommand("del-1", "DisablePump", DateTimeOffset.UtcNow)); var delete = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5)); Assert.True(delete.Success); @@ -311,6 +341,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "DisablePump"); } + /// <summary>Verifies that redeploying an existing instance does not over-count deployed instances.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Redeploy_ExistingInstance_DoesNotOverCountDeployedInstances() { @@ -344,6 +376,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable // ── S6: an Instance Actor that dies during init must not linger as a dead ref ── + /// <summary>Verifies that an Instance Actor init failure at startup evicts the dead reference and logs a site event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActorInitFailure_OnStartup_EvictsDeadRefAndLogsSiteEvent() { @@ -367,6 +401,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable }, TimeSpan.FromSeconds(10)); } + /// <summary>Verifies that a deploy whose actor dies during initialization reports failure rather than success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deploy_WhoseActorDiesDuringInit_ReportsFailed_NotSuccess() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRouteTraceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRouteTraceTests.cs index a70b54f7..0c02e37a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRouteTraceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRouteTraceTests.cs @@ -27,6 +27,7 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Creates a temporary LocalDb-backed site storage and the script services the actors need.</summary> public DeploymentManagerRouteTraceTests() { _localDb = TestLocalDb.CreateTemp("dm-route-trace-test"); @@ -36,6 +37,7 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable _sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { Shutdown(); @@ -67,6 +69,8 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable return actor; } + /// <summary>Verifies that a traced route call returns the child execution id and the inline trace events.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteCall_WithTrace_ReturnsChildExecutionId_AndInlineTraceEvents() { @@ -92,6 +96,8 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable Assert.All(rows, r => Assert.Equal(response.ChildExecutionId, r.ExecutionId)); } + /// <summary>Verifies that an untraced route call returns the execution id but no trace events.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteCall_WithoutTrace_ReturnsExecutionId_ButNoTraceEvents() { @@ -105,6 +111,8 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable Assert.Null(response.TraceEvents); } + /// <summary>Verifies that routing a set-attributes request forwards the trace flag on the command.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RouteSetAttributes_ForwardsTraceFlag_OnTheCommand() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentWarmThenGateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentWarmThenGateTests.cs index 2a3ed54c..efca980f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentWarmThenGateTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentWarmThenGateTests.cs @@ -15,12 +15,12 @@ using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// WP3.1 test group 9 — warm-then-gate deploys and startup batch pre-warm. +/// Warm-then-gate deploys and startup batch pre-warm. /// /// <para>The site-side compile gate (S3) must stay synchronous on the Deployment Manager's /// thread, because redeploy-supersede and delete-during-redeploy both depend on strict mailbox /// FIFO. But the Roslyn compile it performs used to hold the singleton for the whole -/// compilation, stalling every OTHER instance's commands behind one instance's scripts. WP3.1 +/// compilation, stalling every OTHER instance's commands behind one instance's scripts. The new flow /// warms the compile off-thread first and then re-runs the gate as pure cache hits, with a /// per-instance in-flight guard preserving same-instance ordering.</para> /// @@ -44,6 +44,7 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Initializes a new instance of the <see cref="DeploymentWarmThenGateTests"/> class.</summary> public DeploymentWarmThenGateTests() { _localDb = TestLocalDb.CreateTemp("dm-warm-gate-test"); @@ -56,6 +57,7 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { Shutdown(); @@ -95,6 +97,7 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable { private int _reports; + /// <summary>Gets the deployed instance count from the most recent <c>SetInstanceCounts</c> call.</summary> public int LastDeployedCount { get; private set; } /// <summary> @@ -107,29 +110,48 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable /// value and proves nothing about the manager having started at all. /// </summary> public int ReportCount => Volatile.Read(ref _reports); + /// <inheritdoc /> public void IncrementScriptError() { } + /// <inheritdoc /> public void IncrementAlarmError() { } + /// <inheritdoc /> public void IncrementDeadLetter() { } + /// <inheritdoc /> public void IncrementSiteAuditWriteFailures() { } + /// <inheritdoc /> public void IncrementAuditRedactionFailure() { } + /// <inheritdoc /> public void UpdateSiteAuditBacklog(Commons.Types.SiteAuditBacklogSnapshot snapshot) { } + /// <inheritdoc /> public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) { } + /// <inheritdoc /> public void RemoveConnection(string connectionName) { } + /// <inheritdoc /> public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) { } + /// <inheritdoc /> public void UpdateConnectionEndpoint(string connectionName, string endpoint) { } + /// <inheritdoc /> public void UpdateTagQuality(string connectionName, int good, int bad, int uncertain) { } + /// <inheritdoc /> public void SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths) { } + /// <inheritdoc /> public void SetInstanceCounts(int deployed, int enabled, int disabled) { LastDeployedCount = deployed; Interlocked.Increment(ref _reports); } + /// <inheritdoc /> public void SetParkedMessageCount(int count) { } + /// <inheritdoc /> public void SetNodeHostname(string hostname) { } + /// <inheritdoc /> public void SetClusterNodes(IReadOnlyList<Commons.Messages.Health.NodeStatus> nodes) { } + /// <inheritdoc /> public void SetActiveNode(bool isActive) { } + /// <inheritdoc /> public bool IsActiveNode => true; + /// <inheritdoc /> public Commons.Messages.Health.SiteHealthReport CollectReport(string siteId) => throw new NotSupportedException(); } @@ -147,6 +169,8 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable : [new ResolvedScript { CanonicalName = "Worker", Code = scriptCode, TriggerType = "Call" }] }); + /// <summary>Verifies that a delete arriving during the compile warm is queued and applied after the deploy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteArrivingDuringTheCompileWarm_IsQueuedAndAppliedAfterTheDeploy() { @@ -177,10 +201,12 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable // ahead of the warm it would have removed nothing and the deploy would have left the // count at 1. This is the ordering signal rather than the SQLite row, because the // deploy's store and the delete's remove are independent background tasks whose - // completion order the actor has never guaranteed (true before WP3.1 as well). + // completion order the actor has never guaranteed (true before warm-then-gate as well). AwaitAssert(() => Assert.Equal(0, health.LastDeployedCount), TimeSpan.FromSeconds(10)); } + /// <summary>Verifies that a second deploy during the warm supersedes the first and answers its deployer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SecondDeployDuringTheWarm_SupersedesTheFirst_AndAnswersItsDeployer() { @@ -212,6 +238,8 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable Assert.Equal("h2", row.RevisionHash); } + /// <summary>Verifies that deploys for different instances do not block each other.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeploysForDifferentInstances_DoNotBlockEachOther() { @@ -239,12 +267,14 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable Assert.Contains(configs, c => c.InstanceUniqueName == "PumpY"); } + /// <summary>Verifies that staggered startup pre-warms each batch so the Instance Actor PreStart compiles are cache hits.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaggeredStartup_PreWarmsEachBatchSoInstanceActorPreStartCompilesAreCacheHits() { // Two instances sharing one script body. The batch pre-warm compiles it once; the // second config's warm and BOTH Instance Actors' PreStart compiles are then hits. - // Before WP3.1 every Instance Actor Roslyn-compiled its own scripts inside PreStart, + // Before the batch pre-warm every Instance Actor Roslyn-compiled its own scripts inside PreStart, // serialising a site's whole failover recovery behind compilation. const string sharedCode = "return 41 + 1;"; await _storage.StoreDeployedConfigAsync( diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs index d566f725..e0489b6a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs @@ -15,7 +15,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// Gitea #36 end-to-end over the REAL <see cref="InstanceActor"/> waiter and DCL ingest path: +/// End-to-end test over the REAL <see cref="InstanceActor"/> waiter and DCL ingest path: /// the cache still holds the previous run's <c>CompleteFlag = True</c>, the script's reset /// write has NOT echoed back yet. The legacy overload fast-paths on the stale value (the /// defect, pinned as documentation); the reset-aware overload holds the batch + trigger until @@ -33,6 +33,7 @@ public class HandshakeResetEchoTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Initializes the test with a temporary LocalDb-backed site storage and script compilation services.</summary> public HandshakeResetEchoTests() { _localDb = TestLocalDb.CreateTemp("handshake-reset-echo"); @@ -42,6 +43,7 @@ public class HandshakeResetEchoTests : TestKit, IDisposable _sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -90,6 +92,7 @@ public class HandshakeResetEchoTests : TestKit, IDisposable /// cache — callers migrate to the reset-aware overload instead. Never "fix" the fast-path in /// place; delete this test only if the legacy overload itself is removed. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LegacyOverload_FastPathsOnThePreviousRunsCachedTrue() { @@ -106,6 +109,8 @@ public class HandshakeResetEchoTests : TestKit, IDisposable Assert.True(await call); } + /// <summary>Verifies that the reset-aware overload holds the batch until the reset echoes and then matches the fresh true value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResetAwareOverload_HoldsTheBatchUntilTheResetEchoes_ThenMatchesTheFreshTrue() { @@ -151,6 +156,8 @@ public class HandshakeResetEchoTests : TestKit, IDisposable Assert.Equal("Good", result.Quality); } + /// <summary>Verifies that the reset-aware overload times out in the reset phase when no echo arrives and never writes the batch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResetAwareOverload_NoEcho_TimesOutInResetPhase_AndNeverWritesTheBatch() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildAttributeRaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildAttributeRaceTests.cs index 0f12b6e1..6fa0a817 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildAttributeRaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildAttributeRaceTests.cs @@ -14,7 +14,7 @@ using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// Regression coverage for SiteRuntime-017 — the Instance Actor must not hand its +/// Regression coverage: the Instance Actor must not hand its /// own live mutable <c>_attributes</c> dictionary by reference into the /// <see cref="ScriptActor"/> / <see cref="AlarmActor"/> constructors. /// @@ -42,6 +42,7 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable private readonly SiteRuntimeOptions _options; private readonly TestLocalDb _localDb; + /// <summary>Initializes the temporary database, storage, compilation service and options used by the tests.</summary> public InstanceActorChildAttributeRaceTests() { _localDb = TestLocalDb.CreateTemp("instance-race-test"); @@ -60,6 +61,7 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable }; } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -164,6 +166,8 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable return (Dictionary<string, object?>)field!.GetValue(instance)!; } + /// <summary>Verifies that child actors are seeded from an isolated copy of the attributes, not the live attributes dictionary.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ChildActors_AreSeededFromAnIsolatedCopy_NotTheLiveAttributesDictionary() { @@ -192,7 +196,7 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable // Every child Script Actor must have been seeded from a dictionary that // is NOT the Instance Actor's live _attributes field — otherwise the // child constructor would enumerate a dictionary the Instance Actor - // mutates on another thread (SiteRuntime-017). + // mutates on another thread. foreach (var name in new[] { "WorkerA", "WorkerB" }) { var scriptActor = await ResolveChildInstanceAsync<ScriptActor>(testRef, $"script-{name}"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildRoutingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildRoutingTests.cs index cb80b5a4..ced04a78 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildRoutingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildRoutingTests.cs @@ -11,7 +11,7 @@ using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// PLAN-03 Task 18 (P2): the Instance Actor routes each attribute change only to the +/// The Instance Actor routes each attribute change only to the /// children interested in it. A value-change / conditional script (and a monitored-attribute /// alarm) subscribes to a single named attribute; an Expression-triggered script/alarm hears /// every change (it keeps a full attribute snapshot). These tests pin the routing-map @@ -25,6 +25,7 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable private readonly SiteRuntimeOptions _options; private readonly TestLocalDb _localDb; + /// <summary>Creates the test class with a fresh storage service and actor system.</summary> public InstanceActorChildRoutingTests() { _localDb = TestLocalDb.CreateTemp("instance-routing-test"); @@ -43,6 +44,7 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable }; } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -101,6 +103,7 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable return testRef.UnderlyingActor; } + /// <summary>Verifies that the routing maps are built from the trigger configurations.</summary> [Fact] public void RoutingMaps_AreBuiltFromTriggerConfigs() { @@ -113,6 +116,7 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable Assert.Single(actor.AllChangeChildren); } + /// <summary>Verifies that a monitored-attribute alarm routes only to its attribute while an expression alarm routes to all changes.</summary> [Fact] public void MonitoredAttributeAlarm_RoutesToItsAttribute_ExpressionAlarm_RoutesToAllChanges() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorIntegrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorIntegrationTests.cs index e2d0af31..1956afc4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorIntegrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorIntegrationTests.cs @@ -15,7 +15,7 @@ using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// Integration tests for InstanceActor with child Script/Alarm actors (WP-15, WP-16, WP-24, WP-25). +/// Integration tests for InstanceActor with child Script/Alarm actors. /// </summary> public class InstanceActorIntegrationTests : TestKit, IDisposable { @@ -25,6 +25,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable private readonly SiteRuntimeOptions _options; private readonly TestLocalDb _localDb; + /// <summary>Creates the temporary local database, storage service, script services and runtime options the tests share.</summary> public InstanceActorIntegrationTests() { _localDb = TestLocalDb.CreateTemp("instance-int-test"); @@ -44,6 +45,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -82,6 +84,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable NullLogger<InstanceActor>.Instance))); } + /// <summary>Verifies that the instance actor creates script actors from its configuration.</summary> [Fact] public void InstanceActor_CreatesScriptActors_FromConfig() { @@ -103,6 +106,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable Assert.Equal(42, result.ReturnValue); } + /// <summary>Verifies that calling an unknown script returns an error.</summary> [Fact] public void InstanceActor_ScriptCallRequest_UnknownScript_ReturnsError() { @@ -114,10 +118,11 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable Assert.Contains("not found", result.ErrorMessage); } + /// <summary>Verifies that rapid attribute changes are serialized through the actor mailbox without corrupting state.</summary> [Fact] public void InstanceActor_WP24_StateMutationsSerializedThroughMailbox() { - // WP-24: Instance Actor processes messages sequentially. + // Instance Actor processes messages sequentially. // Verify that rapid attribute changes don't corrupt state. var actor = CreateInstanceWithScripts("Pump1"); @@ -143,6 +148,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable Assert.Equal("49", response.Value?.ToString()); } + /// <summary>Verifies that subscribing to the debug view returns a snapshot.</summary> [Fact] public void InstanceActor_WP25_DebugViewSubscribe_ReturnsSnapshot() { @@ -159,6 +165,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable Assert.True(snapshot.SnapshotTimestamp > DateTimeOffset.MinValue); } + /// <summary>Verifies that a debug view subscription no longer forwards events through the cluster client.</summary> [Fact] public void InstanceActor_WP25_DebugViewSubscribe_NoLongerForwardsEventsViaClusterClient() { @@ -179,6 +186,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable ExpectNoMsg(TimeSpan.FromSeconds(1)); } + /// <summary>Verifies that unsubscribing from the debug view stops notifications.</summary> [Fact] public void InstanceActor_WP25_DebugViewUnsubscribe_StopsNotifications() { @@ -199,6 +207,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable ExpectNoMsg(TimeSpan.FromSeconds(1)); } + /// <summary>Verifies that the instance actor creates alarm actors from its configuration.</summary> [Fact] public void InstanceActor_CreatesAlarmActors_FromConfig() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorMesReportableSnapshotTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorMesReportableSnapshotTests.cs index 45e7293e..87253828 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorMesReportableSnapshotTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorMesReportableSnapshotTests.cs @@ -29,6 +29,7 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable private readonly SiteRuntimeOptions _options = new(); private readonly TestLocalDb _localDb; + /// <summary>Initializes the test class with a temporary storage setup.</summary> public InstanceActorMesReportableSnapshotTests() { _localDb = TestLocalDb.CreateTemp("instance-mes-reportable"); @@ -99,6 +100,7 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable m => m.CorrelationId == correlationId, TimeSpan.FromSeconds(1)); } + /// <summary>Verifies that the alarm snapshot stamps the MES-reportable flag on both fired and unfired alarms.</summary> [Fact] public void AlarmSnapshot_StampsIsMesReportable_OnFiredAndUnfiredAlarms() { @@ -136,6 +138,7 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable Assert.False(unfired.IsMesReportable); } + /// <summary>Verifies that an unfired reportable alarm is stamped before it ever fires.</summary> [Fact] public void AlarmSnapshot_UnfiredReportableAlarm_IsStampedBeforeItEverFires() { @@ -151,6 +154,7 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable Assert.False(Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike").IsMesReportable); } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs index 2d776630..b7c6c787 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs @@ -18,7 +18,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// Task 16: InstanceActor spawns NativeAlarmActor children from the flattened +/// InstanceActor spawns NativeAlarmActor children from the flattened /// configuration and surfaces enriched native alarm state in the DebugView snapshot. /// </summary> public class InstanceActorNativeAlarmTests : TestKit, IDisposable @@ -29,6 +29,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable private readonly SiteRuntimeOptions _options = new(); private readonly TestLocalDb _localDb; + /// <summary>Initializes the test class and its test doubles.</summary> public InstanceActorNativeAlarmTests() { _localDb = TestLocalDb.CreateTemp("instance-native"); @@ -64,6 +65,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable ] }; + /// <summary>Verifies that the instance actor spawns a native alarm actor that subscribes through the DCL.</summary> [Fact] public void SpawnsNativeAlarmActor_WhichSubscribesViaDcl() { @@ -76,6 +78,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable Assert.Equal("ns=2;s=T01", req.SourceReference); } + /// <summary>Verifies that the debug view snapshot includes the native alarm after a transition.</summary> [Fact] public void DebugViewSnapshot_IncludesNativeAlarm_AfterTransition() { @@ -97,6 +100,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable a.SourceReference == "T01.Hi" && a.Kind == AlarmKind.NativeOpcUa && a.Condition.Severity == 800); } + /// <summary>Verifies that a quiet native binding emits a placeholder in the snapshot.</summary> [Fact] public void Snapshot_QuietNativeBinding_EmitsPlaceholder() { @@ -119,6 +123,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable Assert.Equal(AlarmKind.NativeOpcUa, placeholder.Kind); } + /// <summary>Verifies that a native binding with a live condition emits no placeholder.</summary> [Fact] public void Snapshot_NativeBindingWithLiveCondition_NoPlaceholder() { @@ -127,7 +132,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable dcl.ExpectMsg<SubscribeAlarmsRequest>(); // The NativeAlarmActor emits a live condition stamped with the binding's - // canonical name (DV-1: NativeSourceCanonicalName), so the binding is "active". + // canonical name (NativeSourceCanonicalName), so the binding is "active". actor.Tell(new AlarmStateChanged("inst", "Pressure.Hi", AlarmState.Active, 800, DateTimeOffset.UtcNow) { Kind = AlarmKind.NativeOpcUa, @@ -151,6 +156,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable // ── MES alarm-status API §5.2: the script-facing alarm snapshot ──────── + /// <summary>Verifies that GetAlarmSnapshot returns the same rows as the debug view snapshot.</summary> [Fact] public void GetAlarmSnapshot_ReturnsTheSameRowsAsTheDebugViewSnapshot() { @@ -190,6 +196,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable reply.Alarms.Select(a => a.AlarmName).OrderBy(n => n)); } + /// <summary>Verifies that GetAlarmSnapshot with no alarms replies with only the configured placeholders.</summary> [Fact] public void GetAlarmSnapshot_WithNoAlarms_RepliesWithTheConfiguredPlaceholdersOnly() { @@ -206,6 +213,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable Assert.Null(placeholder.AckTime); } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorSetAttributeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorSetAttributeTests.cs index c1540b93..d8f850f8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorSetAttributeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorSetAttributeTests.cs @@ -14,7 +14,7 @@ using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// Regression tests for SiteRuntime-001: Instance.SetAttribute must route writes +/// Regression tests: Instance.SetAttribute must route writes /// to the Data Connection Layer for data-sourced attributes instead of persisting /// a local static override. /// </summary> @@ -26,6 +26,7 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable private readonly SiteRuntimeOptions _options; private readonly TestLocalDb _localDb; + /// <summary>Initializes the test class with an actor system and storage.</summary> public InstanceActorSetAttributeTests() { _localDb = TestLocalDb.CreateTemp("instance-setattr-test"); @@ -40,6 +41,7 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable _options = new SiteRuntimeOptions(); } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -87,6 +89,8 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable ] }; + /// <summary>Verifies that setting a data-sourced attribute issues a DCL write and does not persist an override.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttribute_DataSourcedAttribute_IssuesDclWriteAndDoesNotPersistOverride() { @@ -114,6 +118,7 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable Assert.Empty(overrides); } + /// <summary>Verifies that setting a data-sourced attribute does not optimistically update memory.</summary> [Fact] public void SetAttribute_DataSourcedAttribute_DoesNotOptimisticallyUpdateMemory() { @@ -135,6 +140,7 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable Assert.Equal("10", get.Value?.ToString()); } + /// <summary>Verifies that a DCL write failure for a data-sourced attribute is returned to the caller.</summary> [Fact] public void SetAttribute_DataSourcedAttribute_DclWriteFailure_ReturnedToCaller() { @@ -153,6 +159,8 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable Assert.Contains("device rejected write", response.ErrorMessage); } + /// <summary>Verifies that setting a static attribute still persists the override and does not call the DCL.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttribute_StaticAttribute_StillPersistsOverrideAndDoesNotCallDcl() { @@ -182,10 +190,12 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable Assert.Equal("Backup", overrides["Label"]); } - // SiteRuntime-025: SetAttribute on an unknown attribute name must NOT + // SetAttribute on an unknown attribute name must NOT // pollute the in-memory dictionary, NOT publish a synthetic // AttributeValueChanged, and NOT persist a durable override row. + /// <summary>Verifies that setting an unknown attribute returns a failure and does not persist an override.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttribute_UnknownAttribute_ReturnsFailureAndDoesNotPersistOverride() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTagSubscribeBackoffTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTagSubscribeBackoffTests.cs index aaebb22e..936c527b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTagSubscribeBackoffTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTagSubscribeBackoffTests.cs @@ -12,6 +12,7 @@ public class InstanceActorTagSubscribeBackoffTests private static readonly TimeSpan Floor = TimeSpan.FromSeconds(5); private static readonly TimeSpan Max = TimeSpan.FromSeconds(60); + /// <summary>Verifies the case: doubles, with No Jitter.</summary> [Fact] public void Doubles_WithNoJitter() { @@ -19,6 +20,7 @@ public class InstanceActorTagSubscribeBackoffTests Assert.Equal(TimeSpan.FromSeconds(10), next); } + /// <summary>Verifies the case: jitter, spans Plus Minus Twenty Percent.</summary> [Fact] public void Jitter_SpansPlusMinusTwentyPercent() { @@ -28,6 +30,7 @@ public class InstanceActorTagSubscribeBackoffTests Assert.Equal(TimeSpan.FromSeconds(12), high); // 10 s × 1.2 } + /// <summary>Verifies the case: never Exceeds Ceiling, even With Positive Jitter.</summary> [Fact] public void NeverExceedsCeiling_EvenWithPositiveJitter() { @@ -35,6 +38,7 @@ public class InstanceActorTagSubscribeBackoffTests Assert.Equal(Max, next); } + /// <summary>Verifies the case: never Below Floor, even With Negative Jitter.</summary> [Fact] public void NeverBelowFloor_EvenWithNegativeJitter() { @@ -42,6 +46,7 @@ public class InstanceActorTagSubscribeBackoffTests Assert.True(next >= Floor, $"{next} < floor"); } + /// <summary>Verifies the case: ceiling Below Floor, degrades To Fixed Floor.</summary> [Fact] public void CeilingBelowFloor_DegradesToFixedFloor() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs index b45ebaeb..c2a960bc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -28,6 +28,7 @@ public class InstanceActorTests : TestKit, IDisposable private readonly SiteRuntimeOptions _options; private readonly TestLocalDb _localDb; + /// <summary>Initializes the test class with an in-memory site storage and the shared actor dependencies.</summary> public InstanceActorTests() { _localDb = TestLocalDb.CreateTemp("instance-actor-test"); @@ -55,6 +56,7 @@ public class InstanceActorTests : TestKit, IDisposable NullLogger<InstanceActor>.Instance))); } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -65,8 +67,9 @@ public class InstanceActorTests : TestKit, IDisposable TestLocalDb.DeleteFiles(path); } - // ── M1.6: site event log `instance_lifecycle` category ────────────────── + // ── Site event log `instance_lifecycle` category ────────────────── + /// <summary>Verifies that stopping the actor releases its tag subscription under its owner identity.</summary> [Fact] public void Stop_ReleasesTagSubscriptionWithOwnerIdentity() { @@ -78,6 +81,7 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Equal(instance, dcl.LastSender); } + /// <summary>Verifies that starting an instance actor emits an instance lifecycle site event.</summary> [Fact] public void InstanceActor_Start_EmitsInstanceLifecycleSiteEvent() { @@ -112,6 +116,7 @@ public class InstanceActorTests : TestKit, IDisposable }, TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that stopping an instance actor emits an instance lifecycle site event.</summary> [Fact] public void InstanceActor_Stop_EmitsInstanceLifecycleSiteEvent() { @@ -152,6 +157,7 @@ public class InstanceActorTests : TestKit, IDisposable }, TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that the instance actor loads its attributes from the configuration.</summary> [Fact] public void InstanceActor_LoadsAttributesFromConfig() { @@ -177,6 +183,7 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Equal("corr-1", response.CorrelationId); } + /// <summary>Verifies that getting an unknown attribute reports not found.</summary> [Fact] public void InstanceActor_GetAttribute_NotFound_ReturnsFalse() { @@ -196,6 +203,7 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Null(response.Value); } + /// <summary>Verifies that setting a static attribute updates it in memory.</summary> [Fact] public void InstanceActor_SetStaticAttribute_UpdatesInMemory() { @@ -225,6 +233,8 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Equal("100.0", getResponse.Value?.ToString()); } + /// <summary>Verifies that setting a static attribute persists it to SQLite.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActor_SetStaticAttribute_PersistsToSQLite() { @@ -254,7 +264,7 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// Perf remediation (arch-review WP1.5): a burst of rapid static-attribute writes must + /// Perf remediation: a burst of rapid static-attribute writes must /// coalesce into ONE batched SQLite transaction on a short single-shot timer — mirroring /// NativeAlarmActor's MarkDirtyUpsert/FlushDirtyUpserts (P4) shape — rather than a write /// per <see cref="SetStaticAttributeCommand"/>. Pinned two ways: (1) nothing is persisted @@ -262,6 +272,7 @@ public class InstanceActorTests : TestKit, IDisposable /// synchronously and updated in-memory state; (2) only the LATEST value per attribute /// survives, across writes to two different attributes coalesced into the same flush. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActor_RapidStaticWrites_CoalesceIntoSingleFlush() { @@ -324,6 +335,8 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Equal("Running", afterFlush["Label"]); } + /// <summary>Verifies that static overrides are loaded from SQLite.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActor_LoadsStaticOverridesFromSQLite() { @@ -353,6 +366,8 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Equal("200.0", response.Value?.ToString()); } + /// <summary>Verifies that a static override is reset on redeployment.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaticOverride_ResetOnRedeployment() { @@ -364,7 +379,7 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Single(overrides); // Clear overrides BEFORE the successor exists — the ordering DeploymentManager now - // guarantees (arch-review 2026-08-19 P1-2). It used to clear inside the deploy's + // guarantees. It used to clear inside the deploy's // persistence task, i.e. after creating the actor, so this "simulation" pinned an // ordering the production path did not have; the end-to-end contract lives in // RedeployStaticOverrideResetTests. @@ -394,6 +409,7 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Equal("98.6", response.Value?.ToString()); } + /// <summary>Verifies that a data-sourced attribute starts with uncertain quality.</summary> [Fact] public void InstanceActor_DataSourcedAttribute_StartsWithUncertainQuality() { @@ -424,7 +440,7 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// SiteRuntime-019: the disable/enable lifecycle is owned entirely by the + /// The disable/enable lifecycle is owned entirely by the /// Deployment Manager (it stops / re-creates the Instance Actor itself and /// replies to the caller). The Instance Actor must NOT handle /// <see cref="DisableInstanceCommand"/> / <see cref="EnableInstanceCommand"/> @@ -450,6 +466,7 @@ public class InstanceActorTests : TestKit, IDisposable ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that a static attribute starts with good quality.</summary> [Fact] public void InstanceActor_StaticAttribute_StartsWithGoodQuality() { @@ -537,7 +554,7 @@ public class InstanceActorTests : TestKit, IDisposable } } - // ── MV-8: data-sourced List attribute coercion ───────────────────────── + // ── Data-sourced List attribute coercion ───────────────────────── private IActorRef CreateInstanceActorWithDcl(string instanceName, FlattenedConfiguration config, TestProbe dcl) { @@ -558,7 +575,7 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// MV-8: when a data-sourced attribute is declared <c>DataType.List</c>, an + /// When a data-sourced attribute is declared <c>DataType.List</c>, an /// incoming OPC UA array value (a CLR array surfaces from the SDK) must be /// coerced into a typed <c>List<int></c> whose elements match the /// attribute's ElementDataType. The stored value must be a real list — not a @@ -598,7 +615,7 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// MV-8: array elements coming in as a different CLR type (here, strings that + /// Array elements coming in as a different CLR type (here, strings that /// are valid integers) must still coerce to the declared element type. /// </summary> [Fact] @@ -635,7 +652,7 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// MV-8: an element that cannot be coerced to the declared element type must + /// An element that cannot be coerced to the declared element type must /// set the attribute quality to <c>Bad</c> and must NOT crash the actor (it /// stays alive and continues to answer queries). /// </summary> @@ -675,7 +692,7 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// MV-8 design contract: a failed coercion keeps the PRIOR value. A good + /// Design contract: a failed coercion keeps the PRIOR value. A good /// array is delivered first and stored as a typed list; a subsequent array /// with a non-coercible element must NOT overwrite that value — the stored /// value stays the prior list while the quality flips to <c>Bad</c>. @@ -718,7 +735,7 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// MV-8 guard: scalar (non-List) data-sourced attributes keep the existing + /// Guard: scalar (non-List) data-sourced attributes keep the existing /// pass-through behaviour — a scalar value is stored unchanged. /// </summary> [Fact] @@ -846,10 +863,10 @@ public class InstanceActorTests : TestKit, IDisposable ExpectNoMsg(within); } - // ── MV-7: static (authored) List attribute decode ────────────────────── + // ── Static (authored) List attribute decode ────────────────────── /// <summary> - /// MV-7: a STATIC List attribute carries its default as the canonical JSON + /// A STATIC List attribute carries its default as the canonical JSON /// array string. On load the actor must decode it to a typed list so a /// script reading the attribute receives a real collection, not the raw /// JSON string. @@ -882,11 +899,12 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// MV-7: a SetStaticAttribute write on a List attribute decodes the canonical + /// A SetStaticAttribute write on a List attribute decodes the canonical /// JSON value into a typed list for in-memory reads, but the PERSISTED form /// (SQLite static override) must remain the canonical JSON string — never a /// CLR-list .ToString(). /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActor_SetStaticListAttribute_ReadsTypedList_PersistsJsonString() { @@ -925,10 +943,11 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// MV-7: a persisted static override for a List attribute is a canonical JSON + /// A persisted static override for a List attribute is a canonical JSON /// string in SQLite; on load it must be decoded to a typed list, the same as /// the config default. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActor_StaticListOverride_LoadsAsTypedList() { @@ -961,7 +980,7 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// MV-7: a malformed stored List value must NOT crash the actor — it loads + /// A malformed stored List value must NOT crash the actor — it loads /// with quality Bad and the actor stays alive and answering. /// </summary> [Fact] @@ -995,7 +1014,7 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// MV-7 guard: a scalar static attribute is unaffected by the List decode + /// Guard: a scalar static attribute is unaffected by the List decode /// path — it still returns its raw string value. /// </summary> [Fact] @@ -1021,13 +1040,14 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// MV-7 (review fix): a SetStaticAttribute write whose value fails to decode as + /// A SetStaticAttribute write whose value fails to decode as /// a list (e.g. truncated JSON) on a List attribute must be REJECTED — reply /// Success=false with a clear error and persist NOTHING. The script path always /// pre-encodes valid JSON, but the Inbound API / direct-command path can submit /// an arbitrary value, so a malformed value must not silently null the in-memory /// value, publish "Good" quality, and durably persist a poison override. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActor_SetStaticListAttribute_Malformed_Rejected_NotPersisted() { @@ -1067,11 +1087,12 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// MV-7 (review fix): an empty-list value "[]" decodes to a non-null empty list + /// An empty-list value "[]" decodes to a non-null empty list /// and must be accepted (NOT mistaken for a malformed value, which also decodes /// to null). This pins the boundary between the "clearing/empty" and "poison" /// cases that both surface as a null decode result. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActor_SetStaticListAttribute_EmptyList_Accepted() { @@ -1108,14 +1129,15 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Equal("[]", overrides["Labels"]); } - // ── NJ-4: old-form List static override normalization on load ──────────── + // ── Old-form List static override normalization on load ──────────── /// <summary> - /// NJ-4: an OLD array-of-strings static override (<c>["10","20"]</c>) for an + /// An OLD array-of-strings static override (<c>["10","20"]</c>) for an /// Int32 List attribute must be re-persisted in the native form (<c>[10,20]</c>) /// when the actor loads it at startup. The in-memory read still returns the /// typed list {10,20}; the on-disk value is normalized to native JSON. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActor_OldFormListOverride_NormalizedToNativeOnLoad() { @@ -1153,10 +1175,11 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// NJ-4: a NATIVE-form static override (<c>[10,20]</c>) is already canonical, so + /// A NATIVE-form static override (<c>[10,20]</c>) is already canonical, so /// load-time normalization must be a no-op — the on-disk value is unchanged /// (idempotent: native → native is byte-identical, so no re-persist occurs). /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActor_NativeFormListOverride_NotRePersistedOnLoad() { @@ -1191,9 +1214,10 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// NJ-4: a scalar static override is unaffected by the List normalization path — + /// A scalar static override is unaffected by the List normalization path — /// its on-disk value is left exactly as stored (no native re-encode). /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActor_ScalarOverride_NotTouchedByListNormalization() { @@ -1222,10 +1246,11 @@ public class InstanceActorTests : TestKit, IDisposable } /// <summary> - /// NJ-4: a malformed stored List override (truncated JSON) must NOT crash the + /// A malformed stored List override (truncated JSON) must NOT crash the /// actor and must NOT be re-persisted — it loads with Bad quality (as today), /// the actor stays alive, and the poison on-disk value is left unchanged. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActor_MalformedListOverride_BadQuality_NotRePersisted() { @@ -1298,6 +1323,7 @@ public class InstanceActorTests : TestKit, IDisposable return (actor, dcl); } + /// <summary>Verifies that a failed subscribe response is retried.</summary> [Fact] public void FailedSubscribeResponse_IsRetried() { @@ -1313,6 +1339,7 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Equal(first.TagPaths, retry.TagPaths); } + /// <summary>Verifies that a lost subscribe response is retried and a success stops the retrying.</summary> [Fact] public void LostSubscribeResponse_IsRetried_AndSuccessStopsRetrying() { @@ -1331,6 +1358,7 @@ public class InstanceActorTests : TestKit, IDisposable // --- Lever 1 (2026-08-28): in-flight guard --- + /// <summary>Verifies that a slow subscribe does not stack duplicate requests while one is in flight.</summary> [Fact] public void SlowSubscribe_DoesNotStackDuplicates_WhileInFlight() { @@ -1344,10 +1372,11 @@ public class InstanceActorTests : TestKit, IDisposable var (_, dclProbe) = CreateInstanceWithDataSourcedAttribute(options); dclProbe.ExpectMsg<SubscribeTagsRequest>(); - // Pre-Lever-1 this window would carry ~4 more requests (one per 200 ms tick). + // Before the in-flight guard this window would carry ~4 more requests (one per 200 ms tick). dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(1000)); } + /// <summary>Verifies that a slow subscribe is re-issued once after the in-flight ceiling.</summary> [Fact] public void SlowSubscribe_ReissuesAfterInFlightCeiling_AndOnlyOnce() { @@ -1367,6 +1396,7 @@ public class InstanceActorTests : TestKit, IDisposable dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400)); } + /// <summary>Verifies that a reply to a superseded request is ignored and the latest request still governs.</summary> [Fact] public void ReplyToSupersededRequest_IsIgnored_AndLatestStillGoverns() { @@ -1396,6 +1426,7 @@ public class InstanceActorTests : TestKit, IDisposable // --- Lever 1 (2026-08-28): backoff on failure, reset on success --- + /// <summary>Verifies that repeated failures back off and a success resets the interval to its floor.</summary> [Fact] public void RepeatedFailures_BackOff_AndSuccessResetsToFloor() { @@ -1431,6 +1462,7 @@ public class InstanceActorTests : TestKit, IDisposable dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(1200)); } + /// <summary>Verifies that a failure reply re-issues the request even though the in-flight guard has not expired.</summary> [Fact] public void FailureReply_ReissuesEvenThoughGuardHasNotExpired() { @@ -1453,6 +1485,7 @@ public class InstanceActorTests : TestKit, IDisposable // --- Lever 1 (2026-08-28): Good quality resets the backoff --- + /// <summary>Verifies that a flapping Good quality does not defer the retry.</summary> [Fact] public void FlappingGood_DoesNotDeferTheRetry() { @@ -1489,6 +1522,7 @@ public class InstanceActorTests : TestKit, IDisposable dclProbe.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromMilliseconds(50)); } + /// <summary>Verifies that the connection returning to Good resets the backoff and retries promptly.</summary> [Fact] public void ConnectionBackToGood_ResetsBackoff_AndRetriesPromptly() { @@ -1518,6 +1552,7 @@ public class InstanceActorTests : TestKit, IDisposable Assert.True(sw.Elapsed < TimeSpan.FromMilliseconds(1000), $"retry took {sw.Elapsed} — backoff not reset"); } + /// <summary>Verifies that a late reply for a failed and re-issued request is ignored.</summary> [Fact] public void LateReplyForFailedAndReissuedRequest_IsIgnored() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTraceArmTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTraceArmTests.cs index d10e87ce..3a3207d9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTraceArmTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTraceArmTests.cs @@ -31,6 +31,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Initializes the test with a temporary site database and the script services.</summary> public InstanceActorTraceArmTests() { _localDb = TestLocalDb.CreateTemp("instance-tracearm-test"); @@ -40,6 +41,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable _sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { Shutdown(); @@ -107,6 +109,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable m => m.AttributeName == "Processed" && Equals(m.Value, value), TimeSpan.FromSeconds(10)); } + /// <summary>Verifies that a traced data write followed by its echo publishes a change stamped with the source and trace.</summary> [Fact] public void TracedDataWrite_ThenEcho_PublishesChangeStampedWithSourceAndTrace() { @@ -122,6 +125,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable Assert.True(published.SourceExecutionInferred); } + /// <summary>Verifies that an untraced data write followed by its echo publishes an unstamped change.</summary> [Fact] public void UntracedDataWrite_ThenEcho_PublishesUnstampedChange() { @@ -183,6 +187,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable Assert.True(published.Trace); } + /// <summary>Verifies that the trace arm is consumed only once.</summary> [Fact] public void Arm_IsConsumedOnce() { @@ -199,6 +204,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable Assert.False(second.Trace); } + /// <summary>Verifies that an expired trace arm is ignored.</summary> [Fact] [Trait("Category", "Slow")] public void Arm_Expired_IsIgnored() @@ -213,6 +219,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable Assert.False(published.Trace); } + /// <summary>Verifies that a traced static write publishes the trace flag.</summary> [Fact] public void StaticWrite_Traced_PublishesTraceFlag() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs index 102947e9..771a1c19 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs @@ -29,6 +29,7 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable private readonly SiteRuntimeOptions _options; private readonly TestLocalDb _localDb; + /// <summary>Initializes a new instance of the <c>InstanceActorWaitForAttributeTests</c> class.</summary> public InstanceActorWaitForAttributeTests() { _localDb = TestLocalDb.CreateTemp("instance-waitfor-test"); @@ -56,6 +57,7 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable NullLogger<InstanceActor>.Instance))); } + /// <inheritdoc /> void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; @@ -474,7 +476,7 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable // The pending length>=3 waiter must NOT fire on this non-change republish. ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - // A REAL change to a length-3 list resolves the still-pending waiter. + // A REAL change to a three-element list resolves the still-pending waiter. actor.Tell(new TagValueUpdate("PLC", tag, new[] { 7, 8, 9 }, QualityCode.Good, DateTimeOffset.UtcNow)); var realChange = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5)); Assert.Equal("wfa-len3", realChange.CorrelationId); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceSweepSelfHealTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceSweepSelfHealTests.cs index 66bb941d..baecbfd2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceSweepSelfHealTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceSweepSelfHealTests.cs @@ -49,6 +49,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Creates a temporary LocalDb-backed site store and the compilation services for each test.</summary> public InstanceSweepSelfHealTests() { _localDb = TestLocalDb.CreateTemp("instance-sweep-test"); @@ -60,6 +61,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { // Shut the actors down BEFORE the ILocalDb they write through is disposed. @@ -191,7 +193,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable // ── Row 78: a config that arrives with no deploy, no enable and no reconcile ── /// <summary> - /// THE row-78 reproduction. The node boots with an empty deployed-config store (so the + /// THE reproduction of the missing-start bug. The node boots with an empty deployed-config store (so the /// boot-time load starts nothing), and the config for an ENABLED instance then appears in /// storage on its own — exactly what LocalDb's CDC sync applier does when it seeds a fresh /// node from its peer. No <see cref="DeployInstanceCommand"/>, no @@ -202,6 +204,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// snapshot still answers <c>InstanceNotFound = true</c>, the live symptom verbatim. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sweep_StartsEnabledInstance_WhoseConfigArrivedWithoutADeployOrReconcile() { @@ -231,6 +234,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// pattern as row 67, with the trigger named so an operator can tell a sweep heal from a /// startup-reconcile heal. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SweepStartedInstance_EmitsAWarningSiteEvent_NamingTheSweepTrigger() { @@ -259,6 +263,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// A DISABLED instance legitimately has a config row and no running actor. The sweep must /// never start it — that would silently undo an operator's disable. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sweep_NeverStarts_ADisabledInstance() { @@ -280,6 +285,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// that re-fired every tick would be a restart loop plus an unbounded stream of Warning site /// events. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sweep_StartsTheInstanceExactlyOnce_AcrossManySweeps() { @@ -306,6 +312,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// A sweep that finds nothing wrong must be completely silent — no site events at all. This /// runs on every tick of every healthy site, forever. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sweep_IsSilent_WhenThereIsNothingToHeal() { @@ -327,6 +334,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// site that wants the pre-fix behaviour keeps it (and the option's disable path is proven, /// not merely validated). /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sweep_DoesNothing_WhenTheIntervalIsZero() { @@ -355,6 +363,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// nothing. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sweep_DefersToAnInFlightRedeploy() { @@ -393,17 +402,18 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable // ── Row 72: a running actor serving a revision the store has replaced ── /// <summary> - /// THE row-72 reproduction. An instance is deployed and running at <c>rev-1</c>; the durable - /// row is then refreshed to <c>rev-2</c> out of band — what + /// THE reproduction of the stale-revision bug. An instance is deployed and running at its first + /// revision; the durable row is then refreshed to the second revision out of band — what /// <c>StoreDeployedConfigIfNewerAsync</c> does when the startup reconcile finds a STALE /// config, and what a replicated deploy does on the standby. /// /// <para> - /// <b>Fails before the fix</b>: the actor keeps serving <c>rev-1</c>'s config indefinitely + /// <b>Fails before the fix</b>: the actor keeps serving the first revision's config indefinitely /// (the snapshot still shows the old attribute value) and nothing anywhere says the stored /// and running revisions differ. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sweep_RestartsInstance_ServingAStaleRevision() { @@ -444,6 +454,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// The stale-revision restart must not fire when the stored and running revisions agree — /// otherwise every healthy instance on the site would be restarted once a minute. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sweep_DoesNotRestart_WhenStoredAndRunningRevisionsMatch() { @@ -470,6 +481,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// site events for a config that can never apply, and the running instance is unharmed — /// the gate returns before anything is stopped, so it keeps serving the old config. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sweep_StaleRestartRejectedByTheCompileGate_IsAnnouncedOnceAndNotRetried() { @@ -541,12 +553,12 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable StoreGenerationAtRead: generationAtRead); /// <summary> - /// <b>Deferred register row 101, the row-72 leg.</b> A sweep read dispatched before a deploy + /// <b>Deferred register row 101, the stale-revision leg.</b> A sweep read dispatched before a deploy /// persisted its new row can land after that deploy has completed AND after the deploy /// pipeline released the instance — <see cref="DeploymentManagerActor"/>'s /// <c>InstanceIsOwnedByADeployPipeline</c> only covers a deploy still in flight, so it is no /// defence here. The snapshot then carries the revision the deploy just REPLACED while the - /// running actor carries the new one, which is bit-for-bit the divergence the row-72 repair + /// running actor carries the new one, which is bit-for-bit the divergence the stale-revision repair /// looks for — in the wrong direction. /// /// <para> @@ -556,6 +568,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// claim marks the OLD revision as attempted, so the instance simply stays reverted. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaleSweepSnapshot_CarryingASupersededRevision_DoesNotRestartBackward() { @@ -586,7 +599,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable } /// <summary> - /// The row-101 guard must not blunt the row-72 repair it sits in front of: the SAME message + /// The stale-snapshot guard must not blunt the stale-revision repair it sits in front of: the SAME message /// stamped with the CURRENT generation is a genuine answer and must still converge the /// runtime on the stored revision. /// @@ -597,6 +610,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// meaning, which is the whole point of the guard. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FreshSweepSnapshot_CarryingANewerRevision_StillRestartsTheInstance() { @@ -630,7 +644,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable } /// <summary> - /// <b>Deferred register row 101, the row-78 leg.</b> A sweep read dispatched before a delete + /// <b>Deferred register row 101, the missing-start leg.</b> A sweep read dispatched before a delete /// committed still shows the instance's row — enabled, with a config — while the actor has /// already stopped it and dropped its bookkeeping. The delete specifically clears /// <c>_sweepAttemptedRevisions</c>, so the once-per-revision claim is freshly available and @@ -641,6 +655,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// re-adds it to the deployed-name set, and announces the resurrection as a self-heal. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaleSweepSnapshot_ShowingADeletedInstance_DoesNotResurrectIt() { @@ -667,9 +682,10 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// <summary> /// The mirror of the resurrection test: the same enabled-config-with-no-actor snapshot, - /// stamped with the CURRENT generation, must still perform the row-78 start. Nothing has + /// stamped with the CURRENT generation, must still perform the missing-start heal. Nothing has /// mutated the store through this actor, so <c>0</c> is current here. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FreshSweepSnapshot_ShowingAStoredEnabledInstance_StillStartsIt() { @@ -700,6 +716,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable /// /// <para><b>Fails before the fix</b>: one read, and the pre-mutation snapshot is applied.</para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaleBootSnapshot_IsReReadRatherThanApplied() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs index c854004c..9f9568e5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs @@ -17,7 +17,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// Task 15: NativeAlarmActor mirrors a source's native alarms — subscribe on start, +/// NativeAlarmActor mirrors a source's native alarms — subscribe on start, /// emit enriched AlarmStateChanged, snapshot atomic swap, retention, persistence. /// </summary> public class NativeAlarmActorTests : TestKit, IDisposable @@ -26,6 +26,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable private readonly SiteStorageService _storage; private readonly SiteRuntimeOptions _options = new(); + /// <summary>Initializes the test class and its test doubles.</summary> public NativeAlarmActorTests() { // SiteStorageService takes an ILocalDb now; LocalDb has no in-memory mode, so the @@ -56,6 +57,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable Source(), "inst", instanceActor, dclManager, _storage, options, NullLogger<NativeAlarmActor>.Instance, AlarmKind.NativeOpcUa, serviceProvider))); + /// <summary>Verifies that stopping the actor releases the alarm subscription with the owner identity.</summary> [Fact] public void Stop_ReleasesAlarmSubscriptionWithOwnerIdentity() { @@ -68,6 +70,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable Assert.Equal(actor, dcl.LastSender); } + /// <summary>Verifies that a duplicate snapshot end does not clear a committed alarm.</summary> [Fact] public void DuplicateSnapshotEnd_DoesNotClearCommittedAlarm() { @@ -99,6 +102,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable instance.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } + /// <summary>Verifies that the actor sends a subscribe request for the source binding on start.</summary> [Fact] public void SubscribeOnStart_SendsRequestForSourceBinding() { @@ -111,6 +115,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable Assert.Equal("ns=2;s=T01", req.SourceReference); } + /// <summary>Verifies that a raise emits an enriched AlarmStateChanged.</summary> [Fact] public void Raise_EmitsEnrichedAlarmStateChanged() { @@ -130,10 +135,11 @@ public class NativeAlarmActorTests : TestKit, IDisposable Assert.Equal(800, emitted.Condition.Severity); } + /// <summary>Verifies that a raise stamps the native source canonical name from the configured binding.</summary> [Fact] public void Raise_StampsNativeSourceCanonicalName_FromConfiguredBinding() { - // DV-1: every emitted condition carries the canonical name of the source + // Every emitted condition carries the canonical name of the source // BINDING it belongs to, so the DebugView can nest live native conditions // under their configured binding node. It must equal the binding's // CanonicalName used to construct the actor (Source().CanonicalName). @@ -151,6 +157,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable Assert.Equal("Pressure", emitted.NativeSourceCanonicalName); } + /// <summary>Verifies that a snapshot completing with a missing prior alarm emits a return to normal.</summary> [Fact] public void SnapshotComplete_WithMissingPriorAlarm_EmitsReturnToNormal() { @@ -174,6 +181,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable Assert.Equal(AlarmState.Normal, cleared.State); } + /// <summary>Verifies that an older transition is ignored.</summary> [Fact] public void OlderTransition_IsIgnored() { @@ -196,7 +204,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable instance.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } - // ── PLAN-03 Task 20 (P4): coalesced batched persistence ───────────────── + // ── Coalesced batched persistence ───────────────── private IActorRef SpawnWithFlush(IActorRef instanceActor, IActorRef dclManager, TimeSpan flushInterval) => ActorOf(Props.Create(() => new NativeAlarmActor( @@ -208,6 +216,8 @@ public class NativeAlarmActorTests : TestKit, IDisposable sourceRef, AlarmTransitionKind.Raise, new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, severity), time))); + /// <summary>Verifies that an alarm storm coalesces persistence so the latest write per source reference wins.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AlarmStorm_CoalescesPersistence_LatestPerSourceRefWins() { @@ -226,12 +236,14 @@ public class NativeAlarmActorTests : TestKit, IDisposable var rows = await _storage.GetNativeAlarmsAsync("inst", Source().CanonicalName); Assert.Equal(5, rows.Count); - // The LAST write for ref-4 carries severity 49 (i = 49, 49 % 5 == 4). + // The LAST write for source ref 4 carries severity 49 (i = 49, 49 % 5 == 4). Assert.Contains(rows, r => r.ConditionJson.Contains("49")); } - // ── PLAN-03 Task 21 (UA4): rehydration restores display metadata ──────── + // ── Rehydration restores display metadata ──────── + /// <summary>Verifies that rehydration restores the display metadata.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Rehydration_RestoresDisplayMetadata() { @@ -266,8 +278,9 @@ public class NativeAlarmActorTests : TestKit, IDisposable Assert.Equal("Tank overflow", emitted.Message); } - // ── M1.5: site event log `alarm` category ────────────────────────────── + // ── Site event log `alarm` category ────────────────────────────── + /// <summary>Verifies that a raise emits an alarm site event.</summary> [Fact] public void Raise_EmitsAlarmSiteEvent() { @@ -293,6 +306,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable }, TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that a clear emits an informational alarm site event.</summary> [Fact] public void Clear_EmitsInfoAlarmSiteEvent() { @@ -323,6 +337,8 @@ public class NativeAlarmActorTests : TestKit, IDisposable }, TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that rehydration does not emit a site event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Rehydration_DoesNotEmitSiteEvent() { @@ -352,6 +368,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable TimeSpan.FromSeconds(1)); } + /// <summary>Verifies that a snapshot swap with an existing active condition does not re-emit.</summary> [Fact] public void SnapshotSwap_ExistingActiveCondition_DoesNotReEmit() { @@ -386,6 +403,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable Assert.Single(siteLog.OfType("alarm")); } + /// <summary>Verifies that an acknowledge emits an informational alarm site event mentioning acknowledged.</summary> [Fact] public void Acknowledge_EmitsInfoAlarmSiteEventMentioningAcknowledged() { @@ -418,17 +436,18 @@ public class NativeAlarmActorTests : TestKit, IDisposable }, TimeSpan.FromSeconds(2)); } - // ── SiteRuntime-028: cap eviction emits a return-to-normal for an active drop ── + // ── Cap eviction emits a return-to-normal for an active drop ── + /// <summary>Verifies that enforcing the cap evicts the oldest active condition and emits a return to normal and a drop signal.</summary> [Fact] public void EnforceCap_EvictsActiveOldestCondition_EmitsReturnToNormalAndDropSignal() { - // SiteRuntime-028: a cap eviction that drops a still-Active condition without a + // A cap eviction that drops a still-Active condition without a // return-to-normal leaves the Instance Actor (and central's stream / the // operator Alarm Summary) showing a phantom Active alarm forever. With cap=1, // raising a second condition evicts the oldest (still Active) one — which must // produce a Normal AlarmStateChanged for the evicted SourceReference, plus the - // SiteRuntime-027 NativeAlarmDropped so the parent evicts its stale key. + // NativeAlarmDropped so the parent evicts its stale key. var instance = CreateTestProbe(); var dcl = CreateTestProbe(); var options = new SiteRuntimeOptions { MirroredAlarmCapPerSource = 1 }; @@ -456,12 +475,13 @@ public class NativeAlarmActorTests : TestKit, IDisposable instance.ExpectMsg<NativeAlarmDropped>(m => m.SourceReference == "OLD.Hi"); } - // ── SiteRuntime-027: terminal retention drop signals the parent to evict its key ── + // ── Terminal retention drop signals the parent to evict its key ── + /// <summary>Verifies that a retention drop of a resolved condition emits a return to normal and then a drop signal.</summary> [Fact] public void RetentionDrop_ResolvedCondition_EmitsReturnToNormalThenDropSignal() { - // SiteRuntime-027: when a native condition resolves (inactive AND acknowledged) + // When a native condition resolves (inactive AND acknowledged) // it drops out of the mirror. The Instance Actor must be told (NativeAlarmDropped) // so its _latestAlarmEvents map does not retain a stale (Normal) entry forever — // otherwise a source that mints a fresh SourceReference per occurrence leaks one @@ -486,6 +506,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable instance.ExpectMsg<NativeAlarmDropped>(m => m.SourceReference == "T01.Hi"); } + /// <inheritdoc /> void IDisposable.Dispose() { // Shut the actor system down FIRST: in-flight alarm actors still hold the @@ -500,6 +521,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable // ── MES alarm-status API §6.4: AckTime through the mirror ────────────── + /// <summary>Verifies that the emitted state carries the adapter's ack time verbatim.</summary> [Fact] public void Emit_CarriesTheAdaptersAckTimeVerbatim() { @@ -527,6 +549,8 @@ public class NativeAlarmActorTests : TestKit, IDisposable Assert.True(acked.Condition.Acknowledged); } + /// <summary>Verifies that rehydration restores the ack time so it survives failover.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Rehydration_RestoresAckTime_SoItSurvivesFailover() { @@ -561,6 +585,8 @@ public class NativeAlarmActorTests : TestKit, IDisposable Assert.Equal(ackedAt, emitted.AckTime); } + /// <summary>Verifies that rehydrating a row from before ack times existed leaves the ack time null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Rehydration_OfAPreAckTimeRow_LeavesAckTimeNull() { @@ -591,6 +617,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable Assert.Null(emitted.AckTime); // …with no ack time invented } + /// <summary>Verifies that a lost subscribe response causes the subscribe to be resent.</summary> [Fact] public void LostSubscribeResponse_ResendsSubscribe() { @@ -604,6 +631,9 @@ public class NativeAlarmActorTests : TestKit, IDisposable // Swallow the response entirely — the actor must still re-subscribe. dcl.ExpectMsg<SubscribeAlarmsRequest>(TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that a late rehydration cannot resurrect a resolved or snapshot-removed alarm.</summary> + /// <param name="snapshot">Whether the scenario removes the alarm through a snapshot rather than resolving it.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] @@ -627,6 +657,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable Assert.DoesNotContain(messages.OfType<AlarmStateChanged>(), e => e.State == AlarmState.Active); } + /// <summary>Verifies that a stale success cannot cancel the current subscribe retry.</summary> [Fact] public void SR3_StaleSuccessCannotCancelCurrentSubscribeRetry() { @@ -641,6 +672,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that a connection-wide restart discards an interrupted MX snapshot.</summary> [Fact] public void DC5_ConnectionWideRestartDiscardsInterruptedMxSnapshot() { @@ -654,6 +686,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } + /// <summary>Verifies that a live clear during a snapshot cannot be resurrected by the snapshot end.</summary> [Fact] public void DC5_LiveClearDuringSnapshotCannotBeResurrectedBySnapshotEnd() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ReconcileStartsInstanceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ReconcileStartsInstanceTests.cs index e8755edd..ef7bf077 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ReconcileStartsInstanceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ReconcileStartsInstanceTests.cs @@ -47,6 +47,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Initializes the test class with a fresh local database and the actor-system fixtures.</summary> public ReconcileStartsInstanceTests() { _localDb = TestLocalDb.CreateTemp("reconcile-start-test"); @@ -59,6 +60,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { // Shut the actors down BEFORE the ILocalDb they write through is disposed. @@ -79,13 +81,13 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable /// would still pass, while proving the BOOT LOAD works rather than the startup-reconcile path /// it claims to cover. A sleep only makes that race unlikely; on a loaded machine the load can /// still be in flight when it expires, and the test silently changes what it proves instead of - /// failing. The row-78 fix session hit exactly this trap and closed it in - /// <c>InstanceSweepSelfHealTests</c>; this is the same gate, applied to row 67's suite. + /// failing. An earlier fix session hit exactly this trap and closed it in + /// <c>InstanceSweepSelfHealTests</c>; this is the same gate, applied to this suite. /// </para> /// /// <para> /// The sweep is disabled (<c>InstanceSweepIntervalSeconds = 0</c>) so these tests keep proving - /// the row-67 <see cref="EnsureInstanceStartedCommand"/> path specifically — with the sweep + /// the <see cref="EnsureInstanceStartedCommand"/> path specifically — with the sweep /// armed, its periodic self-heal would independently start the same instances and the suite /// would no longer distinguish the two mechanisms. /// </para> @@ -165,7 +167,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable // ── The reproduction ── /// <summary> - /// THE row-67 reproduction, end to end over one shared storage file: the node boots with no + /// THE reproduction, end to end over one shared storage file: the node boots with no /// config for an enabled instance (so the Deployment Manager's startup load starts nothing), /// then the startup reconcile pass fetches and stores that config. The instance must be /// RUNNING afterwards — not merely present in SQLite waiting for a second restart. @@ -176,6 +178,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable /// Instance Actor. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartupReconcile_StoringConfigForEnabledInstance_StartsTheInstanceActor() { @@ -221,6 +224,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable /// <c>deployment</c> site event naming the instance, so an operator can see that a node /// booted without a config and self-repaired. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReconcileStartedInstance_EmitsASiteEvent() { @@ -319,6 +323,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable /// The command is idempotent: both site nodes' reconcile actors legitimately raise it for /// the same instance, and a repeat must not disturb (or replace) the running actor. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnsureInstanceStarted_IsIdempotent_WhenAlreadyRunning() { @@ -353,6 +358,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable /// stopped even if an ensure-started request arrives for it (e.g. a disable landed between /// the reconcile fetch and the start request). /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnsureInstanceStarted_DisabledInstance_StaysStopped() { @@ -373,6 +379,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable /// no-op — the normal case on an unreplicated site whose peer node healed its own file. /// The Deployment Manager must not crash or invent an instance. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnsureInstanceStarted_UnknownInstance_IsAQuietNoOp() { @@ -404,11 +411,12 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable /// /// <para> /// This test holds the read open, commits a disable behind it, then releases it — the - /// interleaving the fix's store-write-generation stamp exists to reject (arch-review - /// 2026-08-19 P1-1). <b>Fails before the fix</b>: the stale enabled snapshot passes every + /// interleaving the fix's store-write-generation stamp exists to reject. + /// <b>Fails before the fix</b>: the stale enabled snapshot passes every /// guard and the Instance Actor is created for a disabled instance. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnsureInstanceStarted_ResultLandingAfterADisable_IsDiscarded() { @@ -451,6 +459,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable /// /// <para><b>Fails before the fix</b> at the final assert.</para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnsureInstanceStarted_ResultLandingAfterADelete_IsDiscarded() { @@ -485,8 +494,9 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable /// <summary> /// The complement of the two tests above: with no mutation in the read's window the heal /// still starts the instance, so the generation check rejects superseded snapshots without - /// disabling the row-67 repair itself. + /// disabling the repair itself. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnsureInstanceStarted_ResultLandingWithNoInterveningWrite_StillStarts() { @@ -523,7 +533,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable /// Reads real storage every time and only parks AFTER the read, so the returned snapshot is /// exactly what production would have seen at that instant. Inert until <see cref="Arm"/> /// is called, which keeps the Deployment Manager's boot load — a separate seam - /// (<c>_configLoader</c>, register P1-4) untouched by this gate — unblocked. + /// (<c>_configLoader</c>) untouched by this gate — unblocked. /// </para> /// </summary> private sealed class GatedConfigLoader(SiteStorageService storage) @@ -532,13 +542,20 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable private readonly TaskCompletionSource _parked = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// <summary>Arms the gate so the next load parks until released.</summary> public void Arm() => _release = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); + /// <summary>Releases the parked load so it can complete.</summary> public void Release() => _release!.TrySetResult(); + /// <summary>Waits until a load has parked at the gate.</summary> + /// <returns>A task that completes once a load is parked at the gate.</returns> public Task WaitUntilParkedAsync() => _parked.Task.WaitAsync(TimeSpan.FromSeconds(10)); + /// <summary>Reads the deployed config from storage and then parks at the gate if armed.</summary> + /// <param name="instanceName">The unique name of the instance to load.</param> + /// <returns>The stored deployed instance, or null when none exists.</returns> public async Task<DeployedInstance?> LoadAsync(string instanceName) { var config = await storage.GetDeployedConfigAsync(instanceName); @@ -559,10 +576,14 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable private sealed class FakeConfigFetcher : IDeploymentConfigFetcher { private readonly Func<string, Task<string>> _behavior; + /// <summary>Gets the recorded fetch calls (base URL, deployment id, token).</summary> public ConcurrentQueue<(string BaseUrl, string DeploymentId, string Token)> Calls { get; } = new(); + /// <summary>Initializes the fake with the per-deployment-id behavior to run on each fetch.</summary> + /// <param name="behavior">Maps a deployment id to the fetch result.</param> public FakeConfigFetcher(Func<string, Task<string>> behavior) => _behavior = behavior; + /// <inheritdoc /> public async Task<string> FetchAsync( string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/RedeployStaticOverrideResetTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/RedeployStaticOverrideResetTests.cs index 0d350138..b736ffb6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/RedeployStaticOverrideResetTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/RedeployStaticOverrideResetTests.cs @@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// Regression tests for arch-review 2026-08-19 P1-2 — <b>"static overrides are reset on +/// Regression tests for arch-review 2026-08-19 — <b>"static overrides are reset on /// redeployment" was racy in both directions</b>. /// /// <para> @@ -45,6 +45,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; + /// <summary>Creates a temporary LocalDb-backed site storage, compilation service and shared script library for each test.</summary> public RedeployStaticOverrideResetTests() { _localDb = TestLocalDb.CreateTemp("redeploy-override-test"); @@ -57,6 +58,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { // Shut the actors down BEFORE the ILocalDb they write through is disposed. @@ -135,6 +137,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable /// <see cref="RedeployResetGate_HoldsTheOverrideLoadUntilTheResetHasCommitted"/>. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Redeploy_DoesNotApplyPreRedeployOverridesToTheNewActor() { @@ -179,6 +182,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable /// after it is released proves the read is ordered behind the reset rather than merely /// losing a race to it. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RedeployResetGate_HoldsTheOverrideLoadUntilTheResetHasCommitted() { @@ -223,6 +227,8 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable // Leg 2: final persistence is acknowledged independently of termination. // The manager waits for both before resetting state; a standalone actor still // persists its final dirty batch asynchronously after stopping. + /// <summary>Verifies that stopping an Instance Actor completes its final override flush asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InstanceActorStop_CompletesFinalOverrideFlushAsynchronously() { @@ -264,6 +270,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable /// the clear and durably resurrect the predecessor's values — invisible until the next /// restart loaded them. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Redeploy_LateFlushFromTheStoppedActor_CannotResurrectClearedOverrides() { @@ -297,6 +304,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable /// up on its config defaults, and an override written after it must persist normally. Pins /// that the reset gate did not turn the ordinary deploy path into a no-op. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FirstDeploy_StillLoadsAndPersistsStaticOverridesNormally() { @@ -318,6 +326,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable "corr-get", instanceName, "Temperature", DateTimeOffset.UtcNow)); Assert.Equal("123.4", ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5)).Value?.ToString()); } + /// <summary>Verifies that stopping an actor does not block the dispatcher while the SQLite writer is busy.</summary> [Fact] public void SR5_StopDoesNotBlockDispatcherWhileSqliteWriterIsBusy() { @@ -337,6 +346,8 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable ExpectTerminated(actor, TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that a redeploy waits for the flush acknowledgement after the predecessor terminates.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SR5_RedeployWaitsForFlushAcknowledgementAfterTermination() { @@ -360,6 +371,9 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable Assert.Empty(await _storage.GetStaticOverridesAsync(name)); } + /// <summary>Verifies that cancelling a queued redeploy stops its predecessor.</summary> + /// <param name="delete">Whether the queued operations are deletes rather than redeploys.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] @@ -392,6 +406,8 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable transaction.Commit(); } + /// <summary>Verifies that a delete waits for the final flush and rejects an overlapping start.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SR5_DeleteWaitsForFinalFlushAndRejectsAnOverlappingStart() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs index 92a09b88..3238b275 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs @@ -15,10 +15,8 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// WP-15: Script Actor and Script Execution Actor tests. -/// WP-20: Recursion limit tests. -/// WP-22: Tell vs Ask convention tests. -/// WP-32: Script error handling tests. +/// Script Actor and Script Execution Actor tests, including recursion limit, +/// Tell vs Ask convention and script error handling coverage. /// </summary> public class ScriptActorTests : TestKit, IDisposable { @@ -26,7 +24,7 @@ public class ScriptActorTests : TestKit, IDisposable private readonly SiteRuntimeOptions _options; private readonly ScriptCompilationService _compilationService; - // Gitea #16/#18: every ScriptActor built here runs its trigger-expression + // Every ScriptActor built here runs its trigger-expression // evaluation and spawned script bodies on THIS class's own scheduler, never the // process-wide singleton. That is what makes the faulted-task test below // un-leakable across the assembly: any worker a test strands (or the 30 s bounded @@ -34,6 +32,7 @@ public class ScriptActorTests : TestKit, IDisposable // teardown, so it can never starve unrelated test classes' script executions. private readonly ScriptExecutionScheduler _scheduler; + /// <summary>Initializes the test with an isolated script scheduler, shared script library and compilation service.</summary> public ScriptActorTests() { _compilationService = new ScriptCompilationService( @@ -48,6 +47,7 @@ public class ScriptActorTests : TestKit, IDisposable _scheduler = new ScriptExecutionScheduler(_options.ScriptExecutionThreadCount); } + /// <inheritdoc /> void IDisposable.Dispose() { Shutdown(); @@ -65,6 +65,7 @@ public class ScriptActorTests : TestKit, IDisposable return script; } + /// <summary>Verifies that calling a script returns its result.</summary> [Fact] public void ScriptActor_CallScript_ReturnsResult() { @@ -91,7 +92,7 @@ public class ScriptActorTests : TestKit, IDisposable null, // serviceProvider _scheduler))); - // Ask pattern (WP-22) for CallScript + // Ask pattern for CallScript scriptActor.Tell(new ScriptCallRequest("GetAnswer", null, 0, "corr-1")); var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10)); @@ -99,6 +100,7 @@ public class ScriptActorTests : TestKit, IDisposable Assert.Equal(42, result.ReturnValue); } + /// <summary>Verifies that calling a script with parameters passes them to the script.</summary> [Fact] public void ScriptActor_CallScript_WithParameters_Works() { @@ -133,6 +135,7 @@ public class ScriptActorTests : TestKit, IDisposable Assert.Equal(7, result.ReturnValue); } + /// <summary>Verifies that a script actor with no compiled script replies with an error.</summary> [Fact] public void ScriptActor_NullCompiledScript_ReturnsError() { @@ -165,6 +168,7 @@ public class ScriptActorTests : TestKit, IDisposable Assert.Contains("not compiled", result.ErrorMessage); } + /// <summary>Verifies that a value-change trigger spawns a script execution.</summary> [Fact] public void ScriptActor_ValueChangeTrigger_SpawnsExecution() { @@ -202,6 +206,7 @@ public class ScriptActorTests : TestKit, IDisposable ExpectNoMsg(TimeSpan.FromSeconds(1)); } + /// <summary>Verifies that a trigger arriving within the minimum time between runs is skipped.</summary> [Fact] public void ScriptActor_MinTimeBetweenRuns_SkipsIfTooSoon() { @@ -243,6 +248,7 @@ public class ScriptActorTests : TestKit, IDisposable ExpectNoMsg(TimeSpan.FromSeconds(1)); } + /// <summary>Verifies that a script failure does not disable the script actor.</summary> [Fact] public void ScriptActor_WP32_ScriptFailure_DoesNotDisable() { @@ -342,6 +348,7 @@ public class ScriptActorTests : TestKit, IDisposable private AttributeValueChanged Change(string attribute, object? value) => new("TestInstance", attribute, attribute, value, "Good", DateTimeOffset.UtcNow); + /// <summary>Verifies that the non-numeric fallback comparison of a conditional trigger is culture-invariant.</summary> [Fact] public void ConditionalTrigger_NonNumericFallback_IsCultureInvariant() { @@ -393,6 +400,7 @@ public class ScriptActorTests : TestKit, IDisposable return s; } + /// <summary>Verifies that a faulted expression evaluation clears the in-flight flag and drains the pending evaluation.</summary> [Fact] public void ExpressionEvalTaskFault_ClearsInFlight_AndDrainsPendingEvaluation() { @@ -406,10 +414,10 @@ public class ScriptActorTests : TestKit, IDisposable actor.Tell(Change("A", "1")); // eval starts off-thread and BLOCKS → _evalInFlight = true AwaitAssert(() => Assert.Equal(1, EvalGate.Entries), TimeSpan.FromSeconds(10)); - // WP3.1: the blocked evaluation no longer occupies a script-execution worker at + // The blocked evaluation no longer occupies a script-execution worker at // all — it runs on the shared thread pool behind TriggerEvalGate, which is the - // whole point of finding #4. The blocking pool must be completely idle here; the - // pre-WP3.1 assertion was the opposite (BusyThreadCount == 1). + // whole point of moving it off the blocking pool. The blocking pool must be completely idle here; the + // earlier assertion was the opposite (BusyThreadCount == 1). Assert.Equal(0, _scheduler.BusyThreadCount); Assert.Equal(0, _scheduler.QueueDepth); @@ -433,14 +441,15 @@ public class ScriptActorTests : TestKit, IDisposable } } + /// <summary>Verifies that an expression trigger is evaluated off the blocking script pool and still fires.</summary> [Fact] public void ExpressionTrigger_EvaluatesOffTheBlockingScriptPool_AndStillFires() { - // WP3.1 retarget of the former P1 assertion, whose sense is deliberately INVERTED. + // Retarget of the former P1 assertion, whose sense is deliberately INVERTED. // // P1 moved trigger-expression evaluation off the actor's dispatcher thread and onto // the dedicated script-execution scheduler, and this test asserted exactly that - // ("evaluated on a script-execution-* thread"). WP3.1 (finding #4) proved that + // ("evaluated on a script-execution-* thread"). Later work proved that // destination wrong: sharing the blocking pool meant N blocked script bodies stalled // every Expression trigger on the node indefinitely. Evaluations are non-blocking by // construction, so they now run as plain async work on the shared .NET thread pool @@ -463,6 +472,7 @@ public class ScriptActorTests : TestKit, IDisposable instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(10)); // fired ⇒ evaluated off the blocking pool } + /// <summary>Verifies that a while-true conditional trigger fires on the edge and then re-fires while the condition holds.</summary> [Fact] public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds() { @@ -482,6 +492,7 @@ public class ScriptActorTests : TestKit, IDisposable instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 2 } + /// <summary>Verifies that a while-true conditional trigger stops re-firing when the condition goes false.</summary> [Fact] public void ScriptActor_ConditionalWhileTrue_StopsReFiringWhenConditionGoesFalse() { @@ -501,6 +512,7 @@ public class ScriptActorTests : TestKit, IDisposable instance.ExpectNoMsg(TimeSpan.FromMilliseconds(700)); // re-firing has stopped } + /// <summary>Verifies that a while-true conditional trigger re-arms after the condition goes false and then true again.</summary> [Fact] public void ScriptActor_ConditionalWhileTrue_ReArmsAfterConditionFalseThenTrueAgain() { @@ -520,6 +532,7 @@ public class ScriptActorTests : TestKit, IDisposable instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); } + /// <summary>Verifies that a while-true conditional trigger without a minimum time between runs fires only once.</summary> [Fact] public void ScriptActor_ConditionalWhileTrue_WithoutMinTimeBetweenRuns_FiresOnceOnly() { @@ -535,6 +548,7 @@ public class ScriptActorTests : TestKit, IDisposable instance.ExpectNoMsg(TimeSpan.FromMilliseconds(900)); // no repeats } + /// <summary>Verifies that an on-true conditional trigger fires on each change while true, without a timer.</summary> [Fact] public void ScriptActor_ConditionalOnTrue_FiresOnEachChangeWhileTrue_NoTimer() { @@ -554,6 +568,7 @@ public class ScriptActorTests : TestKit, IDisposable instance.ExpectNoMsg(TimeSpan.FromMilliseconds(600)); // no self-driven re-fire } + /// <summary>Verifies that a while-true expression trigger re-fires while the expression holds.</summary> [Fact] public void ScriptActor_ExpressionWhileTrue_ReFiresWhileExpressionHolds() { @@ -574,6 +589,7 @@ public class ScriptActorTests : TestKit, IDisposable instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that an on-true expression trigger fires once per false-to-true edge.</summary> [Fact] public void ScriptActor_ExpressionOnTrue_FiresOncePerFalseToTrueEdge() { @@ -618,7 +634,7 @@ public static class EvalGate /// Resets the gate to a fresh, permit-free state and zeroes the entry counter. /// Called at the start of the test so counts never carry across runs — the old /// static leaked <c>_entries</c> from whatever compiled an expression before it - /// (Gitea #16). + /// . /// </summary> public static void Reset() { @@ -630,9 +646,10 @@ public static class EvalGate /// Counts the entry, then blocks the calling script-scheduler thread until a permit /// is released. The wait is BOUNDED (30 s): even if a test releases too few permits, /// a stranded evaluation frees its worker instead of parking it for the rest of the - /// process — the leak mechanism behind Gitea #16. Returns false so a trigger + /// process — the leak mechanism behind stranded workers. Returns false so a trigger /// expression built on it evaluates to "not fired". /// </summary> + /// <returns>Always <see langword="false"/>.</returns> public static bool Block() { Interlocked.Increment(ref _entries); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTraceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTraceTests.cs index 85c03941..b94d8b44 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTraceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTraceTests.cs @@ -27,12 +27,18 @@ public class ScriptActorTraceTests : TestKit, IDisposable { private sealed class CapturingAuditWriter : IAuditWriter { + /// <summary>The audit events captured so far.</summary> public List<AuditEvent> Events { get; } = new(); + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) Events.Add(evt); return Task.CompletedTask; } + /// <summary>Polls the captured events until one with the given action appears or the timeout elapses.</summary> + /// <param name="action">The audit action to wait for.</param> + /// <param name="timeout">The maximum time to wait.</param> + /// <returns>The first matching event, or <see langword="null"/> if none arrived in time.</returns> public AuditEvent? WaitForKind(string action, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; @@ -55,6 +61,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable private readonly CapturingAuditWriter _writer = new(); private readonly IServiceProvider _services; + /// <summary>Creates the shared script library, options, scheduler and service provider used by every test.</summary> public ScriptActorTraceTests() { _sharedLibrary = new SharedScriptLibrary( @@ -67,6 +74,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable _services = sc.BuildServiceProvider(); } + /// <inheritdoc /> void IDisposable.Dispose() { Shutdown(); @@ -91,6 +99,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable NullLogger<ScriptActor>.Instance, null, null, null, _services, _scheduler))); } + /// <summary>Verifies that a traced call replies with the execution ID and a ScriptRun trace event.</summary> [Fact] public void Call_WithTrace_ReplyCarriesExecutionId_AndAScriptRunEvent() { @@ -117,6 +126,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable Assert.NotNull(_writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(5))); } + /// <summary>Verifies that a traced call whose script fails returns a failed ScriptRun trace event.</summary> [Fact] public void Call_WithTrace_FailedRun_ReturnsFailedScriptRunEvent() { @@ -132,6 +142,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable Assert.Contains("kaboom", row.ErrorMessage); } + /// <summary>Verifies that an untraced call replies with no trace events but still carries the execution ID.</summary> [Fact] public void Call_WithoutTrace_ReplyHasNoTraceEvents_ButStillCarriesExecutionId() { @@ -147,6 +158,9 @@ public class ScriptActorTraceTests : TestKit, IDisposable Assert.Empty(_writer.Events); } + /// <summary>Verifies that a traced value change makes the triggered run a child of the source execution ID.</summary> + /// <param name="inferred">Whether the change is an inferred device update rather than a direct write.</param> + /// <param name="triggerSource">The trigger source label stamped on the change.</param> [Theory] [InlineData(false, "value-change")] [InlineData(true, "inferred-device-update")] @@ -170,6 +184,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable Assert.Equal(triggerSource, extra.TriggerSource); } + /// <summary>Verifies that an untraced value change leaves the triggered run as a root and emits no trace events.</summary> [Fact] public void ValueChangeTrigger_UntracedChange_RunStaysRoot_AndEmitsNothing() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptDeadlineAtEnqueueTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptDeadlineAtEnqueueTests.cs index b1ea03cf..a6b0ec06 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptDeadlineAtEnqueueTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptDeadlineAtEnqueueTests.cs @@ -12,10 +12,10 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// WP3.1 test group 3 — a script's execution deadline is armed when the run is ENQUEUED, not +/// A script's execution deadline is armed when the run is ENQUEUED, not /// when it is dequeued. /// -/// <para>Before WP3.1 the deadline <see cref="CancellationTokenSource"/> was constructed inside +/// <para>Previously the deadline <see cref="CancellationTokenSource"/> was constructed inside /// the queued body, so a run that spent ten minutes waiting behind blocked scripts still got a /// fresh full 30 s budget when it finally started — and then ran work whose triggering /// condition was long stale. Now queue wait consumes the run's own budget, and a body that @@ -26,6 +26,7 @@ public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable { private readonly SharedScriptLibrary _sharedLibrary; + /// <summary>Initializes the test with an isolated script execution scheduler.</summary> public ScriptDeadlineAtEnqueueTests() { var compilationService = new ScriptCompilationService( @@ -36,6 +37,7 @@ public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable DeadlineHooks.SecondScriptRan = false; } + /// <inheritdoc /> void IDisposable.Dispose() { DeadlineHooks.Gate.Release(8); @@ -64,6 +66,7 @@ public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable NullLogger<ScriptActor>.Instance, null, null, null, serviceProvider, scheduler, null))); } + /// <summary>Verifies that a run that dequeues past its deadline is shed without executing its body.</summary> [Fact] public void ARunThatDequeuesPastItsDeadline_IsShedWithoutExecutingItsBody() { @@ -86,7 +89,7 @@ public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable { ScriptExecutionTimeoutSeconds = 1, StuckScriptGraceMs = 1000, - // WP3.2 defaults per-run Started/Completed events OFF, which would make the + // The defaults leave per-run Started/Completed events OFF, which would make the // "no started event" assertion below vacuously true whether the body ran or not. // Opt back in so that assertion actually discriminates: a body that reached the // run loop WOULD emit "started" here, and its absence is therefore evidence. diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptRecursionVsRunCapTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptRecursionVsRunCapTests.cs index 9f2a0f07..27aa0b1c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptRecursionVsRunCapTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptRecursionVsRunCapTests.cs @@ -26,7 +26,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <c>ScriptRuntimeContext.CallScript</c> — unreachable.</para> /// /// <para>The cap now applies to <c>callDepth == 0</c> launches only: trigger-driven runs and -/// depth-0 Ask calls, i.e. genuinely NEW work. Both halves are pinned below.</para> +/// depth 0 Ask calls, i.e. genuinely NEW work. Both halves are pinned below.</para> /// </summary> public class ScriptRecursionVsRunCapTests : TestKit, IDisposable { @@ -43,6 +43,7 @@ public class ScriptRecursionVsRunCapTests : TestKit, IDisposable RecursionHooks.Reset(); } + /// <inheritdoc /> void IDisposable.Dispose() { RecursionHooks.Gate.Release(64); @@ -142,8 +143,8 @@ public class ScriptRecursionVsRunCapTests : TestKit, IDisposable } /// <summary> - /// The exemption is scoped to nesting: with the cap already full of depth-0 runs, another - /// depth-0 request is still shed, while a nested (<c>callDepth > 0</c>) request is + /// The exemption is scoped to nesting: with the cap already full of depth 0 runs, another + /// depth 0 request is still shed, while a nested (<c>callDepth > 0</c>) request is /// launched. This is the discriminating pin — a blanket "skip the cap" would fail the first /// half, and the pre-fix behaviour fails the second. /// </summary> @@ -164,7 +165,7 @@ public class ScriptRecursionVsRunCapTests : TestKit, IDisposable null, null, health, new SingleServiceProvider(siteLog), _scheduler, null)), "cap-" + Guid.NewGuid().ToString("N")); - // Fill the cap with four depth-0 runs, all blocked in their bodies. + // Fill the cap with four depth 0 runs, all blocked in their bodies. for (var i = 0; i < 4; i++) actor.Tell(new ScriptCallRequest("Hot", null, 0, $"corr-{i}"), ActorRefs.NoSender); @@ -195,7 +196,7 @@ public class ScriptRecursionVsRunCapTests : TestKit, IDisposable // It is running, not answered: no shed reply reached the nested caller. nested.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - // Exactly the one depth-0 refusal was counted. + // Exactly the one depth 0 refusal was counted. Assert.Equal(1, health.CollectReport("site-1").ScriptRunShedCount); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptRunLauncherParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptRunLauncherParityTests.cs index cb31222c..63126208 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptRunLauncherParityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptRunLauncherParityTests.cs @@ -18,10 +18,10 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// WP3.1 supervision-parity set (design memo §4, seven pins) — the reworked successor to +/// Supervision-parity set (design memo §4, seven pins) — the reworked successor to /// <c>ExecutionActorTests</c>. /// -/// <para>WP3.1 eliminated the short-lived <c>ScriptExecutionActor</c> and +/// <para>The refactor eliminated the short-lived <c>ScriptExecutionActor</c> and /// <c>AlarmExecutionActor</c>: neither had a <c>Receive</c> handler, a <c>PostStop</c>, or any /// state, and neither's <c>IActorRef</c> was ever a message target — the entire lifecycle lived /// in a detached task. Runs are now launched directly by the coordinator via @@ -35,9 +35,10 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedLibrary; private readonly ScriptCompilationService _compilationService; - /// <summary>Own pool per test class (#18 seam), so a wedged body cannot strand the process-wide one.</summary> + /// <summary>Own pool per test class, so a wedged body cannot strand the process-wide one.</summary> private readonly ScriptExecutionScheduler _scheduler = new(4); + /// <summary>Initializes the test with a compilation service and shared script library.</summary> public ScriptRunLauncherParityTests() { _compilationService = new ScriptCompilationService( @@ -46,6 +47,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { Shutdown(); @@ -72,7 +74,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = timeoutSeconds, StuckScriptGraceMs = graceMs, - // WP3.2: per-run Started/Completed Info events default OFF; tests that pin the + // Per-run Started/Completed Info events default OFF; tests that pin the // legacy per-run event shape opt back in explicitly via this flag. PerRunScriptEvents = perRunScriptEvents }; @@ -104,6 +106,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable // ── Pin 1: throwing body — coordinator survives, everything is reported ─────── + /// <summary>Verifies that a throwing script body leaves the script actor alive and reports everything.</summary> [Fact] public void ThrowingScriptBody_LeavesScriptActorAliveAndReportsEverything() { @@ -150,7 +153,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable /// The only failure the removed per-run child could surface was a constructor throw — /// e.g. queueing onto a disposed <see cref="ScriptExecutionScheduler"/>. The old /// <c>OneForOneStrategy</c> logged it and stopped the child, leaving the Ask caller to - /// hang with no reply at all. WP3.1 folds that into a launch-path catch that replies, an + /// hang with no reply at all. The launcher folds that into a launch-path catch that replies, an /// intentional improvement pinned here so it is explicit rather than accidental. /// </summary> [Fact] @@ -182,6 +185,9 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable // ── Pin 3: exactly one DI scope per run, disposed on every path ─────────────── + /// <summary>Verifies that each run creates one DI scope and disposes it exactly once.</summary> + /// <param name="code">The script source to run.</param> + /// <param name="timeoutSeconds">The script timeout in seconds.</param> [Theory] [InlineData("return 1;", 30)] // success [InlineData("throw new InvalidOperationException(\"boom\");", 30)] // failure @@ -240,6 +246,9 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable // ── Pin 5: timeout resolution parity (perScript ?? global, <= 0 => global) ──── + /// <summary>Verifies that timeout resolution matches the behavior of the removed execution actor.</summary> + /// <param name="globalSeconds">The global script timeout in seconds.</param> + /// <param name="perScriptSeconds">The per-script timeout in seconds, or null to use the global timeout.</param> [Theory] [InlineData(300, 1)] // per-script override wins over a much longer global [InlineData(1, null)] // null per-script falls back to the global @@ -377,6 +386,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable // ── Retargeted from ExecutionActorTests: success path + operational events ──── + /// <summary>Verifies that a successful run replies with the return value.</summary> [Fact] public void SuccessfulRun_RepliesWithTheReturnValue() { @@ -391,11 +401,12 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable Assert.Equal(42, result.ReturnValue); } + /// <summary>Verifies that a successful run emits Started then Completed info events.</summary> [Fact] public void SuccessfulRun_EmitsStartedThenCompletedInfoEvents() { var siteLog = new FakeSiteEventLogger(); - // WP3.2: per-run Info events are opt-in now (default off, interval summary instead) — + // Per-run Info events are opt-in now (default off, interval summary instead) — // this test pins the legacy per-run shape, so it opts back in explicitly. var actor = BuildScriptActor( CompileScript("return 7 * 6;"), Options(perRunScriptEvents: true), new SingleServiceProvider(siteLog)); @@ -419,11 +430,12 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable }, TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that a failing run emits a Started info event then an Error event.</summary> [Fact] public void FailingRun_EmitsStartedInfoThenErrorEvent() { var siteLog = new FakeSiteEventLogger(); - // WP3.2: the started Info event is opt-in (default off); the Error event on failure + // The started Info event is opt-in (default off); the Error event on failure // stays unconditional regardless. This test pins the opted-in shape explicitly. var actor = BuildScriptActor( CompileScript("throw new InvalidOperationException(\"boom\");"), @@ -443,11 +455,12 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable }, TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that a fire-and-forget run needs no reply target.</summary> [Fact] public void FireAndForgetRun_NeedsNoReplyTarget() { var siteLog = new FakeSiteEventLogger(); - // WP3.2: opted in so the completed Info event (the observable proof the + // Opted in so the completed Info event (the observable proof the // fire-and-forget run actually ran to completion) is emitted; the policy itself is // covered separately by ScriptRunEventPolicyTests. var actor = BuildScriptActor( @@ -463,7 +476,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable TimeSpan.FromSeconds(10)); } - // ── WP3.2: site_events volume policy — actor-level integration ──────────────── + // ── site_events volume policy — actor-level integration ──────────────── // // The pure ScriptRunEventPolicy matching rules (exact/wildcard/global/non-matching) are // unit-tested directly in ScriptRunEventPolicyTests; these prove the policy is actually @@ -471,6 +484,8 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable // the aggregate still sees the run, per-script opt-in works end to end, and the // IOptionsMonitor read makes the switch hot-togglable without an actor restart. + /// <summary>Verifies that with default options a successful run emits no per-run events but the summary recorder still sees it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DefaultOptions_SuccessfulRun_EmitsNoPerRunEvents_ButTheSummaryRecorderSeesIt() { @@ -499,6 +514,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable flushLog.OfType("script").Single().Message); } + /// <summary>Verifies that opting in a script by exact match restores per-run events for that script only.</summary> [Fact] public void PerScriptOptIn_ExactMatch_RestoresPerRunEventsForThatScriptOnly() { @@ -515,6 +531,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable AwaitAssert(() => Assert.Equal(2, siteLog.OfType("script").Count), TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that opting in with an instance wildcard restores per-run events.</summary> [Fact] public void PerScriptOptIn_InstanceWildcard_RestoresPerRunEvents() { @@ -531,6 +548,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable AwaitAssert(() => Assert.Equal(2, siteLog.OfType("script").Count), TimeSpan.FromSeconds(5)); } + /// <summary>Verifies that flipping the options monitor changes the next run with no actor restart.</summary> [Fact] public void HotToggle_FlippingTheOptionsMonitor_ChangesTheNextRunWithNoActorRestart() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptRunShedTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptRunShedTests.cs index b6ea8915..2eefc0ba 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptRunShedTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptRunShedTests.cs @@ -21,9 +21,9 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// WP3.1 test group 6 — the per-script in-flight cap and its shed policy. +/// Test group 6 — the per-script in-flight cap and its shed policy. /// -/// <para>Before WP3.1 every trigger spawned another run unconditionally: a trigger firing +/// <para>Previously every trigger spawned another run unconditionally: a trigger firing /// faster than its script completes produced unbounded fan-out onto a bounded thread pool. /// The cap (<see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/>, default 4) sheds the /// NEWEST run instead. Keeping the four already queued/running — which are closest to their @@ -40,6 +40,7 @@ public class ScriptRunShedTests : TestKit, IDisposable private readonly SharedScriptLibrary _sharedLibrary; private readonly ScriptExecutionScheduler _scheduler = new(8); + /// <summary>Creates the shared script library and the gate that blocks scripts so runs stay in flight.</summary> public ScriptRunShedTests() { var compilationService = new ScriptCompilationService( @@ -49,6 +50,7 @@ public class ScriptRunShedTests : TestKit, IDisposable ShedHooks.Gate = new SemaphoreSlim(0); } + /// <inheritdoc /> void IDisposable.Dispose() { ShedHooks.Gate.Release(64); @@ -79,6 +81,7 @@ public class ScriptRunShedTests : TestKit, IDisposable StuckScriptGraceMs = 120_000 }; + /// <summary>Verifies that a fifth concurrent run is shed, counted and reported as a site event once, and the caller is answered with an error.</summary> [Fact] public void FifthConcurrentRun_IsShed_Counted_EventedOnce_AndAnsweredWithAnError() { @@ -144,6 +147,7 @@ public class ScriptRunShedTests : TestKit, IDisposable AwaitAssert(() => Assert.Equal(4, actor.UnderlyingActor.RunsInFlight), TimeSpan.FromSeconds(15)); } + /// <summary>Verifies that alarm on-trigger runs are shed at the same cap and produce an alarm-scoped site event.</summary> [Fact] public void AlarmOnTriggerRuns_ShedAtTheSameCap_WithAnAlarmScopedSiteEvent() { @@ -233,17 +237,24 @@ public class ScriptRunShedTests : TestKit, IDisposable { private readonly List<AuditEvent> _events = new(); + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (_events) _events.Add(evt); return Task.CompletedTask; } + /// <summary>Takes a copy of the rows captured so far.</summary> + /// <returns>An array of the captured audit events.</returns> public AuditEvent[] Snapshot() { lock (_events) return _events.ToArray(); } + /// <summary>Polls until a captured row matches the predicate or the timeout expires.</summary> + /// <param name="predicate">The condition a row must satisfy.</param> + /// <param name="timeout">The maximum time to wait.</param> + /// <returns>The first matching audit event, or null if none matched in time.</returns> public AuditEvent? WaitFor(Func<AuditEvent, bool> predicate, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; @@ -279,6 +290,7 @@ public class ScriptRunShedTests : TestKit, IDisposable private static bool IsFailedScriptRun(AuditEvent evt) => evt.Action == "ScriptTrace.ScriptRun" && evt.AsRow().Status.ToString() == "Failed"; + /// <summary>Verifies that a traced run shed at the cap emits a failed script-run row parented to the writer.</summary> [Fact] public void TracedRun_ShedAtTheCap_EmitsAFailedScriptRunRow_ParentedToTheWriter() { @@ -325,6 +337,7 @@ public class ScriptRunShedTests : TestKit, IDisposable Assert.Equal(4, actor.UnderlyingActor.RunsInFlight); } + /// <summary>Verifies that a traced run suppressed by the minimum time between runs emits a failed script-run row.</summary> [Fact] public void TracedRun_SuppressedByMinTimeBetweenRuns_EmitsAFailedScriptRunRow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SelfHealCreatesDclConnectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SelfHealCreatesDclConnectionTests.cs index 88276070..e67c99b5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SelfHealCreatesDclConnectionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SelfHealCreatesDclConnectionTests.cs @@ -30,14 +30,14 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <c>ApplyDeployment</c>. The two self-heal starts did not: /// </para> /// <list type="bullet"> -/// <item><c>StartStoredInstance</c> — the path shared by the row-67 startup-reconcile -/// ensure-started command and the row-78 sweep.</item> +/// <item><c>StartStoredInstance</c> — the path shared by the startup-reconcile +/// ensure-started command and the sweep.</item> /// <item><c>HandleEnableResult</c> — enabling an instance that was disabled at boot.</item> /// </list> /// /// <para> -/// <b>Row 85's live sequence (2026-08-16).</b> A fresh node-a was seeded by LocalDb CDC -/// replication; the row-78 sweep correctly started the enabled instances, and then every tag +/// <b>The live sequence (2026-08-16).</b> A fresh node-a was seeded by LocalDb CDC +/// replication; the sweep correctly started the enabled instances, and then every tag /// subscribe failed for 6+ minutes with <c>Unknown connection: OPC PLC Simulator</c> / /// <c>No connection actor for OPC PLC Simulator</c>, because nothing on that node had created the /// connection actor. A second pair restart healed it — the boot load creates connections, so it @@ -73,6 +73,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable /// </summary> private readonly ConcurrentQueue<CreateConnectionCommand> _createdConnections = new(); + /// <summary>Initializes a new instance of the <c>SelfHealCreatesDclConnectionTests</c> class.</summary> public SelfHealCreatesDclConnectionTests() { _localDb = TestLocalDb.CreateTemp("dcl-selfheal-test"); @@ -84,6 +85,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <inheritdoc /> void IDisposable.Dispose() { // Shut the actors down BEFORE the ILocalDb they write through is disposed. @@ -170,20 +172,21 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable return Task.CompletedTask; }, TimeSpan.FromSeconds(20)); - // ── The row-85 reproduction: a self-heal start must bring its connections with it ── + // ── The reproduction: a self-heal start must bring its connections with it ── /// <summary> - /// THE row-85 reproduction. The node boots with an empty store, then a config for an ENABLED + /// THE reproduction. The node boots with an empty store, then a config for an ENABLED /// instance appears in storage on its own — exactly what LocalDb's CDC sync applier does when - /// it seeds a fresh node from its peer. The row-78 sweep starts the Instance Actor; the DCL + /// it seeds a fresh node from its peer. The sweep starts the Instance Actor; the DCL /// must be told to create the connection that instance's tags subscribe through. /// /// <para> - /// <b>Fails before the fix</b> at the connection assert: the instance starts (row 78 works) + /// <b>Fails before the fix</b> at the connection assert: the instance starts (the sweep works) /// but NO <see cref="CreateConnectionCommand"/> is ever sent, which is the live symptom — /// the instance is up and every tag subscribe is answered <c>Unknown connection</c>. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SweepStartedInstance_AlsoCreatesItsDclConnection() { @@ -204,12 +207,13 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable } /// <summary> - /// The row-67 startup-reconcile heal shares <c>StartStoredInstance</c> with the row-78 sweep, + /// The startup-reconcile heal shares <c>StartStoredInstance</c> with the sweep, /// so it had the identical gap: the reconcile fetched and stored a config, the Deployment /// Manager started the instance, and its connections were never created. /// /// <para><b>Fails before the fix.</b></para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReconcileEnsureStartedInstance_AlsoCreatesItsDclConnection() { @@ -235,6 +239,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable /// /// <para><b>Fails before the fix.</b></para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnabledInstance_DisabledAtBoot_AlsoCreatesItsDclConnection() { @@ -268,6 +273,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable /// <c>_createdConnections</c> hash guard inside <c>EnsureDclConnection</c> were ever lost. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SweepStartedInstance_CreatesTheConnection_ExactlyOnce_AcrossManySweeps() { @@ -290,6 +296,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable /// all. The hash guard is what makes the connection-ensure safe to sit in the creation /// funnel, and this pins it against a config that never changes. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sweep_SendsNoConnectionCommands_WhenThereIsNothingToHeal() { @@ -319,6 +326,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable /// connections — it passes both before and after the fix, which is the point of a /// refactor guard. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OrdinaryDeploy_StillCreatesItsDclConnection() { @@ -344,6 +352,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable /// stored BEFORE the manager starts, so the boot load — not the sweep — is what creates the /// instance, and it must still bring its connections with it. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BootLoadedInstance_StillCreatesItsDclConnection() { @@ -365,6 +374,8 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable /// </summary> private sealed class DclConnectionSpy : ReceiveActor { + /// <summary>Initializes a new instance of the <c>DclConnectionSpy</c> class.</summary> + /// <param name="creates">The creates.</param> public DclConnectionSpy(ConcurrentQueue<CreateConnectionCommand> creates) { Receive<CreateConnectionCommand>(creates.Enqueue); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReconciliationActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReconciliationActorTests.cs index 7fa1825d..f256d0ab 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReconciliationActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReconciliationActorTests.cs @@ -25,6 +25,7 @@ public class SiteReconciliationActorTests : TestKit, IDisposable private readonly SiteStorageService _storage; private readonly TestLocalDb _localDb; + /// <summary>Initializes the test class with a fresh local database and the actor-system fixtures.</summary> public SiteReconciliationActorTests() { // SiteStorageService takes an ILocalDb now; LocalDb has no in-memory mode, so the @@ -35,6 +36,7 @@ public class SiteReconciliationActorTests : TestKit, IDisposable _storage.InitializeAsync().GetAwaiter().GetResult(); } + /// <inheritdoc /> void IDisposable.Dispose() { // Shut the actor system down FIRST: a reconcile continuation may still be @@ -62,6 +64,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable TimeSpan.FromMilliseconds(50), // initial delay — fast for tests askTimeout ?? TimeSpan.FromSeconds(5)))); // ask timeout + /// <summary>Verifies that an instance missing at the site is fetched from central and stored.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MissingInstance_IsFetchedAndStored() { @@ -101,6 +105,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable Assert.Equal("tok-b", call.Token); } + /// <summary>Verifies that a stale instance is refreshed from central.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StaleInstance_IsRefreshed() { @@ -134,6 +140,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable Assert.Equal("tok-a", call.Token); } + /// <summary>Verifies that an orphaned instance is logged but not deleted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Orphan_IsLoggedNotDeleted() { @@ -163,6 +171,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable Assert.Empty(fetcher.Calls); } + /// <summary>Verifies that no fetch is made when there is no gap between central and the site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoGap_DoesNotFetch() { @@ -181,6 +191,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable Assert.Empty(fetcher.Calls); } + /// <summary>Verifies that a fetch failure for one item does not abort the remaining items.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PerItemFetchFailure_DoesNotAbortTheRest() { @@ -216,6 +228,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } + /// <summary>Verifies that a superseded item is skipped quietly while the other items are still applied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SupersededItem_IsSkippedQuietly_OthersStillApply() { @@ -259,6 +273,7 @@ public class SiteReconciliationActorTests : TestKit, IDisposable ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } + /// <summary>Verifies that the actor survives a failed ask to central.</summary> [Fact] public void CentralAskFails_ActorSurvives() { @@ -289,10 +304,14 @@ public class SiteReconciliationActorTests : TestKit, IDisposable private sealed class FakeConfigFetcher : IDeploymentConfigFetcher { private readonly Func<string, Task<string>> _behavior; + /// <summary>Gets the recorded fetch calls (base URL, deployment id, token).</summary> public ConcurrentQueue<(string BaseUrl, string DeploymentId, string Token)> Calls { get; } = new(); + /// <summary>Initializes the fake with the per-deployment-id behavior to run on each fetch.</summary> + /// <param name="behavior">Maps a deployment id to the fetch result.</param> public FakeConfigFetcher(Func<string, Task<string>> behavior) => _behavior = behavior; + /// <inheritdoc /> public async Task<string> FetchAsync( string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct) { @@ -305,11 +324,23 @@ public class SiteReconciliationActorTests : TestKit, IDisposable /// <summary>Thread-safe capturing logger so tests can assert on emitted warnings.</summary> private sealed class CapturingLogger<T> : ILogger<T> { + /// <summary>Gets the captured log entries (level and formatted message).</summary> public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + /// <inheritdoc /> IDisposable? ILogger.BeginScope<TState>(TState state) => null; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level.</param> + /// <returns>Always true.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The log state type.</typeparam> + /// <param name="logLevel">The log level.</param> + /// <param name="eventId">The event id.</param> + /// <param name="state">The log state.</param> + /// <param name="exception">The associated exception, if any.</param> + /// <param name="formatter">Formats the state and exception into a message.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/StuckScriptWatchdogTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/StuckScriptWatchdogTests.cs index 84714c0e..d0a9c082 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/StuckScriptWatchdogTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/StuckScriptWatchdogTests.cs @@ -13,10 +13,10 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// WP3.1 test group 4 — the stuck-script watchdog now REPLACES the thread a wedged script is +/// The stuck-script watchdog now REPLACES the thread a wedged script is /// holding, not just names it. /// -/// <para>Before WP3.1 the watchdog was observability only: a script blocked in synchronous, +/// <para>Previously the watchdog was observability only: a script blocked in synchronous, /// uninterruptible I/O never observes the cooperative cancellation the timeout requests, so /// the pool simply lost that dedicated thread — permanently, and silently apart from one log /// line. Eight such scripts left the site with no script execution at all. The watchdog now @@ -29,6 +29,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable { private readonly SharedScriptLibrary _sharedLibrary; + /// <summary>Initializes a new instance of <c>StuckScriptWatchdogTests</c>.</summary> public StuckScriptWatchdogTests() { var compilationService = new ScriptCompilationService( @@ -38,6 +39,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable WatchdogHooks.Gate = new SemaphoreSlim(0); } + /// <inheritdoc /> void IDisposable.Dispose() { // Free any still-wedged worker before tearing down, so no test leaves a blocked thread. @@ -81,6 +83,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable MaxScriptCallDepth = 10 }; + /// <summary>Verifies that wedged script detaches its worker starts a replacement and drains when freed.</summary> [Fact] public void WedgedScript_DetachesItsWorker_StartsAReplacement_AndDrainsWhenFreed() { @@ -107,7 +110,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable TimeSpan.FromSeconds(5)); // The replacement worker is live: a fresh script runs even though the pool's only - // original thread is still blocked. Before WP3.1 this reply never came. + // original thread is still blocked. Previously this reply never came. var healthy = BuildScriptActor("Healthy", CompileRaw("return 7;"), options, scheduler, null); var caller = CreateTestProbe(); healthy.Tell(new ScriptCallRequest("Healthy", null, 0, "corr-healthy"), caller.Ref); @@ -124,6 +127,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable Assert.Equal(1, scheduler.MaximumConcurrencyLevel); } + /// <summary>Verifies that at the detach cap no further replacement is started and an error event is emitted.</summary> [Fact] public void AtTheDetachCap_NoFurtherReplacementIsStarted_AndAnErrorEventIsEmitted() { @@ -155,6 +159,8 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable WatchdogHooks.Gate.Release(2); } + /// <summary>Verifies that detached thread count is surfaced on the site health report.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DetachedThreadCount_IsSurfacedOnTheSiteHealthReport() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/TriggerEvalStarvationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/TriggerEvalStarvationTests.cs index 47da020f..8e064a84 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/TriggerEvalStarvationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/TriggerEvalStarvationTests.cs @@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// <summary> -/// WP3.1 test groups 1 and 2 — the regression pin for arch-review finding #4 (High). +/// Test groups 1 and 2 — the regression pin for the trigger-evaluation starvation finding. /// /// <para><b>The finding.</b> Trigger-expression evaluation used to be queued onto the same /// fixed 8-thread <see cref="ScriptExecutionScheduler"/> that runs blocking script bodies. Eight @@ -33,6 +33,7 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable { private readonly SharedScriptLibrary _sharedLibrary; + /// <summary>Initializes a new instance of the <c>TriggerEvalStarvationTests</c> class.</summary> public TriggerEvalStarvationTests() { var compilationService = new ScriptCompilationService( @@ -42,6 +43,7 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable StarvationHooks.Gate = new SemaphoreSlim(0); } + /// <inheritdoc /> void IDisposable.Dispose() { StarvationHooks.Gate.Release(32); @@ -70,11 +72,11 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable } /// <summary> - /// GROUP 1 — the finding-#4 regression pin. Eight script bodies blocked on a semaphore + /// GROUP 1 — the trigger-evaluation starvation regression pin. Eight script bodies blocked on a semaphore /// occupy every thread of an 8-thread pool; an Expression-triggered alarm must still raise /// in well under 2 s. /// - /// <para>On the pre-WP3.1 wiring this test does not merely fail slowly — it never + /// <para>On the previous wiring this test does not merely fail slowly — it never /// completes: the evaluation sits in the same FIFO behind eight bodies that only unblock /// after the assertion window, and its 2 s timeout has not even started ticking.</para> /// </summary> @@ -138,6 +140,7 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable /// false) at its timeout rather than stalling indefinitely — and the trigger must drain /// rather than park, so the next change still evaluates. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SaturatedEvalGate_ResolvesTheQueuedEvaluationAtItsEnqueueAnchoredDeadline() { @@ -169,7 +172,7 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable // 3. The next evaluation can never acquire the gate. Its deadline was armed at // enqueue, so it cancels at ~1 s, is treated as false, and clears the alarm. - // Pre-WP3.1 the clock started at dequeue, so this would hang forever. + // Previously the clock started at dequeue, so this would hang forever. var stopwatch = Stopwatch.StartNew(); alarm.Tell(new AttributeValueChanged("Inst1", "A", "A", 2, "Good", DateTimeOffset.UtcNow)); var cleared = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/DeployCompileValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/DeployCompileValidatorTests.cs index 81c36ece..eb0ab14a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/DeployCompileValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/DeployCompileValidatorTests.cs @@ -29,6 +29,7 @@ public class DeployCompileValidatorTests NativeAlarmSources = new List<ResolvedNativeAlarmSource>() }); + /// <summary>Verifies that broken script, returns errors.</summary> [Fact] public void BrokenScript_ReturnsErrors() { @@ -37,12 +38,14 @@ public class DeployCompileValidatorTests Assert.Contains(errors, e => e.Contains("S1")); } + /// <summary>Verifies that valid script, returns no errors.</summary> [Fact] public void ValidScript_ReturnsNoErrors() { Assert.Empty(NewValidator().Validate(ConfigWith("return 1 + 1;"))); } + /// <summary>Verifies that broken trigger expression, returns errors.</summary> [Fact] public void BrokenTriggerExpression_ReturnsErrors() { @@ -69,6 +72,7 @@ public class DeployCompileValidatorTests Assert.Contains(errors, e => e.Contains("script-trigger-S1")); } + /// <summary>Verifies that malformed json, returns single deserialize error.</summary> [Fact] public void MalformedJson_ReturnsSingleDeserializeError() { @@ -77,6 +81,7 @@ public class DeployCompileValidatorTests Assert.Contains("deserialize", errors[0], StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that missing on trigger script, returns error.</summary> [Fact] public void MissingOnTriggerScript_ReturnsError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/HttpDeploymentConfigFetcherTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/HttpDeploymentConfigFetcherTests.cs index 5d99c60d..f7838d9f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/HttpDeploymentConfigFetcherTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/HttpDeploymentConfigFetcherTests.cs @@ -20,6 +20,8 @@ public class HttpDeploymentConfigFetcherTests // Happy path // ------------------------------------------------------------------------- + /// <summary>Verifies that a 200 response returns the body.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_200_ReturnsBody() { @@ -32,6 +34,8 @@ public class HttpDeploymentConfigFetcherTests Assert.Equal(expectedBody, result); } + /// <summary>Verifies that the request URL is built correctly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_200_RequestUrlIsCorrect() { @@ -46,6 +50,8 @@ public class HttpDeploymentConfigFetcherTests handler.LastRequest!.RequestUri!.ToString()); } + /// <summary>Verifies that the deployment token header is set on the request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_200_SetsDeploymentTokenHeader() { @@ -59,6 +65,8 @@ public class HttpDeploymentConfigFetcherTests Assert.Equal("my-secret-token", handler.LastRequest.Headers.GetValues("X-Deployment-Token").Single()); } + /// <summary>Verifies that a base URL with a trailing slash does not produce a double slash.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_BaseUrlWithTrailingSlash_NoDoubleSlashInUrl() { @@ -73,6 +81,8 @@ public class HttpDeploymentConfigFetcherTests Assert.Equal("http://central/api/internal/deployments/dep-1/config", url); } + /// <summary>Verifies that a deployment id needing escaping is escaped exactly once in the URL.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_DeploymentIdNeedingEscaping_EscapesOnceInUrl() { @@ -94,6 +104,8 @@ public class HttpDeploymentConfigFetcherTests // 404 → IsSuperseded = true // ------------------------------------------------------------------------- + /// <summary>Verifies that a 404 response throws with the superseded flag set.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_404_ThrowsWithIsSupersededTrue() { @@ -111,6 +123,8 @@ public class HttpDeploymentConfigFetcherTests // Other HTTP errors → IsSuperseded = false // ------------------------------------------------------------------------- + /// <summary>Verifies that a 401 response throws with the superseded flag clear.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_401_ThrowsWithIsSupersededFalse() { @@ -124,6 +138,8 @@ public class HttpDeploymentConfigFetcherTests Assert.Contains("401", ex.Message); } + /// <summary>Verifies that a 500 response throws with the superseded flag clear.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_500_ThrowsWithIsSupersededFalse() { @@ -141,6 +157,8 @@ public class HttpDeploymentConfigFetcherTests // Transport failure → DeploymentConfigFetchException (not raw HttpRequestException) // ------------------------------------------------------------------------- + /// <summary>Verifies that a transport exception is wrapped in a deployment config fetch exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_TransportException_WrapsAsDeploymentConfigFetchException() { @@ -155,6 +173,8 @@ public class HttpDeploymentConfigFetcherTests Assert.Contains("Connection refused", ex.Message); } + /// <summary>Verifies that an HTTP client timeout is wrapped as a transport error rather than a cancellation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_HttpClientTimeout_WrapsAsTransportError_NotCancellation() { @@ -173,6 +193,8 @@ public class HttpDeploymentConfigFetcherTests // Cancellation propagates as-is // ------------------------------------------------------------------------- + /// <summary>Verifies that a cancelled token propagates an operation-canceled exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FetchAsync_CancelledToken_PropagatesOperationCanceledException() { @@ -194,14 +216,19 @@ public class HttpDeploymentConfigFetcherTests { private readonly HttpStatusCode _status; private readonly string _body; + /// <summary>The last request the handler received.</summary> public HttpRequestMessage? LastRequest { get; private set; } + /// <summary>Creates a handler that always replies with the given status and body.</summary> + /// <param name="status">The HTTP status code to return.</param> + /// <param name="body">The response body to return.</param> public StubHandler(HttpStatusCode status, string body) { _status = status; _body = body; } + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) { LastRequest = request; @@ -216,14 +243,18 @@ public class HttpDeploymentConfigFetcherTests private sealed class ThrowingHandler : HttpMessageHandler { private readonly Exception _ex; + /// <summary>Creates a handler that throws the given exception.</summary> + /// <param name="ex">The exception to throw.</param> public ThrowingHandler(Exception ex) => _ex = ex; + /// <inheritdoc /> protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) => throw _ex; } private sealed class DelayHandler : HttpMessageHandler { + /// <inheritdoc /> protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) { await Task.Delay(Timeout.Infinite, ct); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Integration/FailoverIntegrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Integration/FailoverIntegrationTests.cs index 96dea029..4296a098 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Integration/FailoverIntegrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Integration/FailoverIntegrationTests.cs @@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Integration; /// which is complex for unit tests. Marked with Category=Integration /// for separate test run configuration. /// -/// WP-7: Dual-Node Recovery verification points: +/// Dual-Node Recovery verification points: /// - Both nodes are seed nodes (config-verified) /// - min-nr-of-members=1 allows single-node cluster formation /// - First node forms cluster, singleton starts, rebuilds from SQLite @@ -16,6 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Integration; /// </summary> public class FailoverIntegrationTests { + /// <summary>Verifies that a single node forms a singleton cluster and rebuilds its state from SQLite.</summary> [Fact] [Trait("Category", "Integration")] public void SingleNode_FormsSingletonCluster_RebuildFromSQLite() @@ -27,11 +28,12 @@ public class FailoverIntegrationTests Assert.True(true, "Covered by DeploymentManagerActorTests"); } + /// <summary>Verifies that a graceful shutdown hands the singleton over to the standby.</summary> [Fact] [Trait("Category", "Integration")] public void GracefulShutdown_SingletonHandover() { - // WP-6: CoordinatedShutdown triggers graceful cluster leave. + // CoordinatedShutdown triggers graceful cluster leave. // The AkkaHostedService.StopAsync runs CoordinatedShutdown which: // 1. Leaves the cluster gracefully // 2. Singleton manager detects leave and starts handover @@ -42,6 +44,7 @@ public class FailoverIntegrationTests Assert.True(true, "Requires multi-process test infrastructure"); } + /// <summary>Verifies that after a crash the split-brain resolver downs the node and the singleton restarts on the standby.</summary> [Fact] [Trait("Category", "Integration")] public void CrashRecovery_SBRDownsNode_SingletonRestartsOnStandby() @@ -57,11 +60,12 @@ public class FailoverIntegrationTests Assert.True(true, "Requires multi-process test infrastructure"); } + /// <summary>Verifies that when both nodes restart they recover their state from SQLite.</summary> [Fact] [Trait("Category", "Integration")] public void DualNodeRecovery_BothNodesRestart_FromSQLite() { - // WP-7: When both nodes restart (full site power cycle): + // When both nodes restart (full site power cycle): // 1. First node starts, forms cluster (min-nr-of-members=1) // 2. Singleton starts on first node // 3. DeploymentManager reads all configs from persistent SQLite diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/NegativeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/NegativeTests.cs index f10835d5..629b6028 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/NegativeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/NegativeTests.cs @@ -10,6 +10,8 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests; /// </summary> public class NegativeTests { + /// <summary>Verifies the case: schema, no Alarm State Table.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Schema_NoAlarmStateTable() { @@ -69,6 +71,8 @@ public class NegativeTests } } + /// <summary>Verifies the case: schema, no Local Config Authoring.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Schema_NoLocalConfigAuthoring() { @@ -106,6 +110,7 @@ public class NegativeTests Assert.Equal(2, tableCount); } + /// <summary>Verifies the case: site Node, does Not Bind Http Ports.</summary> [Fact] public void SiteNode_DoesNotBindHttpPorts() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs index a8cb4bb4..96315bc8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence; /// <summary> -/// WP-33: Local Artifact Storage tests — shared scripts, external systems, +/// Local Artifact Storage tests — shared scripts, external systems, /// database connections, notification lists. /// </summary> /// <remarks> @@ -17,11 +17,14 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable private readonly TestLocalDb _localDb; private SiteStorageService _storage = null!; + /// <summary>Creates a temporary LocalDb for the tests.</summary> public ArtifactStorageTests() { _localDb = TestLocalDb.CreateTemp("artifact-test"); } + /// <summary>Creates and initializes the storage service under test.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { _storage = new SiteStorageService( @@ -30,8 +33,11 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable await _storage.InitializeAsync(); } + /// <summary>Does nothing; there is no asynchronous cleanup.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <summary>Disposes the test database and deletes its files.</summary> public void Dispose() { // Dispose first — the master connection anchors the WAL, so the sidecars @@ -43,6 +49,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable // ── Shared Script Storage ── + /// <summary>Verifies that a stored shared script round-trips.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreSharedScript_RoundTrips() { @@ -56,6 +64,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable Assert.Equal("int", scripts[0].ReturnDefinition); } + /// <summary>Verifies that storing a shared script with an existing name upserts it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreSharedScript_Upserts_OnConflict() { @@ -68,6 +78,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable Assert.Equal("{\"x\":\"int\"}", scripts[0].ParameterDefinitions); } + /// <summary>Verifies that multiple shared scripts can be stored side by side.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreSharedScript_MultipleScripts() { @@ -79,6 +91,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable Assert.Equal(3, scripts.Count); } + /// <summary>Verifies that a shared script with null optional fields can be stored.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreSharedScript_NullableFields() { @@ -92,6 +106,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable // ── External System Storage ── + /// <summary>Verifies that storing an external system does not throw.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreExternalSystem_DoesNotThrow() { @@ -102,6 +118,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable // No exception = success. Query verification would need a Get method. } + /// <summary>Verifies that storing an external system with an existing name upserts it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreExternalSystem_Upserts() { @@ -113,6 +131,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable // ── Database Connection Storage ── + /// <summary>Verifies that storing a database connection does not throw.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreDatabaseConnection_DoesNotThrow() { @@ -120,6 +140,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable "MainDB", "Server=localhost;Database=main", 3, TimeSpan.FromSeconds(1)); } + /// <summary>Verifies that storing a database connection with an existing name upserts it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreDatabaseConnection_Upserts() { @@ -139,6 +161,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable // shared script / DB connection / data connection) stayed orphaned on the site // forever and remained callable from site scripts. + /// <summary>Verifies that deleting shared scripts except a keep set removes orphans and keeps the present ones.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteSharedScriptsExcept_RemovesOrphans_KeepsPresent() { @@ -153,6 +177,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable Assert.Equal(["Keep1", "Keep2"], names); } + /// <summary>Verifies that deleting external systems except a keep set removes orphans and keeps the present ones.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteExternalSystemsExcept_RemovesOrphans_KeepsPresent() { @@ -165,6 +191,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable Assert.Equal(["MES"], await TableNamesAsync("external_systems")); } + /// <summary>Verifies that deleting database connections with an empty keep set deletes all of them.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteDatabaseConnectionsExcept_EmptyKeepSet_DeletesAll() { @@ -178,6 +206,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable Assert.Empty(await TableNamesAsync("database_connections")); } + /// <summary>Verifies that deleting data connection definitions except a keep set removes orphans and keeps the present ones.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteDataConnectionDefinitionsExcept_RemovesOrphans_KeepsPresent() { @@ -191,6 +221,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable Assert.Equal(["PlcA"], names); } + /// <summary>Verifies that deleting with no orphans returns an empty result and is idempotent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteRowsExcept_NoOrphans_ReturnsEmpty_AndIsIdempotent() { @@ -213,10 +245,10 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable return names; } - // ── DeploymentManager-025 / SiteRuntime-031: central-only notif/SMTP purge ── + // ── Central-only notif/SMTP purge ── // // Notification config is central-only. The site-side write paths and - // SiteNotificationRepository were removed 2026-07-10 (arch-review 08 §1.3/#23); + // SiteNotificationRepository were removed 2026-07-10 (arch-review 08 §1.3); // PurgeCentralOnlyNotificationConfigAsync is retained as the security cleanup for // DBs written by older builds. These tests seed the (still-present) tables via raw // SQL — the only way rows can now exist — and assert the purge empties them. @@ -257,6 +289,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable return (long)(await command.ExecuteScalarAsync())!; } + /// <summary>Verifies that the central-only purge removes persisted notification list and SMTP rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PurgeCentralOnlyNotificationConfig_RemovesPersistedNotificationListsAndSmtpRows() { @@ -276,6 +310,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable Assert.Equal(0, await RowCountAsync("smtp_configurations")); } + /// <summary>Verifies that the central-only purge is idempotent on empty tables.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PurgeCentralOnlyNotificationConfig_IsIdempotent_OnEmptyTables() { @@ -287,8 +323,10 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable Assert.Equal(0, await RowCountAsync("smtp_configurations")); } - // ── Schema includes all WP-33 tables ── + // ── Schema includes all artifact tables ── + /// <summary>Verifies that initialization creates all artifact tables.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Initialize_CreatesAllArtifactTables() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs index 29949c71..332a0bfe 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence; /// <summary> -/// Task 8, site-secrets artifact deploy: site-local <c>deployed_secrets</c> +/// Site-secrets artifact deploy: site-local <c>deployed_secrets</c> /// (LocalDb-replicated ciphertext) and <c>mirrored_secrets</c> (node-local ledger) stores. /// </summary> /// <remarks> @@ -20,19 +20,25 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable private readonly TestLocalDb _localDb; private SiteStorageService _storage = null!; + /// <summary>Initializes the test class and creates the backing storage.</summary> public DeployedSecretsStorageTests() { _localDb = TestLocalDb.CreateTemp("deployed-secrets"); } + /// <summary>Prepares the fixture before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { _storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance); await _storage.InitializeAsync(); } + /// <summary>Releases the resources used by this instance asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <summary>Releases the resources used by this instance.</summary> public void Dispose() { var path = _localDb.Path; @@ -42,6 +48,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable // ── deployed_secrets: full-set upsert / soft-delete reconciliation ── + /// <summary>Verifies that an upsert is treated as the full set and marks absent rows as removed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Upsert_IsFullSet_MarksAbsentRowsRemoved() { @@ -57,6 +65,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.True(rows.Single(r => r.Name == "B").IsRemoved); } + /// <summary>Verifies that the first upsert applies every row and removes nothing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Upsert_FirstCall_AppliesEveryRow_AndRemovesNothing() { @@ -67,6 +77,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.Empty(removed); } + /// <summary>Verifies that a removed row is not reported as removed again when the set is re-applied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Upsert_ReAppliedRow_IsNotReportedAsRemovedTwice() { @@ -80,6 +92,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.Empty(removedSecond); } + /// <summary>Verifies that a removed row can be re-applied and flips back to not removed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Upsert_RowThatWasRemoved_CanBeReAppliedAndFlipsBackToNotRemoved() { @@ -98,6 +112,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.Equal(2, b.Secret.Revision); } + /// <summary>Verifies that upserting an empty set marks every existing row as removed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Upsert_EmptySet_MarksEveryExistingRowRemoved() { @@ -112,6 +128,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.All(rows, r => Assert.True(r.IsRemoved)); } + /// <summary>Verifies that reading the deployed secrets round-trips every field of the secret artifact.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeployedSecretsAsync_RoundTripsEveryFieldOfTheSecretArtifact() { @@ -138,6 +156,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.Equal(art.Description, row.Secret.Description); } + /// <summary>Verifies that reading the deployed secrets includes rows marked as removed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeployedSecretsAsync_IncludesRemovedRows() { @@ -153,6 +173,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable // ── security: row_json holds only base64 ciphertext, never plaintext ── + /// <summary>Verifies that the stored row JSON deserializes to a secret artifact with no extra properties.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RowJson_DeserializesToSecretArtifact_WithNoExtraProperties() { @@ -204,6 +226,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.Equal(expected, propertyNames); } + /// <summary>Verifies that the stored row JSON encodes every byte-array field as Base64 text.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RowJson_EncodesEveryByteArrayField_AsBase64Text() { @@ -244,6 +268,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable // ── mirrored_secrets: node-local ledger ── + /// <summary>Verifies that a mirrored secret that is set can be read back.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MirroredSecrets_SetThenGet_RoundTrips() { @@ -256,6 +282,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.Equal(3, row.Revision); } + /// <summary>Verifies that setting a mirrored secret twice updates the existing entry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MirroredSecrets_Set_IsUpsert() { @@ -268,6 +296,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.Equal(2, row.Revision); } + /// <summary>Verifies that removing a mirrored secret deletes its ledger row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MirroredSecrets_Remove_DeletesTheLedgerRow() { @@ -278,6 +308,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.Empty(await _storage.GetMirroredSecretsAsync()); } + /// <summary>Verifies that removing an unknown mirrored secret name does nothing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MirroredSecrets_Remove_OfAnUnknownName_IsANoOp() { @@ -286,6 +318,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.Empty(await _storage.GetMirroredSecretsAsync()); } + /// <summary>Verifies that the mirrored-secrets ledger is independent of the deployed secrets.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MirroredSecrets_IsIndependentOf_DeployedSecrets() { @@ -304,8 +338,10 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.Equal("A", mirrored.Name); } - // ── secret_mirror_status (review finding M1) ── + // ── secret_mirror_status (standby mirror failures) ── + /// <summary>Verifies that mirror status round-trips with one row per node, ordered by node name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SecretMirrorStatus_RoundTrips_OneRowPerNode_OrderedByNodeName() { @@ -324,6 +360,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.Equal(at, rows[1].CheckedAtUtc); } + /// <summary>Verifies that setting the mirror status updates that node's existing row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SecretMirrorStatus_Set_UpsertsTheNodesRow() { @@ -336,6 +374,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable Assert.Equal(at.AddHours(1), row.CheckedAtUtc); } + /// <summary>Verifies that an unparseable mirror status row reads as a failure rather than as healthy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SecretMirrorStatus_UnparseableRow_ReadsAsAFailure_NotAsHealthy() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs index 9a38bd45..cd5e9273 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence; /// <summary> -/// Task 14: site-local SQLite <c>native_alarm_state</c> store — mirrored native alarm +/// Site-local SQLite <c>native_alarm_state</c> store — mirrored native alarm /// condition snapshots keyed by (instance, source canonical name, source reference). /// </summary> /// <remarks> @@ -17,19 +17,26 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable private readonly TestLocalDb _localDb; private SiteStorageService _storage = null!; + /// <summary>Initializes the tests with a temporary local database.</summary> public NativeAlarmStateStoreTests() { _localDb = TestLocalDb.CreateTemp("nas"); } + /// <summary>Prepares the test fixture before the first test runs.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { _storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance); await _storage.InitializeAsync(); } + /// <summary>Does nothing; cleanup happens in <c>Dispose</c>.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <summary>Verifies that an upserted alarm row can be read back unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Upsert_Then_Get_RoundTrips() { @@ -43,6 +50,8 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable Assert.Equal(DateTimeOffset.UnixEpoch, rows[0].LastTransitionAt); } + /// <summary>Verifies that upserting the same key replaces the condition and timestamp.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Upsert_SameKey_ReplacesConditionAndTimestamp() { @@ -56,6 +65,8 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable Assert.Equal(DateTimeOffset.UnixEpoch.AddMinutes(5), rows[0].LastTransitionAt); } + /// <summary>Verifies that a read is scoped to the instance and source canonical name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Get_ScopesToInstanceAndSourceCanonicalName() { @@ -69,6 +80,8 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable Assert.Equal("Tank01.Hi", rows[0].SourceReference); } + /// <summary>Verifies that deleting removes only the single targeted row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Delete_RemovesSingleRow() { @@ -82,6 +95,8 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable Assert.Equal("Tank01.Lo", rows[0].SourceReference); } + /// <summary>Verifies that clearing an instance removes all of its sources and leaves other instances untouched.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ClearForInstance_RemovesAllSourcesForInstanceOnly() { @@ -96,6 +111,7 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable Assert.Single(await _storage.GetNativeAlarmsAsync("other", "SrcA")); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { // Dispose first — the master connection anchors the WAL, so the sidecars diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs index 99c23447..4ec57325 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs @@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence; /// </summary> public class SiteStorageSchemaTests { + /// <summary>Verifies that applying the schema is idempotent and creates every table.</summary> [Fact] public void Apply_IsIdempotent_AndCreatesEveryTable() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageServiceTests.cs index 87528b40..f8e37362 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageServiceTests.cs @@ -20,11 +20,14 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable private readonly TestLocalDb _localDb; private SiteStorageService _storage = null!; + /// <summary>Initializes a new instance of the <c>SiteStorageServiceTests</c> class.</summary> public SiteStorageServiceTests() { _localDb = TestLocalDb.CreateTemp("site-storage-test"); } + /// <summary>Prepares the fixture before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { _storage = new SiteStorageService( @@ -33,8 +36,11 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable await _storage.InitializeAsync(); } + /// <summary>Releases the resources used by this instance asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <summary>Releases the resources used by this instance.</summary> public void Dispose() { // Dispose first — the master connection anchors the WAL, so the sidecars @@ -44,6 +50,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable TestLocalDb.DeleteFiles(path); } + /// <summary>Verifies that initialization creates the tables without error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InitializeAsync_CreatesTablesWithoutError() { @@ -52,6 +60,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable await _storage.InitializeAsync(); } + /// <summary>Verifies that initialization enables the write-ahead-log journal mode.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Initialize_EnablesWalJournalMode() { @@ -72,6 +82,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.Equal("wal", mode.ToLowerInvariant()); } + /// <summary>Verifies that a stored deployed config can be retrieved unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreAndRetrieve_DeployedConfig_RoundTrips() { @@ -88,7 +100,7 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.True(configs[0].IsEnabled); } - // ── Register P1-4: projected single-row reads ── + // ── Projected single-row reads ── // // GetAllDeployedConfigsAsync + FirstOrDefault materializes every deployed config — // including every ConfigJson blob — to find one row. These lock in that @@ -98,6 +110,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable // defect here is pure I/O cost, not an observable behavior difference, so these tests // pin the new projection's contract rather than reproduce a behavior bug. + /// <summary>Verifies that a single-config read returns the same row as reading all configs and picking the match.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeployedConfigAsync_ReturnsSameRowAs_GetAllThenFirstOrDefault() { @@ -120,6 +134,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.Equal(viaFullScan.DeployedAt, viaProjection.DeployedAt); } + /// <summary>Verifies that reading the config of an unknown instance returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeployedConfigAsync_UnknownInstance_ReturnsNull() { @@ -131,6 +147,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.Null(result); } + /// <summary>Verifies that a metadata read returns the same identity as reading all configs and picking the match.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeployedConfigMetadataAsync_ReturnsSameIdentityAs_GetAllThenFirstOrDefault() { @@ -151,6 +169,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.Equal(viaFullScan.IsEnabled, metadata.IsEnabled); } + /// <summary>Verifies that reading the metadata of an unknown instance returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetDeployedConfigMetadataAsync_UnknownInstance_ReturnsNull() { @@ -162,6 +182,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.Null(result); } + /// <summary>Verifies that storing a deployed config for an existing instance replaces the previous row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreDeployedConfig_Upserts_OnConflict() { @@ -178,6 +200,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.False(configs[0].IsEnabled); } + /// <summary>Verifies that removing a deployed config also removes its static overrides.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RemoveDeployedConfig_RemovesConfigAndOverrides() { @@ -194,6 +218,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.Empty(overrides); } + /// <summary>Verifies that setting the instance enabled state updates the stored flag.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetInstanceEnabled_UpdatesFlag() { @@ -211,6 +237,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.True(configs[0].IsEnabled); } + /// <summary>Verifies that setting the enabled state of a nonexistent instance does not throw.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetInstanceEnabled_NonExistent_DoesNotThrow() { @@ -220,6 +248,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable // ── Static Override Tests ── + /// <summary>Verifies that a static override can be stored and read back unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAndGetStaticOverride_RoundTrips() { @@ -231,6 +261,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.Equal("98.6", overrides["Temperature"]); } + /// <summary>Verifies that setting a static override that already exists replaces its value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetStaticOverride_Upserts_OnConflict() { @@ -243,6 +275,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.Equal("100.0", overrides["Temperature"]); } + /// <summary>Verifies that clearing static overrides removes all of an instance's overrides.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ClearStaticOverrides_RemovesAll() { @@ -255,6 +289,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.Empty(overrides); } + /// <summary>Verifies that static overrides are isolated per instance.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetStaticOverrides_IsolatedPerInstance() { @@ -270,6 +306,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.True(pump2.ContainsKey("Pressure")); } + /// <summary>Verifies that multiple instances have independent lifecycles.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MultipleInstances_IndependentLifecycle() { @@ -289,6 +327,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable // ── Negative Tests ── + /// <summary>Verifies that the schema does not contain an alarm state table.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Schema_DoesNotContain_AlarmStateTable() { @@ -339,6 +379,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable await cmd.ExecuteNonQueryAsync(); } + /// <summary>Verifies that a conditional store inserts the config when no row exists.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreDeployedConfigIfNewer_NoExistingRow_Inserts() { @@ -356,6 +398,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.True(configs[0].IsEnabled); } + /// <summary>Verifies that a conditional store overwrites an existing older row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreDeployedConfigIfNewer_ExistingOlderRow_Overwrites() { @@ -375,6 +419,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.False(configs[0].IsEnabled); } + /// <summary>Verifies that a conditional store does nothing when the existing row is newer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreDeployedConfigIfNewer_ExistingNewerRow_IsNoop() { @@ -397,6 +443,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable Assert.False(configs[0].IsEnabled); } + /// <summary>Verifies that a conditional store does nothing when the deployment times are equal.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreDeployedConfigIfNewer_EqualDeployedAt_IsNoop() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs index 9c647ea9..931ac364 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs @@ -6,13 +6,13 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Repositories; /// <summary> -/// SiteRuntime-006 / SiteRuntime-007 regression tests for the site-local repositories. +/// Regression tests for the site-local repositories. /// -/// SiteRuntime-006: the repositories must obtain a SQLite connection through +/// The repositories must obtain a SQLite connection through /// <see cref="SiteStorageService.CreateConnection"/>, not by reading a private field /// via reflection. /// -/// SiteRuntime-007: the synthetic integer IDs derived from entity names must be stable +/// The synthetic integer IDs derived from entity names must be stable /// across process restarts (a freshly-constructed service/repository), so an ID handed /// to a caller still resolves the same entity later. /// </summary> @@ -20,11 +20,13 @@ public class SiteRepositoryTests : IDisposable { private readonly TestLocalDb _localDb; + /// <summary>Initializes a temporary local database for the tests.</summary> public SiteRepositoryTests() { _localDb = TestLocalDb.CreateTemp("site-repo-test"); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { // Dispose first — the master connection anchors the WAL, so the sidecars @@ -46,10 +48,11 @@ public class SiteRepositoryTests : IDisposable => new(_localDb.Db, NullLogger<SiteStorageService>.Instance); /// <summary> - /// SiteRuntime-006: an external system stored via <see cref="SiteStorageService"/> + /// An external system stored via <see cref="SiteStorageService"/> /// can be read back through the repository — proving the repository's connection /// (now obtained from <see cref="SiteStorageService.CreateConnection"/>) is valid. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExternalSystemRepository_RoundTripsStoredDefinition() { @@ -71,6 +74,7 @@ public class SiteRepositoryTests : IDisposable /// <see cref="SiteStorageService.StoreExternalSystemAsync"/> must round-trip through the /// site SQLite store and back out via the repository, so site-side calls honor it. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiteStorage_ExternalSystem_TimeoutSeconds_RoundTrips() { @@ -93,6 +97,7 @@ public class SiteRepositoryTests : IDisposable /// silently falling back to <c>DefaultMaxRetries</c> (50) x <c>DefaultRetryInterval</c> (30 s). /// Before the fix the site table had no such columns and this read back 0 / TimeSpan.Zero. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiteStorage_ExternalSystem_RetryPolicy_RoundTrips() { @@ -116,6 +121,7 @@ public class SiteRepositoryTests : IDisposable /// engine defaults apply. This is what makes the new columns behaviourally invisible until /// an operator actually configures a policy. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiteStorage_ExternalSystem_RetryPolicy_DefaultsToUnset() { @@ -133,11 +139,12 @@ public class SiteRepositoryTests : IDisposable } /// <summary> - /// SiteRuntime-007: the synthetic ID for an external system must be identical when + /// The synthetic ID for an external system must be identical when /// the storage service and repository are re-created (simulating a process restart). /// With the old <see cref="string.GetHashCode()"/> the ID was randomized per process /// and a by-ID lookup after a restart would fail. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExternalSystemRepository_SyntheticId_IsStableAcrossRestart() { @@ -162,12 +169,13 @@ public class SiteRepositoryTests : IDisposable Assert.Equal("StableSystem", found.Name); } - // ── ExternalSystemGateway-011: name-keyed repository lookups ── + // ── Name-keyed repository lookups ── /// <summary> - /// ExternalSystemGateway-011: the site repository's name-keyed external-system + /// The site repository's name-keyed external-system /// lookup returns the matching row, and the same synthetic ID as the by-ID path. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExternalSystemRepository_GetByName_ReturnsMatchingDefinition() { @@ -192,8 +200,9 @@ public class SiteRepositoryTests : IDisposable } /// <summary> - /// ExternalSystemGateway-011: a missing name resolves to <c>null</c>. + /// A missing name resolves to <c>null</c>. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExternalSystemRepository_GetByName_MissingName_ReturnsNull() { @@ -208,9 +217,10 @@ public class SiteRepositoryTests : IDisposable } /// <summary> - /// ExternalSystemGateway-011: the site repository's name-keyed method lookup + /// The site repository's name-keyed method lookup /// returns the method scoped to its parent system, or <c>null</c> for a miss. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExternalSystemRepository_GetMethodByName_ResolvesScopedToSystem() { @@ -233,9 +243,10 @@ public class SiteRepositoryTests : IDisposable } /// <summary> - /// ExternalSystemGateway-011: the site repository's name-keyed database-connection + /// The site repository's name-keyed database-connection /// lookup returns the matching row, or <c>null</c> for a miss. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DatabaseConnectionRepository_GetByName_ReturnsMatchingDefinition() { @@ -258,18 +269,19 @@ public class SiteRepositoryTests : IDisposable } // NotificationRepository_SyntheticId_IsStableAcrossRestart was removed 2026-07-10 - // (arch-review 08 §1.3/#23) along with the vestigial SiteNotificationRepository — + // (arch-review 08 §1.3) along with the vestigial SiteNotificationRepository — // notification config is central-only and never lives on a site. The synthetic-ID // stability guarantee is still exercised by ExternalSystemRepository_SyntheticId_IsStableAcrossRestart. - // ── WP2.6a: ExternalSystemDefinitionCache (arch-review misc — site external-system resolution) ── + // ── ExternalSystemDefinitionCache (arch-review misc — site external-system resolution) ── /// <summary> - /// WP2.6a: two repository instances sharing one <see cref="ExternalSystemDefinitionCache"/> + /// Two repository instances sharing one <see cref="ExternalSystemDefinitionCache"/> /// (the production DI shape — one singleton cache, many scoped repository instances) must /// see the SAME cached snapshot: a row written after the cache was already populated by /// the first repository is invisible to the second until the cache is invalidated. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExternalSystemRepository_SharedCache_HitServesStaleSnapshotUntilInvalidated() { @@ -298,11 +310,12 @@ public class SiteRepositoryTests : IDisposable } /// <summary> - /// WP2.6a: the by-ID lookups (system and method) must resolve correctly out of the + /// The by-ID lookups (system and method) must resolve correctly out of the /// cached snapshot — proving the O(1) id-index build in <c>LoadSnapshotAsync</c> is /// wired correctly for both entity kinds, not just the by-name path already covered - /// by ExternalSystemGateway-011. + /// by the name-keyed lookup tests. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExternalSystemRepository_SharedCache_ByIdLookups_ResolveSystemAndMethod() { @@ -334,12 +347,13 @@ public class SiteRepositoryTests : IDisposable } /// <summary> - /// WP2.6a: a repository constructed via the single-arg (no shared cache) constructor + /// A repository constructed via the single-arg (no shared cache) constructor /// gets its own private cache — a second such repository over the same storage must /// see rows written after the first repository's cache was already populated, /// preserving the pre-cache "always fresh" behavior for callers that opt out of /// sharing. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExternalSystemRepository_PrivateCache_DoesNotShareAcrossInstances() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs index 6ee41acf..62244f2b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs @@ -28,6 +28,7 @@ public class AlarmsAccessorTests : TestKit, IDisposable instanceName: "CvdReactor01", logger: NullLogger<ScriptRuntimeContext>.Instance); + /// <inheritdoc /> void IDisposable.Dispose() => Shutdown(); private static readonly DateTimeOffset RaisedAt = new(2026, 8, 1, 10, 0, 0, TimeSpan.Zero); @@ -63,6 +64,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable request.CorrelationId, request.InstanceUniqueName, alarms, DateTimeOffset.UtcNow)); } + /// <summary>Verifies that the current-alarms call asks the instance actor for its own instance.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CurrentAsync_AsksTheInstanceActor_ForItsOwnInstance() { @@ -84,6 +87,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable Assert.Empty(await pending); } + /// <summary>Verifies that the current-alarms call projects every native alarm field, including the acknowledgement time.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CurrentAsync_ProjectsEveryNativeField_IncludingAckTime() { @@ -118,6 +123,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable Assert.False(alarm.IsConfiguredPlaceholder); } + /// <summary>Verifies that an unacknowledged native alarm reports no acknowledgement time.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CurrentAsync_UnackedNativeAlarm_ReportsNoAckTime() { @@ -138,6 +145,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable Assert.False(alarm.Shelved); } + /// <summary>Verifies that a computed alarm reads its state from the derived condition.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CurrentAsync_ComputedAlarm_ReadsFromTheDerivedCondition() { @@ -161,6 +170,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable Assert.Equal(string.Empty, alarm.NativeSourceCanonicalName); } + /// <summary>Verifies that placeholder rows are surfaced so that quiet bindings can be told apart.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CurrentAsync_SurfacesPlaceholderRows_SoQuietBindingsAreDistinguishable() { @@ -187,6 +198,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable Assert.Equal("Z28061.HeartbeatTimeoutAlarm", real.Name); } + /// <summary>Verifies that the current-alarms call projects the MES-reportable flag.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CurrentAsync_ProjectsIsMesReportable() { @@ -206,6 +219,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable Assert.False(Assert.Single(alarms, a => a.Name == "HighTemp").IsMesReportable); } + /// <summary>Verifies that the MES-reportable call returns only the flagged rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MesReportableAsync_FiltersToFlaggedRows() { @@ -224,6 +239,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable Assert.True(alarm.IsMesReportable); } + /// <summary>Verifies that the current-alarms call is not scope-prefixed, so every scope sees the same list.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CurrentAsync_IsNotScopePrefixed_EveryScopeSeesTheSameList() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AttributeSchedulerRegressionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AttributeSchedulerRegressionTests.cs index eca8523a..c50f8a6b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AttributeSchedulerRegressionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AttributeSchedulerRegressionTests.cs @@ -8,6 +8,9 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; public class AttributeSchedulerRegressionTests : TestKit { + /// <summary>Verifies that saturated workers complete synchronous attribute access without tripping the watchdog.</summary> + /// <param name="write">The write.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AuditingDbTransactionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AuditingDbTransactionTests.cs index 8be75003..4bf9a709 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AuditingDbTransactionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AuditingDbTransactionTests.cs @@ -8,6 +8,9 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; public class AuditingDbTransactionTests { + /// <summary>Verifies that sr11, transaction connection and command readback remain audited.</summary> + /// <param name="asynchronous">Test case input.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] @@ -43,6 +46,7 @@ public class AuditingDbTransactionTests Assert.Equal(42L, check.ExecuteScalar()); } + /// <summary>Verifies that sr11, command connection cannot expose provider.</summary> [Fact] public void SR11_CommandConnectionCannotExposeProvider() { @@ -52,6 +56,7 @@ public class AuditingDbTransactionTests using var command = connection.CreateCommand(); Assert.Same(connection, command.Connection); } + /// <summary>Verifies that sr4, connection cannot be retargeted or reveal credentials.</summary> [Fact] public void SR4_ConnectionCannotBeRetargetedOrRevealCredentials() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs index e1b8b5c5..45af5064 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// M3.3: compile-surface parity guard. The shared +/// Compile-surface parity guard. The shared /// <see cref="ScriptCompileSurface"/> / <see cref="TriggerCompileSurface"/> are /// compile-only stubs the design-time deploy gate binds candidate scripts /// against; they must mirror the real SiteRuntime <see cref="ScriptGlobals"/> / @@ -25,6 +25,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// </summary> public class CompileSurfaceParityTests { + /// <summary>Verifies that the script compile surface exposes every member name of the runtime script globals.</summary> [Fact] public void ScriptCompileSurface_MemberNames_AreSupersetOf_ScriptGlobals() { @@ -33,6 +34,7 @@ public class CompileSurfaceParityTests globals: typeof(ScriptGlobals)); } + /// <summary>Verifies that the trigger compile surface exposes every member name of the trigger expression globals.</summary> [Fact] public void TriggerCompileSurface_MemberNames_AreSupersetOf_TriggerExpressionGlobals() { @@ -63,6 +65,8 @@ public class CompileSurfaceParityTests /// many overloads for each (name, arity) as the runtime accessor declares. /// </para> /// </summary> + /// <param name="runtimeAccessor">The runtime script accessor type whose public methods are enumerated.</param> + /// <param name="compileMirror">The compile-only mirror type that must cover those methods.</param> [Theory] [MemberData(nameof(AccessorMirrorPairs))] public void CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities( @@ -95,6 +99,7 @@ public class CompileSurfaceParityTests /// Runtime script-accessor type ↔ compile-surface mirror pairs guarded by /// <see cref="CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities"/>. /// </summary> + /// <returns>The runtime accessor and compile mirror type pairs, one per theory case.</returns> public static IEnumerable<object[]> AccessorMirrorPairs() => new[] { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs index e0c8f8b6..529d29ce 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// Audit Log #23 — M3 Bundle E (Task E6): every script-initiated +/// Every script-initiated /// <c>Database.CachedWrite</c> emits exactly one <c>CachedSubmit</c> /// combined-telemetry packet at enqueue time on the <c>DbOutbound</c> /// channel, returns a fresh <see cref="TrackedOperationId"/>, and threads @@ -22,9 +22,12 @@ public class DatabaseCachedWriteEmissionTests { private sealed class CapturingForwarder : ICachedCallTelemetryForwarder { + /// <summary>Gets the telemetry packets captured so far.</summary> public List<CachedCallTelemetry> Telemetry { get; } = new(); + /// <summary>Gets or sets an exception to throw from <see cref="ForwardAsync"/> instead of capturing the packet.</summary> public Exception? ThrowOnForward { get; set; } + /// <inheritdoc /> public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default) { if (ThrowOnForward != null) @@ -41,7 +44,7 @@ public class DatabaseCachedWriteEmissionTests private const string SourceScript = "ScriptActor:WriteAudit"; /// <summary> - /// Audit Log #23: a fixed per-execution id so the cached-row tests can + /// A fixed per-execution id so the cached-row tests can /// assert <see cref="AuditEvent.ExecutionId"/> against a known value. /// </summary> private static readonly Guid TestExecutionId = Guid.NewGuid(); @@ -55,7 +58,7 @@ public class DatabaseCachedWriteEmissionTests gateway, InstanceName, NullLogger.Instance, - // Audit Log #23: the per-execution id stamped into ExecutionId on + // The per-execution id stamped into ExecutionId on // every script-side row. Cached rows keep CorrelationId = // TrackedOperationId (the per-operation lifecycle id). TestExecutionId, @@ -65,6 +68,8 @@ public class DatabaseCachedWriteEmissionTests parentExecutionId: parentExecutionId); } + /// <summary>Verifies that enqueueing a cached write emits a CachedSubmit telemetry packet on the DbOutbound channel.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_EmitsSubmitTelemetry_OnEnqueue_KindCachedSubmit_ChannelDbOutbound() { @@ -77,7 +82,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny<CancellationToken>(), It.IsAny<TrackedOperationId?>(), It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>())) - // M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The + // CachedWriteAsync returns an ExternalCallResult. The // buffered result (WasBuffered=true) models the transient-failure // path these enqueue-telemetry tests exercise — only the CachedSubmit // packet is emitted; the S&F retry loop (not the helper) owns the @@ -99,7 +104,7 @@ public class DatabaseCachedWriteEmissionTests // ExecutionId is the per-execution id from the runtime context. Assert.Equal(trackedId.Value, packet.Audit.AsRow().CorrelationId); Assert.Equal(TestExecutionId, packet.Audit.AsRow().ExecutionId); - // Audit Log #23 (ParentExecutionId): null for a non-routed run. + // ParentExecutionId is null for a non-routed run. Assert.Null(packet.Audit.AsRow().ParentExecutionId); Assert.Equal(trackedId, packet.Operational.TrackedOperationId); @@ -111,6 +116,8 @@ public class DatabaseCachedWriteEmissionTests Assert.Null(packet.Operational.TerminalAtUtc); } + /// <summary>Verifies that the emitted telemetry carries populated provenance fields.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_ProvenancePopulated() { @@ -123,7 +130,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny<CancellationToken>(), It.IsAny<TrackedOperationId?>(), It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>())) - // M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The + // CachedWriteAsync returns an ExternalCallResult. The // buffered result (WasBuffered=true) models the transient-failure // path these enqueue-telemetry tests exercise — only the CachedSubmit // packet is emitted; the S&F retry loop (not the helper) owns the @@ -141,10 +148,12 @@ public class DatabaseCachedWriteEmissionTests Assert.Equal(SiteId, packet.Operational.SourceSite); } + /// <summary>Verifies that a routed run stamps its parent execution id on the submit telemetry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_RoutedRun_StampsParentExecutionId_OnSubmitTelemetry() { - // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // An inbound-API-routed run // carries the spawning execution's id; the CachedSubmit telemetry row // must stamp it in ParentExecutionId. var parentExecutionId = Guid.NewGuid(); @@ -157,7 +166,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny<CancellationToken>(), It.IsAny<TrackedOperationId?>(), It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>())) - // M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The + // CachedWriteAsync returns an ExternalCallResult. The // buffered result (WasBuffered=true) models the transient-failure // path these enqueue-telemetry tests exercise — only the CachedSubmit // packet is emitted; the S&F retry loop (not the helper) owns the @@ -172,6 +181,8 @@ public class DatabaseCachedWriteEmissionTests Assert.Equal(parentExecutionId, packet.Audit.AsRow().ParentExecutionId); } + /// <summary>Verifies that a cached write returns a tracked operation id and passes the same id to the gateway.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_ReturnsTrackedOperationId_ThreadsIdToGateway() { @@ -184,7 +195,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny<CancellationToken>(), It.IsAny<TrackedOperationId?>(), It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>())) - // M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The + // CachedWriteAsync returns an ExternalCallResult. The // buffered result (WasBuffered=true) models the transient-failure // path these enqueue-telemetry tests exercise — only the CachedSubmit // packet is emitted; the S&F retry loop (not the helper) owns the @@ -207,7 +218,7 @@ public class DatabaseCachedWriteEmissionTests } /// <summary> - /// Audit Log #23 (ExecutionId Task 4): the helper → gateway hop of the + /// The helper → gateway hop of the /// threading chain. The cached-write helper must forward the runtime /// context's <c>ExecutionId</c> and <c>SourceScript</c> verbatim into /// <see cref="IDatabaseGateway.CachedWriteAsync"/> — so the buffered retry @@ -215,6 +226,7 @@ public class DatabaseCachedWriteEmissionTests /// asserts the exact id/script (not <c>It.IsAny</c>), so a regression that /// dropped the threading would fail here. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_ThreadsExecutionIdAndSourceScript_IntoGateway() { @@ -227,7 +239,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny<CancellationToken>(), It.IsAny<TrackedOperationId?>(), It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>())) - // M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The + // CachedWriteAsync returns an ExternalCallResult. The // buffered result (WasBuffered=true) models the transient-failure // path these enqueue-telemetry tests exercise — only the CachedSubmit // packet is emitted; the S&F retry loop (not the helper) owns the @@ -253,13 +265,14 @@ public class DatabaseCachedWriteEmissionTests } /// <summary> - /// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for + /// The helper → gateway hop for /// <c>ParentExecutionId</c>. A cached write enqueued from an inbound-API- /// routed script run must forward the runtime context's /// <c>ParentExecutionId</c> verbatim into /// <see cref="IDatabaseGateway.CachedWriteAsync"/> so the buffered retry /// loop later stamps it onto its audit rows. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_ThreadsParentExecutionId_IntoGateway() { @@ -273,7 +286,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny<CancellationToken>(), It.IsAny<TrackedOperationId?>(), It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>())) - // M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The + // CachedWriteAsync returns an ExternalCallResult. The // buffered result (WasBuffered=true) models the transient-failure // path these enqueue-telemetry tests exercise — only the CachedSubmit // packet is emitted; the S&F retry loop (not the helper) owns the @@ -296,9 +309,10 @@ public class DatabaseCachedWriteEmissionTests } /// <summary> - /// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a + /// A non-routed run threads a /// <c>null</c> ParentExecutionId into the gateway — the additive default. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_NonRoutedRun_ThreadsNullParentExecutionId_IntoGateway() { @@ -311,7 +325,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny<CancellationToken>(), It.IsAny<TrackedOperationId?>(), It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>())) - // M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The + // CachedWriteAsync returns an ExternalCallResult. The // buffered result (WasBuffered=true) models the transient-failure // path these enqueue-telemetry tests exercise — only the CachedSubmit // packet is emitted; the S&F retry loop (not the helper) owns the @@ -333,6 +347,8 @@ public class DatabaseCachedWriteEmissionTests Times.Once); } + /// <summary>Verifies that a cached write still returns a tracked operation id when the telemetry forwarder throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_ForwarderThrows_StillReturnsTrackedOperationId() { @@ -345,7 +361,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny<CancellationToken>(), It.IsAny<TrackedOperationId?>(), It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>())) - // M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The + // CachedWriteAsync returns an ExternalCallResult. The // buffered result (WasBuffered=true) models the transient-failure // path these enqueue-telemetry tests exercise — only the CachedSubmit // packet is emitted; the S&F retry loop (not the helper) owns the @@ -370,8 +386,10 @@ public class DatabaseCachedWriteEmissionTests Times.Once); } - // ── SourceNode-stamping (Task 14) ── + // ── SourceNode-stamping ── + /// <summary>Verifies that the submit telemetry row is stamped with the source node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_StampsSourceNode_OnSubmitTelemetryRow() { @@ -388,7 +406,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny<CancellationToken>(), It.IsAny<TrackedOperationId?>(), It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>())) - // M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The + // CachedWriteAsync returns an ExternalCallResult. The // buffered result (WasBuffered=true) models the transient-failure // path these enqueue-telemetry tests exercise — only the CachedSubmit // packet is emitted; the S&F retry loop (not the helper) owns the @@ -414,6 +432,8 @@ public class DatabaseCachedWriteEmissionTests Assert.Equal("node-a", packet.Operational.SourceNode); } + /// <summary>Verifies that the source node stays null when no source node is wired.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_NoSourceNodeWired_LeavesSourceNodeNull() { @@ -429,7 +449,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny<CancellationToken>(), It.IsAny<TrackedOperationId?>(), It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>())) - // M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The + // CachedWriteAsync returns an ExternalCallResult. The // buffered result (WasBuffered=true) models the transient-failure // path these enqueue-telemetry tests exercise — only the CachedSubmit // packet is emitted; the S&F retry loop (not the helper) owns the @@ -444,7 +464,7 @@ public class DatabaseCachedWriteEmissionTests Assert.Null(packet.Operational.SourceNode); } - // ── M2.3 (#7): immediate-completion lifecycle (WasBuffered=false) ── + // ── Immediate-completion lifecycle (WasBuffered=false) ── private static Mock<IDatabaseGateway> GatewayReturning(ExternalCallResult result) { @@ -461,6 +481,8 @@ public class DatabaseCachedWriteEmissionTests return gateway; } + /// <summary>Verifies that an immediate success emits submit and attempted telemetry followed by a delivered resolve.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_ImmediateSuccess_EmitsSubmitAttemptedThenDeliveredResolve() { @@ -494,6 +516,8 @@ public class DatabaseCachedWriteEmissionTests Assert.NotNull(resolve.Operational.TerminalAtUtc); } + /// <summary>Verifies that an immediate permanent failure emits submit and attempted telemetry followed by a failed resolve.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_ImmediatePermanentFailure_EmitsSubmitAttemptedThenFailedResolve() { @@ -520,6 +544,8 @@ public class DatabaseCachedWriteEmissionTests Assert.NotEqual(default, trackedId); } + /// <summary>Verifies that a buffered transient failure emits only the submit telemetry and no terminal rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedWrite_BufferedTransient_EmitsOnlySubmit_NoTerminalRows() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs index 8b17e19c..016142a9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs @@ -11,7 +11,7 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// Audit Log #23 — M4 Bundle A (Tasks A1+A2): every synchronous DB call made +/// Every synchronous DB call made /// through <c>Database.Connection("name")</c> emits exactly one /// <c>DbOutbound</c>/<c>DbWrite</c> audit event with an <c>Extra</c> envelope /// distinguishing writes (<c>op="write"</c>, <c>rowsAffected=N</c>) from reads @@ -23,18 +23,21 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; public class DatabaseSyncEmissionTests { /// <summary> - /// In-memory <see cref="IAuditWriter"/> mirroring the M2 Bundle F stub — + /// In-memory <see cref="IAuditWriter"/> mirroring the shared audit-writer stub — /// captures every event and may be configured to throw to verify the /// 3-layer fail-safe (mirrors <c>CapturingAuditWriter</c> in /// <c>ExternalSystemCallAuditEmissionTests</c>). /// </summary> private sealed class CapturingAuditWriter : IAuditWriter { - // C3 (Task 2.5): store the decomposed row view so assertions keep + // Store the decomposed row view so assertions keep // reading the ScadaBridge domain fields as typed properties. + /// <summary>Gets the captured audit rows.</summary> public List<AuditRowProjection.AuditRowValues> Events { get; } = new(); + /// <summary>Gets or sets whether the writer throws on every write.</summary> public Exception? ThrowOnWrite { get; set; } + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { if (ThrowOnWrite != null) @@ -53,7 +56,7 @@ public class DatabaseSyncEmissionTests private const string ConnectionName = "machineData"; /// <summary> - /// Audit Log #23: a fixed per-execution id used by the default + /// A fixed per-execution id used by the default /// <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/> /// overload so assertions can compare against a known value. /// </summary> @@ -116,6 +119,8 @@ public class DatabaseSyncEmissionTests return live; } + /// <summary>Verifies that a successful insert emits one DbWrite event with Delivered status, a write op and the affected row count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Execute_InsertSuccess_EmitsOneEvent_KindDbWrite_StatusDelivered_OpWrite_RowsAffected() { @@ -147,6 +152,8 @@ public class DatabaseSyncEmissionTests Assert.StartsWith(ConnectionName, evt.Target); } + /// <summary>Verifies that a successful scalar execution emits a DbWrite event with a write op.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteScalar_Success_EmitsKindDbWrite_OpWrite() { @@ -170,7 +177,7 @@ public class DatabaseSyncEmissionTests Assert.Equal(AuditKind.DbWrite, evt.Kind); Assert.Equal(AuditStatus.Delivered, evt.Status); Assert.NotNull(evt.Extra); - // ExecuteScalar is classified as "write" per the M4 vocabulary lock + // ExecuteScalar is classified as "write" per the audit vocabulary lock // (Channel=DbOutbound, Kind=DbWrite, Extra.op="write") — the // rowsAffected for a SELECT-on-SqlCommand is -1 in ADO.NET; the audit // wrapper records whatever DbCommand.ExecuteScalar returned via the @@ -179,6 +186,8 @@ public class DatabaseSyncEmissionTests Assert.Contains("rowsAffected", evt.Extra); } + /// <summary>Verifies that a failing execution emits an event with Failed status and the error message set.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Execute_Throws_EmitsEvent_StatusFailed_ErrorMessageSet() { @@ -204,6 +213,8 @@ public class DatabaseSyncEmissionTests Assert.Contains("does_not_exist", evt.ErrorDetail); } + /// <summary>Verifies that a successful reader execution emits a DbWrite event with a read op and the rows returned.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteReader_Success_EmitsKindDbWrite_OpRead_RowsReturned() { @@ -239,6 +250,8 @@ public class DatabaseSyncEmissionTests Assert.Contains("\"rowsReturned\":2", evt.Extra); } + /// <summary>Verifies that a throwing audit writer does not change the script call's result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditWriter_Throws_ScriptCall_ReturnsOriginalResult() { @@ -265,6 +278,8 @@ public class DatabaseSyncEmissionTests Assert.Empty(writer.Events); } + /// <summary>Verifies that the event provenance fields are populated from the execution context.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Provenance_PopulatedFromContext() { @@ -288,21 +303,23 @@ public class DatabaseSyncEmissionTests Assert.Equal(SourceScript, evt.SourceScript); // Outbound channel: Actor carries the calling script identity. Assert.Equal(SourceScript, evt.Actor); - // Audit Log #23: the sync DbWrite row carries the per-execution id the + // The sync DbWrite row carries the per-execution id the // helper was constructed with in ExecutionId. CorrelationId is null — // a sync one-shot call has no operation lifecycle. Assert.Equal(TestExecutionId, evt.ExecutionId); Assert.Null(evt.CorrelationId); - // Audit Log #23 (ParentExecutionId): null for a non-routed run — the + // ParentExecutionId: null for a non-routed run — the // default CreateHelper supplies no parentExecutionId. Assert.Null(evt.ParentExecutionId); Assert.NotEqual(Guid.Empty, evt.EventId); } + /// <summary>Verifies that a sync DbWrite in a routed run stamps the parent execution id from the context.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SyncDbWrite_RoutedRun_StampsParentExecutionId_FromContext() { - // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // ParentExecutionId: an inbound-API-routed run // carries the spawning execution's id; the sync DbWrite row must stamp // it in ParentExecutionId alongside its own fresh ExecutionId. using var keepAlive = new SqliteConnection("Data Source=kp;Mode=Memory;Cache=Shared"); @@ -326,6 +343,8 @@ public class DatabaseSyncEmissionTests Assert.Equal(executionId, evt.ExecutionId); } + /// <summary>Verifies that a sync DbWrite in a non-routed run leaves the parent execution id null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SyncDbWrite_NonRoutedRun_ParentExecutionIdIsNull() { @@ -349,6 +368,8 @@ public class DatabaseSyncEmissionTests Assert.Null(evt.ParentExecutionId); } + /// <summary>Verifies that a sync DbWrite stamps the execution id and leaves the correlation id null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SyncDbWrite_StampsExecutionId_AndNullCorrelationId() { @@ -373,6 +394,8 @@ public class DatabaseSyncEmissionTests Assert.Null(evt.CorrelationId); } + /// <summary>Verifies that the emitted event records a non-zero duration.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DurationMs_NonZero() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs index 21929b9a..d3b3bd02 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs @@ -12,7 +12,7 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// Audit Log #23 — execution-correlation tests exercised through a full +/// Audit Log execution-correlation tests exercised through a full /// <see cref="ScriptRuntimeContext"/>: /// /// <list type="bullet"> @@ -40,10 +40,12 @@ public class ExecutionCorrelationContextTests /// </summary> private sealed class CapturingAuditWriter : IAuditWriter { - // C3 (Task 2.5): store the decomposed row view so assertions keep + // Store the decomposed row view so assertions keep // reading the ScadaBridge domain fields as typed properties. + /// <summary>The audit rows captured by this writer, in write order.</summary> public List<AuditRowProjection.AuditRowValues> Events { get; } = new(); + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt.AsRow()); @@ -119,6 +121,8 @@ public class ExecutionCorrelationContextTests return live; } + /// <summary>Verifies that no execution id supplied, sync call, stamps fresh non empty execution id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoExecutionIdSupplied_SyncCall_StampsFreshNonEmptyExecutionId() { @@ -141,6 +145,8 @@ public class ExecutionCorrelationContextTests Assert.Null(evt.CorrelationId); } + /// <summary>Verifies that same context, api call and db write, share the same execution id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SameContext_ApiCallAndDbWrite_ShareTheSameExecutionId() { @@ -189,10 +195,12 @@ public class ExecutionCorrelationContextTests Assert.Null(dbRow.CorrelationId); } + /// <summary>Verifies that parent execution id supplied, stamped on emitted row, and distinct from own execution id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ParentExecutionIdSupplied_StampedOnEmittedRow_AndDistinctFromOwnExecutionId() { - // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed call + // ParentExecutionId: an inbound-API-routed call // supplies the spawning execution's ExecutionId as the routed script's // ParentExecutionId. Every audit row the routed script emits must carry // that value in AuditEvent.ParentExecutionId — and still carry its OWN @@ -224,6 +232,8 @@ public class ExecutionCorrelationContextTests Assert.NotEqual(parentExecutionId, evt.ExecutionId!.Value); } + /// <summary>Verifies that no parent execution id supplied, non routed run, parent stays null on emitted row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNullOnEmittedRow() { @@ -246,6 +256,8 @@ public class ExecutionCorrelationContextTests Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); } + /// <summary>Verifies that parent execution id supplied, stamped on api and db rows, from same context.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ParentExecutionIdSupplied_StampedOnApiAndDbRows_FromSameContext() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs index 8ce5c03d..b49a1bb0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// Audit Log #23 — M3 Bundle E (Task E3): every script-initiated +/// Every script-initiated /// <c>ExternalSystem.CachedCall</c> emits exactly one <c>CachedSubmit</c> /// combined-telemetry packet at enqueue time, returns a fresh /// <see cref="TrackedOperationId"/>, and threads that id down to the @@ -24,9 +24,12 @@ public class ExternalSystemCachedCallEmissionTests { private sealed class CapturingForwarder : ICachedCallTelemetryForwarder { + /// <summary>Gets the telemetry packets the forwarder has received.</summary> public List<CachedCallTelemetry> Telemetry { get; } = new(); + /// <summary>Gets or sets a value indicating whether the forwarder should throw when forwarding.</summary> public Exception? ThrowOnForward { get; set; } + /// <inheritdoc /> public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default) { if (ThrowOnForward != null) @@ -43,7 +46,7 @@ public class ExternalSystemCachedCallEmissionTests private const string SourceScript = "ScriptActor:CheckPressure"; /// <summary> - /// Audit Log #23: a fixed per-execution id so the cached-row tests can + /// A fixed per-execution id so the cached-row tests can /// assert <see cref="AuditEvent.ExecutionId"/> against a known value. /// </summary> private static readonly Guid TestExecutionId = Guid.NewGuid(); @@ -57,7 +60,7 @@ public class ExternalSystemCachedCallEmissionTests client, InstanceName, NullLogger.Instance, - // Audit Log #23: the per-execution id stamped into ExecutionId on + // The per-execution id stamped into ExecutionId on // every script-side row. Cached rows keep CorrelationId = // TrackedOperationId (the per-operation lifecycle id). TestExecutionId, @@ -68,6 +71,8 @@ public class ExternalSystemCachedCallEmissionTests parentExecutionId: parentExecutionId); } + /// <summary>Verifies that a cached call emits submit telemetry on enqueue.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_EmitsSubmitTelemetry_OnEnqueue() { @@ -98,7 +103,7 @@ public class ExternalSystemCachedCallEmissionTests // ExecutionId is the per-execution id from the runtime context. Assert.Equal(trackedId.Value, packet.Audit.AsRow().CorrelationId); Assert.Equal(TestExecutionId, packet.Audit.AsRow().ExecutionId); - // C3 (Task 2.5): ForwardState is no longer a field on the canonical + // ForwardState is no longer a field on the canonical // record — it is a site-storage-only concern (the SQLite writer defaults // it to Pending on insert), so the telemetry packet no longer carries it. @@ -113,6 +118,8 @@ public class ExternalSystemCachedCallEmissionTests Assert.Null(packet.Operational.TerminalAtUtc); } + /// <summary>Verifies that an immediately completed cached call captures the request arguments and the response body.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_ImmediateCompletion_CapturesRequestArgs_AndResponseBody() { @@ -150,6 +157,8 @@ public class ExternalSystemCachedCallEmissionTests Assert.Equal("{\"ok\":true}", resolve.Audit.AsRow().ResponseSummary); } + /// <summary>Verifies that a cached call returns a tracked operation id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_ReturnsTrackedOperationId() { @@ -194,7 +203,7 @@ public class ExternalSystemCachedCallEmissionTests } /// <summary> - /// Audit Log #23 (ExecutionId Task 4): the helper → gateway hop of the + /// The helper → gateway hop of the /// threading chain. The cached-call helper must forward the runtime /// context's <c>ExecutionId</c> and <c>SourceScript</c> verbatim into /// <see cref="IExternalSystemClient.CachedCallAsync"/> — so the buffered @@ -202,6 +211,7 @@ public class ExternalSystemCachedCallEmissionTests /// This asserts the exact id/script (not <c>It.IsAny</c>), so a regression /// that dropped the threading would fail here. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_ThreadsExecutionIdAndSourceScript_IntoClient() { @@ -235,13 +245,14 @@ public class ExternalSystemCachedCallEmissionTests } /// <summary> - /// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for + /// The helper → gateway hop for /// <c>ParentExecutionId</c>. A cached call enqueued from an inbound-API- /// routed script run must forward the runtime context's /// <c>ParentExecutionId</c> verbatim into /// <see cref="IExternalSystemClient.CachedCallAsync"/> so the buffered /// retry loop later stamps it onto its audit rows. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_ThreadsParentExecutionId_IntoClient() { @@ -273,9 +284,10 @@ public class ExternalSystemCachedCallEmissionTests } /// <summary> - /// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a + /// A non-routed run threads a /// <c>null</c> ParentExecutionId into the client — the additive default. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_NonRoutedRun_ThreadsNullParentExecutionId_IntoClient() { @@ -305,6 +317,8 @@ public class ExternalSystemCachedCallEmissionTests Times.Once); } + /// <summary>Verifies that a cached call still returns the tracked operation id and proceeds when the forwarder throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_ForwarderThrows_StillReturnsTrackedOperationId_OriginalCallProceeds() { @@ -340,6 +354,8 @@ public class ExternalSystemCachedCallEmissionTests Times.Once); } + /// <summary>Verifies that cached call provenance is populated from the context.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_Provenance_Populated_FromContext() { @@ -365,6 +381,8 @@ public class ExternalSystemCachedCallEmissionTests Assert.Equal(SiteId, packet.Operational.SourceSite); } + /// <summary>Verifies that a cached call without a forwarder still returns the tracked operation id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_NoForwarder_StillReturnsTrackedOperationId() { @@ -396,20 +414,21 @@ public class ExternalSystemCachedCallEmissionTests } /// <summary> - /// Audit Log #23 — M3 Bundle F (F2): when the underlying client call + /// When the underlying client call /// completes immediately (no S&F buffering, <c>WasBuffered=false</c>), /// the S&F retry loop never engages and the /// <c>ICachedCallLifecycleObserver</c> hook never fires. The cached-call /// helper itself must therefore emit the terminal lifecycle rows — /// otherwise <c>Tracking.Status(id)</c> would return <c>Submitted</c> /// forever and the audit log would be missing the <c>Attempted</c> / - /// <c>CachedResolve</c> pair the M3 contract requires. + /// <c>CachedResolve</c> pair the contract requires. /// /// Expected emissions on immediate success: /// 1. CachedSubmit / Submitted (already covered) /// 2. ApiCallCached / Attempted /// 3. CachedResolve / Delivered (TerminalAtUtc set) /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_ImmediateSuccess_EmitsAttemptedAndCachedResolve() { @@ -464,17 +483,19 @@ public class ExternalSystemCachedCallEmissionTests // Terminal row carries TerminalAtUtc. Assert.NotNull(resolve.Operational.TerminalAtUtc); - // Audit Log #23 (ParentExecutionId): null on every script-side cached + // ParentExecutionId: null on every script-side cached // row for a non-routed run. Assert.Null(submit.Audit.AsRow().ParentExecutionId); Assert.Null(attempted.Audit.AsRow().ParentExecutionId); Assert.Null(resolve.Audit.AsRow().ParentExecutionId); } + /// <summary>Verifies that a routed run stamps the parent execution id on all script-side rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_RoutedRun_StampsParentExecutionId_OnAllScriptSideRows() { - // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // An inbound-API-routed run // carries the spawning execution's id; every script-side cached row // (CachedSubmit, ApiCallCached, CachedResolve) must stamp it in // ParentExecutionId. @@ -500,12 +521,13 @@ public class ExternalSystemCachedCallEmissionTests } /// <summary> - /// Audit Log #23 — M3 Bundle F (F2): the immediate-failure terminal + /// The immediate-failure terminal /// path. When the client returns <c>Success=false</c> with /// <c>WasBuffered=false</c> (a permanent failure or a transient failure /// without an S&F engine to buffer it), the cached-call helper must /// still emit Attempted + CachedResolve with the failed status. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_ImmediateFailure_EmitsAttemptedAndCachedResolveFailed() { @@ -543,12 +565,13 @@ public class ExternalSystemCachedCallEmissionTests } /// <summary> - /// Audit Log #23 — M3 Bundle F (F2): when the client reports + /// When the client reports /// <c>WasBuffered=true</c>, the helper hands the operation to S&F and /// the retry-loop observer owns the Attempted + Resolve emissions. The /// helper must NOT emit those rows itself (otherwise we'd get duplicate /// Attempted + Resolve audit rows under the same tracking id). /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_BufferedPath_DoesNotEmitTerminalTelemetryFromHelper() { @@ -574,12 +597,14 @@ public class ExternalSystemCachedCallEmissionTests Assert.Equal(AuditKind.CachedSubmit, only.Audit.AsRow().Kind); } - // ── SourceNode-stamping (Task 14) ── + // ── SourceNode-stamping ── + /// <summary>Verifies that a cached call stamps the source node on every site call operational row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_StampsSourceNode_OnEverySiteCallOperationalRow() { - // SourceNode-stamping (Task 14): when the helper is constructed with + // SourceNode stamping: when the helper is constructed with // a non-null sourceNode, every SiteCallOperational it produces // (CachedSubmit on enqueue + the immediate-completion Attempted/ // CachedResolve pair when WasBuffered=false) carries that node name. @@ -614,6 +639,8 @@ public class ExternalSystemCachedCallEmissionTests Assert.All(forwarder.Telemetry, t => Assert.Equal("node-a", t.Operational.SourceNode)); } + /// <summary>Verifies that a cached call with no source node wired leaves the source node null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CachedCall_NoSourceNodeWired_LeavesSourceNodeNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs index 351723e1..24802bd1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs @@ -11,7 +11,7 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// Audit Log #23 — M2 Bundle F (Task F1): every script-initiated +/// Every script-initiated /// <c>ExternalSystem.Call</c> emits exactly one <c>ApiOutbound</c>/<c>ApiCall</c> /// audit event via the wrapper inside /// <see cref="ScriptRuntimeContext.ExternalSystemHelper"/>. The audit emission @@ -28,11 +28,14 @@ public class ExternalSystemCallAuditEmissionTests /// </summary> private sealed class CapturingAuditWriter : IAuditWriter { - // C3 (Task 2.5): store the decomposed row view so assertions keep + // Store the decomposed row view so assertions keep // reading the ScadaBridge domain fields as typed properties. + /// <summary>Gets the captured audit rows.</summary> public List<AuditRowProjection.AuditRowValues> Events { get; } = new(); + /// <summary>Gets or sets an exception to throw from WriteAsync.</summary> public Exception? ThrowOnWrite { get; set; } + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { if (ThrowOnWrite != null) @@ -50,7 +53,7 @@ public class ExternalSystemCallAuditEmissionTests private const string SourceScript = "ScriptActor:CheckPressure"; /// <summary> - /// Audit Log #23: a fixed per-execution id used by the default + /// A fixed per-execution id used by the default /// <see cref="CreateHelper(IExternalSystemClient, IAuditWriter?)"/> /// overload so assertions can compare against a known value. /// </summary> @@ -79,6 +82,8 @@ public class ExternalSystemCallAuditEmissionTests parentExecutionId: parentExecutionId); } + /// <summary>Verifies that a successful call emits one ApiOutbound ApiCall event with a Delivered status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_Success_EmitsOneEvent_Channel_ApiOutbound_Kind_ApiCall_Status_Delivered() { @@ -98,7 +103,7 @@ public class ExternalSystemCallAuditEmissionTests Assert.Equal(AuditKind.ApiCall, evt.Kind); Assert.Equal(AuditStatus.Delivered, evt.Status); Assert.Equal("ERP.GetOrder", evt.Target); - // C3 (Task 2.5): ForwardState is no longer a field on the canonical + // ForwardState is no longer a field on the canonical // record — it is a site-storage-only concern. The emitter no longer // sets it; the site SQLite writer defaults it to Pending on insert. Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind); @@ -109,6 +114,8 @@ public class ExternalSystemCallAuditEmissionTests Assert.Equal("{}", evt.ResponseSummary); } + /// <summary>Verifies that the audit row captures the request arguments and the response body.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_CapturesRequestArgs_AndResponseBody_OnTheAuditRow() { @@ -129,6 +136,8 @@ public class ExternalSystemCallAuditEmissionTests Assert.Equal("{\"tempC\":11.4}", evt.ResponseSummary); } + /// <summary>Verifies that an HTTP 500 emits a Failed event with status 500 and an error message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_HTTP500_EmitsEvent_Status_Failed_HttpStatus_500_ErrorMessage_Set() { @@ -150,6 +159,8 @@ public class ExternalSystemCallAuditEmissionTests Assert.Contains("500", evt.ErrorMessage); } + /// <summary>Verifies that an HTTP 400 emits a Failed event with status 400.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_HTTP400_EmitsEvent_Status_Failed_HttpStatus_400() { @@ -169,6 +180,8 @@ public class ExternalSystemCallAuditEmissionTests Assert.Equal(400, evt.HttpStatus); } + /// <summary>Verifies that a client network exception emits a Failed event whose error message comes from the exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_ClientThrows_NetworkException_EmitsEvent_Status_Failed_ErrorMessage_FromException() { @@ -192,6 +205,8 @@ public class ExternalSystemCallAuditEmissionTests Assert.Contains("HttpRequestException", evt.ErrorDetail); } + /// <summary>Verifies that a throwing audit writer leaves the script call result unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AuditWriter_Throws_Script_Call_Returns_Original_Result_Unchanged() { @@ -212,6 +227,8 @@ public class ExternalSystemCallAuditEmissionTests Assert.Empty(writer.Events); } + /// <summary>Verifies that provenance fields are populated from the context.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Provenance_Populated_FromContext() { @@ -234,20 +251,22 @@ public class ExternalSystemCallAuditEmissionTests Assert.Equal(SourceScript, evt.SourceScript); // Outbound channel: Actor carries the calling script identity. Assert.Equal(SourceScript, evt.Actor); - // Audit Log #23: the sync ApiCall row carries the per-execution id the + // The sync ApiCall row carries the per-execution id the // helper was constructed with in ExecutionId. CorrelationId is null — // a sync one-shot call has no operation lifecycle. Assert.Equal(TestExecutionId, evt.ExecutionId); Assert.Null(evt.CorrelationId); - // Audit Log #23 (ParentExecutionId): null for a non-routed run — the + // ParentExecutionId: null for a non-routed run — the // default CreateHelper supplies no parentExecutionId. Assert.Null(evt.ParentExecutionId); } + /// <summary>Verifies that a routed run stamps the parent execution id from the context.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_RoutedRun_StampsParentExecutionId_FromContext() { - // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // ParentExecutionId: an inbound-API-routed run // carries the spawning execution's id; the sync ApiCall row must stamp // it in ParentExecutionId alongside its own fresh ExecutionId. var client = new Mock<IExternalSystemClient>(); @@ -265,6 +284,8 @@ public class ExternalSystemCallAuditEmissionTests Assert.Equal(TestExecutionId, evt.ExecutionId); } + /// <summary>Verifies that a non-routed run has a null parent execution id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_NonRoutedRun_ParentExecutionIdIsNull() { @@ -283,6 +304,8 @@ public class ExternalSystemCallAuditEmissionTests Assert.Null(evt.ParentExecutionId); } + /// <summary>Verifies that a sync API call stamps the execution id and a null correlation id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_SyncApiCall_StampsExecutionId_AndNullCorrelationId() { @@ -302,6 +325,8 @@ public class ExternalSystemCallAuditEmissionTests Assert.Null(evt.CorrelationId); } + /// <summary>Verifies that two calls on the same helper share the same execution id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Call_TwoCallsOnSameHelper_ShareTheSameExecutionId() { @@ -326,6 +351,8 @@ public class ExternalSystemCallAuditEmissionTests Assert.Null(writer.Events[1].CorrelationId); } + /// <summary>Verifies that the duration is recorded as non-zero.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DurationMs_Recorded_NonZero() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs index b256e5a9..ca280820 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs @@ -11,13 +11,13 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// Notification Outbox (Task 19): tests for the async <c>Notify.Send</c> / +/// Notification Outbox: tests for the async <c>Notify.Send</c> / /// <c>Notify.Status</c> script API. /// /// In the outbox design <c>Notify.To("list").Send(...)</c> no longer delivers email /// inline — it generates a stable <c>NotificationId</c>, enqueues a /// <see cref="StoreAndForwardCategory.Notification"/> message into the site -/// Store-and-Forward Engine (which Task 18 retargets to forward to central), and +/// Store-and-Forward Engine (which forwards to central), and /// returns the <c>NotificationId</c> immediately. <c>Notify.Status(id)</c> queries /// central for delivery status, reporting the site-local <c>Forwarding</c> state /// while the notification is still buffered at the site. @@ -28,6 +28,7 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _saf; + /// <summary>Initializes the test with an in-memory store-and-forward storage and service.</summary> public NotifyHelperTests() { // LocalDb has no in-memory mode, so the store runs over a real temp file. @@ -43,10 +44,15 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable _saf = new StoreAndForwardService(_storage, options, NullLogger<StoreAndForwardService>.Instance); } + /// <summary>Initializes the storage before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() => await _storage.InitializeAsync(); + /// <summary>Does nothing; storage is released in <c>Dispose</c>.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) @@ -79,6 +85,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable sourceNode: sourceNode); } + /// <summary>Verifies that send, enqueues notification into store and forward, and returns notification id immediately.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_EnqueuesNotificationIntoStoreAndForward_AndReturnsNotificationIdImmediately() { @@ -95,6 +103,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(1, depth.GetValueOrDefault(StoreAndForwardCategory.Notification)); } + /// <summary>Verifies that send, buffered payload, carries list subject body and notification id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_BufferedPayload_CarriesListSubjectBodyAndNotificationId() { @@ -123,6 +133,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal("Plant.Pump3", payload.SourceInstanceId); } + /// <summary>Verifies that send, when helper has source script, stamps it on the notification submit.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_WhenHelperHasSourceScript_StampsItOnTheNotificationSubmit() { @@ -140,10 +152,12 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal("ScriptActor:MonitorSpeed", payload!.SourceScript); } + /// <summary>Verifies that send, stamps execution id, on the notification submit payload.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_StampsExecutionId_OnTheNotificationSubmitPayload() { - // Audit Log #23 (ExecutionId Task 5): Notify.Send must stamp the + // ExecutionId: Notify.Send must stamp the // script run's ExecutionId onto the NotificationSubmit so it rides // inside the serialized S&F payload to central, where the dispatcher // echoes it onto the NotifyDeliver rows. This is the SAME id stamped @@ -161,10 +175,12 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(executionId, payload!.OriginExecutionId); } + /// <summary>Verifies that send, stamps parent execution id, on the notification submit payload.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_StampsParentExecutionId_OnTheNotificationSubmitPayload() { - // Audit Log ParentExecutionId (Task 7): for an inbound-API-routed run, + // ParentExecutionId: for an inbound-API-routed run, // Notify.Send must stamp the routed run's parent ExecutionId onto the // NotificationSubmit so it rides inside the serialized S&F payload to // central, where the dispatcher echoes it onto the NotifyDeliver rows. @@ -182,6 +198,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(parentExecutionId, payload!.OriginParentExecutionId); } + /// <summary>Verifies that send, non routed run, leaves origin parent execution id null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_NonRoutedRun_LeavesOriginParentExecutionIdNull() { @@ -199,10 +217,12 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Null(payload!.OriginParentExecutionId); } + /// <summary>Verifies that send, stamps source node, on the notification submit payload.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_StampsSourceNode_OnTheNotificationSubmitPayload() { - // SourceNode-stamping (Task 13): when the helper is wired with the + // SourceNode-stamping: when the helper is wired with the // local INodeIdentityProvider's NodeName, Notify.Send must stamp it // onto the NotificationSubmit so it rides inside the serialized S&F // payload to central, where NotificationOutboxActor persists it on @@ -219,6 +239,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal("node-a", payload!.SourceNode); } + /// <summary>Verifies that send, no node identity, leaves source node null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_NoNodeIdentity_LeavesSourceNodeNull() { @@ -238,6 +260,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Null(payload!.SourceNode); } + /// <summary>Verifies that send, when helper has no source script, leaves source script null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull() { @@ -252,6 +276,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Null(payload!.SourceScript); } + /// <summary>Verifies that status, when still buffered at site, reports forwarding.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_WhenStillBufferedAtSite_ReportsForwarding() { @@ -277,6 +303,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal("Forwarding", status.Status); } + /// <summary>Verifies that status, when central reports delivered, maps the central response.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_WhenCentralReportsDelivered_MapsTheCentralResponse() { @@ -299,6 +327,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(deliveredAt, status.DeliveredAt); } + /// <summary>Verifies that status, when central not found and not buffered, reports unknown.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_WhenCentralNotFoundAndNotBuffered_ReportsUnknown() { @@ -318,10 +348,12 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal("Unknown", status.Status); } + /// <summary>Verifies that notify send, buffers unbounded, and never invokes forwarder inline.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NotifySend_BuffersUnbounded_AndNeverInvokesForwarderInline() { - // Task 13: Notify.Send must buffer with maxRetries: 0 (never park for retry + // Notify.Send must buffer with maxRetries: 0 (never park for retry // exhaustion) and deferToSweep: true (never run the forwarder's central Ask // inline on the scarce script thread). A central outage must return in // milliseconds, and the row must not strand behind operator unparking. diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs index 4c1f5d7c..bbf1b369 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs @@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// Audit Log #23 — M4 Bundle C (Task C1): every script-initiated +/// Every script-initiated /// <c>Notify.To("list").Send(...)</c> emits exactly one /// <c>Notification</c>/<c>NotifySend</c> audit event via the wrapper inside /// <see cref="ScriptRuntimeContext.NotifyTarget"/>. The audit emission is @@ -33,11 +33,14 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable /// </summary> private sealed class CapturingAuditWriter : IAuditWriter { - // C3 (Task 2.5): store the decomposed row view so assertions keep + // Store the decomposed row view so assertions keep // reading the ScadaBridge domain fields as typed properties. + /// <summary>Gets the recorded audit rows.</summary> public List<AuditRowProjection.AuditRowValues> Events { get; } = new(); + /// <summary>Gets or sets an exception to throw on write.</summary> public Exception? ThrowOnWrite { get; set; } + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { if (ThrowOnWrite != null) @@ -58,7 +61,7 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable private const string Body = "Pump 3 tripped"; /// <summary> - /// Audit Log #23: a fixed per-execution id so the NotifySend test can + /// A fixed per-execution id so the NotifySend test can /// assert <see cref="AuditEvent.ExecutionId"/> against a known value. /// </summary> private static readonly Guid TestExecutionId = Guid.NewGuid(); @@ -67,6 +70,7 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _saf; + /// <summary>Initializes the test class with in-memory storage and a recording audit writer.</summary> public NotifySendAuditEmissionTests() { // LocalDb has no in-memory mode, so the store runs over a real temp file. @@ -82,10 +86,15 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable _saf = new StoreAndForwardService(_storage, options, NullLogger<StoreAndForwardService>.Instance); } + /// <summary>Prepares the test fixture before the first test runs.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() => await _storage.InitializeAsync(); + /// <summary>Does nothing; cleanup happens in <c>Dispose</c>.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) @@ -118,6 +127,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable parentExecutionId: parentExecutionId); } + /// <summary>Verifies that a successful send emits one NotifySend event with Submitted status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_Success_EmitsOneEvent_KindNotifySend_StatusSubmitted() { @@ -144,6 +155,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(SourceScript, evt.Actor); } + /// <summary>Verifies that the audit target is populated with the list name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_PopulatesTarget_AsListName() { @@ -156,6 +169,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(ListName, evt.Target); } + /// <summary>Verifies that the request summary is populated as the subject and body JSON.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_PopulatesRequestSummary_AsSubjectBodyJson() { @@ -168,7 +183,7 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable Assert.NotNull(evt.RequestSummary); // Round-trip the JSON to assert the exact shape, not raw text — the // contract is "JSON of {subject, body}", which downstream redaction - // (M5) can reshape; M4 captures verbatim. + // can reshape; the row captures it verbatim. using var doc = JsonDocument.Parse(evt.RequestSummary!); var root = doc.RootElement; Assert.Equal(JsonValueKind.Object, root.ValueKind); @@ -176,6 +191,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(Body, root.GetProperty("body").GetString()); } + /// <summary>Verifies that a throwing audit writer does not prevent the original send from returning.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_AuditWriter_Throws_OriginalSendStillReturns() { @@ -201,6 +218,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable Assert.Empty(writer.Events); } + /// <summary>Verifies that the provenance fields are populated from the script context.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_Provenance_PopulatedFromContext() { @@ -217,6 +236,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(SourceScript, evt.Actor); } + /// <summary>Verifies that the notification id is parsed as the correlation id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_NotificationIdParsed_AsCorrelationId() { @@ -235,14 +256,16 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable Assert.NotNull(evt.CorrelationId); Assert.Equal(expected, evt.CorrelationId); Assert.Equal(TestExecutionId, evt.ExecutionId); - // Audit Log #23 (ParentExecutionId): null for a non-routed run. + // ParentExecutionId: null for a non-routed run. Assert.Null(evt.ParentExecutionId); } + /// <summary>Verifies that a routed run stamps the parent execution id on the NotifySend row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_RoutedRun_StampsParentExecutionId_OnNotifySendRow() { - // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // ParentExecutionId: an inbound-API-routed run // carries the spawning execution's id; the NotifySend row must stamp // it in ParentExecutionId alongside its own ExecutionId. var parentExecutionId = Guid.NewGuid(); @@ -256,6 +279,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(TestExecutionId, evt.ExecutionId); } + /// <summary>Verifies that a non-routed run leaves the parent execution id null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_NonRoutedRun_ParentExecutionIdIsNull() { @@ -270,10 +295,12 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable Assert.Null(evt.ParentExecutionId); } + /// <summary>Verifies that sending without an audit writer still returns a notification id and enqueues the message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Send_WithoutAuditWriter_StillReturnsNotificationId_AndEnqueues() { - // Audit is opt-in (mirrors M2 Bundle F behaviour): a null writer must + // Audit is opt-in: a null writer must // degrade to a no-op audit path so tests / minimal hosts that don't // wire AddAuditLog still work. var notify = CreateHelper(auditWriter: null); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs index c886a005..1751890e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs @@ -15,7 +15,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): nested +/// ParentExecutionId tag-cascade: nested /// <c>CallScript</c> / <c>CallShared</c> invocations and alarm on-trigger runs /// must form a true execution tree, where each spawned run records its /// immediate spawner's <c>ExecutionId</c> as its <c>ParentExecutionId</c>. @@ -52,8 +52,10 @@ public class ParentExecutionTreeTests : TestKit /// </summary> private sealed class CapturingAuditWriter : IAuditWriter { + /// <summary>Gets the audit rows captured so far.</summary> public List<AuditRowProjection.AuditRowValues> Events { get; } = new(); + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt.AsRow()); @@ -101,6 +103,8 @@ public class ParentExecutionTreeTests : TestKit // Nested CallScript (actor-routed) — A → CallScript(B) // ------------------------------------------------------------------------- + /// <summary>Verifies that <c>CallScript</c> stamps the calling run's own execution id as the child's parent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallScript_StampsCallingRunsOwnExecutionId_AsChildParent() { @@ -122,6 +126,8 @@ public class ParentExecutionTreeTests : TestKit await call; } + /// <summary>Verifies that <c>CallScript</c> from a routed run uses the run's own execution id rather than an inherited parent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallScript_FromRoutedRun_UsesOwnExecutionId_NotInheritedParent() { @@ -151,6 +157,8 @@ public class ParentExecutionTreeTests : TestKit // Nested CallShared (inline) — B → CallShared(C) // ------------------------------------------------------------------------- + /// <summary>Verifies that a <c>CallShared</c> child run has the caller's execution id as parent and a fresh execution id of its own.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallShared_ChildRun_ParentIsCallersExecutionId_FreshOwnExecutionId() { @@ -186,6 +194,8 @@ public class ParentExecutionTreeTests : TestKit Assert.NotEqual(bExecutionId, evt.ExecutionId!.Value); } + /// <summary>Verifies that a <c>CallShared</c> from a routed run has the caller as parent rather than an inherited grandparent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallShared_FromRoutedRun_ChildParentIsCaller_NotInheritedGrandparent() { @@ -222,6 +232,7 @@ public class ParentExecutionTreeTests : TestKit // Alarm on-trigger plumbing // ------------------------------------------------------------------------- + /// <summary>Verifies that creating a child context for a shared script makes the caller's execution the parent and mints a fresh id.</summary> [Fact] public void CreateChildContextForSharedScript_ParentIsCallerExecution_FreshOwnId() { @@ -237,6 +248,7 @@ public class ParentExecutionTreeTests : TestKit Assert.NotEqual(bExecutionId, child.ExecutionId); } + /// <summary>Verifies that a nested <c>CallScript</c> from an alarm on-trigger script carries the alarm run's own execution id as parent.</summary> [Fact] public void AlarmOnTrigger_NestedCallScript_CarriesAlarmRunsOwnExecutionId_AsParent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/RecursionLimitSiteEventTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/RecursionLimitSiteEventTests.cs index f3c3e00e..3c247c92 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/RecursionLimitSiteEventTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/RecursionLimitSiteEventTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// M2.12 (#25): recursion-limit violations at <c>CallScript</c> and +/// Recursion-limit violations at <c>CallScript</c> and /// <c>CallShared</c> must emit a <c>script</c> Error site event via /// <see cref="ISiteEventLogger"/> in addition to the existing /// <c>ILogger.LogError</c> + throw. @@ -46,6 +46,8 @@ public class RecursionLimitSiteEventTests // CallScript // ------------------------------------------------------------------------- + /// <summary>Verifies that exceeding the CallScript recursion limit emits a script error site event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallScript_RecursionLimitExceeded_EmitsScriptErrorSiteEvent() { @@ -76,6 +78,8 @@ public class RecursionLimitSiteEventTests Times.Once); } + /// <summary>Verifies that exceeding the CallScript recursion limit with no site event logger configured does not throw.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallScript_RecursionLimitExceeded_NullLogger_DoesNotThrow() { @@ -93,6 +97,8 @@ public class RecursionLimitSiteEventTests // CallShared (via Scripts.CallShared) // ------------------------------------------------------------------------- + /// <summary>Verifies that exceeding the CallShared recursion limit emits a script error site event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallShared_RecursionLimitExceeded_EmitsScriptErrorSiteEvent() { @@ -121,6 +127,8 @@ public class RecursionLimitSiteEventTests Times.Once); } + /// <summary>Verifies that exceeding the CallShared recursion limit with no site event logger configured does not throw.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallShared_RecursionLimitExceeded_NullLogger_DoesNotThrow() { @@ -136,6 +144,8 @@ public class RecursionLimitSiteEventTests // Verify the event shape precisely (category, severity, message content) // ------------------------------------------------------------------------- + /// <summary>Verifies that the recursion-limit site event has the correct category and severity.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallScript_RecursionLimitExceeded_EventHasCorrectCategoryAndSeverity() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SandboxTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SandboxTests.cs index fabd199b..17c2467b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SandboxTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SandboxTests.cs @@ -5,13 +5,14 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// WP-6 (Phase 8): Script sandboxing verification. +/// Script sandboxing verification. /// Adversarial tests that verify forbidden APIs are blocked at compilation time. /// </summary> public class SandboxTests { private readonly ScriptCompilationService _service; + /// <summary>Initializes the test class with a script compilation service.</summary> public SandboxTests() { _service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance); @@ -19,6 +20,7 @@ public class SandboxTests // ── System.IO forbidden ── + /// <summary>Verifies that reading a file via System.IO is blocked at compilation.</summary> [Fact] public void Sandbox_FileRead_Blocked() { @@ -27,6 +29,7 @@ public class SandboxTests Assert.Contains(result.Errors, e => e.Contains("System.IO")); } + /// <summary>Verifies that writing a file via System.IO is blocked at compilation.</summary> [Fact] public void Sandbox_FileWrite_Blocked() { @@ -34,6 +37,7 @@ public class SandboxTests Assert.False(result.IsSuccess); } + /// <summary>Verifies that creating a directory via System.IO is blocked at compilation.</summary> [Fact] public void Sandbox_DirectoryCreate_Blocked() { @@ -41,6 +45,7 @@ public class SandboxTests Assert.False(result.IsSuccess); } + /// <summary>Verifies that using a FileStream is blocked at compilation.</summary> [Fact] public void Sandbox_FileStream_Blocked() { @@ -48,6 +53,7 @@ public class SandboxTests Assert.False(result.IsSuccess); } + /// <summary>Verifies that using a StreamReader is blocked at compilation.</summary> [Fact] public void Sandbox_StreamReader_Blocked() { @@ -57,6 +63,7 @@ public class SandboxTests // ── Process forbidden ── + /// <summary>Verifies that starting a process is blocked at compilation.</summary> [Fact] public void Sandbox_ProcessStart_Blocked() { @@ -65,6 +72,7 @@ public class SandboxTests Assert.Contains(result.Errors, e => e.Contains("Process")); } + /// <summary>Verifies that using ProcessStartInfo is blocked at compilation.</summary> [Fact] public void Sandbox_ProcessStartInfo_Blocked() { @@ -78,6 +86,7 @@ public class SandboxTests // ── Threading forbidden (except Tasks/CancellationToken) ── + /// <summary>Verifies that creating a thread is blocked at compilation.</summary> [Fact] public void Sandbox_ThreadCreate_Blocked() { @@ -86,6 +95,7 @@ public class SandboxTests Assert.Contains(result.Errors, e => e.Contains("System.Threading")); } + /// <summary>Verifies that using a Mutex is blocked at compilation.</summary> [Fact] public void Sandbox_Mutex_Blocked() { @@ -93,6 +103,7 @@ public class SandboxTests Assert.False(result.IsSuccess); } + /// <summary>Verifies that using a Semaphore is blocked at compilation.</summary> [Fact] public void Sandbox_Semaphore_Blocked() { @@ -100,6 +111,7 @@ public class SandboxTests Assert.False(result.IsSuccess); } + /// <summary>Verifies that Task.Delay is allowed.</summary> [Fact] public void Sandbox_TaskDelay_Allowed() { @@ -108,6 +120,7 @@ public class SandboxTests Assert.Empty(violations); } + /// <summary>Verifies that using a CancellationToken is allowed.</summary> [Fact] public void Sandbox_CancellationToken_Allowed() { @@ -116,6 +129,7 @@ public class SandboxTests Assert.Empty(violations); } + /// <summary>Verifies that using a CancellationTokenSource is allowed.</summary> [Fact] public void Sandbox_CancellationTokenSource_Allowed() { @@ -126,6 +140,7 @@ public class SandboxTests // ── Reflection forbidden ── + /// <summary>Verifies that reflection via GetType is blocked at compilation.</summary> [Fact] public void Sandbox_GetType_Reflection_Blocked() { @@ -134,6 +149,7 @@ public class SandboxTests Assert.False(result.IsSuccess); } + /// <summary>Verifies that loading an assembly is blocked at compilation.</summary> [Fact] public void Sandbox_AssemblyLoad_Blocked() { @@ -142,6 +158,7 @@ public class SandboxTests Assert.False(result.IsSuccess); } + /// <summary>Verifies that creating an instance via Activator reflection is blocked at compilation.</summary> [Fact] public void Sandbox_ActivatorCreateInstance_ViaReflection_Blocked() { @@ -152,6 +169,7 @@ public class SandboxTests // ── Raw network forbidden ── + /// <summary>Verifies that using a TcpClient is blocked at compilation.</summary> [Fact] public void Sandbox_TcpClient_Blocked() { @@ -160,6 +178,7 @@ public class SandboxTests Assert.Contains(result.Errors, e => e.Contains("System.Net.Sockets")); } + /// <summary>Verifies that using a UdpClient is blocked at compilation.</summary> [Fact] public void Sandbox_UdpClient_Blocked() { @@ -167,6 +186,7 @@ public class SandboxTests Assert.False(result.IsSuccess); } + /// <summary>Verifies that using an HttpClient is blocked at compilation.</summary> [Fact] public void Sandbox_HttpClient_Blocked() { @@ -175,6 +195,7 @@ public class SandboxTests Assert.Contains(result.Errors, e => e.Contains("System.Net.Http")); } + /// <summary>Verifies that using an HttpRequestMessage is blocked at compilation.</summary> [Fact] public void Sandbox_HttpRequestMessage_Blocked() { @@ -185,6 +206,7 @@ public class SandboxTests // ── Allowed operations ── + /// <summary>Verifies that basic math operations are allowed.</summary> [Fact] public void Sandbox_BasicMath_Allowed() { @@ -192,6 +214,7 @@ public class SandboxTests Assert.True(result.IsSuccess); } + /// <summary>Verifies that LINQ operations are allowed.</summary> [Fact] public void Sandbox_LinqOperations_Allowed() { @@ -200,6 +223,7 @@ public class SandboxTests Assert.True(result.IsSuccess); } + /// <summary>Verifies that string operations are allowed.</summary> [Fact] public void Sandbox_StringOperations_Allowed() { @@ -208,6 +232,7 @@ public class SandboxTests Assert.True(result.IsSuccess); } + /// <summary>Verifies that DateTime operations are allowed.</summary> [Fact] public void Sandbox_DateTimeOperations_Allowed() { @@ -257,6 +282,8 @@ public class SandboxTests CancellationToken = cts.Token }; + /// <summary>Verifies that an infinite loop is cancelled by the cancellation token.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sandbox_InfiniteLoop_CancelledByToken() { @@ -284,6 +311,8 @@ public class SandboxTests }); } + /// <summary>Verifies that a long-running script times out.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sandbox_LongRunningScript_TimesOut() { @@ -314,6 +343,8 @@ public class SandboxTests }); } + /// <summary>Verifies that a script that is never cancelled runs to completion.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Sandbox_UncancelledScript_RunsToCompletion() { @@ -348,6 +379,7 @@ public class SandboxTests // ── Combined adversarial attempts ── + /// <summary>Verifies that multiple violations in one script are all detected.</summary> [Fact] public void Sandbox_MultipleViolationsInOneScript_AllDetected() { @@ -363,6 +395,7 @@ public class SandboxTests $"Expected at least 4 violations but got {violations.Count}: {string.Join("; ", violations)}"); } + /// <summary>Verifies that a forbidden using directive is still detected.</summary> [Fact] public void Sandbox_UsingDirective_StillDetected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs index 22e23994..d8bd9770 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs @@ -16,6 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// </summary> public class ScopeAccessorTests { + /// <summary>Verifies that the root scope has an empty self path.</summary> [Fact] public void Root_SelfPath_Empty() { @@ -24,6 +25,7 @@ public class ScopeAccessorTests Assert.False(ScriptScope.Root.HasParent); } + /// <summary>Verifies that a composition scope has a parent scope.</summary> [Fact] public void CompositionScope_HasParent() { @@ -32,6 +34,7 @@ public class ScopeAccessorTests Assert.Equal("", scope.ParentPath); } + /// <summary>Verifies that an attribute accessor in the root scope resolves a bare key.</summary> [Fact] public void AttributeAccessor_RootScope_ResolvesBareKey() { @@ -39,6 +42,7 @@ public class ScopeAccessorTests Assert.Equal("Temperature", acc.Resolve("Temperature")); } + /// <summary>Verifies that an attribute accessor in a composed scope prepends the composition path.</summary> [Fact] public void AttributeAccessor_ComposedScope_PrependsPath() { @@ -46,6 +50,7 @@ public class ScopeAccessorTests Assert.Equal("TempSensor.Temperature", acc.Resolve("Temperature")); } + /// <summary>Verifies that an attribute accessor in a nested scope chains the full composition path.</summary> [Fact] public void AttributeAccessor_NestedScope_ChainsPath() { @@ -53,6 +58,7 @@ public class ScopeAccessorTests Assert.Equal("Motor.TempSensor.Temperature", acc.Resolve("Temperature")); } + /// <summary>Verifies that attributes reached through a composition accessor share its scope.</summary> [Fact] public void CompositionAccessor_AttributesShareScope() { @@ -61,6 +67,7 @@ public class ScopeAccessorTests Assert.Equal("TempSensor", comp.Attributes.ScopePrefix); } + /// <summary>Verifies that resolving a script through a composition accessor prepends the composition path.</summary> [Fact] public void CompositionAccessor_ResolveScript_PrependsPath() { @@ -68,6 +75,7 @@ public class ScopeAccessorTests Assert.Equal("TempSensor.Sample", comp.ResolveScript("Sample")); } + /// <summary>Verifies that a composition accessor with an empty path leaves the script name bare.</summary> [Fact] public void CompositionAccessor_EmptyPath_LeavesScriptNameBare() { @@ -75,6 +83,7 @@ public class ScopeAccessorTests Assert.Equal("Sample", comp.ResolveScript("Sample")); } + /// <summary>Verifies that a children accessor from the root scope returns a child with no path prefix.</summary> [Fact] public void ChildrenAccessor_FromRoot_GivesUnpathedChild() { @@ -83,6 +92,7 @@ public class ScopeAccessorTests Assert.Equal("TempSensor", temp.Path); } + /// <summary>Verifies that a children accessor from a composition scope prefixes the child with the composition path.</summary> [Fact] public void ChildrenAccessor_FromComposition_PrefixesChild() { @@ -105,6 +115,7 @@ public class ScopeAccessorTests // These tests verify that contract at the codec level, which is exactly what // the fix makes the accessor invoke. + /// <summary>Verifies that encoding a list produces a JSON array.</summary> [Fact] public void AttributeValueCodec_Encode_List_ProducesJsonArray() { @@ -115,6 +126,7 @@ public class ScopeAccessorTests Assert.Equal("[\"a\",\"b\"]", encoded); } + /// <summary>Verifies that encoding a scalar value passes it through unchanged.</summary> [Fact] public void AttributeValueCodec_Encode_Scalar_PassesThrough() { @@ -124,6 +136,7 @@ public class ScopeAccessorTests Assert.Equal("x", encoded); } + /// <summary>Verifies that encoding null returns null.</summary> [Fact] public void AttributeValueCodec_Encode_Null_ReturnsNull() { @@ -132,10 +145,11 @@ public class ScopeAccessorTests Assert.Null(AttributeValueCodec.Encode(null)); } + /// <summary>Verifies that encoding an integer list produces a JSON array of numbers.</summary> [Fact] public void AttributeValueCodec_Encode_IntList_ProducesJsonArray() { - // Integer list elements encode as native-typed JSON numbers (NJ-1): + // Integer list elements encode as native-typed JSON numbers: // [1,2,3], not the old quoted-element form ["1","2","3"]. var list = new List<int> { 1, 2, 3 }; var encoded = AttributeValueCodec.Encode(list); @@ -164,8 +178,10 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable instanceName: "Pump1", logger: NullLogger<ScriptRuntimeContext>.Instance); + /// <inheritdoc /> void IDisposable.Dispose() => Shutdown(); + /// <summary>Verifies that WaitAsync with a value applies scope resolution before sending the request.</summary> [Fact] public void WaitAsync_Value_AppliesScopeResolution_BeforeSendingRequest() { @@ -188,6 +204,7 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable Assert.Equal("Pump1", req.InstanceName); } + /// <summary>Verifies that WaitAsync with a predicate applies scope resolution and sends the predicate.</summary> [Fact] public void WaitAsync_Predicate_AppliesScopeResolution_AndSendsPredicate() { @@ -206,6 +223,7 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable Assert.NotNull(req.Predicate); } + /// <summary>Verifies that WaitAsync in the root scope leaves the key bare.</summary> [Fact] public void WaitAsync_RootScope_LeavesKeyBare() { @@ -221,6 +239,8 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable // ── WaitForAsync (spec §3): scope resolution + populated WaitResult ─────── + /// <summary>Verifies that WaitForAsync with a value applies scope resolution and surfaces the populated wait result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAsync_Value_AppliesScopeResolution_AndSurfacesPopulatedWaitResult() { @@ -250,6 +270,8 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable Assert.False(result.TimedOut); } + /// <summary>Verifies that WaitForAsync with a predicate applies scope resolution and surfaces the wait result.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAsync_Predicate_AppliesScopeResolution_AndSurfacesWaitResult() { @@ -274,6 +296,8 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable Assert.Equal(42, result.Value); } + /// <summary>Verifies that WaitForAsync passes the require-good-quality flag through into the request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitForAsync_RequireGoodQuality_ThreadsFlagIntoRequest() { @@ -295,7 +319,7 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable Assert.Null(result.Value); } - // ── Reset-aware WriteBatchAndWaitAsync overload (Gitea #36) ────────────── + // ── Reset-aware WriteBatchAndWaitAsync overload ────────────── /// <summary> /// The reset-aware overload must scope-resolve and codec-encode the reset key/value @@ -304,6 +328,7 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable /// <see cref="HandshakeResult.ResetFailed"/> result WITHOUT writing the batch or /// the trigger (the ExpectNoMsg at the end is that assertion). /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WriteBatchAndWaitAsync_ResetAware_ResolvesScopeAndDefaultsResetTimeout() { @@ -343,7 +368,7 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable } /// <summary> - /// Gitea #36 review: a null <c>resetValue</c> would encode to null, which writes an empty + /// A null <c>resetValue</c> would encode to null, which writes an empty /// string on leg 1 and then hands the waiter a null target — "any change" — which fast-paths /// on the stale cached value and reinstates exactly the race this overload exists to close. /// The accessor rejects it before anything reaches the InstanceActor. diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs index fd2a70d7..5a86905c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs @@ -4,9 +4,9 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// WP-19: Script Trust Model tests — validates forbidden API detection and compilation. +/// Script Trust Model tests — validates forbidden API detection and compilation. /// -/// As of the M3.3 consolidation, <c>ScriptCompilationService.ValidateTrustModel</c> +/// As of the consolidation, <c>ScriptCompilationService.ValidateTrustModel</c> /// delegates its forbidden-API verdict to the shared authoritative /// <c>ScriptAnalysis.ScriptTrustValidator</c>, which is stricter than SiteRuntime's /// original deny-list: ALL of <c>System.Net</c> is forbidden (not just Sockets/Http), @@ -26,11 +26,13 @@ public class ScriptCompilationServiceTests private readonly SiteScriptCompileCache _cache = new(); private readonly ScriptCompilationService _service; + /// <summary>Initializes the test class with a compilation service.</summary> public ScriptCompilationServiceTests() { _service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance, _cache); } + /// <summary>Verifies that compiling a valid script succeeds.</summary> [Fact] public void Compile_ValidScript_Succeeds() { @@ -40,6 +42,7 @@ public class ScriptCompilationServiceTests Assert.Empty(result.Errors); } + /// <summary>Verifies that compiling the same code twice shares a single Roslyn compile.</summary> [Fact] public void Compile_SameCodeTwice_SharesOneRoslynCompile() { @@ -55,6 +58,7 @@ public class ScriptCompilationServiceTests Assert.Equal(1, _cache.Count); } + /// <summary>Verifies that a script and a trigger expression with the same code do not cross-contaminate.</summary> [Fact] public void Compile_ScriptAndTriggerExpression_DoNotCrossContaminate() { @@ -66,6 +70,7 @@ public class ScriptCompilationServiceTests Assert.Equal(0, _cache.Hits); } + /// <summary>Verifies that compiling invalid syntax returns errors.</summary> [Fact] public void Compile_InvalidSyntax_ReturnsErrors() { @@ -74,6 +79,7 @@ public class ScriptCompilationServiceTests Assert.NotEmpty(result.Errors); } + /// <summary>Verifies that System.IO is forbidden by the trust model.</summary> [Fact] public void ValidateTrustModel_SystemIO_Forbidden() { @@ -82,6 +88,7 @@ public class ScriptCompilationServiceTests Assert.Contains(violations, v => v.Contains("System.IO")); } + /// <summary>Verifies that Process is forbidden by the trust model.</summary> [Fact] public void ValidateTrustModel_Process_Forbidden() { @@ -90,6 +97,7 @@ public class ScriptCompilationServiceTests Assert.NotEmpty(violations); } + /// <summary>Verifies that Reflection is forbidden by the trust model.</summary> [Fact] public void ValidateTrustModel_Reflection_Forbidden() { @@ -98,6 +106,7 @@ public class ScriptCompilationServiceTests Assert.NotEmpty(violations); } + /// <summary>Verifies that Sockets are forbidden by the trust model.</summary> [Fact] public void ValidateTrustModel_Sockets_Forbidden() { @@ -106,6 +115,7 @@ public class ScriptCompilationServiceTests Assert.NotEmpty(violations); } + /// <summary>Verifies that HttpClient is forbidden by the trust model.</summary> [Fact] public void ValidateTrustModel_HttpClient_Forbidden() { @@ -114,6 +124,7 @@ public class ScriptCompilationServiceTests Assert.NotEmpty(violations); } + /// <summary>Verifies that async and await are allowed by the trust model.</summary> [Fact] public void ValidateTrustModel_AsyncAwait_Allowed() { @@ -123,6 +134,7 @@ public class ScriptCompilationServiceTests Assert.Empty(violations); } + /// <summary>Verifies that CancellationToken is allowed by the trust model.</summary> [Fact] public void ValidateTrustModel_CancellationToken_Allowed() { @@ -131,6 +143,7 @@ public class ScriptCompilationServiceTests Assert.Empty(violations); } + /// <summary>Verifies that clean code produces no trust model violations.</summary> [Fact] public void ValidateTrustModel_CleanCode_NoViolations() { @@ -143,6 +156,7 @@ public class ScriptCompilationServiceTests Assert.Empty(violations); } + /// <summary>Verifies that compiling a script that uses a forbidden API fails validation.</summary> [Fact] public void Compile_ForbiddenApi_FailsValidation() { @@ -151,8 +165,9 @@ public class ScriptCompilationServiceTests Assert.NotEmpty(result.Errors); } - // ── M3.3: stricter shared-validator behavior ── + // ── Stricter shared-validator behavior ── + /// <summary>Verifies that System.Net.Dns is forbidden by the trust model.</summary> [Fact] public void ValidateTrustModel_SystemNetDns_Forbidden() { @@ -164,6 +179,7 @@ public class ScriptCompilationServiceTests Assert.Contains(violations, v => v.Contains("System.Net")); } + /// <summary>Verifies that a reflection gateway reached through a permitted type is forbidden.</summary> [Fact] public void ValidateTrustModel_ReflectionGatewayViaPermittedType_Forbidden() { @@ -175,6 +191,7 @@ public class ScriptCompilationServiceTests Assert.NotEmpty(violations); } + /// <summary>Verifies that dynamic is forbidden by the trust model.</summary> [Fact] public void ValidateTrustModel_Dynamic_Forbidden() { @@ -182,6 +199,7 @@ public class ScriptCompilationServiceTests Assert.NotEmpty(violations); } + /// <summary>Verifies that Activator is forbidden by the trust model.</summary> [Fact] public void ValidateTrustModel_Activator_Forbidden() { @@ -190,6 +208,7 @@ public class ScriptCompilationServiceTests Assert.NotEmpty(violations); } + /// <summary>Verifies that InteropServices are forbidden by the trust model.</summary> [Fact] public void ValidateTrustModel_InteropServices_Forbidden() { @@ -198,6 +217,7 @@ public class ScriptCompilationServiceTests Assert.NotEmpty(violations); } + /// <summary>Verifies that Stopwatch is allowed by the trust model.</summary> [Fact] public void ValidateTrustModel_Stopwatch_Allowed() { @@ -255,6 +275,7 @@ public class ScriptCompilationServiceTests Assert.Same(first.CompiledScript.Options, second.CompiledScript.Options); } + /// <summary>Verifies that compilation uses the process-wide caching metadata resolver.</summary> [Fact] public void Compile_UsesProcessWideCachingMetadataResolver() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptExecutionSchedulerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptExecutionSchedulerTests.cs index 580d9e2b..d9265248 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptExecutionSchedulerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptExecutionSchedulerTests.cs @@ -4,12 +4,14 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// SiteRuntime-009: the dedicated script-execution scheduler must run script bodies on +/// The dedicated script-execution scheduler must run script bodies on /// its own dedicated threads, not on the shared .NET thread pool, so blocking script /// I/O cannot starve the global pool. /// </summary> public class ScriptExecutionSchedulerTests { + /// <summary>Verifies that the scheduler does not inline work on another scheduler's worker thread.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Scheduler_DoesNotInlineOnAnotherSchedulersWorker() { @@ -24,6 +26,8 @@ public class ScriptExecutionSchedulerTests }, CancellationToken.None, TaskCreationOptions.None, first); } + /// <summary>Verifies that the scheduler runs work off the shared thread pool.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Scheduler_RunsWork_OffTheThreadPool() { @@ -43,6 +47,7 @@ public class ScriptExecutionSchedulerTests Assert.StartsWith("script-execution-", threadName); } + /// <summary>Verifies that the scheduler respects the configured thread count.</summary> [Fact] public void Scheduler_RespectsConfiguredThreadCount() { @@ -50,6 +55,7 @@ public class ScriptExecutionSchedulerTests Assert.Equal(4, scheduler.MaximumConcurrencyLevel); } + /// <summary>Verifies that the shared scheduler returns the same instance for the same options.</summary> [Fact] public void Scheduler_Shared_ReturnsSameInstanceForOptions() { @@ -59,11 +65,12 @@ public class ScriptExecutionSchedulerTests Assert.Same(a, b); } - // Gitea #18: IsDisposed backs the Shared() recreate guard so a disposed scheduler + // IsDisposed backs the Shared() recreate guard so a disposed scheduler // can never be handed back and silently poison every later execution. Verified on a // local instance — deliberately NOT by disposing the process-wide singleton, which // would strand any script another test class queued on it (the very static hazard - // #18 is about). + // this test guards against). + /// <summary>Verifies that <c>IsDisposed</c> flips to true when the scheduler is disposed.</summary> [Fact] public void IsDisposed_FlipsOnDispose() { @@ -79,6 +86,8 @@ public class ScriptExecutionSchedulerTests // SiteRuntime-S2/UA5: the scheduler exposes observability gauges so a stuck / // saturated script-execution pool is visible to operators via site health. + /// <summary>Verifies that the gauges reflect the busy thread count and the queue depth.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Gauges_ReflectBusyThreadAndQueueDepth() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptFileReaderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptFileReaderTests.cs index 783f8034..2c3b87f4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptFileReaderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptFileReaderTests.cs @@ -19,6 +19,7 @@ public sealed class ScriptFileReaderTests : IDisposable private readonly string _base; private readonly string _root; + /// <summary>Initializes the tests with a temporary root directory.</summary> public ScriptFileReaderTests() { _base = Path.Combine(Path.GetTempPath(), "sb-files-" + Guid.NewGuid().ToString("N")); @@ -26,6 +27,7 @@ public sealed class ScriptFileReaderTests : IDisposable Directory.CreateDirectory(_root); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { try { Directory.Delete(_base, recursive: true); } catch { /* best effort */ } @@ -52,6 +54,8 @@ public sealed class ScriptFileReaderTests : IDisposable private static async Task<ScriptFileAccessFailure> FailureOf(Func<Task> act) => (await Assert.ThrowsAsync<ScriptFileAccessException>(act)).Failure; + /// <summary>Verifies that file access is disabled by default when no roots are configured.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NoRoots_IsDisabled_ByDefault() { @@ -62,6 +66,8 @@ public sealed class ScriptFileReaderTests : IDisposable Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => reader.ExistsAsync(path, default))); } + /// <summary>Verifies that text under a root is read and a byte order mark is detected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadsText_UnderRoot_AndDetectsBom() { @@ -73,6 +79,8 @@ public sealed class ScriptFileReaderTests : IDisposable Assert.Equal("ReactorTempSP,1050\n", text); } + /// <summary>Verifies that a file in a nested directory under the root can be read.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadsFromNestedDirectory() { @@ -80,6 +88,9 @@ public sealed class ScriptFileReaderTests : IDisposable Assert.Equal("a,1", await Reader().ReadTextAsync(path, default)); } + /// <summary>Verifies that an empty or relative path is rejected.</summary> + /// <param name="path">The empty or relative path to read.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("")] [InlineData(" ")] @@ -89,6 +100,8 @@ public sealed class ScriptFileReaderTests : IDisposable Assert.Equal(ScriptFileAccessFailure.InvalidPath, await FailureOf(() => Reader().ReadTextAsync(path, default))); } + /// <summary>Verifies that a path outside the configured root is rejected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RejectsPathOutsideRoot() { @@ -99,6 +112,8 @@ public sealed class ScriptFileReaderTests : IDisposable await FailureOf(() => Reader().ReadTextAsync(outside, default))); } + /// <summary>Verifies that a dot-dot traversal out of the root is rejected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RejectsDotDotTraversalOutOfRoot() { @@ -109,6 +124,8 @@ public sealed class ScriptFileReaderTests : IDisposable await FailureOf(() => Reader().ReadTextAsync(sneaky, default))); } + /// <summary>Verifies that a sibling directory sharing the root's name prefix is not treated as inside the root.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SiblingDirectorySharingThePrefix_IsNotInsideTheRoot() { @@ -122,6 +139,8 @@ public sealed class ScriptFileReaderTests : IDisposable await FailureOf(() => Reader().ReadTextAsync(path, default))); } + /// <summary>Verifies that the root directory itself is not treated as a file.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TheRootItself_IsNotAFile() { @@ -129,6 +148,8 @@ public sealed class ScriptFileReaderTests : IDisposable await FailureOf(() => Reader().ReadTextAsync(_root, default))); } + /// <summary>Verifies that a symlinked file inside the root is refused.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SymlinkedFileInsideRoot_IsRefused() { @@ -141,6 +162,8 @@ public sealed class ScriptFileReaderTests : IDisposable await FailureOf(() => Reader().ReadTextAsync(link, default))); } + /// <summary>Verifies that a symlinked directory inside the root is refused.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SymlinkedDirectoryInsideRoot_IsRefused() { @@ -153,6 +176,8 @@ public sealed class ScriptFileReaderTests : IDisposable await FailureOf(() => Reader().ReadTextAsync(Path.Combine(_root, "linked", "secret.txt"), default))); } + /// <summary>Verifies that a missing file reports not found and that the existence check returns false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MissingFile_IsNotFound_AndExistsIsFalse() { @@ -162,6 +187,8 @@ public sealed class ScriptFileReaderTests : IDisposable Assert.False(await Reader().ExistsAsync(path, default)); } + /// <summary>Verifies that the existence check returns true for a file but still enforces the roots.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Exists_TrueForFile_ButStillEnforcesRoots() { @@ -171,6 +198,8 @@ public sealed class ScriptFileReaderTests : IDisposable await FailureOf(() => Reader().ExistsAsync(Path.Combine(_base, "whatever.txt"), default))); } + /// <summary>Verifies that a file over the size cap is reported as too large.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task FileOverTheCap_IsTooLarge() { @@ -180,6 +209,8 @@ public sealed class ScriptFileReaderTests : IDisposable Assert.Equal(2048, (await Reader(maxBytes: 2048).ReadTextAsync(path, default)).Length); } + /// <summary>Verifies that a file under a second configured root can be read.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SecondRoot_IsHonoured() { @@ -191,6 +222,8 @@ public sealed class ScriptFileReaderTests : IDisposable Assert.Equal("ok", await Reader(1024 * 1024, _root, other).ReadTextAsync(path, default)); } + /// <summary>Verifies that a root configured with a trailing separator works.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RootConfiguredWithTrailingSeparator_Works() { @@ -198,6 +231,7 @@ public sealed class ScriptFileReaderTests : IDisposable Assert.Equal("ok", await Reader(1024 * 1024, _root + Path.DirectorySeparatorChar).ReadTextAsync(path, default)); } + /// <summary>Verifies that line splitting handles mixed line endings and drops a trailing empty line.</summary> [Fact] public void SplitLines_HandlesMixedEndings_AndDropsTrailingEmptyLine() { @@ -206,6 +240,8 @@ public sealed class ScriptFileReaderTests : IDisposable Assert.Equal([""], FilesAccessor.SplitLines("\n")); } + /// <summary>Verifies that the script accessor throws a disabled error when no reader is configured.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Accessor_WithoutReader_ThrowsDisabled() { @@ -213,6 +249,8 @@ public sealed class ScriptFileReaderTests : IDisposable Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => files.ReadTextAsync("C:/x.txt"))); } + /// <summary>Verifies that the script accessor honours the script deadline.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Accessor_HonoursTheScriptDeadline() { @@ -224,6 +262,8 @@ public sealed class ScriptFileReaderTests : IDisposable await Assert.ThrowsAnyAsync<OperationCanceledException>(() => files.ReadTextAsync(path)); } + /// <summary>Verifies that a real script compiles through the trust gate and parses a recipe file.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RealScript_CompilesThroughTheTrustGate_AndParsesARecipe() { @@ -263,6 +303,7 @@ public sealed class ScriptFileReaderTests : IDisposable Assert.Equal("missing", await run(Path.Combine(_root, "nope.txt"))); } + /// <summary>Verifies that raw System.IO usage is still rejected by the trust gate.</summary> [Fact] public void RawSystemIo_IsStillRejectedByTheTrustGate() { @@ -274,8 +315,15 @@ public sealed class ScriptFileReaderTests : IDisposable private sealed class StaticMonitor(SiteRuntimeOptions value) : IOptionsMonitor<SiteRuntimeOptions> { + /// <summary>Gets the fixed options instance.</summary> public SiteRuntimeOptions CurrentValue => value; + /// <summary>Returns the fixed options instance regardless of name.</summary> + /// <param name="name">The options name, ignored.</param> + /// <returns>The fixed options instance.</returns> public SiteRuntimeOptions Get(string? name) => value; + /// <summary>Ignores the listener because the options never change.</summary> + /// <param name="listener">The change listener, ignored.</param> + /// <returns>Always <see langword="null"/>.</returns> public IDisposable? OnChange(Action<SiteRuntimeOptions, string?> listener) => null; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptLoggerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptLoggerTests.cs index ec6bc731..be2c6fe1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptLoggerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptLoggerTests.cs @@ -20,12 +20,27 @@ public class ScriptLoggerTests /// <summary>A minimal recording <see cref="ILogger"/> — clearer here than a mocking framework.</summary> private sealed class RecordingLogger(bool enabled = true) : ILogger { + /// <summary>Gets the entries recorded so far.</summary> public List<LogEntry> Entries { get; } = []; + /// <summary>Returns no scope because the fake does not track scopes.</summary> + /// <typeparam name="TState">The type of the state object.</typeparam> + /// <param name="state">The scope or log state.</param> + /// <returns>A no-op scope.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + /// <summary>Reports whether logging is enabled for the given level.</summary> + /// <param name="logLevel">The log level.</param> + /// <returns>True if the level is enabled; otherwise false.</returns> public bool IsEnabled(LogLevel logLevel) => enabled; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The type of the state object.</typeparam> + /// <param name="level">The log level.</param> + /// <param name="eventId">The event id.</param> + /// <param name="state">The scope or log state.</param> + /// <param name="exception">The exception associated with the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message text.</param> public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) => Entries.Add(new LogEntry(level, formatter(state, exception), exception)); } @@ -47,6 +62,7 @@ public class ScriptLoggerTests return (logger, node, rows); } + /// <summary>Verifies that an untraced run forwards to the node logger and emits no row.</summary> [Fact] public void UntracedRun_ForwardsToTheNodeLogger_AndEmitsNoRow() { @@ -64,6 +80,7 @@ public class ScriptLoggerTests Assert.Equal(LogLevel.Warning, node.Entries[1].Level); } + /// <summary>Verifies that a traced run emits one script-sourced row per line.</summary> [Fact] public void TracedRun_EmitsOneScriptSourcedRowPerLine() { @@ -76,6 +93,7 @@ public class ScriptLoggerTests Assert.Equal(("info", "script", "msg", (Exception?)null, (Guid?)null), (row.Level, row.Source, row.Message, row.Exception, row.StepEventId)); } + /// <summary>Verifies that every log level maps to its wire name.</summary> [Fact] public void EveryLevel_MapsToItsWireName() { @@ -92,6 +110,7 @@ public class ScriptLoggerTests node.Entries.Select(e => e.Level)); } + /// <summary>Verifies that a line inside a step scope is attributed to that step and the scope unwinds afterwards.</summary> [Fact] public void LineInsideAStepScope_IsAttributedToThatStep_AndTheScopeUnwinds() { @@ -112,6 +131,7 @@ public class ScriptLoggerTests Assert.Null(rows[1].StepEventId); } + /// <summary>Verifies that nested step scopes unwind to the enclosing step.</summary> [Fact] public void NestedStepScopes_Unwind_ToTheEnclosingStep() { @@ -138,6 +158,7 @@ public class ScriptLoggerTests Assert.Equal(outer, rows[1].StepEventId); } + /// <summary>Verifies that lines past the per-run cap are dropped and flushing names the dropped count exactly once.</summary> [Fact] public void PastThePerRunCap_LinesAreDropped_AndFlushDroppedNamesTheCount_Once() { @@ -159,7 +180,7 @@ public class ScriptLoggerTests var closing = rows[^1]; Assert.Equal("warn", closing.Level); Assert.Equal("runtime", closing.Source); - // M3: worded identically to the central InboundScriptLogger closing row. + // Worded identically to the central InboundScriptLogger closing row. Assert.Contains("2 further log line(s) were not captured", closing.Message); Assert.Contains("ScadaBridge:SiteRuntime:MaxLogLinesPerRun = 3", closing.Message); Assert.Null(closing.StepEventId); @@ -169,6 +190,7 @@ public class ScriptLoggerTests Assert.Equal(4, rows.Count); } + /// <summary>Verifies that flushing emits nothing when no line was dropped.</summary> [Fact] public void FlushDropped_EmitsNothing_WhenNoLineWasDropped() { @@ -180,6 +202,7 @@ public class ScriptLoggerTests Assert.Single(rows); } + /// <summary>Verifies that messages longer than the cap are clipped with an ellipsis.</summary> [Fact] public void MessagesLongerThanTheCap_AreClippedWithAnEllipsis() { @@ -192,6 +215,7 @@ public class ScriptLoggerTests Assert.EndsWith("…", message); } + /// <summary>Verifies that a message exactly at the cap passes through unclipped.</summary> [Fact] public void AMessageExactlyAtTheCap_PassesThroughUnclipped() { @@ -203,6 +227,7 @@ public class ScriptLoggerTests Assert.Equal(exact, Assert.Single(rows).Message); } + /// <summary>Verifies that a surrogate pair straddling the cap is dropped whole.</summary> [Fact] public void ASurrogatePairStraddlingTheCap_IsDroppedWhole() { @@ -219,6 +244,7 @@ public class ScriptLoggerTests Assert.DoesNotContain(message, static c => char.IsSurrogate(c)); } + /// <summary>Verifies that a throwing emitter never reaches the script and the node logger still gets the line.</summary> [Fact] public void AThrowingEmitter_NeverReachesTheScript_AndTheNodeLoggerStillGotTheLine() { @@ -238,6 +264,7 @@ public class ScriptLoggerTests Assert.Contains("still logged", node.Entries[0].Message); } + /// <summary>Verifies that a throwing node logger never reaches the script and the row is still emitted.</summary> [Fact] public void AThrowingNodeLogger_NeverReachesTheScript_AndTheRowIsStillEmitted() { @@ -258,14 +285,29 @@ public class ScriptLoggerTests /// <summary>A node logger whose sink fails, standing in for a wedged Serilog sink.</summary> private sealed class ThrowingLogger : ILogger { + /// <summary>Returns no scope because the fake does not track scopes.</summary> + /// <typeparam name="TState">The type of the state object.</typeparam> + /// <param name="state">The scope or log state.</param> + /// <returns>A no-op scope.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + /// <summary>Reports whether logging is enabled for the given level.</summary> + /// <param name="logLevel">The log level.</param> + /// <returns>True if the level is enabled; otherwise false.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Throws to simulate a failing log sink.</summary> + /// <typeparam name="TState">The type of the state object.</typeparam> + /// <param name="level">The log level.</param> + /// <param name="eventId">The event id.</param> + /// <param name="state">The scope or log state.</param> + /// <param name="exception">The exception associated with the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message text.</param> public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) => throw new InvalidOperationException("sink is down"); } + /// <summary>Verifies that an attached exception reaches both the node logger and the row.</summary> [Fact] public void AnAttachedException_ReachesBothTheNodeLoggerAndTheRow() { @@ -278,6 +320,7 @@ public class ScriptLoggerTests Assert.Same(boom, Assert.Single(rows).Exception); } + /// <summary>Verifies that runtime lines are row-only and carry the runtime source.</summary> [Fact] public void RuntimeLines_AreRowOnly_AndCarryTheRuntimeSource() { @@ -293,6 +336,7 @@ public class ScriptLoggerTests Assert.Equal("runtime", row.Source); } + /// <summary>Verifies that runtime lines share the per-run cap with script lines.</summary> [Fact] public void RuntimeLines_ShareThePerRunCapWithScriptLines() { @@ -307,6 +351,7 @@ public class ScriptLoggerTests Assert.Contains("1 further log line(s) were not captured", rows[^1].Message); } + /// <summary>Verifies that the maximum line count must be positive.</summary> [Fact] public void MaxLinesMustBePositive() { @@ -314,6 +359,7 @@ public class ScriptLoggerTests new ScriptLogger(new RecordingLogger(), emit: null, maxLines: 0, "i", "s", null)); } + /// <summary>Verifies that the node logger is required.</summary> [Fact] public void TheNodeLoggerIsRequired() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptPoolSizingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptPoolSizingTests.cs index 0280db31..1c88b1db 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptPoolSizingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptPoolSizingTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// WP3.1 test group 5 — the blocking script pool is no longer a fixed 8 threads forever. It +/// The blocking script pool is no longer a fixed 8 threads forever. It /// scales with the number of running instances between a configured floor and ceiling, and it /// is deliberately GROW-ONLY: undeploying instances leaves idle threads (which cost nothing /// measurable) rather than paying for drain/steal complexity. @@ -17,8 +17,11 @@ public class ScriptPoolSizingTests ScriptExecutionMaxThreadCount = ceiling }; + /// <summary>Verifies that the target thread count applies the floor, ratio and ceiling.</summary> + /// <param name="instances">The number of running instances.</param> + /// <param name="expected">The expected target thread count.</param> [Theory] - // At or below floor * 8 instances the result is exactly the pre-WP3.1 fixed size — + // At or below floor * 8 instances the result is exactly the original fixed size — // existing configurations are byte-for-byte unchanged in behaviour. [InlineData(0, 8)] [InlineData(1, 8)] @@ -33,6 +36,7 @@ public class ScriptPoolSizingTests public void ComputeTargetThreads_AppliesFloorRatioAndCeiling(int instances, int expected) => Assert.Equal(expected, ScriptExecutionScheduler.ComputeTargetThreads(instances, Options())); + /// <summary>Verifies that an overridden floor and ceiling are honoured.</summary> [Fact] public void ComputeTargetThreads_HonoursAnOverriddenFloorAndCeiling() { @@ -43,6 +47,7 @@ public class ScriptPoolSizingTests Assert.Equal(4, ScriptExecutionScheduler.ComputeTargetThreads(1000, options)); } + /// <summary>Verifies that the target thread count is never less than one, even with a degenerate floor.</summary> [Fact] public void ComputeTargetThreads_NeverReturnsLessThanOne_EvenWithADegenerateFloor() { @@ -53,6 +58,7 @@ public class ScriptPoolSizingTests Assert.Equal(1, ScriptExecutionScheduler.ComputeTargetThreads(500, options)); } + /// <summary>Verifies that EnsureCapacity grows once, is idempotent and never shrinks.</summary> [Fact] public void EnsureCapacity_GrowsOnce_IsIdempotent_AndNeverShrinks() { @@ -71,6 +77,8 @@ public class ScriptPoolSizingTests Assert.Equal(5, scheduler.MaximumConcurrencyLevel); } + /// <summary>Verifies that EnsureCapacity widens the gauges so the whole pool is observable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnsureCapacity_WidensTheGauges_SoTheWholePoolIsObservable() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunEventPolicyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunEventPolicyTests.cs index ef392bf9..185dc510 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunEventPolicyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunEventPolicyTests.cs @@ -3,8 +3,8 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// WP3.2 (site_events volume policy — docs/plans/2026-08-15-site-events-policy-design.md, -/// stage (b) test plan items 1–3): pure unit tests for +/// Site-events volume policy (docs/plans/2026-08-15-site-events-policy-design.md): +/// pure unit tests for /// <see cref="ScriptRunEventPolicy.ShouldEmitPerRun"/>, isolated from any actor or DI /// plumbing — the actor-level wiring (hot toggle, hosted-service registration) is covered by /// <c>ScriptRunLauncherParityTests</c>. @@ -19,12 +19,14 @@ public class ScriptRunEventPolicyTests PerRunScriptEventScripts = (perRunScriptEventScripts ?? []).ToList() }; + /// <summary>Verifies that the default options emit no per-run events.</summary> [Fact] public void DefaultOptions_EmitsNothing() { Assert.False(ScriptRunEventPolicy.ShouldEmitPerRun(Options(), "Inst1", "Runner")); } + /// <summary>Verifies that turning the global switch on emits for every script.</summary> [Fact] public void GlobalSwitchOn_EmitsForEveryScript() { @@ -34,6 +36,7 @@ public class ScriptRunEventPolicyTests Assert.True(ScriptRunEventPolicy.ShouldEmitPerRun(options, "AnyOtherInstance", "AnyOtherScript")); } + /// <summary>Verifies that an exact per-script match emits.</summary> [Fact] public void ExactPerScriptMatch_Emits() { @@ -42,6 +45,7 @@ public class ScriptRunEventPolicyTests Assert.True(ScriptRunEventPolicy.ShouldEmitPerRun(options, "Inst1", "Runner")); } + /// <summary>Verifies that an exact per-script match does not match a different script on the same instance.</summary> [Fact] public void ExactPerScriptMatch_DoesNotMatchADifferentScriptOnTheSameInstance() { @@ -50,6 +54,7 @@ public class ScriptRunEventPolicyTests Assert.False(ScriptRunEventPolicy.ShouldEmitPerRun(options, "Inst1", "OtherScript")); } + /// <summary>Verifies that an exact per-script match does not match the same script name on a different instance.</summary> [Fact] public void ExactPerScriptMatch_DoesNotMatchTheSameScriptNameOnADifferentInstance() { @@ -58,6 +63,7 @@ public class ScriptRunEventPolicyTests Assert.False(ScriptRunEventPolicy.ShouldEmitPerRun(options, "Inst2", "Runner")); } + /// <summary>Verifies that an instance wildcard emits for every script on that instance only.</summary> [Fact] public void InstanceWildcard_EmitsForEveryScriptOnThatInstanceOnly() { @@ -68,6 +74,7 @@ public class ScriptRunEventPolicyTests Assert.False(ScriptRunEventPolicy.ShouldEmitPerRun(options, "Inst2", "Runner")); } + /// <summary>Verifies that a script that does not match the opt-in list stays silent.</summary> [Fact] public void NonMatchingScript_StaysSilent() { @@ -76,6 +83,7 @@ public class ScriptRunEventPolicyTests Assert.False(ScriptRunEventPolicy.ShouldEmitPerRun(options, "Inst3", "Runner")); } + /// <summary>Verifies that an empty opt-in list stays silent.</summary> [Fact] public void EmptyOptInList_StaysSilent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunLauncherTraceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunLauncherTraceTests.cs index c0065352..cc3535c0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunLauncherTraceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunLauncherTraceTests.cs @@ -22,15 +22,17 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// </summary> public class ScriptRunLauncherTraceTests : TestKit, IDisposable { - /// <summary>Own pool per class (#18 seam), so a wedged body cannot strand the process-wide one.</summary> + /// <summary>Own pool per class, so a wedged body cannot strand the process-wide one.</summary> private readonly ScriptExecutionScheduler _scheduler = new(4); private readonly SharedScriptLibrary _sharedLibrary; + /// <summary>Initializes the test class with its script scheduler and shared script library.</summary> public ScriptRunLauncherTraceTests() => _sharedLibrary = new SharedScriptLibrary( new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance), NullLogger<SharedScriptLibrary>.Instance); + /// <inheritdoc /> void IDisposable.Dispose() { Shutdown(); @@ -98,6 +100,7 @@ public class ScriptRunLauncherTraceTests : TestKit, IDisposable // ── (a) a throwing run carries the exception's full detail ─────────────────── + /// <summary>Verifies that a traced run that throws records the full exception detail on its script-run row.</summary> [Fact] public void ThrowingTracedRun_ScriptRunRow_CarriesTheFullExceptionDetail() { @@ -115,6 +118,7 @@ public class ScriptRunLauncherTraceTests : TestKit, IDisposable // ── (b) a timeout carries NO detail (the stack would be the launcher's) ────── + /// <summary>Verifies that a traced run that times out records no exception detail on its script-run row.</summary> [Fact] public void TimedOutTracedRun_ScriptRunRow_CarriesNoExceptionDetail() { @@ -131,6 +135,7 @@ public class ScriptRunLauncherTraceTests : TestKit, IDisposable // ── (c) the per-run line cap, its closing row, and emission order ──────────── + /// <summary>Verifies that a traced run caps its log lines and emits the closing row before the anchor row.</summary> [Fact] public void TracedRun_CapsLogLines_AndEmitsTheClosingRowBeforeTheAnchor() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunSummaryRecorderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunSummaryRecorderTests.cs index 6bf0606e..81a03f1b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunSummaryRecorderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunSummaryRecorderTests.cs @@ -6,12 +6,14 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// WP3.2 (site_events volume policy — docs/plans/2026-08-15-site-events-policy-design.md, -/// stage (b) test plan item 5): <see cref="ScriptRunSummaryRecorder"/> in isolation — no +/// Site-events volume policy (docs/plans/2026-08-15-site-events-policy-design.md): +/// <see cref="ScriptRunSummaryRecorder"/> in isolation — no /// actor, no hosted service, just the accumulate/flush contract. /// </summary> public class ScriptRunSummaryRecorderTests { + /// <summary>Verifies that flushing with no activity emits no row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ZeroActivity_FlushEmitsNoRow() { @@ -24,6 +26,8 @@ public class ScriptRunSummaryRecorderTests Assert.Empty(siteLog.Entries); } + /// <summary>Verifies that flushing mixed outcomes emits one row with the correct totals.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MixedOutcomes_FlushEmitsOneRowWithCorrectTotals() { @@ -63,6 +67,8 @@ public class ScriptRunSummaryRecorderTests Assert.Equal(3, scripts.GetArrayLength()); } + /// <summary>Verifies that the counters reset after a flush.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CountersReset_AfterFlush() { @@ -81,6 +87,8 @@ public class ScriptRunSummaryRecorderTests Assert.Empty(secondLog.Entries); } + /// <summary>Verifies that more than fifty scripts cap the breakdown and roll up the remainder.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoreThanFiftyScripts_CapsBreakdownAndRollsUpTheRest() { @@ -122,6 +130,8 @@ public class ScriptRunSummaryRecorderTests Assert.Equal(60, top.GetProperty("started").GetInt64()); } + /// <summary>Verifies that concurrent increments are race-free.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConcurrentIncrements_AreRaceFree() { @@ -149,6 +159,8 @@ public class ScriptRunSummaryRecorderTests Assert.Contains($"{expectedTotal} completed", row.Message); } + /// <summary>Verifies that a run spanning a flush boundary still reports its completion in the next window.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RunSpanningAFlushBoundary_StillReportsItsCompletionInTheNextWindow() { @@ -183,6 +195,8 @@ public class ScriptRunSummaryRecorderTests Assert.Equal(42, script.GetProperty("maxDurationMs").GetInt64()); } + /// <summary>Verifies that a completion-only entry does not undo idle flush suppression.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CompletionOnlyEntry_DoesNotResurrectTheIdleFlushSuppression() { @@ -198,6 +212,8 @@ public class ScriptRunSummaryRecorderTests Assert.Empty(idleLog.Entries); } + /// <summary>Verifies that duration tracking reports the average and maximum.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DurationTracking_ReportsAverageAndMax() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs index 7c00bf80..f4905df6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs @@ -24,7 +24,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// emits <c>ScriptTrace</c> rows for attribute writes and waits (into the inline collector /// AND the audit writer), stamps <c>Trace</c> on the commands it sends, and inherits the flag /// into nested calls. An untraced context is byte-for-byte the pre-feature behaviour. -/// Outbound-calls addendum (plan 2026-09-06 Task 3): the <c>ApiOutbound</c> / +/// Outbound calls: the <c>ApiOutbound</c> / /// <c>DbOutbound</c> / <c>Notification</c> rows a run emits ride the same collector — the /// SAME <see cref="AuditEvent"/> instance the writer / telemetry forwarder receives — and /// nothing lands after the collector is <see cref="FlowTraceCollector.Complete"/>d. @@ -35,7 +35,9 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit private sealed class CapturingAuditWriter : IAuditWriter { + /// <summary>The audit events written so far.</summary> public List<AuditEvent> Events { get; } = new(); + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) Events.Add(evt); @@ -45,7 +47,9 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit private sealed class CapturingForwarder : ICachedCallTelemetryForwarder { + /// <summary>The cached-call telemetry packets forwarded so far.</summary> public List<CachedCallTelemetry> Telemetry { get; } = new(); + /// <inheritdoc /> public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default) { Telemetry.Add(telemetry); @@ -58,6 +62,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit private sealed class TempStoreAndForward : IDisposable { private readonly TestLocalDb _localDb; + /// <summary>The store-and-forward service under test.</summary> public StoreAndForwardService Service { get; } private TempStoreAndForward(TestLocalDb localDb, StoreAndForwardService service) @@ -66,6 +71,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Service = service; } + /// <summary>Creates a store-and-forward service over a temporary LocalDb file.</summary> + /// <returns>A task that resolves to the new temporary store-and-forward wrapper.</returns> public static async Task<TempStoreAndForward> CreateAsync() { var localDb = TestLocalDb.CreateTemp("TraceEmissionNotify"); @@ -81,6 +88,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit new StoreAndForwardService(storage, options, NullLogger<StoreAndForwardService>.Instance)); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _localDb.Dispose(); @@ -159,6 +167,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit isTraced: traced, traceCollector: collector, maxLogLinesPerRun: maxLogLinesPerRun); + /// <summary>Verifies that a traced SetAttribute emits an attribute-write row and passes the trace flag on the command.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttribute_WhenTraced_EmitsAttributeWriteRow_AndPassesTraceOnCommand() { @@ -194,6 +204,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Same(evt, Assert.Single(collector.Snapshot())); } + /// <summary>Verifies that an untraced SetAttribute emits nothing and sends a command with the trace flag false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttribute_WhenNotTraced_EmitsNothing_AndCommandTraceFalse() { @@ -216,6 +228,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit /// carries is the FILTERED record — while the writer still receives the raw event and /// redacts it itself (redaction is idempotent, so the stored row is identical). /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttribute_WhenTraced_InlineCopyIsRedacted_WriterStillSeesTheRawEvent() { @@ -240,10 +253,15 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit /// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary> private sealed class UpperRedactor : IAuditRedactor { + /// <summary>Upper-cases the event details so tests can see the filtered copy.</summary> + /// <param name="rawEvent">The audit event to apply.</param> + /// <returns>The event with upper-cased details.</returns> public AuditEvent Apply(AuditEvent rawEvent) => rawEvent with { DetailsJson = rawEvent.DetailsJson?.ToUpperInvariant() }; } + /// <summary>Verifies that a traced SetAttribute whose write fails emits a failed row and then throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttribute_WhenTraced_FailedWrite_EmitsFailedRow_ThenThrows() { @@ -261,6 +279,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Equal("device rejected", row.ErrorMessage); } + /// <summary>Verifies that a traced wait emits an attribute-wait row with a satisfied outcome.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitInternal_WhenTraced_EmitsAttributeWait_WithOutcomeSatisfied() { @@ -287,6 +307,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Single(collector.Snapshot()); } + /// <summary>Verifies that a traced wait that times out emits a failed row with a timeout outcome.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitInternal_WhenTraced_Timeout_EmitsFailedRow_WithTimeoutOutcome() { @@ -305,6 +327,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Equal("timeout", ScriptTraceExtra.TryParse(row.Extra)!.WaitOutcome); } + /// <summary>Verifies that an untraced wait emits nothing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WaitInternal_WhenNotTraced_EmitsNothing() { @@ -320,6 +344,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Empty(writer.Events); } + /// <summary>Verifies that a traced CallScript propagates the trace flag on the script call request.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallScript_WhenTraced_PropagatesTraceOnScriptCallRequest() { @@ -337,6 +363,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.False(CreateContext(probe.Ref, null, traced: false, null).CreateChildContextForSharedScript(1).IsTraced); } + /// <summary>Verifies that an untraced CallScript sends a request with the trace flag false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallScript_WhenNotTraced_RequestTraceIsFalse() { @@ -350,8 +378,10 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit await call; } - // ---- Outbound rows ride the collector (plan 2026-09-06 Task 3) ------------------------- + // ---- Outbound rows ride the collector ------------------------- + /// <summary>Verifies that a traced external system call collects the same API-call row that the writer receives.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExternalSystemCall_WhenTraced_CollectsTheSameApiCallRow_TheWriterReceives() { @@ -372,6 +402,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Equal(exec, Row(collected).ExecutionId); } + /// <summary>Verifies that a buffered cached external call collects only the cached-submit row, as the same instance the telemetry receives.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExternalSystemCachedCall_Buffered_CollectsCachedSubmitOnly_SameInstanceAsTelemetry() { @@ -390,6 +422,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Equal(trackedId.Value, Row(collected).CorrelationId); } + /// <summary>Verifies that an immediately completing cached external call collects the submit, attempted and resolve rows while the run is open.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExternalSystemCachedCall_ImmediateCompletion_CollectsSubmitAttemptedResolve_WhileRunIsOpen() { @@ -410,6 +444,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit snap.Select(e => Row(e).Kind)); } + /// <summary>Verifies that outbound rows emitted after the collector completed do not touch the collector but still reach telemetry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OutboundRows_AfterCollectorCompleted_DoNotTouchTheCollector_ButStillReachTelemetry() { @@ -439,6 +475,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Equal(snapshot.Select(e => e.EventId), collector.Snapshot().Select(e => e.EventId)); } + /// <summary>Verifies that a traced database execute collects the same database-write row that the writer receives.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DatabaseExecute_WhenTraced_CollectsTheSameDbWriteRow_TheWriterReceives() { @@ -464,6 +502,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Contains("\"rowsAffected\":1", Row(collected).Extra); } + /// <summary>Verifies that a buffered cached database write collects the cached-submit row, as the same instance the telemetry receives.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DatabaseCachedWrite_Buffered_CollectsCachedSubmit_SameInstanceAsTelemetry() { @@ -490,6 +530,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Equal(trackedId.Value, Row(collected).CorrelationId); } + /// <summary>Verifies that a traced Notify.Send collects the same notification row that the writer receives.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NotifySend_WhenTraced_CollectsTheSameNotifySendRow_TheWriterReceives() { @@ -510,6 +552,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Equal(Guid.Parse(notificationId), Row(collected).CorrelationId); } + /// <summary>Verifies that without a collector the writer and telemetry still receive every outbound row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OutboundRows_WhenNoCollector_WriterAndTelemetryStillReceiveEveryRow() { @@ -543,6 +587,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Equal(AuditKind.CachedSubmit, Row(Assert.Single(forwarder.Telemetry).Audit).Kind); } + /// <summary>Verifies that a throwing trace sink never fails the call and the writer still gets the row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ThrowingTraceSink_NeverFailsTheCall_AndTheWriterStillGetsTheRow() { @@ -565,10 +611,12 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit /// the runtime line test (c) needs a genuine <c>_logger.LogWarning</c> inside a step body.</summary> private sealed class ThrowingAuditWriter : IAuditWriter { + /// <inheritdoc /> public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("writer down"); } + /// <summary>Verifies that a traced Log call emits one script-log row to both the collector and the writer.</summary> [Fact] public void Log_WhenTraced_EmitsOneScriptLogRow_ToTheCollectorAndTheWriter() { @@ -598,6 +646,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Equal(1, extra.Seq); } + /// <summary>Verifies that an untraced Log call reaches neither the writer nor the collector.</summary> [Fact] public void Log_WhenNotTraced_ReachesNeitherTheWriterNorTheCollector() { @@ -614,6 +663,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.False(collector.LogsTruncated); } + /// <summary>Verifies that a trace write failure does not create diagnostic trace rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TraceWriteFailure_DoesNotCreateDiagnosticTraceRows() { @@ -628,6 +679,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Equal(AuditKind.AttributeWrite, Row(collector.Snapshot().Single()).Kind); } + /// <summary>Verifies that the context caches its helpers per context.</summary> [Fact] public void Helpers_AreCachedPerContext() { @@ -642,6 +694,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.NotSame(ctx.Scripts, child.Scripts); } + /// <summary>Verifies that a Log call with an exception carries the message and the full exception detail.</summary> [Fact] public void Log_WithException_CarriesTheMessageAndTheFullDetail() { @@ -657,6 +710,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Equal("warn", ScriptTraceExtra.TryParse(row.Extra)!.LogLevel); } + /// <summary>Verifies that log rows use the low-priority lane so that step rows keep their reserve.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LogRows_RideTheLowPriorityLane_SoStepRowsKeepTheirReserve() { @@ -682,6 +737,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit Assert.Contains(collector.Snapshot(), e => Row(e).Kind == AuditKind.AttributeWrite); } + /// <summary>Verifies that FlushDropped emits the closing row once the per-run cap is exceeded.</summary> [Fact] public void FlushDropped_EmitsTheClosingRow_OnceThePerRunCapIsExceeded() { @@ -709,6 +765,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit /// that node. Provoked the same way as the write case — a writer that throws makes the /// helper's own best-effort "audit write threw" warning fire inside the scope. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RuntimeWarning_InsideATracedExternalCall_IsAttributedToTheApiCallStep() { @@ -732,6 +789,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit /// inline collector — a traced run without one still writes durable <c>ScriptLog</c> rows that /// have to name their step. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TracedExternalCall_WithoutACollector_StillNamesTheApiCallStep() { @@ -786,6 +844,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit /// The batch and wait scopes unwind even when trace writes fail; their internal /// diagnostics stay out of the collector, and the later script line belongs to the run. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task NestedStepScopes_IgnoreTraceDiagnostics_AndUnwind() { @@ -820,10 +879,11 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit } /// <summary> - /// Gitea #36: the reset-aware handshake is four traced steps in leg order — reset write, + /// The reset-aware handshake is four traced steps in leg order — reset write, /// reset-echo wait, batch write, response wait — each with its own row id, with no new /// audit kinds (the Flow Debugger projection needs no change). /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WriteBatchResetAndWait_WhenTraced_EmitsFourStepRowsInLegOrder() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptSchedulerStatsReporterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptSchedulerStatsReporterTests.cs index b24a6e17..34fea1f5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptSchedulerStatsReporterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptSchedulerStatsReporterTests.cs @@ -13,6 +13,8 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// </summary> public class ScriptSchedulerStatsReporterTests { + /// <summary>Verifies that the reporter pushes the scheduler statistics to the health collector.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reporter_PushesSchedulerStatsToCollector() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SharedScriptLibraryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SharedScriptLibraryTests.cs index 27c7b673..10cbd097 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SharedScriptLibraryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SharedScriptLibraryTests.cs @@ -4,13 +4,14 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// WP-17: Shared Script Library tests — compile, register, execute inline. +/// Shared Script Library tests — compile, register, execute inline. /// </summary> public class SharedScriptLibraryTests { private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _library; + /// <summary>Initializes a new instance of the <c>SharedScriptLibraryTests</c> class.</summary> public SharedScriptLibraryTests() { _compilationService = new ScriptCompilationService( @@ -19,6 +20,7 @@ public class SharedScriptLibraryTests _compilationService, NullLogger<SharedScriptLibrary>.Instance); } + /// <summary>Verifies that compile and register valid script succeeds.</summary> [Fact] public void CompileAndRegister_ValidScript_Succeeds() { @@ -27,6 +29,7 @@ public class SharedScriptLibraryTests Assert.True(_library.Contains("add")); } + /// <summary>Verifies that compile and register invalid script returns false.</summary> [Fact] public void CompileAndRegister_InvalidScript_ReturnsFalse() { @@ -35,6 +38,7 @@ public class SharedScriptLibraryTests Assert.False(_library.Contains("bad")); } + /// <summary>Verifies that compile and register forbidden api returns false.</summary> [Fact] public void CompileAndRegister_ForbiddenApi_ReturnsFalse() { @@ -42,6 +46,7 @@ public class SharedScriptLibraryTests Assert.False(result); } + /// <summary>Verifies that compile and register replaces existing script.</summary> [Fact] public void CompileAndRegister_Replaces_ExistingScript() { @@ -53,6 +58,7 @@ public class SharedScriptLibraryTests Assert.Equal(1, _library.GetRegisteredScriptNames().Count(n => n == "calc")); } + /// <summary>Verifies that remove registered script returns true.</summary> [Fact] public void Remove_RegisteredScript_ReturnsTrue() { @@ -61,12 +67,14 @@ public class SharedScriptLibraryTests Assert.False(_library.Contains("temp")); } + /// <summary>Verifies that remove nonexistent script returns false.</summary> [Fact] public void Remove_NonexistentScript_ReturnsFalse() { Assert.False(_library.Remove("nonexistent")); } + /// <summary>Verifies that get registered script names returns all names.</summary> [Fact] public void GetRegisteredScriptNames_ReturnsAllNames() { @@ -81,6 +89,8 @@ public class SharedScriptLibraryTests Assert.Contains("c", names); } + /// <summary>Verifies that execute async nonexistent script throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteAsync_NonexistentScript_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SiteScriptCompileCacheTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SiteScriptCompileCacheTests.cs index 7b5af38f..0f8f1f8d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SiteScriptCompileCacheTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SiteScriptCompileCacheTests.cs @@ -37,6 +37,7 @@ public class SiteScriptCompileCacheTests private static ScriptCompilationResult Ok() => ScriptCompilationResult.Succeeded(CSharpScript.Create<object?>("1", ScriptOptions.Default, typeof(ScriptGlobals))); + /// <summary>Verifies that GetOrAdd with the same code and globals computes the value only once.</summary> [Fact] public void GetOrAdd_SameCodeAndGlobals_ComputesOnce() { @@ -52,6 +53,7 @@ public class SiteScriptCompileCacheTests Assert.Equal(1, _cache.Count); } + /// <summary>Verifies that GetOrAdd with the same code but different globals creates separate entries.</summary> [Fact] public void GetOrAdd_SameCode_DifferentGlobals_AreSeparateEntries() { @@ -114,6 +116,7 @@ public class SiteScriptCompileCacheTests () => throw new InvalidOperationException("our entry was evicted by another cache"))); } + /// <summary>Verifies that overflowing the cache keeps it within its bound.</summary> [Fact] public void Overflow_StaysWithinTheBound() { @@ -132,10 +135,10 @@ public class SiteScriptCompileCacheTests Assert.True(recomputed > 0, "no oldest-batch entry was evicted — the overflow sweep did not run"); } - // ── WP3.1: approximate LRU replaced the wholesale-Clear overflow cliff ────────── + // ── Approximate LRU replaced the wholesale-Clear overflow cliff ────────── /// <summary> - /// WP3.1 (test group 8): overflow must evict only the OLDEST batch, keeping hot entries. + /// Overflow must evict only the OLDEST batch, keeping hot entries. /// The old behaviour cleared all 1024 entries, so the very next deploy or Instance-Actor /// start paid a full recompile storm on actor threads. /// </summary> @@ -177,10 +180,11 @@ public class SiteScriptCompileCacheTests } /// <summary> - /// WP3.1: concurrent inserts crossing the bound together must not blow past it — the + /// Concurrent inserts crossing the bound together must not blow past it — the /// eviction sweep is double-checked under its own lock precisely so a stampede performs /// one sweep rather than one per racing thread. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConcurrentGetOrAddStorm_KeepsTheBound() { @@ -199,7 +203,7 @@ public class SiteScriptCompileCacheTests // (register row 100 — it used to be 512, to absorb inserts from other test classes // sharing the process-global cache; nothing foreign can reach this instance). // The property under test is that 3200 distinct inserts do not accumulate — before - // WP3.1 this was a wholesale clear, and a per-entry eviction bug would show up here as + // the change to approximate LRU this was a wholesale clear, and a per-entry eviction bug would show up here as // unbounded growth, not as a handful of extra rows. Assert.True(_cache.Count <= SiteScriptCompileCache.MaxEntries + tasks, $"cache grew past its bound: {_cache.Count} vs max {SiteScriptCompileCache.MaxEntries} " + diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TraceCapturingLoggerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TraceCapturingLoggerTests.cs index 657fb1ec..4bc2ca02 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TraceCapturingLoggerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TraceCapturingLoggerTests.cs @@ -18,17 +18,33 @@ public class TraceCapturingLoggerTests /// <summary>An inner logger that records what it was asked to write, at a configurable minimum level.</summary> private sealed class RecordingLogger(LogLevel minimum = LogLevel.Trace) : ILogger { + /// <summary>Gets the entries written to this logger.</summary> public List<(LogLevel Level, string Message)> Entries { get; } = []; + /// <summary>Gets the scopes begun on this logger.</summary> public List<object?> Scopes { get; } = []; + /// <summary>Records the scope state and returns no scope handle.</summary> + /// <typeparam name="TState">The type of the scope state.</typeparam> + /// <param name="state">The scope state.</param> + /// <returns>Always <see langword="null"/>.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull { Scopes.Add(state); return null; } + /// <summary>Reports whether the level meets the configured minimum.</summary> + /// <param name="logLevel">The log level being checked.</param> + /// <returns><see langword="true"/> when the level is at or above the minimum.</returns> public bool IsEnabled(LogLevel logLevel) => logLevel >= minimum; + /// <summary>Records the formatted message for later assertions.</summary> + /// <typeparam name="TState">The type of the log state.</typeparam> + /// <param name="level">The log level.</param> + /// <param name="eventId">The event id.</param> + /// <param name="state">The log state.</param> + /// <param name="exception">The exception being logged, if any.</param> + /// <param name="formatter">The message formatter.</param> public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) => Entries.Add((level, formatter(state, exception))); } @@ -47,6 +63,7 @@ public class TraceCapturingLoggerTests return (new TraceCapturingLogger(inner, sink), inner, rows); } + /// <summary>Verifies that a debug entry is forwarded to the inner logger but not captured.</summary> [Fact] public void Debug_IsForwarded_ButNotCaptured() { @@ -58,6 +75,7 @@ public class TraceCapturingLoggerTests Assert.Empty(rows); } + /// <summary>Verifies that a warning is captured as a runtime row together with its exception.</summary> [Fact] public void Warning_IsCapturedAsARuntimeRow_WithItsException() { @@ -74,6 +92,7 @@ public class TraceCapturingLoggerTests Assert.Same(boom, row.Exception); } + /// <summary>Verifies that error and critical entries are both captured as errors.</summary> [Fact] public void ErrorAndCritical_AreBothCapturedAsError() { @@ -85,6 +104,7 @@ public class TraceCapturingLoggerTests Assert.Equal(["error", "error"], rows.Select(r => r.Level)); } + /// <summary>Verifies that information level is enabled even when the inner logger disables it.</summary> [Fact] public void InformationIsEnabled_EvenWhenTheInnerLoggerSaysNo() { @@ -102,6 +122,7 @@ public class TraceCapturingLoggerTests Assert.Equal("captured anyway", row.Message); } + /// <summary>Verifies that below information level the inner logger decides whether the level is enabled.</summary> [Fact] public void BelowInformation_TheInnerLoggerDecidesEnablement() { @@ -110,6 +131,7 @@ public class TraceCapturingLoggerTests Assert.False(logger.IsEnabled(LogLevel.Debug)); } + /// <summary>Verifies that a disabled inner logger is not written to but the row is still captured.</summary> [Fact] public void ADisabledInnerLogger_IsNotWrittenTo_ButTheRowIsStillCaptured() { @@ -121,6 +143,7 @@ public class TraceCapturingLoggerTests Assert.Single(rows); } + /// <summary>Verifies that captured lines carry the ambient step id.</summary> [Fact] public void CapturedLines_CarryTheAmbientStepId() { @@ -135,6 +158,7 @@ public class TraceCapturingLoggerTests Assert.Equal(stepId, Assert.Single(rows).StepEventId); } + /// <summary>Verifies that the constructor rejects a logger that is already decorated.</summary> [Fact] public void Ctor_RejectsAnAlreadyDecoratedLogger() { @@ -147,6 +171,7 @@ public class TraceCapturingLoggerTests Assert.Equal("inner", ex.ParamName); } + /// <summary>Verifies that the constructor requires both collaborators.</summary> [Fact] public void Ctor_RequiresBothCollaborators() { @@ -156,10 +181,11 @@ public class TraceCapturingLoggerTests Assert.Throws<ArgumentNullException>(() => new TraceCapturingLogger(new RecordingLogger(), null!)); } + /// <summary>Verifies that a throwing formatter is swallowed and drops only that line.</summary> [Fact] public void AThrowingFormatter_IsSwallowed_AndDropsOnlyThatLine() { - // Review M3. Most of what this decorator captures is the runtime's own best-effort + // Most of what this decorator captures is the runtime's own best-effort // "audit write failed" warnings, logged from INSIDE a catch block: a formatter exception // escaping here would replace the original failure with a formatting one on a path whose // contract is that it never throws. The inner logger is disabled for this level so the @@ -177,6 +203,7 @@ public class TraceCapturingLoggerTests Assert.Equal("still alive", Assert.Single(rows).Message); } + /// <summary>Verifies that beginning a scope delegates to the inner logger.</summary> [Fact] public void BeginScope_DelegatesToTheInnerLogger() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TrackingApiTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TrackingApiTests.cs index d278ec0c..91bc0887 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TrackingApiTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TrackingApiTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// Audit Log #23 (M3 Bundle A — Task A3) — script-side API tests for +/// Script-side API tests for /// <c>Tracking.Status(TrackedOperationId)</c>. The helper reads the site-local /// <see cref="IOperationTrackingStore"/> directly (no central round-trip) and /// returns the latest <see cref="TrackingStatusSnapshot"/>, or <c>null</c> when @@ -21,6 +21,8 @@ public class TrackingApiTests return new ScriptRuntimeContext.TrackingHelper(store, NullLogger.Instance); } + /// <summary>Verifies that status for an unknown id returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_UnknownId_ReturnsNull() { @@ -35,6 +37,8 @@ public class TrackingApiTests Assert.Null(result); } + /// <summary>Verifies that status for a known id returns the latest snapshot.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_KnownId_ReturnsLatestSnapshot() { @@ -66,6 +70,8 @@ public class TrackingApiTests Assert.Equal(expected, result); } + /// <summary>Verifies that status throws when no tracking store is wired.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_NoStoreWired_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TriggerEvalGateLifecycleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TriggerEvalGateLifecycleTests.cs index 8b6653a6..45dfb5b5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TriggerEvalGateLifecycleTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TriggerEvalGateLifecycleTests.cs @@ -4,6 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; public class TriggerEvalGateLifecycleTests { + /// <summary>Verifies that the shared gate is process-owned and remains usable after a caller disposes it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SharedGate_IsProcessOwnedAndRemainsUsableAfterCallerDisposes() { @@ -15,6 +17,7 @@ public class TriggerEvalGateLifecycleTests gate.Release(); } + /// <summary>Verifies that an owned gate is disposed by its caller.</summary> [Fact] public void OwnedGate_IsDisposedByCaller() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TrustModelSemanticTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TrustModelSemanticTests.cs index e38ca2f8..eaf57396 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TrustModelSemanticTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TrustModelSemanticTests.cs @@ -4,12 +4,12 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// SiteRuntime-011: regression tests for the semantic-analysis trust-model validation. +/// Regression tests for the semantic-analysis trust-model validation. /// The previous implementation was a raw substring scan of the source text — it both /// missed forbidden APIs (no literal namespace string) and raised false positives on /// the namespace string appearing in comments, string literals or unrelated identifiers. /// -/// As of M3.3, <c>ValidateTrustModel</c> delegates to the shared authoritative +/// The <c>ValidateTrustModel</c> delegates to the shared authoritative /// <c>ScriptAnalysis.ScriptTrustValidator</c>, which retains this same Roslyn /// semantic-symbol analysis (plus reflection-gateway hardening), so these bypass / /// false-positive / allowed-exception regressions continue to hold through the @@ -22,6 +22,8 @@ public class TrustModelSemanticTests // ── Bypass cases (under-inclusive substring scan would MISS these) ── + /// <summary>Verifies that a global-qualified forbidden type is detected by the trust model.</summary> + /// <returns>The result.</returns> [Fact] public void TrustModel_GlobalQualifiedForbiddenType_IsDetected() { @@ -32,6 +34,8 @@ public class TrustModelSemanticTests Assert.NotEmpty(violations); } + /// <summary>Verifies that a forbidden type referenced through a using alias is detected.</summary> + /// <returns>The result.</returns> [Fact] public void TrustModel_ForbiddenTypeViaUsingAlias_IsDetected() { @@ -49,6 +53,8 @@ public class TrustModelSemanticTests // ── False-positive cases (over-inclusive substring scan would WRONGLY flag these) ── + /// <summary>Verifies that a forbidden namespace inside a string literal is not flagged.</summary> + /// <returns>The result.</returns> [Fact] public void TrustModel_ForbiddenNamespaceInStringLiteral_IsNotFlagged() { @@ -58,6 +64,8 @@ public class TrustModelSemanticTests Assert.Empty(violations); } + /// <summary>Verifies that a forbidden namespace inside a comment is not flagged.</summary> + /// <returns>The result.</returns> [Fact] public void TrustModel_ForbiddenNamespaceInComment_IsNotFlagged() { @@ -70,6 +78,8 @@ public class TrustModelSemanticTests Assert.Empty(violations); } + /// <summary>Verifies that an unrelated identifier containing a forbidden substring is not flagged.</summary> + /// <returns>The result.</returns> [Fact] public void TrustModel_UnrelatedIdentifierContainingForbiddenSubstring_IsNotFlagged() { @@ -84,6 +94,8 @@ public class TrustModelSemanticTests // ── Allowed exceptions still resolve correctly ── + /// <summary>Verifies that Task and CancellationToken remain allowed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public void TrustModel_TaskAndCancellationToken_RemainAllowed() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs index 334d893f..a7170471 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// <summary> -/// Gitea #36 / design 2026-09-10 §5: the reset-aware handshake runs its legs in a FIXED +/// Design 2026-09-10 §5: the reset-aware handshake runs its legs in a FIXED /// order — reset write, reset-echo wait, batch + trigger, response wait — and never writes /// the batch unless the reset echo was observed. The probe stands in for the InstanceActor /// so the order and the "nothing after a failed reset" property are directly observable. @@ -30,6 +30,8 @@ public class WriteBatchResetAndWaitTests : TestKit currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(30), instanceName: Instance, logger: NullLogger.Instance); + /// <summary>Verifies that the legs run in order and the result reports the response phase.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Legs_RunInOrder_AndReportTheResponsePhase() { @@ -78,6 +80,8 @@ public class WriteBatchResetAndWaitTests : TestKit Assert.Null(result.ErrorMessage); } + /// <summary>Verifies that a reset echo timeout reports the reset phase and never writes the batch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResetEchoTimeout_ReportsResetPhase_AndNeverWritesTheBatch() { @@ -100,10 +104,12 @@ public class WriteBatchResetAndWaitTests : TestKit Assert.True(result.ResetFailed); Assert.Contains("MoveOutCompleteFlag", result.ErrorMessage); - // The property that fixes #36: no batch, no trigger, after a reset that was never echoed. + // The property under test: no batch, no trigger, after a reset that was never echoed. probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies that a response timeout reports the response phase.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResponseTimeout_ReportsResponsePhase() { @@ -137,6 +143,7 @@ public class WriteBatchResetAndWaitTests : TestKit /// A reset wait that fails WITHOUT timing out (a predicate blew up in the waiter) surfaces the /// actor's own message verbatim rather than a synthesized one, and still writes nothing further. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResetWaitError_PassesTheActorsMessageThrough() { @@ -166,6 +173,7 @@ public class WriteBatchResetAndWaitTests : TestKit /// The batch leg keeps throwing after a successful reset — a device-rejected batch is a hard /// failure, not a <see cref="HandshakeResult"/> the script could mistake for a timeout. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BatchFailureAfterASuccessfulReset_StillThrows() { @@ -188,6 +196,8 @@ public class WriteBatchResetAndWaitTests : TestKit Assert.Contains("Batch write failed", ex.Message); } + /// <summary>Verifies that a reset write failure throws, just like a failure of the batch leg.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ResetWriteFailure_Throws_LikeTheBatchLeg() { @@ -207,6 +217,7 @@ public class WriteBatchResetAndWaitTests : TestKit } /// <summary>The bool overload is untouched: batch first, then the wait, no reset leg.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LegacyOverload_StillWritesTheBatchFirst() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorRealStackTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorRealStackTests.cs index 05ac47e0..8bfae213 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorRealStackTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorRealStackTests.cs @@ -13,7 +13,7 @@ using ZB.MOM.WW.Secrets.Sqlite; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Secrets; /// <summary> -/// Task 9 fix round 2 (review <c>task-9-rereview-1.md</c> recommendation #2): runs +/// Runs /// <see cref="DeployedSecretsMirror"/> against the REAL <c>ZB.MOM.WW.Secrets</c> stack — /// <see cref="SqliteSecretStore"/> + <see cref="AesGcmEnvelopeCipher"/> + /// <see cref="DefaultSecretResolver"/> over a real temp-file SQLite database — instead of the @@ -40,6 +40,8 @@ public sealed class DeployedSecretsMirrorRealStackTests : IAsyncLifetime, IDispo private DefaultSecretResolver _resolver = null!; private SiteStorageService _storage = null!; + /// <summary>Prepares the test fixture before the first test runs.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { var connectionFactory = new SecretsSqliteConnectionFactory(_secretsDbPath); @@ -58,8 +60,11 @@ public sealed class DeployedSecretsMirrorRealStackTests : IAsyncLifetime, IDispo await _storage.InitializeAsync(); } + /// <summary>Does nothing; cleanup happens in <c>Dispose</c>.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() { var path = _localDb.Path; @@ -71,6 +76,8 @@ public sealed class DeployedSecretsMirrorRealStackTests : IAsyncLifetime, IDispo } } + /// <summary>Verifies that against the real secrets stack an encrypt, apply, delete and re-add cycle yields a recovered row that resolves to the original plaintext.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RealStack_EncryptApplyDeleteReAdd_TheRecoveredRowResolvesToTheOriginalPlaintext() { @@ -150,10 +157,15 @@ public sealed class DeployedSecretsMirrorRealStackTests : IAsyncLifetime, IDispo { private readonly byte[] _key = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32); + /// <summary>Initializes the provider with a key encryption key id.</summary> + /// <param name="kekId">The key encryption key id reported by the provider.</param> public FakeMasterKeyProvider(string kekId) => KekId = kekId; + /// <summary>Gets the key encryption key id.</summary> public string KekId { get; } + /// <summary>Returns the fixed master key.</summary> + /// <returns>The master key bytes.</returns> public ReadOnlyMemory<byte> GetMasterKey() => _key; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs index c333a6b4..b4435421 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs @@ -12,11 +12,11 @@ using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Secrets; /// <summary> -/// Task 9, site-secrets artifact deploy: <see cref="DeployedSecretsMirror"/> — per-node apply +/// Site-secrets artifact deploy: <see cref="DeployedSecretsMirror"/> — per-node apply /// + decrypt-verify of <c>deployed_secrets</c> into this node's own secret store. /// </summary> /// <remarks> -/// Fix round 1 (review <c>task-9-review.md</c>): <see cref="FakeSecretStore"/> now honours the +/// <see cref="FakeSecretStore"/> honours the /// real <c>SqliteSecretStore</c> semantics — <c>ApplyReplicatedAsync</c> is strict /// last-writer-wins via <see cref="SecretLastWriterWins.IsNewer"/>, and <c>DeleteAsync</c> /// tombstones rather than hard-deleting — so tests exercise the same conflict shapes the real @@ -26,11 +26,13 @@ public class DeployedSecretsMirrorTests : IDisposable { private readonly TestLocalDb _localDb; + /// <summary>Initializes the test class with a temporary LocalDb database.</summary> public DeployedSecretsMirrorTests() { _localDb = TestLocalDb.CreateTemp("deployed-secrets-mirror"); } + /// <summary>Releases the resources used by this instance.</summary> public void Dispose() { var path = _localDb.Path; @@ -40,6 +42,8 @@ public class DeployedSecretsMirrorTests : IDisposable // ── Apply / remove / out-of-band ── + /// <summary>Verifies that reconcile applies and verifies deployed secrets and deletes only the names it mirrored.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_Applies_Verifies_AndOnlyDeletesWhatItMirrored() { @@ -69,6 +73,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.True(store.IsTombstoned("a")); } + /// <summary>Verifies that a newer out-of-band secret is neither overwritten nor ledgered and survives a later removal.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_OutOfBandNewerSecret_NotOverwritten_NotLedgered_AndSurvivesLaterRemoval() { @@ -101,6 +107,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Equal(operatorCiphertext, store.Rows["a"].Ciphertext); } + /// <summary>Verifies that a removed then re-added secret recovers from its tombstone, reapplies and verifies.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_RemoveThenReAdd_TombstoneIsRecovered_ReappliesAndVerifies() { @@ -142,6 +150,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Equal(1, mirrored.Single(m => m.Name == "a").Revision); } + /// <summary>Verifies that a clock-skewed recovered row still converges on the next central revision.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_ClockSkewedRecoveredRow_StillConvergesOnNextCentralRevision() { @@ -187,6 +197,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Equal(2, mirrored.Single(m => m.Name == "a").Revision); } + /// <summary>Verifies that reconcile reapplies a secret only when its revision changes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_ReAppliesOnlyWhenRevisionChanges() { @@ -208,6 +220,8 @@ public class DeployedSecretsMirrorTests : IDisposable // ── Same-revision drift (final review I1) ── + /// <summary>Verifies that an owned name deleted locally is healed and verified rather than misreported as a KEK mismatch.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_OwnedNameDeletedLocally_IsHealedAndVerified_NotMisreportedAsKekMismatch() { @@ -238,6 +252,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Equal(1, (await storage.GetMirroredSecretsAsync()).Single(m => m.Name == "a").Revision); } + /// <summary>Verifies that an owned name edited locally is reverted to the deployed ciphertext.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_OwnedNameEditedLocally_IsRevertedToTheDeployedCiphertext() { @@ -263,6 +279,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Contains("a", invalidator.Invalidated); } + /// <summary>Verifies that owned-name drift is logged once per drift rather than on every pass.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_OwnedNameDrift_LogsOncePerDriftNotEveryPass() { @@ -281,8 +299,10 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Error); } - // ── Unreadable row_json (final review M4) ── + // ── Unreadable row_json ── + /// <summary>Verifies that an unparseable row JSON fails only that name while other names still apply.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_UnparseableRowJson_IsAFailedEntryForThatName_OthersStillApply() { @@ -301,6 +321,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.False(store.Contains("bad")); } + /// <summary>Verifies that an unparseable removed row still removes a mirrored name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_UnparseableRemovedRow_StillRemovesAMirroredName() { @@ -326,6 +348,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Equal(1, await command.ExecuteNonQueryAsync()); } + /// <summary>Verifies that reconcile applies through <c>ApplyReplicatedAsync</c> with a row mapped from the secret artifact.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_AppliesThroughApplyReplicatedAsync_MappedFromSecretArtifact() { @@ -340,6 +364,9 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Equal(SecretContentType.Text, applied.ContentType); } + /// <summary>Verifies that reconcile round-trips every secret content type.</summary> + /// <param name="contentType">The secret content type under test.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(SecretContentType.Text)] [InlineData(SecretContentType.ConnectionString)] @@ -358,6 +385,8 @@ public class DeployedSecretsMirrorTests : IDisposable // ── Per-row isolation (ruling 2) ── + /// <summary>Verifies that when one row throws the other rows still apply.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_OneRowThrows_OthersStillApply() { @@ -376,6 +405,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.False(store.Contains("bad")); } + /// <summary>Verifies that a throwing row's failure is de-spammed like any other failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_OneRowThrows_FailureIsDeSpammedLikeAnyOtherFailure() { @@ -395,6 +426,8 @@ public class DeployedSecretsMirrorTests : IDisposable // ── Cache invalidation (ruling 3) ── + /// <summary>Verifies that a successful apply invalidates the cache before verification.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_SuccessfulApply_InvalidatesCache_BeforeVerify() { @@ -406,6 +439,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Contains("a", invalidator.Invalidated); } + /// <summary>Verifies that a removal invalidates the cache.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_Removal_InvalidatesCache() { @@ -420,6 +455,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Contains("a", invalidator.Invalidated); } + /// <summary>Verifies that reconcile still applies when no cache invalidator is registered.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_WithNoInvalidatorRegistered_StillApplies() { @@ -442,6 +479,8 @@ public class DeployedSecretsMirrorTests : IDisposable // ── Decrypt verification ── + /// <summary>Verifies that a resolver decrypt exception is reported together with the KEK id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_DecryptFailure_ExceptionFromResolver_IsReportedWithKekId() { @@ -454,11 +493,13 @@ public class DeployedSecretsMirrorTests : IDisposable var message = Assert.Single(r.Failed); Assert.Contains("kek-central", message); Assert.Contains("'a'", message); - // FailedNames (Task 10): the name behind each Failed entry, in lockstep order, so a + // FailedNames: the name behind each Failed entry, in lockstep order, so a // caller (DeploymentManagerActor) can tell whether a failure belongs to names it shipped. Assert.Equal(["a"], r.FailedNames); } + /// <summary>Verifies that a null resolve is also reported as a decrypt failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_DecryptFailure_NullResolve_IsAlsoReported() { @@ -471,6 +512,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Single(r.Failed); } + /// <summary>Verifies that reconcile verifies on every pass even when the secret is already applied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_VerifiesEveryPass_EvenWhenAlreadyApplied() { @@ -491,6 +534,8 @@ public class DeployedSecretsMirrorTests : IDisposable // ── Error-log de-spam (ruling 1 of the original task, unaffected by fix round 1) ── + /// <summary>Verifies that a decrypt failure logs an error the first time, then debug, then an error again after a success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_DecryptFailure_LogsErrorOnlyFirstTime_ThenDebug_ThenErrorAgainAfterASuccess() { @@ -527,6 +572,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Equal(LogLevel.Error, failureEntriesAfterRecovery[3].Level); } + /// <summary>Verifies that a decrypt failure on a new revision logs an error again even without an intervening success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reconcile_DecryptFailure_NewRevision_LogsErrorAgain_EvenWithoutASuccessInBetween() { @@ -551,6 +598,8 @@ public class DeployedSecretsMirrorTests : IDisposable // ── Cancellation + concurrency (ruling 6) ── + /// <summary>Verifies that a pre-cancelled token makes reconcile throw an <see cref="OperationCanceledException"/>.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReconcileAsync_PreCancelledToken_ThrowsOperationCanceled() { @@ -562,6 +611,8 @@ public class DeployedSecretsMirrorTests : IDisposable () => mirror.ReconcileAsync(cts.Token)); } + /// <summary>Verifies that concurrent reconcile callers both complete without applying twice.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReconcileAsync_ConcurrentCallers_BothCompleteWithoutDoubleApply() { @@ -583,6 +634,7 @@ public class DeployedSecretsMirrorTests : IDisposable // ── Dispose (ruling 6) ── + /// <summary>Verifies that disposing the mirror does not throw and is idempotent.</summary> [Fact] public void Dispose_DoesNotThrow_AndIsIdempotent() { @@ -602,6 +654,8 @@ public class DeployedSecretsMirrorTests : IDisposable // ── Hosted loop ── + /// <summary>Verifies that the background loop runs once at start and again every interval.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteAsync_RunsOnceAtStart_AndAgainEveryInterval() { @@ -627,6 +681,8 @@ public class DeployedSecretsMirrorTests : IDisposable } } + /// <summary>Verifies that a per-row failure is isolated and the loop recovers once the failure clears.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecuteAsync_PerRowFailure_IsIsolated_AndLoopRecoversOnceCleared() { @@ -656,8 +712,10 @@ public class DeployedSecretsMirrorTests : IDisposable } } - // ── Per-node status row for central (review finding M1) ── + // ── Per-node status row for central ── + /// <summary>Verifies that the first pass after startup writes this node's status row even when healthy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_FirstPassAfterStartup_WritesThisNodesRow_EvenWhenHealthy() { @@ -672,6 +730,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Equal(time.Now, status.CheckedAtUtc); } + /// <summary>Verifies that an unchanged failure set is not rewritten on later passes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_UnchangedFailureSet_IsNotRewrittenOnLaterPasses() { @@ -695,6 +755,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Contains("kek-central", failure.Message); } + /// <summary>Verifies that an unchanged failure set is refreshed once the refresh interval has passed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_UnchangedFailureSet_IsRefreshedOnceTheRefreshIntervalHasPassed() { @@ -715,6 +777,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Equal(time.Now, Assert.Single(await storage.GetSecretMirrorStatusesAsync()).CheckedAtUtc); } + /// <summary>Verifies that a status row deleted by a peer prune is rewritten on the next pass.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_RowDeletedByAPeerPrune_IsRewrittenOnTheNextPass() { @@ -735,6 +799,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Equal(time.Now, status.CheckedAtUtc); } + /// <summary>Verifies that a failure set change rewrites the status row and that it clears once healed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_FailureSetChange_RewritesTheRow_AndClearsWhenHealed() { @@ -762,6 +828,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Equal(time.Now, healed.CheckedAtUtc); } + /// <summary>Verifies that the status row carries only names and messages, never ciphertext.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_RowCarriesNamesAndMessagesOnly_NeverCiphertext() { @@ -780,6 +848,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.DoesNotContain(Convert.ToBase64String(new byte[] { 1, 2, 3, 4, 5 }), json); } + /// <summary>Verifies that a status write failure does not break the pass and is retried on the next pass.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_WriteFailure_DoesNotBreakThePass_AndIsRetriedNextPass() { @@ -807,6 +877,8 @@ public class DeployedSecretsMirrorTests : IDisposable Assert.Equal("node-a", Assert.Single(await storage.GetSecretMirrorStatusesAsync()).NodeName); } + /// <summary>Verifies that without a node identity the status row falls back to the machine name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Status_WithoutNodeIdentity_FallsBackToMachineName() { @@ -819,6 +891,7 @@ public class DeployedSecretsMirrorTests : IDisposable private sealed class FixedNodeIdentity(string? name) : INodeIdentityProvider { + /// <inheritdoc /> public string? NodeName { get; } = name; } @@ -890,11 +963,23 @@ public class DeployedSecretsMirrorTests : IDisposable /// <summary>Thread-safe capturing logger so tests can assert on emitted log levels/messages, mirroring SiteReconciliationActorTests.</summary> private sealed class CapturingLogger<T> : ILogger<T> { + /// <summary>Gets the captured log entries in emission order.</summary> public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + /// <inheritdoc /> IDisposable? ILogger.BeginScope<TState>(TState state) => null; + /// <summary>Reports whether logging is enabled for the given level.</summary> + /// <param name="logLevel">The log level.</param> + /// <returns>True if the level is enabled; otherwise false.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The type of the state object.</typeparam> + /// <param name="logLevel">The log level.</param> + /// <param name="eventId">The event id.</param> + /// <param name="state">The scope or log state.</param> + /// <param name="exception">The exception associated with the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message text.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) @@ -904,21 +989,28 @@ public class DeployedSecretsMirrorTests : IDisposable /// <summary>Minimal controllable <see cref="TimeProvider"/> — mirrors the pattern used by <c>ApiMethodCacheTests</c>.</summary> private sealed class FakeTimeProvider : TimeProvider { + /// <summary>Gets or sets the current time reported by this provider.</summary> public DateTimeOffset Now { get; set; } + /// <summary>Initializes the provider at the given start time.</summary> + /// <param name="start">The initial time.</param> public FakeTimeProvider(DateTimeOffset start) => Now = start; + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => Now; } /// <summary>Records every <see cref="ISecretCacheInvalidator.Invalidate"/> call by secret name.</summary> private sealed class FakeSecretCacheInvalidator : ISecretCacheInvalidator { + /// <summary>Gets the secret names invalidated so far, in call order.</summary> public List<string> Invalidated { get; } = new(); + /// <summary>Records the invalidated secret name.</summary> + /// <param name="name">The secret name.</param> public void Invalidate(SecretName name) => Invalidated.Add(name.Value); } /// <summary> /// In-memory <see cref="ISecretStore"/> honouring the REAL <c>SqliteSecretStore</c> - /// semantics (fix round 1, review Important #1): <see cref="ApplyReplicatedAsync"/> is + /// semantics: <see cref="ApplyReplicatedAsync"/> is /// strict last-writer-wins via <see cref="SecretLastWriterWins.IsNewer"/> and applies /// verbatim (no revision bump); <see cref="DeleteAsync"/> tombstones in place (bumps /// revision, stamps <see cref="Now"/>) rather than hard-deleting. @@ -941,6 +1033,10 @@ public class DeployedSecretsMirrorTests : IDisposable public IReadOnlyDictionary<string, StoredSecret> Rows => _rows; /// <summary>Seeds a LIVE row directly (bypassing ApplyReplicatedAsync/LWW) — an out-of-band secret.</summary> + /// <param name="name">The secret name.</param> + /// <param name="updatedUtc">The last-updated timestamp stamped on the row.</param> + /// <param name="revision">The row revision.</param> + /// <param name="ciphertext">The ciphertext bytes, or null for a default placeholder.</param> public void Seed(string name, DateTimeOffset updatedUtc, long revision = 1, byte[]? ciphertext = null) => _rows[name] = new StoredSecret { @@ -959,14 +1055,26 @@ public class DeployedSecretsMirrorTests : IDisposable }; /// <summary>True if the row is present and NOT tombstoned.</summary> + /// <param name="name">The secret name.</param> + /// <returns>True if a live row exists for the name.</returns> public bool Contains(string name) => _rows.TryGetValue(name, out var row) && !row.IsDeleted; /// <summary>True if the row is present as a tombstone.</summary> + /// <param name="name">The secret name.</param> + /// <returns>True if the row exists and is tombstoned.</returns> public bool IsTombstoned(string name) => _rows.TryGetValue(name, out var row) && row.IsDeleted; + /// <summary>Looks up the secret by name.</summary> + /// <param name="name">The secret name.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>The secret, or null when none matches.</returns> public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(_rows.TryGetValue(name.Value, out var row) ? row : null); + /// <summary>Stores or replaces the row in the in-memory fake.</summary> + /// <param name="row">The secret row.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task UpsertAsync(StoredSecret row, CancellationToken ct) { _rows[row.Name.Value] = row; @@ -974,6 +1082,10 @@ public class DeployedSecretsMirrorTests : IDisposable } /// <summary>Tombstones (does not hard-delete) — matches <c>SqliteSecretStore.DeleteAsync</c>.</summary> + /// <param name="name">The secret name.</param> + /// <param name="actor">The actor recorded on the tombstone.</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>True if a live row was tombstoned; false if it was absent or already deleted.</returns> public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) { if (!_rows.TryGetValue(name.Value, out var existing) || existing.IsDeleted) @@ -992,13 +1104,23 @@ public class DeployedSecretsMirrorTests : IDisposable return Task.FromResult(true); } + /// <summary>Returns an empty secret listing.</summary> + /// <param name="includeDeleted">Whether deleted secrets are included, which the fake ignores.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>An empty list.</returns> public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) => Task.FromResult<IReadOnlyList<SecretMetadata>>([]); + /// <summary>Returns an empty manifest.</summary> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>An empty manifest.</returns> public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]); /// <summary>Strict LWW on (UpdatedUtc, Revision) via <see cref="SecretLastWriterWins.IsNewer"/>; applied verbatim — matches <c>SqliteSecretStore</c>.</summary> + /// <param name="row">The replicated row to apply.</param> + /// <param name="ct">The cancellation token.</param> + /// <returns>A task that represents the asynchronous operation.</returns> public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) { if (ThrowOnApply) @@ -1016,6 +1138,11 @@ public class DeployedSecretsMirrorTests : IDisposable return Task.CompletedTask; } + /// <summary>Reports that no rewrap was applied.</summary> + /// <param name="rewrappedRow">The rewrapped secret row.</param> + /// <param name="expectedCurrentWrappedDek">The wrapped data key the row is expected to hold currently.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>Always false.</returns> public Task<bool> ApplyRewrapAsync( StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => Task.FromResult(false); @@ -1033,17 +1160,24 @@ public class DeployedSecretsMirrorTests : IDisposable public FakeSecretStore? BackingStore { get; set; } /// <summary>Row absent/tombstoned path — <c>DefaultSecretResolver.GetAsync</c> returns null.</summary> + /// <param name="name">The secret name that should resolve to null.</param> public void ReturnNullFor(string name) => _nullReturning.Add(name); /// <summary>Decryption/integrity failure path — <c>DefaultSecretResolver.GetAsync</c> rethrows <c>SecretDecryptionException</c>.</summary> + /// <param name="name">The secret name whose resolution should throw.</param> public void ThrowFor(string name) => _throwing.Add(name); + /// <summary>Clears every configured null-returning and throwing name.</summary> public void ClearFailures() { _nullReturning.Clear(); _throwing.Clear(); } + /// <summary>Looks up the secret by name.</summary> + /// <param name="name">The secret name.</param> + /// <param name="ct">A token to cancel the operation.</param> + /// <returns>The secret, or null when none matches.</returns> public Task<string?> GetAsync(SecretName name, CancellationToken ct) { Interlocked.Increment(ref CallCount); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/SecretMirrorStatusReporterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/SecretMirrorStatusReporterTests.cs index d1226be8..f17c2f23 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/SecretMirrorStatusReporterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/SecretMirrorStatusReporterTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Secrets; /// <summary> -/// Review finding M1: <see cref="SecretMirrorStatusReporter"/> forwards every node's +/// <see cref="SecretMirrorStatusReporter"/> forwards every node's /// replicated <c>secret_mirror_status</c> row onto the site health report via the collector. /// </summary> public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable @@ -16,14 +16,19 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable private readonly TestLocalDb _localDb = TestLocalDb.CreateTemp("secret-mirror-status"); private SiteStorageService _storage = null!; + /// <summary>Creates the site storage service and initializes its schema.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { _storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance); await _storage.InitializeAsync(); } + /// <summary>Completes immediately; the synchronous <c>Dispose</c> releases the resources.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { var path = _localDb.Path; @@ -31,6 +36,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable TestLocalDb.DeleteFiles(path); } + /// <summary>Verifies that a probe pushes every node's status row onto the next health report.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Probe_PushesEveryNodesRow_OntoTheNextReport() { @@ -51,6 +58,7 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable Assert.Equal("OPC-PWD", Assert.Single(report.SecretMirrorStatuses[1].Failures).SecretName); } + /// <summary>Verifies that the report field is null before any probe has run.</summary> [Fact] public void BeforeAnyProbe_TheReportFieldIsNull() { @@ -59,6 +67,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable Assert.Null(report.SecretMirrorStatuses); } + /// <summary>Verifies that a probe with no rows yet pushes an empty list rather than null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Probe_NoRowsYet_PushesAnEmptyList_NotNull() { @@ -71,6 +81,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable Assert.Empty(collector.CollectReport("site-a").SecretMirrorStatuses!); } + /// <summary>Verifies that a read failure during a probe keeps the previous snapshot and does not throw.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Probe_ReadFailure_KeepsThePreviousSnapshot_AndDoesNotThrow() { @@ -96,6 +108,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable // ── Stale rows (decommissioned / renamed nodes) ── + /// <summary>Verifies that a row older than the stale-after window is left off the report and deleted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Probe_RowOlderThanStaleAfter_IsLeftOffTheReport_AndDeleted() { @@ -114,6 +128,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable Assert.Equal(["node-a"], (await _storage.GetSecretMirrorStatusesAsync()).Select(s => s.NodeName)); } + /// <summary>Verifies that a row exactly at the stale cutoff is kept.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Probe_RowExactlyAtTheCutoff_IsKept() { @@ -130,6 +146,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable Assert.Single(await _storage.GetSecretMirrorStatusesAsync()); } + /// <summary>Verifies that a probe prunes nothing during the start-up grace period and prunes once it has passed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Probe_DuringTheStartUpGrace_PrunesNothing_ThenPrunesAfterIt() { @@ -151,6 +169,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable Assert.Empty(await _storage.GetSecretMirrorStatusesAsync()); } + /// <summary>Verifies that a conditional delete does not remove a row the node rewrote after it was read.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ConditionalDelete_DoesNotRemoveARowTheNodeRewroteAfterTheRead() { @@ -168,12 +188,15 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable private sealed class MutableTime(DateTimeOffset now) : TimeProvider { + /// <summary>Gets or sets the instant that the clock reports.</summary> public DateTimeOffset Now { get; set; } = now; + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => Now; } private sealed class FixedTime(DateTimeOffset now) : TimeProvider { + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => now; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs index 7e272e4c..0d58b82b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs @@ -15,6 +15,7 @@ public class SiteRuntimeOptionsValidatorTests private static ValidateOptionsResult Validate(SiteRuntimeOptions options) => new SiteRuntimeOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that default options are valid.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -22,6 +23,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that zero startup batch size is rejected.</summary> [Fact] public void ZeroStartupBatchSize_IsRejected() { @@ -31,6 +33,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("StartupBatchSize", result.FailureMessage); } + /// <summary>Verifies that zero script execution thread count is rejected.</summary> [Fact] public void ZeroScriptExecutionThreadCount_IsRejected() { @@ -40,6 +43,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("ScriptExecutionThreadCount", result.FailureMessage); } + /// <summary>Verifies that zero tag subscribe retry interval ms is rejected.</summary> [Fact] public void ZeroTagSubscribeRetryIntervalMs_IsRejected() { @@ -49,6 +53,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("TagSubscribeRetryIntervalMs", result.FailureMessage); } + /// <summary>Verifies that negative stuck script grace ms is rejected.</summary> [Fact] public void NegativeStuckScriptGraceMs_IsRejected() { @@ -58,6 +63,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("StuckScriptGraceMs", result.FailureMessage); } + /// <summary>Verifies that zero stream buffer size is rejected.</summary> [Fact] public void ZeroStreamBufferSize_IsRejected() { @@ -67,6 +73,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("StreamBufferSize", result.FailureMessage); } + /// <summary>Verifies that negative startup batch delay ms is rejected.</summary> [Fact] public void NegativeStartupBatchDelayMs_IsRejected() { @@ -76,6 +83,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("StartupBatchDelayMs", result.FailureMessage); } + /// <summary>Verifies that zero alarm publish queue capacity is rejected.</summary> [Fact] public void ZeroAlarmPublishQueueCapacity_IsRejected() { @@ -85,7 +93,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("AlarmPublishQueueCapacity", result.FailureMessage); } - // ── WP3.1 options ────────────────────────────────────────────────────────────── + // ── Script-pool options ────────────────────────────────────────────────────────────── /// <summary> /// The ceiling is the upper bound of the instance-scaled pool; below the floor it would @@ -104,6 +112,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("ScriptExecutionMaxThreadCount", result.FailureMessage); } + /// <summary>Verifies that equal script execution thread count and max is accepted.</summary> [Fact] public void EqualScriptExecutionThreadCountAndMax_IsAccepted() { @@ -126,6 +135,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("TriggerEvalMaxConcurrency", result.FailureMessage); } + /// <summary>Verifies that zero trigger eval timeout seconds is rejected.</summary> [Fact] public void ZeroTriggerEvalTimeoutSeconds_IsRejected() { @@ -159,6 +169,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Equal(200, new SiteRuntimeOptions().MaxLogLinesPerRun); } + /// <summary>Verifies that zero max concurrent runs per script is rejected.</summary> [Fact] public void ZeroMaxConcurrentRunsPerScript_IsRejected() { @@ -169,6 +180,7 @@ public class SiteRuntimeOptionsValidatorTests } // --- Lever 1 (2026-08-28): tag-subscribe backoff + in-flight ceiling --- + /// <summary>Verifies that tag subscribe retry max interval ms below floor is rejected.</summary> [Fact] public void TagSubscribeRetryMaxIntervalMs_BelowFloor_IsRejected() { @@ -182,6 +194,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("TagSubscribeRetryMaxIntervalMs", result.FailureMessage); } + /// <summary>Verifies that tag subscribe retry max interval ms equal to floor is valid.</summary> [Fact] public void TagSubscribeRetryMaxIntervalMs_EqualToFloor_IsValid() { @@ -193,6 +206,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that zero tag subscribe in flight ceiling ms is rejected.</summary> [Fact] public void ZeroTagSubscribeInFlightCeilingMs_IsRejected() { @@ -202,6 +216,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("TagSubscribeInFlightCeilingMs", result.FailureMessage); } + /// <summary>Verifies that script file access defaults to disabled and is valid.</summary> [Fact] public void ScriptFileAccess_DefaultsToDisabled_AndIsValid() { @@ -210,6 +225,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.True(Validate(options).Succeeded); } + /// <summary>Verifies that script file access relative root is rejected.</summary> [Fact] public void ScriptFileAccess_RelativeRoot_IsRejected() { @@ -222,6 +238,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("ScriptFileAccess:AllowedRoots:0", result.FailureMessage); } + /// <summary>Verifies that script file access absolute root is accepted.</summary> [Fact] public void ScriptFileAccess_AbsoluteRoot_IsAccepted() { @@ -230,6 +247,8 @@ public class SiteRuntimeOptionsValidatorTests Assert.True(Validate(options).Succeeded); } + /// <summary>Verifies that script file access max file bytes out of range is rejected.</summary> + /// <param name="max">The max value.</param> [Theory] [InlineData(0L)] [InlineData(64L * 1024 * 1024 + 1)] @@ -244,6 +263,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("ScriptFileAccess:MaxFileBytes", result.FailureMessage); } + /// <summary>Verifies that zero secret mirror status refresh interval is rejected.</summary> [Fact] public void ZeroSecretMirrorStatusRefreshInterval_IsRejected() { @@ -253,6 +273,7 @@ public class SiteRuntimeOptionsValidatorTests Assert.Contains("SecretMirrorStatusRefreshInterval", result.FailureMessage); } + /// <summary>Verifies that secret mirror status stale after under two refreshes is rejected.</summary> [Fact] public void SecretMirrorStatusStaleAfter_UnderTwoRefreshes_IsRejected() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeServiceRegistrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeServiceRegistrationTests.cs index 14987c5c..a85c8b16 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeServiceRegistrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeServiceRegistrationTests.cs @@ -12,14 +12,14 @@ public class SiteRuntimeServiceRegistrationTests /// <summary> /// Register row 100. <c>ScriptCompilationService</c> owns the /// <c>SiteScriptCompileCache</c>, so "one Roslyn compile per script body per process" - /// (N4 / WP3.1's warm-then-gate deploy path) is precisely the statement that DI hands + /// (the warm-then-gate deploy path) is precisely the statement that DI hands /// out ONE of these per host. The cache stopped being <c>static</c> when row 100 removed /// the process-global state tests were clobbering; this registration is what replaced /// the static as the guarantee, so it is pinned here. /// /// <para>Downgrading this to Scoped or Transient would give every consumer — the deploy /// gate, each Instance Actor's PreStart, SharedScriptLibrary — its own empty cache and - /// silently reintroduce the recompile storms WP3.1 removed, with no functional test + /// silently reintroduce the recompile storms the warm-then-gate path removed, with no functional test /// failing. This assertion is the only thing that would notice.</para> /// </summary> [Fact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Streaming/SiteStreamAlarmDropReporterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Streaming/SiteStreamAlarmDropReporterTests.cs index acdad930..bc8707b3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Streaming/SiteStreamAlarmDropReporterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Streaming/SiteStreamAlarmDropReporterTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Streaming; /// <summary> -/// WP2.6d: the hosted reporter must lift <see cref="SiteStreamManager.AlarmPublishDroppedCount"/> +/// The hosted reporter must lift <see cref="SiteStreamManager.AlarmPublishDroppedCount"/> /// onto the site health report via <see cref="ISiteHealthCollector"/>. Uses the real /// collector (mirrors <c>ScriptSchedulerStatsReporterTests</c> — NSubstitute is not /// referenced by this test project). The queue's own drop-counting mechanism is verified @@ -16,8 +16,11 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Streaming; /// </summary> public class SiteStreamAlarmDropReporterTests : TestKit, IDisposable { + /// <inheritdoc /> void IDisposable.Dispose() => Shutdown(); + /// <summary>Verifies that reporter pushes alarm drop count to collector.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reporter_PushesAlarmDropCountToCollector() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Streaming/SiteStreamManagerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Streaming/SiteStreamManagerTests.cs index f187e6a0..26242f51 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Streaming/SiteStreamManagerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Streaming/SiteStreamManagerTests.cs @@ -8,13 +8,14 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Streaming; /// <summary> -/// WP-23: Site-Wide Akka Stream tests. -/// WP-25: Debug View Backend tests (subscribe/unsubscribe). +/// Site-Wide Akka Stream tests. +/// Debug View Backend tests (subscribe/unsubscribe). /// </summary> public class SiteStreamManagerTests : TestKit, IDisposable { private readonly SiteStreamManager _streamManager; + /// <summary>Initializes a new instance of the <see cref="SiteStreamManagerTests"/> class.</summary> public SiteStreamManagerTests() { var options = new SiteRuntimeOptions { StreamBufferSize = 100 }; @@ -23,11 +24,13 @@ public class SiteStreamManagerTests : TestKit, IDisposable _streamManager.Initialize(Sys); } + /// <inheritdoc /> void IDisposable.Dispose() { Shutdown(); } + /// <summary>Verifies the case: subscribe, creates Subscription.</summary> [Fact] public void Subscribe_CreatesSubscription() { @@ -38,6 +41,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable Assert.Equal(1, _streamManager.SubscriptionCount); } + /// <summary>Verifies the case: unsubscribe, removes Subscription.</summary> [Fact] public void Unsubscribe_RemovesSubscription() { @@ -48,12 +52,14 @@ public class SiteStreamManagerTests : TestKit, IDisposable Assert.Equal(0, _streamManager.SubscriptionCount); } + /// <summary>Verifies the case: unsubscribe, invalid Id, returns False.</summary> [Fact] public void Unsubscribe_InvalidId_ReturnsFalse() { Assert.False(_streamManager.Unsubscribe("nonexistent")); } + /// <summary>Verifies the case: publish Attribute Value Changed, forwards To Subscriber.</summary> [Fact] public void PublishAttributeValueChanged_ForwardsToSubscriber() { @@ -69,6 +75,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable Assert.Equal("Temperature", received.AttributeName); } + /// <summary>Verifies the case: publish Alarm State Changed, forwards To Subscriber.</summary> [Fact] public void PublishAlarmStateChanged_ForwardsToSubscriber() { @@ -84,6 +91,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable Assert.Equal(AlarmState.Active, received.State); } + /// <summary>Verifies the case: publish Attribute Value Changed, filtersby Instance.</summary> [Fact] public void PublishAttributeValueChanged_FiltersbyInstance() { @@ -103,6 +111,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable probe2.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies the case: subscribe Site Alarms, forwards Alarms For All Instances, but Not Attributes.</summary> [Fact] public void SubscribeSiteAlarms_ForwardsAlarmsForAllInstances_ButNotAttributes() { @@ -135,6 +144,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// <summary>Verifies the case: subscribe Site Alarms, subscription Removable Via Unsubscribe.</summary> [Fact] public void SubscribeSiteAlarms_SubscriptionRemovableViaUnsubscribe() { @@ -147,6 +157,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable Assert.Equal(0, _streamManager.SubscriptionCount); } + /// <summary>Verifies the case: remove Subscriber, removes All Subscriptions For Actor.</summary> [Fact] public void RemoveSubscriber_RemovesAllSubscriptionsForActor() { @@ -160,10 +171,10 @@ public class SiteStreamManagerTests : TestKit, IDisposable Assert.Equal(0, _streamManager.SubscriptionCount); } - // ── WP2.6d: separate alarm publish path, drop counter, skip-at-zero-subscribers ── + // ── separate alarm publish path, drop counter, skip-at-zero-subscribers ── /// <summary> - /// WP2.6d: alarm state changes now travel a dedicated publish source, isolated from + /// Alarm state changes now travel a dedicated publish source, isolated from /// the (far higher-volume) attribute path — a burst of attribute events for OTHER /// instances, interleaved with an alarm-only subscriber's events, must not cause any /// alarm to be lost. @@ -176,7 +187,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable // A storm of attribute events for a DIFFERENT instance — none of which the // alarm subscriber is even listening to — interleaved with alarm events. Before - // WP2.6d these shared one upstream buffer; now they are fully separate sources. + // Previously these shared one upstream buffer; now they are fully separate sources. for (var i = 0; i < 500; i++) { _streamManager.PublishAttributeValueChanged(new AttributeValueChanged( @@ -191,7 +202,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable } /// <summary> - /// WP2.6d: with zero subscribers of any kind, PublishAlarmStateChanged/ + /// With zero subscribers of any kind, PublishAlarmStateChanged/ /// PublishAttributeValueChanged must be no-ops — no exception, and (for alarms) the /// event never reaches the bounded hand-off queue, so /// <see cref="SiteStreamManager.AlarmPublishDroppedCount"/> stays at zero rather than @@ -217,7 +228,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable } /// <summary> - /// WP2.6d: once the bounded alarm hand-off queue (the exact factory + /// Once the bounded alarm hand-off queue (the exact factory /// <see cref="SiteStreamManager.Initialize"/> wires into the drop-count callback) is /// full, a further write evicts the oldest queued item and fires the drop callback. /// Exercised directly against the queue factory — deterministic, no actor diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/FakeSiteEventLogger.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/FakeSiteEventLogger.cs index dc47bfd1..a75ba1eb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/FakeSiteEventLogger.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/FakeSiteEventLogger.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; /// <summary> -/// M1 Site Event Logging categories: a capturing fake <see cref="ISiteEventLogger"/> +/// A capturing fake <see cref="ISiteEventLogger"/> /// used by the actor tests to assert that the right operational events are emitted. /// Thread-safe — the actors fire-and-forget <c>LogEventAsync</c> from background /// tasks, so multiple captures can land concurrently. @@ -29,9 +29,12 @@ public sealed class FakeSiteEventLogger : ISiteEventLogger public IReadOnlyList<Entry> Entries => _entries.ToArray(); /// <summary>Captured events filtered to a single category.</summary> + /// <param name="eventType">The event category to filter on.</param> + /// <returns>The captured events with the given category.</returns> public IReadOnlyList<Entry> OfType(string eventType) => _entries.Where(e => e.EventType == eventType).ToArray(); + // Captures the event so tests can assert on it. /// <inheritdoc /> public Task LogEventAsync( string eventType, @@ -45,6 +48,7 @@ public sealed class FakeSiteEventLogger : ISiteEventLogger return Task.CompletedTask; } + // Always zero for this fake. /// <inheritdoc /> public long FailedWriteCount => 0; } @@ -63,12 +67,12 @@ public sealed class FakeSiteEventLogger : ISiteEventLogger /// </summary> /// <param name="logger">The event logger to resolve for <see cref="ISiteEventLogger"/>.</param> /// <param name="optionsMonitor"> -/// WP3.2: optional <see cref="IOptionsMonitor{TOptions}"/> for <see cref="SiteRuntimeOptions"/> +/// Optional <see cref="IOptionsMonitor{TOptions}"/> for <see cref="SiteRuntimeOptions"/> /// — resolves for the hot-toggle tests; null (the default) exercises the launcher's -/// constructor-passed-options fallback, matching every pre-WP3.2 caller of this class. +/// constructor-passed-options fallback, matching every earlier caller of this class. /// </param> /// <param name="summaryRecorder"> -/// WP3.2: optional <see cref="ScriptRunSummaryRecorder"/> — resolves so a test can assert on +/// Optional <see cref="ScriptRunSummaryRecorder"/> — resolves so a test can assert on /// the aggregate counters a run produced; null (the default) exercises the launcher's /// null-safe skip path. /// </param> @@ -82,7 +86,9 @@ public sealed class SingleServiceProvider( private readonly IOptionsMonitor<SiteRuntimeOptions>? _optionsMonitor = optionsMonitor; private readonly ScriptRunSummaryRecorder? _summaryRecorder = summaryRecorder; - /// <inheritdoc /> + /// <summary>Resolves the logger, the scope factory and the optional options monitor and summary recorder.</summary> + /// <param name="serviceType">The service type to resolve.</param> + /// <returns>The matching service, or null when the type is not served.</returns> public object? GetService(Type serviceType) { if (serviceType == typeof(ISiteEventLogger)) return _logger; @@ -92,12 +98,13 @@ public sealed class SingleServiceProvider( return null; } - /// <inheritdoc /> + /// <summary>Creates a scope that re-exposes this provider.</summary> + /// <returns>This instance as the scope.</returns> public IServiceScope CreateScope() => this; - /// <inheritdoc /> + /// <summary>Gets the provider for this scope, which is this instance.</summary> public IServiceProvider ServiceProvider => this; - /// <inheritdoc /> + /// <summary>Does nothing; the scope owns no resources.</summary> public void Dispose() { } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/ScopeSpyServiceProvider.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/ScopeSpyServiceProvider.cs index bd12adff..86a6c86e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/ScopeSpyServiceProvider.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/ScopeSpyServiceProvider.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.SiteEventLogging; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; /// <summary> -/// WP3.1 parity pin 3: an <see cref="IServiceProvider"/> that counts +/// An <see cref="IServiceProvider"/> that counts /// <see cref="IServiceScopeFactory.CreateScope"/> calls and each scope's /// <see cref="IDisposable.Dispose"/>, so a test can assert that one script run creates /// exactly one DI scope and disposes it exactly once — on the success, failure, AND timeout @@ -24,7 +24,9 @@ public sealed class ScopeSpyServiceProvider(ISiteEventLogger? logger = null) /// <summary>Scope disposals observed so far (double-disposal is counted twice, deliberately).</summary> public int ScopesDisposed => Volatile.Read(ref _scopesDisposed); - /// <inheritdoc /> + /// <summary>Resolves the site event logger and this scope factory; every other service resolves to <c>null</c>.</summary> + /// <param name="serviceType">The requested service type.</param> + /// <returns>The matching service, or <c>null</c> when unsupported.</returns> public object? GetService(Type serviceType) { if (serviceType == typeof(ISiteEventLogger)) return logger; @@ -32,7 +34,8 @@ public sealed class ScopeSpyServiceProvider(ISiteEventLogger? logger = null) return null; } - /// <inheritdoc /> + /// <summary>Creates a counted scope whose disposal is also counted.</summary> + /// <returns>A spy scope.</returns> public IServiceScope CreateScope() { Interlocked.Increment(ref _scopesCreated); @@ -41,8 +44,10 @@ public sealed class ScopeSpyServiceProvider(ISiteEventLogger? logger = null) private sealed class SpyScope(ScopeSpyServiceProvider owner) : IServiceScope { + /// <summary>Gets the owning provider as the scope's service provider.</summary> public IServiceProvider ServiceProvider => owner; + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() => Interlocked.Increment(ref owner._scopesDisposed); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/TestOptionsMonitor.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/TestOptionsMonitor.cs index ac909e78..35fbca16 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/TestOptionsMonitor.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/TestOptionsMonitor.cs @@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; /// <summary> /// Minimal <see cref="IOptionsMonitor{TOptions}"/> test double with a mutable /// <see cref="Set"/> so a test can flip a value mid-run and assert the NEXT read observes it -/// — used by the WP3.2 hot-toggle tests +/// — used by the hot-toggle tests /// (<c>SiteRuntimeOptions.PerRunScriptEvents</c>/<c>PerRunScriptEventScripts</c> are read via /// <c>IOptionsMonitor.CurrentValue</c> per run specifically so they don't need a restart). /// Avoids depending on <c>Microsoft.Extensions.Configuration</c>'s reload-token plumbing, @@ -15,15 +15,20 @@ public sealed class TestOptionsMonitor<T>(T initial) : IOptionsMonitor<T> { private T _current = initial; - /// <inheritdoc /> + /// <summary>Gets the current value, as most recently set by <see cref="Set"/>.</summary> public T CurrentValue => _current; - /// <inheritdoc /> + /// <summary>Returns the current value regardless of the requested name.</summary> + /// <param name="name">The named-options key, ignored by this double.</param> + /// <returns>The current value.</returns> public T Get(string? name) => _current; - /// <inheritdoc /> + /// <summary>Change notifications are not supported by this double; no registration is made.</summary> + /// <param name="listener">The change listener, ignored by this double.</param> + /// <returns>Always <see langword="null"/>.</returns> public IDisposable? OnChange(Action<T, string?> listener) => null; /// <summary>Replaces the current value, observed by the next <see cref="CurrentValue"/> read.</summary> + /// <param name="value">The new value.</param> public void Set(T value) => _current = value; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingOptionsValidatorTests.cs index 519fc7a7..8aa81634 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingOptionsValidatorTests.cs @@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Tracking; /// </summary> public class OperationTrackingOptionsValidatorTests { + /// <summary>Verifies that default options are valid.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -18,6 +19,7 @@ public class OperationTrackingOptionsValidatorTests Assert.True(validator.Validate(null, new OperationTrackingOptions()).Succeeded); } + /// <summary>Verifies that empty connection string fails.</summary> [Fact] public void EmptyConnectionString_Fails() { @@ -28,6 +30,7 @@ public class OperationTrackingOptionsValidatorTests f => f.Contains(nameof(OperationTrackingOptions.ConnectionString), StringComparison.Ordinal)); } + /// <summary>Verifies that zero retention days fails.</summary> [Fact] public void ZeroRetentionDays_Fails() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingSchemaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingSchemaTests.cs index 49ae95af..09c60df7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingSchemaTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingSchemaTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Tracking; /// <summary> -/// LocalDb Phase 1 (Task 2) — the <c>OperationTracking</c> DDL is extracted out of +/// LocalDb Phase 1 — the <c>OperationTracking</c> DDL is extracted out of /// <see cref="OperationTrackingStore"/> so it can also be applied from the Host's /// <c>AddZbLocalDb</c> onReady callback, which owns table creation for the consolidated /// site database. These tests pin the extracted schema against the shape the store has @@ -35,6 +35,7 @@ public class OperationTrackingSchemaTests return cols; } + /// <summary>Verifies that applying the schema creates the table with the expected columns.</summary> [Fact] public void Apply_CreatesTableWithExpectedColumns() { @@ -52,6 +53,7 @@ public class OperationTrackingSchemaTests cols.Select(c => c.Name)); } + /// <summary>Verifies that the schema uses a text primary key.</summary> [Fact] public void Apply_UsesTextPrimaryKey() { @@ -66,6 +68,7 @@ public class OperationTrackingSchemaTests Assert.Equal("TEXT", pk.Type); } + /// <summary>Verifies that the schema has no blob columns.</summary> [Fact] public void Apply_HasNoBlobColumns() { @@ -80,6 +83,7 @@ public class OperationTrackingSchemaTests c => c.Type.Contains("BLOB", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that applying the schema creates the expected indexes.</summary> [Fact] public void Apply_CreatesExpectedIndexes() { @@ -100,6 +104,7 @@ public class OperationTrackingSchemaTests indexes); } + /// <summary>Verifies that applying the schema repeatedly is idempotent.</summary> [Fact] public void Apply_IsIdempotent() { @@ -111,6 +116,7 @@ public class OperationTrackingSchemaTests Assert.Equal(13, TableInfo(conn, "OperationTracking").Count); } + /// <summary>Verifies that applying the schema adds the SourceNode column to a legacy table that lacks it.</summary> [Fact] public void Apply_AddsSourceNodeToLegacyTableMissingIt() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingStoreTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingStoreTests.cs index 2a070e2c..dca2dbfc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingStoreTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingStoreTests.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Tracking; /// <summary> -/// Audit Log #23 (M3 Bundle A — Task A2) — schema + behaviour tests for the +/// Schema + behaviour tests for the /// site-local <see cref="OperationTrackingStore"/>. /// </summary> /// <remarks> @@ -36,6 +36,7 @@ public class OperationTrackingStoreTests : IDisposable private readonly List<ServiceProvider> _providers = []; private readonly List<string> _tempFiles = []; + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { foreach (var provider in _providers) @@ -102,6 +103,7 @@ public class OperationTrackingStoreTests : IDisposable return connection; } + /// <summary>Verifies that constructing the store creates the operation tracking schema on first use.</summary> [Fact] public void Constructor_CreatesOperationTracking_SchemaOnFirstUse() { @@ -136,6 +138,7 @@ public class OperationTrackingStoreTests : IDisposable } } + /// <summary>Verifies that initialization creates the operation tracking table with a SourceNode column.</summary> [Fact] public void Initialize_creates_OperationTracking_with_SourceNode_column() { @@ -202,6 +205,8 @@ public class OperationTrackingStoreTests : IDisposable private OperationTrackingStore CreateStoreOver(string databasePath) => new(CreateLocalDb(databasePath), NullLogger<OperationTrackingStore>.Instance); + /// <summary>Verifies that initialization adds the SourceNode column to a pre-existing schema.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Initialize_adds_SourceNode_to_pre_existing_schema() { @@ -255,6 +260,8 @@ public class OperationTrackingStoreTests : IDisposable } } + /// <summary>Verifies that recording an enqueue persists the source node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RecordEnqueueAsync_persists_SourceNode() { @@ -275,6 +282,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.Equal("node-a", snapshot!.SourceNode); } + /// <summary>Verifies that recording an enqueue persists a null source node.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RecordEnqueueAsync_persists_null_SourceNode() { @@ -295,6 +304,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.Null(snapshot!.SourceNode); } + /// <summary>Verifies that recording an enqueue inserts a submitted row with a retry count of zero.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RecordEnqueueAsync_InsertsSubmittedRow_WithRetryCountZero() { @@ -326,6 +337,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.Equal(DateTimeKind.Utc, snapshot.UpdatedAtUtc.Kind); } + /// <summary>Verifies that a duplicate enqueue is a no-op and the first write wins.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RecordEnqueueAsync_Duplicate_IsNoOp_FirstWriteWins() { @@ -346,6 +359,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.Equal(0, snapshot.RetryCount); } + /// <summary>Verifies that recording an attempt advances the status and retry count on a non-terminal row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RecordAttemptAsync_AdvancesStatusAndRetryCount_OnNonTerminalRow() { @@ -374,6 +389,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.True(snapshot.UpdatedAtUtc >= snapshot.CreatedAtUtc); } + /// <summary>Verifies that recording an attempt on a terminal row is a no-op.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RecordAttemptAsync_OnTerminalRow_IsNoOp() { @@ -409,6 +426,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.NotNull(afterLate.TerminalAtUtc); } + /// <summary>Verifies that recording a terminal outcome flips the row to terminal and sets the terminal timestamp.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RecordTerminalAsync_FlipsToTerminal_WithTerminalAtUtcSet() { @@ -435,6 +454,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.Equal(503, snapshot.HttpStatus); } + /// <summary>Verifies that getting the status of an unknown operation returns null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetStatusAsync_Unknown_ReturnsNull() { @@ -447,6 +468,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.Null(snapshot); } + /// <summary>Verifies that getting the status returns the latest snapshot after multiple attempts.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetStatusAsync_ReturnsLatestSnapshot_AfterMultipleAttempts() { @@ -466,6 +489,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.Equal(504, snapshot.HttpStatus); } + /// <summary>Verifies that purging removes old terminal rows while keeping recent and non-terminal rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PurgeTerminalAsync_RemovesOldTerminalRows_KeepsRecent_KeepsNonTerminal() { @@ -510,8 +535,10 @@ public class OperationTrackingStoreTests : IDisposable Assert.NotNull(await store.GetStatusAsync(cId)); // kept (non-terminal) } - // ── Site Call Audit #22: ReadChangedSinceAsync (reconciliation pull) ─── + // ── Site call audit: ReadChangedSinceAsync (reconciliation pull) ─── + /// <summary>Verifies that the changed-since read returns rows at or after the cursor, oldest first.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadChangedSinceAsync_ReturnsRowsAtOrAfterCursor_OldestFirst() { @@ -546,6 +573,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.True(result[0].UpdatedAtUtc <= result[1].UpdatedAtUtc); } + /// <summary>Verifies that the changed-since read from the minimum timestamp returns all rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadChangedSinceAsync_FromMinValue_ReturnsAllRows() { @@ -560,6 +589,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.Equal(2, result.Count); } + /// <summary>Verifies that the changed-since read is capped to the batch size.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadChangedSinceAsync_IsBatchCapped() { @@ -587,6 +618,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.Equal(ids[2], result[2].TrackedOperationId); } + /// <summary>Verifies that the changed-since read maps a tracking row onto the site call operational record.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadChangedSinceAsync_MapsTrackingRowOntoSiteCallOperational() { @@ -647,6 +680,8 @@ public class OperationTrackingStoreTests : IDisposable // ── 2026-09-06: writes off the script thread (single-writer queue) ───── // Design: docs/plans/2026-09-06-tracking-store-async-writes-design.md + /// <summary>Verifies that a write returns while another connection holds the write lock and lands once it is released.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Write_ReturnsWhileAnotherConnectionHoldsTheWriteLock_AndLandsOnceReleased() { @@ -684,6 +719,8 @@ public class OperationTrackingStoreTests : IDisposable } } + /// <summary>Verifies that reads see every write queued before them, in order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Reads_SeeEveryWriteQueuedBeforeThem_InOrder() { @@ -714,6 +751,8 @@ public class OperationTrackingStoreTests : IDisposable } } + /// <summary>Verifies that async dispose drains queued writes before closing the connection.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DisposeAsync_DrainsQueuedWrites_BeforeClosingTheConnection() { @@ -731,6 +770,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.Equal(200L, (long)count.ExecuteScalar()!); } + /// <summary>Verifies that writing after dispose throws an object-disposed exception.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Write_AfterDispose_ThrowsObjectDisposed() { @@ -740,12 +781,14 @@ public class OperationTrackingStoreTests : IDisposable store.RecordEnqueueAsync(TrackedOperationId.New(), "ApiCallCached", null, null, null, null)); } - // ── SiteRuntime-024: read/write split + sync-safe Dispose ────────────── + // ── Read/write split + sync-safe Dispose ────────────── + /// <summary>Verifies that concurrent reads do not block on an in-flight write.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SR024_ConcurrentReads_DoNotBlockOnInFlightWrite() { - // Regression test for SiteRuntime-024 (perf half). Pre-fix, every + // Regression test for the read/write split (perf half). Pre-fix, every // GetStatusAsync took the same _gate as RecordTerminalAsync, so a single // long-running write would queue up every concurrent status query. After // the fix, reads open a fresh SqliteConnection per call and don't take @@ -786,10 +829,12 @@ public class OperationTrackingStoreTests : IDisposable } } + /// <summary>Verifies that a synchronous dispose from a fresh thread does not deadlock.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SR024_SyncDispose_DoesNotDeadlock_WhenInvokedFromFreshThread() { - // Regression test for SiteRuntime-024 (deadlock half). Pre-fix, Dispose + // Regression test for the sync-safe Dispose (deadlock half). Pre-fix, Dispose // bridged to async via DisposeAsyncCore().AsTask().GetAwaiter().GetResult() // — sync-over-async on a SemaphoreSlim can deadlock under a non-reentrant // SyncContext (host shutdown continuations). Post-fix, Dispose runs @@ -828,6 +873,8 @@ public class OperationTrackingStoreTests : IDisposable Assert.True(await disposeReturned.Task); } + /// <summary>Verifies that async dispose does not deadlock and is idempotent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SR024_AsyncDispose_DoesNotDeadlock_AndIsIdempotent() { @@ -851,8 +898,10 @@ public class OperationTrackingStoreTests : IDisposable await store.DisposeAsync(); } - // ── Task 24: pin the inclusive (>=) reconciliation cursor boundary ── + // ── Pin the inclusive (>=) reconciliation cursor boundary ── + /// <summary>Verifies that the changed-since boundary timestamp is inclusive and an upsert on a newer status absorbs duplicates.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadChangedSince_BoundaryTimestamp_IsInclusive_UpsertOnNewerAbsorbsDuplicates() { @@ -895,7 +944,7 @@ public class OperationTrackingStoreTests : IDisposable Assert.Equal(2, rows.Count); } - // ── Task 15: composite (UpdatedAtUtc, TrackedOperationId) keyset ── + // ── Composite (UpdatedAtUtc, TrackedOperationId) keyset ── /// <summary> /// Seeds <c>batchSize + 2</c> rows that all share ONE <c>UpdatedAtUtc</c> and @@ -904,6 +953,7 @@ public class OperationTrackingStoreTests : IDisposable /// returns the REMAINING rows with no overlap — the stall the plain inclusive /// <c>>=</c> resume can never escape. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadChangedSince_CompositeKeyset_AdvancesPastSharedTimestamp() { @@ -945,6 +995,7 @@ public class OperationTrackingStoreTests : IDisposable /// shared timestamp re-reads the same page (the exact stall the keyset fixes) — /// an older central that never sends <c>after_id</c> is byte-for-byte unaffected. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReadChangedSince_WithoutKeyset_ReReadsSamePage_LegacyPreserved() { @@ -984,6 +1035,8 @@ public class OperationTrackingStoreTests : IDisposable cmd.ExecuteNonQuery(); return boundary; } + /// <summary>Verifies that a synchronous dispose does not close the connection while the writer gate is held.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ORCH1_SyncDisposeDoesNotCloseConnectionWhileWriterGateIsHeld() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs index 3ee0ae61..af062880 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// <summary> -/// Audit Log #23 — M3 Bundle E Tasks E4 + E5: the store-and-forward retry +/// Cached-call lifecycle observer: the store-and-forward retry /// loop invokes <see cref="ICachedCallLifecycleObserver"/> after every /// cached-call attempt. The observer is given a /// <see cref="CachedCallAttemptContext"/> derived from the underlying @@ -32,6 +32,7 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable private readonly StoreAndForwardOptions _options; private readonly CapturingObserver _observer; + /// <summary>Initializes the test with a temporary local database, storage and observer.</summary> public CachedCallAttemptEmissionTests() { _localDb = TestLocalDb.CreateTemp("E4Tests"); @@ -55,8 +56,13 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable siteId: "site-77"); } + /// <summary>Prepares the test class before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() => await _storage.InitializeAsync(); + /// <summary>Releases the resources held by this test class asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { var path = _localDb.Path; @@ -70,9 +76,12 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable /// </summary> private sealed class CapturingObserver : ICachedCallLifecycleObserver { + /// <summary>The observer notifications received, in order.</summary> public List<CachedCallAttemptContext> Notifications { get; } = new(); + /// <summary>When true, the observer throws on every notification.</summary> public Exception? ThrowOnNotify { get; set; } + /// <inheritdoc /> public Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default) { if (ThrowOnNotify != null) @@ -90,7 +99,7 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable private async Task<TrackedOperationId> EnqueueBufferedAsync( StoreAndForwardCategory category, string target, int maxRetries = 3) { - // The TrackedOperationId is the S&F message id (Bundle E3 contract). + // The TrackedOperationId is the S&F message id (see the cached-call contract). var trackedId = TrackedOperationId.New(); await _service.EnqueueAsync( category, @@ -104,8 +113,10 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable return trackedId; } - // ── Task E4: per-attempt observer notifications ── + // ── Per-attempt observer notifications ── + /// <summary>Verifies that a failed attempt with an HTTP 500 emits attempted telemetry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Attempt_FailWithHttp500_EmitsAttemptedTelemetry() { @@ -129,6 +140,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Equal("Plant.Pump42", notification.SourceInstanceId); } + /// <summary>Verifies that a successful attempt emits a delivered outcome.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Attempt_Success_EmitsDeliveredOutcome() { @@ -147,6 +160,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Null(notification.LastError); } + /// <summary>Verifies that a permanent failure emits a permanent-failure outcome.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Attempt_PermanentFailure_EmitsPermanentFailureOutcome() { @@ -162,6 +177,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Equal(CachedCallAttemptOutcome.PermanentFailure, notification.Outcome); } + /// <summary>Verifies that an attempt of a cached database write emits on the database outbound channel.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Attempt_CachedDbWrite_EmitsDbOutboundChannel() { @@ -178,6 +195,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Equal("myDb", notification.Target); } + /// <summary>Verifies that an attempt in the notification category produces no observer notification.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Attempt_NotificationCategory_NoObserverNotification() { @@ -198,12 +217,14 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Empty(_observer.Notifications); } + /// <summary>Verifies that an attempt whose message id is not a GUID produces no observer notification.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Attempt_MessageIdNotAGuid_NoObserverNotification() { - // Pre-M3 cached calls (no TrackedOperationId threaded in) use a random - // GUID-N message id from S&F itself. We should still emit (M3 expects - // post-rollout these are tracked) — BUT pre-rollout messages can have + // Earlier cached calls (no TrackedOperationId threaded in) use a random + // GUID-N message id from S&F itself. We should still emit (post-rollout these + // are tracked) — BUT pre-rollout messages can have // a non-parseable id, in which case the observer is silently skipped // to keep S&F bookkeeping intact. _service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, @@ -221,8 +242,10 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Empty(_observer.Notifications); } - // ── Task E5: terminal-state observer notifications ── + // ── Terminal-state observer notifications ── + /// <summary>Verifies that terminal delivery emits a resolve with the delivered status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Terminal_Delivered_EmitsResolveWithDeliveredStatus() { @@ -242,6 +265,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Equal(CachedCallAttemptOutcome.Delivered, notification.Outcome); } + /// <summary>Verifies that parking after max retries emits a resolve with the parked-max-retries status.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Terminal_Parked_OnMaxRetries_EmitsParkedMaxRetries() { @@ -261,6 +286,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Equal(trackedId, _observer.Notifications[1].TrackedOperationId); } + /// <summary>Verifies that a retry-fail then retry-succeed lifecycle emits the expected sequence.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Lifecycle_RetryFail_RetrySucceed_EmitsExpectedSequence() { @@ -284,8 +311,10 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Equal(trackedId, _observer.Notifications[1].TrackedOperationId); } - // ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ── + // ── ExecutionId / SourceScript ── + /// <summary>Verifies that an attempt carries the execution id and source script from the buffered message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Attempt_CarriesExecutionIdAndSourceScript_FromBufferedMessage() { @@ -317,6 +346,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Equal("Plant.Pump42/OnTick", notification.SourceScript); } + /// <summary>Verifies that a null execution id and source script surface as null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Attempt_NullExecutionIdAndSourceScript_SurfaceAsNull() { @@ -334,6 +365,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Null(notification.SourceScript); } + /// <summary>Verifies that a terminal resolve carries the execution id and source script.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TerminalResolve_CarriesExecutionIdAndSourceScript() { @@ -364,8 +397,10 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Equal("Plant.Tank/OnAlarm", notification.SourceScript); } - // ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ── + // ── ParentExecutionId ── + /// <summary>Verifies that an attempt carries the parent execution id from the buffered message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Attempt_CarriesParentExecutionId_FromBufferedMessage() { @@ -395,6 +430,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Equal(parentExecutionId, notification.ParentExecutionId); } + /// <summary>Verifies that a null parent execution id surfaces as null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Attempt_NullParentExecutionId_SurfacesAsNull() { @@ -412,6 +449,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Null(notification.ParentExecutionId); } + /// <summary>Verifies that a terminal resolve carries the parent execution id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TerminalResolve_CarriesParentExecutionId() { @@ -443,6 +482,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable // ── Best-effort contract: observer throws must NOT corrupt retry bookkeeping ── + /// <summary>Verifies that an observer that throws does not corrupt the retry count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Observer_Throws_DoesNotCorruptRetryCount() { @@ -460,16 +501,21 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Null(msg); } - // ── Task 19: audit-observer decoupled from the sweep via an ordered channel ── + // ── Audit-observer decoupled from the sweep via an ordered channel ── private sealed class DelegatingObserver : ICachedCallLifecycleObserver { private readonly Func<CachedCallAttemptContext, Task> _onNotify; + /// <summary>Initializes the observer with the callback to invoke for each notification.</summary> + /// <param name="onNotify">The callback invoked for each attempt notification.</param> public DelegatingObserver(Func<CachedCallAttemptContext, Task> onNotify) => _onNotify = onNotify; + /// <inheritdoc /> public Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default) => _onNotify(context); } + /// <summary>Verifies that a slow observer does not block the sweep and events arrive in order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SlowObserver_DoesNotBlockTheSweep_AndEventsArriveInOrder() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/NotificationForwarderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/NotificationForwarderTests.cs index b6180556..9e17ed73 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/NotificationForwarderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/NotificationForwarderTests.cs @@ -18,7 +18,7 @@ public class NotificationForwarderTests : TestKit /// <summary> /// Builds a buffered notification S&F message whose payload matches the shape - /// produced by the site <c>Notify.Send</c> enqueue path (Task 19): a serialized + /// produced by the site <c>Notify.Send</c> enqueue path: a serialized /// <see cref="NotificationSubmit"/> carrying a script-generated /// <see cref="NotificationSubmit.NotificationId"/>. The S&F message /// <see cref="StoreAndForwardMessage.Id"/> equals that same id. @@ -50,6 +50,8 @@ public class NotificationForwarderTests : TestKit }; } + /// <summary>Verifies that delivery forwards a notification submit to the central target and returns true when it is accepted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_ForwardsNotificationSubmitToCentralTarget_AndReturnsTrueOnAccept() { @@ -80,10 +82,12 @@ public class NotificationForwarderTests : TestKit Assert.True(await deliverTask); } + /// <summary>Verifies that delivery preserves the origin execution id from the buffered payload.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_PreservesOriginExecutionId_FromBufferedPayload() { - // Audit Log #23: the buffered payload's OriginExecutionId is the per-run + // The buffered payload's OriginExecutionId is the per-run // id stamped at Notify.Send time. The forwarder re-stamps only the four // fields it authoritatively owns (NotificationId, ListName, SourceSiteId, // SourceInstanceId) via the `with` expression — OriginExecutionId is @@ -107,6 +111,8 @@ public class NotificationForwarderTests : TestKit Assert.True(await deliverTask); } + /// <summary>Verifies that delivery falls back to the target when the payload's list name is empty.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_FallsBackToTarget_WhenPayloadListNameIsEmpty() { @@ -143,6 +149,8 @@ public class NotificationForwarderTests : TestKit Assert.True(await deliverTask); } + /// <summary>Verifies that delivery throws a transient error when the acknowledgement is not accepted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_ThrowsTransient_WhenAckIsNotAccepted() { @@ -161,6 +169,8 @@ public class NotificationForwarderTests : TestKit await Assert.ThrowsAnyAsync<Exception>(() => deliverTask); } + /// <summary>Verifies that delivery throws a transient error when no reply arrives within the timeout.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_ThrowsTransient_WhenNoReplyWithinTimeout() { @@ -173,6 +183,8 @@ public class NotificationForwarderTests : TestKit await Assert.ThrowsAnyAsync<Exception>(() => forwarder.DeliverAsync(BufferedNotification())); } + /// <summary>Verifies that delivery uses a stable notification id across retries of the same message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_UsesStableNotificationId_AcrossRetriesOfSameMessage() { @@ -198,10 +210,12 @@ public class NotificationForwarderTests : TestKit Assert.Equal("stable-msg-id", submit1.NotificationId); } + /// <summary>Verifies that a corrupt JSON payload returns false, is parked for forensics and is not forwarded.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_CorruptJsonPayload_ReturnsFalse_ParksForForensics_AndDoesNotForward() { - // Supersedes StoreAndForward-018 (discard-on-corrupt). Returning false is the + // Supersedes the earlier discard-on-corrupt behavior. Returning false is the // handler contract's permanent-failure signal: the engine parks the row, which // preserves the payload for operator forensics. Retrying a corrupt payload is // pointless; deleting it silently (and reporting Delivered) loses data with no @@ -226,12 +240,14 @@ public class NotificationForwarderTests : TestKit centralProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } + /// <summary>Verifies that a payload that deserializes to null returns false, is parked for forensics and is not forwarded.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Deliver_NullDeserializedPayload_ReturnsFalse_ParksForForensics_AndDoesNotForward() { // The companion case to corrupt JSON: the payload is valid JSON but // deserialises to null (e.g. "null"). Same treatment — park (return false) - // rather than silently discard-as-delivered. Supersedes StoreAndForward-018. + // rather than silently discard-as-delivered. Supersedes the earlier discard-on-corrupt behavior. var centralProbe = CreateTestProbe(); var forwarder = new NotificationForwarder( centralProbe.Ref, "site-7", ForwardTimeout); diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedMessageHandlerActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedMessageHandlerActorTests.cs index 3692eb01..8f0ad640 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedMessageHandlerActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedMessageHandlerActorTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// <summary> -/// StoreAndForward-013: tests for the <see cref="ParkedMessageHandlerActor"/> actor +/// Tests for the <see cref="ParkedMessageHandlerActor"/> actor /// bridge — the Query/Retry/Discard request-to-response mapping and the /// <c>ExtractMethodName</c> payload-JSON parsing (including the malformed-JSON branch). /// </summary> @@ -18,6 +18,7 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _service; + /// <summary>Initializes a new instance of the <c>ParkedMessageHandlerActorTests</c> class.</summary> public ParkedMessageHandlerActorTests() { _localDb = TestLocalDb.CreateTemp("ActorTests"); @@ -35,10 +36,15 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab _storage, options, NullLogger<StoreAndForwardService>.Instance); } + /// <summary>Initializes the storage before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() => await _storage.InitializeAsync(); + /// <summary>Does nothing; storage is released in <c>Dispose</c>.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) @@ -60,6 +66,8 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab return result.MessageId; } + /// <summary>Verifies that a query returns the parked entries with the method name extracted from each payload.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_ReturnsParkedEntries_WithExtractedMethodName() { @@ -78,6 +86,8 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab Assert.Equal(StoreAndForwardCategory.ExternalSystem, response.Messages[0].Category); } + /// <summary>Verifies that a notification payload uses its subject as the method name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_NotificationPayload_UsesSubjectAsMethodName() { @@ -92,6 +102,8 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab Assert.Equal("Tank overflow", response.Messages[0].MethodName); } + /// <summary>Verifies that a malformed JSON payload falls back to the category name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_MalformedJsonPayload_FallsBackToCategoryName() { @@ -106,6 +118,8 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab Assert.Equal(StoreAndForwardCategory.CachedDbWrite.ToString(), response.Messages[0].MethodName); } + /// <summary>Verifies that a payload with neither a method name nor a subject falls back to the category name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Query_PayloadWithoutMethodNameOrSubject_FallsBackToCategoryName() { @@ -118,6 +132,8 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab Assert.Equal(StoreAndForwardCategory.ExternalSystem.ToString(), response.Messages[0].MethodName); } + /// <summary>Verifies that retrying a parked message returns success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Retry_ParkedMessage_ReturnsSuccess() { @@ -135,6 +151,7 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status); } + /// <summary>Verifies that retrying an unknown message returns a failure with an explanatory message.</summary> [Fact] public void Retry_UnknownMessage_ReturnsFailureWithMessage() { @@ -147,6 +164,8 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab Assert.NotNull(response.ErrorMessage); } + /// <summary>Verifies that discarding a parked message returns success and removes the message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Discard_ParkedMessage_ReturnsSuccessAndRemovesMessage() { @@ -163,6 +182,7 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab Assert.Null(msg); } + /// <summary>Verifies that discarding an unknown message returns a failure with an explanatory message.</summary> [Fact] public void Discard_UnknownMessage_ReturnsFailureWithMessage() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedOperationRelayTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedOperationRelayTests.cs index 3f32e3ec..75686a3e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedOperationRelayTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedOperationRelayTests.cs @@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// <summary> -/// Task 5 (#22 Retry/Discard relay): tests the site-side execution of a +/// Retry/Discard relay: tests the site-side execution of a /// central→site <see cref="RetryParkedOperation"/> / <see cref="DiscardParkedOperation"/> /// relay command on the <see cref="ParkedMessageHandlerActor"/>. The cached /// call's S&F buffer message id is the <see cref="TrackedOperationId"/>, so @@ -23,6 +23,7 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _service; + /// <summary>Initializes a new instance of the <c>ParkedOperationRelayTests</c> class.</summary> public ParkedOperationRelayTests() { _localDb = TestLocalDb.CreateTemp("RelayTests"); @@ -40,10 +41,15 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable _storage, options, NullLogger<StoreAndForwardService>.Instance); } + /// <summary>Initializes the storage before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() => await _storage.InitializeAsync(); + /// <summary>Does nothing; storage is released in <c>Dispose</c>.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <inheritdoc /> protected override void Dispose(bool disposing) { if (disposing) @@ -70,6 +76,8 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable await _service.RetryPendingMessagesAsync(); } + /// <summary>Verifies that retrying a parked cached call resets it to pending and reports the change as applied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryParkedOperation_ParkedCachedCall_ResetsToPendingAndApplied() { @@ -90,6 +98,8 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status); } + /// <summary>Verifies that discarding a parked cached call removes its row and reports the change as applied.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DiscardParkedOperation_ParkedCachedCall_RemovesRowAndApplied() { @@ -107,6 +117,7 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable Assert.Null(msg); } + /// <summary>Verifies that retrying an unknown operation is a safe no-op.</summary> [Fact] public void RetryParkedOperation_UnknownOperation_IsSafeNoOp() { @@ -120,6 +131,8 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable Assert.Null(ack.ErrorMessage); } + /// <summary>Verifies that retrying a non-parked operation is a safe no-op that does not corrupt the row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryParkedOperation_NonParkedOperation_IsSafeNoOpAndDoesNotCorrupt() { @@ -148,6 +161,8 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(before.RetryCount, after.RetryCount); } + /// <summary>Verifies that discarding a non-parked operation is a safe no-op.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DiscardParkedOperation_NonParkedOperation_IsSafeNoOp() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeCollection.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeCollection.cs index 3baf5298..7bbb6fa0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeCollection.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeCollection.cs @@ -73,6 +73,7 @@ public class QueueDepthGaugeCollectionGuardTests private static readonly Regex DeclaresCollection = new(@"\[\s*Collection\s*\(\s*QueueDepthGaugeCollection\.Name\s*\)\s*\]", RegexOptions.Compiled); + /// <summary>Verifies that every source file that starts a service joins the queue-depth gauge collection.</summary> [Fact] public void EverySourceFileThatStartsAService_JoinsTheGaugeCollection() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeTests.cs index a58524ad..f5d3e801 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// <summary> -/// WP-14 (telemetry follow-on): verifies the cached buffered-message counter that +/// Verifies the cached buffered-message counter that /// backs the <c>scadabridge.store_and_forward.queue.depth</c> observable gauge tracks /// the live (Pending) queue across the existing enqueue / drain / park / requeue paths, /// and that the sync gauge callback reports it. @@ -30,6 +30,7 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _service; + /// <summary>Initializes the test with a SQLite-backed store-and-forward service.</summary> public QueueDepthGaugeTests() { _localDb = TestLocalDb.CreateTemp("QueueDepthTests"); @@ -49,6 +50,8 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable _storage, options, NullLogger<StoreAndForwardService>.Instance); } + /// <summary>Prepares the fixture before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() { await _storage.InitializeAsync(); @@ -57,8 +60,11 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable await _service.StartAsync(); } + /// <summary>Releases the resources used by this instance asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task DisposeAsync() => await _service.StopAsync(); + /// <summary>Releases the resources used by this instance.</summary> public void Dispose() { var path = _localDb.Path; @@ -91,6 +97,8 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable return observed; } + /// <summary>Verifies that gauge, tracks buffered depth, across enqueue drain and park.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Gauge_TracksBufferedDepth_AcrossEnqueueDrainAndPark() { @@ -140,12 +148,13 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable } /// <summary> - /// StoreAndForward-025: after a graceful <see cref="StoreAndForwardService.StopAsync"/> + /// After a graceful <see cref="StoreAndForwardService.StopAsync"/> /// the service must deregister its queue-depth provider from the process-global gauge /// slot, so the gauge stops reporting the stopped instance's (now-frozen) depth and the /// provider closure no longer pins the dead service. With the provider cleared the gauge /// falls back to 0. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StopAsync_ClearsQueueDepthProvider_GaugeFallsBackToZero() { @@ -176,13 +185,14 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable } /// <summary> - /// StoreAndForward-025 (compare-and-clear): when a newer instance has already + /// Compare-and-clear: when a newer instance has already /// registered its provider into the process-global slot, a late /// <see cref="StoreAndForwardService.StopAsync"/> of an older instance must NOT clear /// the slot — the identity-checked clear only removes the slot when it still holds the /// stopping instance's own delegate. After the late stop the gauge must still report /// the newer instance's depth, not 0. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StopAsync_DoesNotClobberNewerInstanceProvider() { @@ -218,13 +228,14 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable } /// <summary> - /// StoreAndForward-028: a same-instance Stop→Start must re-seed the cached depth from a + /// A same-instance Stop→Start must re-seed the cached depth from a /// clean base. <see cref="StoreAndForwardService.StopAsync"/> resets the one-time /// registration guard so a later <see cref="StoreAndForwardService.StartAsync"/> /// re-registers and re-seeds <c>_bufferedCount</c> from the durable Pending count; if /// StopAsync did not also reset <c>_bufferedCount</c>, the restart would ADD the re-read /// count on top of the leftover in-memory value, double-counting the gauge to ~2N. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StartAsync_AfterStop_ReseedsFromCleanBase_NoDoubleCount() { @@ -261,6 +272,8 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable await svc.StopAsync(); } + /// <summary>Verifies that gauge, seeds from existing pending rows, on start.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Gauge_SeedsFromExistingPendingRows_OnStart() { @@ -313,6 +326,7 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable /// pre-increments the in-memory counter (standing in for that concurrent enqueue), /// then starts the service over an empty store and asserts the pre-increment survives. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Gauge_SeedAddsToConcurrentPreSeedIncrement_NotClobber() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardOptionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardOptionsTests.cs index e8b9d0f1..c9c0a1fc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardOptionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardOptionsTests.cs @@ -1,10 +1,11 @@ namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// <summary> -/// WP-9: Tests for StoreAndForwardOptions defaults and configuration. +/// Tests for StoreAndForwardOptions defaults and configuration. /// </summary> public class StoreAndForwardOptionsTests { + /// <summary>Verifies that the default options have reasonable values.</summary> [Fact] public void DefaultOptions_HasReasonableDefaults() { @@ -16,6 +17,7 @@ public class StoreAndForwardOptionsTests Assert.Equal(TimeSpan.FromSeconds(10), options.RetryTimerInterval); } + /// <summary>Verifies that the options can be customized.</summary> [Fact] public void Options_CanBeCustomized() { @@ -32,6 +34,7 @@ public class StoreAndForwardOptionsTests Assert.Equal(100, options.DefaultMaxRetries); } + /// <summary>Verifies that the sweep batch limit defaults to 500.</summary> [Fact] public void SweepBatchLimit_Defaults_To_500() => Assert.Equal(500, new StoreAndForwardOptions().SweepBatchLimit); diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardOptionsValidatorTests.cs index bec17fcb..9e3f076b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardOptionsValidatorTests.cs @@ -14,6 +14,7 @@ public class StoreAndForwardOptionsValidatorTests private static ValidateOptionsResult Validate(StoreAndForwardOptions options) => new StoreAndForwardOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the default options are valid.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -21,6 +22,7 @@ public class StoreAndForwardOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero default retry interval is rejected.</summary> [Fact] public void ZeroDefaultRetryInterval_IsRejected() { @@ -30,6 +32,7 @@ public class StoreAndForwardOptionsValidatorTests Assert.Contains("DefaultRetryInterval", result.FailureMessage); } + /// <summary>Verifies that a zero retry timer interval is rejected.</summary> [Fact] public void ZeroRetryTimerInterval_IsRejected() { @@ -57,6 +60,8 @@ public class StoreAndForwardOptionsValidatorTests // ── R2 T9: sweep-tuning eager validation (N4) ── + /// <summary>Verifies that a negative sweep batch limit fails validation.</summary> + /// <param name="limit">The negative sweep batch limit under test.</param> [Theory] [InlineData(-1)] [InlineData(-500)] @@ -67,6 +72,7 @@ public class StoreAndForwardOptionsValidatorTests Assert.Contains("SweepBatchLimit", result.FailureMessage); } + /// <summary>Verifies that a zero sweep batch limit is valid and means unlimited, as in the legacy behavior.</summary> [Fact] public void Validate_ZeroSweepBatchLimit_IsValidUnlimitedLegacy() { @@ -74,6 +80,8 @@ public class StoreAndForwardOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a non-positive sweep target parallelism fails validation.</summary> + /// <param name="parallelism">The non-positive parallelism value under test.</param> [Theory] [InlineData(0)] [InlineData(-4)] @@ -84,8 +92,10 @@ public class StoreAndForwardOptionsValidatorTests Assert.Contains("SweepTargetParallelism", result.FailureMessage); } - // ── WP2.6c: bounded observer queue capacity ── + // ── Bounded observer queue capacity ── + /// <summary>Verifies that a non-positive observer queue capacity fails validation.</summary> + /// <param name="capacity">The non-positive capacity value under test.</param> [Theory] [InlineData(0)] [InlineData(-1)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardSchemaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardSchemaTests.cs index b4279e5a..26b4b139 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardSchemaTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardSchemaTests.cs @@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// </summary> public class StoreAndForwardSchemaTests { + /// <summary>Verifies that applying the schema is idempotent and creates every column.</summary> [Fact] public void Apply_IsIdempotent_AndCreatesEveryColumn() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs index 48b509b1..bbb1c465 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// <summary> -/// WP-10/12/13/14: Tests for the StoreAndForwardService retry engine and management. +/// Tests for the StoreAndForwardService retry engine and management. /// /// Register row 32: several facts here call <c>StartAsync</c>, which writes the /// process-global queue-depth provider slot, so this class joins @@ -24,6 +24,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable private readonly StoreAndForwardOptions _options; private readonly List<TestLocalDb> _extraLocalDbs = new(); + /// <summary>Creates a temporary local database, storage and service with a zero retry interval for the tests.</summary> public StoreAndForwardServiceTests() { _localDb = TestLocalDb.CreateTemp("SvcTests"); @@ -41,10 +42,15 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable _storage, _options, NullLogger<StoreAndForwardService>.Instance); } + /// <summary>Prepares the fixture before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() => await _storage.InitializeAsync(); + /// <summary>Releases the resources held by this fixture asynchronously.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { DisposeLocalDb(_localDb); @@ -81,8 +87,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable return new StoreAndForwardService(storage, options, NullLogger<StoreAndForwardService>.Instance); } - // ── WP-10: Immediate delivery ── + // ── Immediate delivery ── + /// <summary>Verifies that an immediately delivered message is accepted and not buffered.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_ImmediateDeliverySuccess_ReturnsAcceptedNotBuffered() { @@ -97,6 +105,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.False(result.WasBuffered); } + /// <summary>Verifies that a permanent failure on immediate delivery returns a result that is not accepted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_PermanentFailure_ReturnsNotAccepted() { @@ -111,6 +121,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.False(result.WasBuffered); } + /// <summary>Verifies that a transient failure on immediate delivery buffers the message for retry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_TransientFailure_BuffersForRetry() { @@ -127,11 +139,13 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable var msg = await _storage.GetMessageByIdAsync(result.MessageId); Assert.NotNull(msg); Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status); - // StoreAndForward-003: RetryCount counts sweep retries only; the immediate + // RetryCount counts sweep retries only; the immediate // attempt is attempt 0, so a freshly buffered message has RetryCount 0. Assert.Equal(0, msg.RetryCount); } + /// <summary>Verifies that a message enqueued with no registered handler is buffered for later.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_NoHandler_BuffersForLater() { @@ -143,8 +157,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.True(result.WasBuffered); } - // ── WP-10: Retry engine ── + // ── Retry engine ── + /// <summary>Verifies that a successful retry removes the message from the buffer.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryPendingMessagesAsync_SuccessfulRetry_RemovesMessage() { @@ -167,6 +183,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Null(msg); } + /// <summary>Verifies that a message that reaches its maximum retries is parked.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryPendingMessagesAsync_MaxRetriesReached_ParksMessage() { @@ -177,7 +195,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable StoreAndForwardCategory.ExternalSystem, "api", """{}""", maxRetries: 2); - // StoreAndForward-003: MaxRetries bounds sweep retries (not the immediate + // MaxRetries bounds sweep retries (not the immediate // attempt), so a message with MaxRetries=2 needs two retry sweeps to park. await _service.RetryPendingMessagesAsync(); var afterFirst = await _storage.GetMessageByIdAsync(result.MessageId); @@ -190,8 +208,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status); } - // ── StoreAndForward-003: retry-count accounting ── + // ── Retry-count accounting ── + /// <summary>Verifies that with a maximum of one retry exactly one retry is performed before the message is parked.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryPendingMessagesAsync_MaxRetriesOne_PerformsExactlyOneRetryBeforeParking() { @@ -218,12 +238,14 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Equal(1, msg.RetryCount); // one sweep retry recorded } - // ── StoreAndForward-005: sweep-vs-management race hardening ── + // ── Sweep-vs-management race hardening ── + /// <summary>Verifies that a sweep park write is skipped when the message status changed during delivery.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryMessageAsync_StatusChangedDuringDelivery_SweepParkWriteIsSkipped() { - // StoreAndForward-005: the retry sweep's state-changing writes must be + // The retry sweep's state-changing writes must be // conditional on the status it observed, so a concurrent operator action that // moved the row out of Pending (e.g. between the sweep's snapshot load and its // park write) is not silently overwritten by the sweep's stale view. @@ -261,6 +283,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Equal("operator/other writer", msg.LastError); } + /// <summary>Verifies that a permanent failure during a retry parks the message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryPendingMessagesAsync_PermanentFailureOnRetry_ParksMessage() { @@ -283,8 +307,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status); } - // ── WP-12: Parked message management ── + // ── Parked message management ── + /// <summary>Verifies that retrying a parked message moves it back to the queue.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryParkedMessageAsync_MovesBackToQueue() { @@ -309,9 +335,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable } /// <summary> - /// StoreAndForward-017: the Retry activity-log entry must carry the parked + /// The Retry activity-log entry must carry the parked /// message's true category, not a hard-coded ExternalSystem. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryParkedMessageAsync_ActivityUsesMessageRealCategory() { @@ -336,9 +363,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable } /// <summary> - /// StoreAndForward-017: the Discard activity-log entry must carry the parked + /// The Discard activity-log entry must carry the parked /// message's true category, not a hard-coded ExternalSystem. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DiscardParkedMessageAsync_ActivityUsesMessageRealCategory() { @@ -362,6 +390,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Equal(new[] { StoreAndForwardCategory.CachedDbWrite }, categories); } + /// <summary>Verifies that discarding a parked message removes it permanently.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DiscardParkedMessageAsync_PermanentlyRemoves() { @@ -381,6 +411,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Null(msg); } + /// <summary>Verifies that fetching parked messages returns paginated results.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetParkedMessagesAsync_ReturnsPaginatedResults() { @@ -403,8 +435,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.True(total >= 3); } - // ── WP-13: Messages survive instance deletion ── + // ── Messages survive instance deletion ── + /// <summary>Verifies that buffered messages survive the deletion of their instance.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MessagesForInstance_SurviveAfterDeletion() { @@ -420,8 +454,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Equal(2, count); } - // ── WP-14: Health metrics ── + // ── Health metrics ── + /// <summary>Verifies that the buffer depth is reported correctly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetBufferDepthAsync_ReturnsCorrectDepth() { @@ -439,6 +475,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.True(depth.GetValueOrDefault(StoreAndForwardCategory.Notification) >= 1); } + /// <summary>Verifies that the activity event is raised when a message is enqueued.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OnActivity_RaisedOnEnqueue() { @@ -453,6 +491,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Contains("Delivered", activities); } + /// <summary>Verifies that the activity event is raised when a message is buffered.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OnActivity_RaisedOnBuffer() { @@ -467,12 +507,14 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Contains("Queued", activities); } - // ── StoreAndForward-009: faulting activity subscriber must not corrupt delivery ── + // ── Faulting activity subscriber must not corrupt delivery ── + /// <summary>Verifies that a faulting activity subscriber does not stop an immediately delivered message from being reported as delivered.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_ImmediateDeliverySuccess_FaultingActivitySubscriber_StillReportsDelivered() { - // StoreAndForward-009: a throwing OnActivity subscriber (e.g. the site event + // A throwing OnActivity subscriber (e.g. the site event // log) must not be misclassified as a transient delivery failure. Pre-fix the // subscriber's exception escaped RaiseActivity, was caught by EnqueueAsync's // transient-failure handler, and a successfully delivered message was buffered. @@ -490,10 +532,12 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Null(msg); // nothing left in the buffer } + /// <summary>Verifies that a faulting activity subscriber does not increment the retry count of a successful retry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryMessageAsync_FaultingActivitySubscriber_DoesNotIncrementRetryCount() { - // StoreAndForward-009: a throwing subscriber raised after a successful retry + // A throwing subscriber raised after a successful retry // delivery must not be caught by the retry-failure handler and counted as a // transient failure. var result = await _service.EnqueueAsync( @@ -511,8 +555,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Null(msg); } - // ── WP-10: Per-source-entity retry settings ── + // ── Per-source-entity retry settings ── + /// <summary>Verifies that custom retry settings supplied at enqueue time are respected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_CustomRetrySettings_Respected() { @@ -531,6 +577,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable // ── attemptImmediateDelivery: false — caller already attempted delivery ── + /// <summary>Verifies that enqueuing with immediate delivery disabled buffers the message without invoking the handler.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_AttemptImmediateDeliveryFalse_BuffersWithoutInvokingHandler() { @@ -550,19 +598,20 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable var msg = await _storage.GetMessageByIdAsync(result.MessageId); Assert.NotNull(msg); Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status); - // StoreAndForward-003: the caller's own attempt is attempt 0; RetryCount + // The caller's own attempt is attempt 0; RetryCount // counts only sweep retries, so a freshly buffered message has RetryCount 0. Assert.Equal(0, msg.RetryCount); } - // ─── StoreAndForward-024: StopAsync waits for the in-flight sweep ─── + // ─── StopAsync waits for the in-flight sweep ─── /// <summary> - /// StoreAndForward-024: <see cref="StoreAndForwardService.StopAsync"/> must + /// <see cref="StoreAndForwardService.StopAsync"/> must /// not return until any in-flight retry sweep has completed (or the bounded /// shutdown timeout fires). Pre-fix it disposed the timer and returned /// immediately, leaving a mid-flight sweep touching disposed dependencies. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StopAsync_AwaitsInFlightRetrySweep_BeforeReturning() { @@ -622,10 +671,12 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable "Sweep handler must have finished before StopAsync returned."); } - // ── Task 3 (arch review 02, Stability #2): active-node delivery gate ── + // ── Active-node delivery gate ── // The standby site node applies replicated buffer operations but must never // deliver; the sweep runs only when the gate reports the node is active. + /// <summary>Verifies that the retry sweep skips delivery while the delivery gate reports standby.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetrySweep_SkipsDelivery_WhenDeliveryGateReportsStandby() { @@ -644,6 +695,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Equal(StoreAndForwardMessageStatus.Pending, row!.Status); // row untouched } + /// <summary>Verifies that the retry sweep resumes delivery when the gate flips to active.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetrySweep_ResumesDelivery_WhenGateFlipsActive() { @@ -664,6 +717,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Equal(1, delivered); } + /// <summary>Verifies that the retry sweep treats a throwing gate as standby.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetrySweep_TreatsThrowingGateAsStandby() { @@ -678,11 +733,13 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Equal(0, delivered); } - // ── Task 8 (arch review 02, Performance): per-target short-circuit ── + // ── Per-target short-circuit ── // After the first transient failure to a (category, target) the remaining // messages for that pair are skipped this sweep — N dead-target messages // cost one timeout, not N. Skipped rows keep their RetryCount. + /// <summary>Verifies that after the first transient failure for a target the sweep short-circuits that target's remaining messages.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetrySweep_ShortCircuitsTarget_AfterFirstTransientFailure() { @@ -706,6 +763,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Equal(1, attemptsByTarget["healthy-target"]); // healthy lane unaffected } + /// <summary>Verifies that messages skipped by the sweep do not accrue a retry count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetrySweep_SkippedMessages_DoNotAccrueRetryCount() { @@ -726,10 +785,12 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable Assert.Equal(new[] { 0, 1 }, new[] { r1, r2 }.OrderBy(x => x).ToArray()); } - // ── Task 9 (arch review 02, Performance): parallel per-target lanes ── + // ── Parallel per-target lanes ── // A slow/dead target no longer serializes the whole sweep; delivery within a // single (category, target) lane stays sequential (per-target FIFO). + /// <summary>Verifies that a slow target does not block delivery to other targets.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetrySweep_SlowTarget_DoesNotBlockOtherTargets() { @@ -754,6 +815,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable await sweep; } + /// <summary>Verifies that messages within one target lane are delivered sequentially.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetrySweep_WithinTargetLane_StaysSequential() { @@ -787,7 +850,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable } /// <summary> - /// Register row-40 house pattern for an ABSENCE assertion (mirrors the Medium-wave + /// House pattern for an ABSENCE assertion (mirrors the Medium-wave /// <c>SiteAlarmAggregatorActorTests.AssertHoldsFor</c> treatment, ported here async since /// this class's facts run on the async test thread): proves <paramref name="holds"/> keeps /// being true for the whole <paramref name="window"/> by polling, rather than a single @@ -809,7 +872,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable } } - // ── Task 12: deferToSweep enqueue mode ── + // ── deferToSweep enqueue mode ── /// <summary> /// <c>deferToSweep: true</c> must NOT invoke the delivery handler inline (the @@ -817,6 +880,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable /// the very next sweep — <c>LastAttemptAt</c> stays null so it is not held back by /// RetryInterval. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Enqueue_DeferToSweep_DoesNotInvokeHandlerInline_AndRowIsDueImmediately() { @@ -839,6 +903,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable /// healthy-path latency is milliseconds, not one timer interval — proven with a /// timer set to an hour so only the kicked sweep can deliver. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Enqueue_DeferToSweep_AfterStart_KicksAnImmediateSweep() { @@ -857,6 +922,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable // ── R2 T8: _sweepTask clobber (N3) ── + /// <summary>Verifies that triggering a sweep while one is in flight does not clobber the drain handle.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TriggerSweep_WhileSweepInFlight_DoesNotClobberTheDrainHandle() { @@ -890,6 +957,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable } } + /// <summary>Verifies that stopping waits for the real in-flight sweep even after a redundant trigger.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StopAsync_WaitsForTheRealInFlightSweep_EvenAfterARedundantTrigger() { @@ -923,22 +992,26 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable await stop.WaitAsync(TimeSpan.FromSeconds(5)); // drains the real sweep promptly once released } - // ── WP2.6c: bounded, DropOldest observer queue + drop counter ── + // ── Bounded, DropOldest observer queue + drop counter ── private sealed class BlockingObserver : ICachedCallLifecycleObserver { private readonly TaskCompletionSource _gate; + /// <summary>Creates an observer that blocks every attempt notification until the gate completes.</summary> + /// <param name="gate">The completion source that releases the blocked notifications.</param> public BlockingObserver(TaskCompletionSource gate) => _gate = gate; + /// <inheritdoc /> public async Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default) => await _gate.Task; } /// <summary> - /// WP2.6c: the cached-call audit-observer queue is bounded — once the single-reader + /// The cached-call audit-observer queue is bounded — once the single-reader /// pump is stuck awaiting a slow/stuck observer, further posted notifications must /// evict the oldest queued one (DropOldest) instead of growing without bound, and /// every eviction must increment <see cref="StoreAndForwardService.ObserverQueueDroppedCount"/>. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ObserverQueue_BoundedCapacity_DropsOldestAndCountsDrops() { @@ -1010,15 +1083,30 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable private readonly List<(LogLevel Level, string Message)> _entries = new(); private readonly object _gate = new(); + /// <summary>Gets a snapshot of the log entries captured so far.</summary> public IReadOnlyList<(LogLevel Level, string Message)> Entries { get { lock (_gate) return _entries.ToList(); } } + /// <summary>Returns no scope; the fake logger does not track scopes.</summary> + /// <typeparam name="TState">The type of the scope state.</typeparam> + /// <param name="state">The scope state, ignored.</param> + /// <returns>Always null or a no-op scope.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level being queried.</param> + /// <returns>Always true.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The type of the log state.</typeparam> + /// <param name="logLevel">The severity of the entry.</param> + /// <param name="eventId">The event identifier.</param> + /// <param name="state">The log state.</param> + /// <param name="exception">The associated exception, if any.</param> + /// <param name="formatter">Formats the state and exception into the message.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) @@ -1038,6 +1126,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable /// produce exactly ONE observer-queue-drop Warning log — the first-drop Warning — because /// the episode never runs long enough to cross the periodic rollup interval. /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ObserverQueue_ManyDropsInOneEpisode_LogsExactlyOneWarning() { @@ -1105,6 +1194,9 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable TestLocalDb.DeleteFiles(path); } } + /// <summary>Verifies that a delivery gate flipping to standby during delivery does not start another attempt, for the same or a different target lane.</summary> + /// <param name="differentLane">True when the second message targets a different lane than the one in flight.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardSiteEventTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardSiteEventTests.cs index b21acfd8..bcc7e8cc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardSiteEventTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardSiteEventTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// <summary> -/// M1.7: the StoreAndForwardService emits site operational events for its own +/// The StoreAndForwardService emits site operational events for its own /// buffer/park activity — <c>store_and_forward</c> for cached-call categories /// (ExternalSystem / CachedDbWrite) and <c>notification</c> for the site's /// notification forward-to-central path. Emission rides the existing @@ -21,9 +21,14 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable private sealed class FakeSiteEventLogger : ISiteEventLogger { private readonly ConcurrentQueue<Entry> _entries = new(); + /// <summary>Gets the entries logged so far.</summary> public IReadOnlyList<Entry> Entries => _entries.ToArray(); + /// <summary>Returns the logged entries of one event type.</summary> + /// <param name="t">The event type to filter by.</param> + /// <returns>The logged entries whose event type matches.</returns> public IReadOnlyList<Entry> OfType(string t) => _entries.Where(e => e.EventType == t).ToArray(); + /// <inheritdoc /> public Task LogEventAsync(string eventType, string severity, string? instanceId, string source, string message, string? details = null) { @@ -31,6 +36,7 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable return Task.CompletedTask; } + /// <inheritdoc /> public long FailedWriteCount => 0; } @@ -40,6 +46,7 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable private readonly FakeSiteEventLogger _siteLog = new(); private readonly StoreAndForwardService _service; + /// <summary>Initializes the test class with a temporary LocalDb and a store-and-forward service wired to a fake site event logger.</summary> public StoreAndForwardSiteEventTests() { _localDb = TestLocalDb.CreateTemp("SiteEvt"); @@ -58,8 +65,13 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable siteEventLogger: _siteLog); } + /// <summary>Initializes the fixture before the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() => await _storage.InitializeAsync(); + /// <summary>Releases the fixture after the tests run.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { var path = _localDb.Path; @@ -67,6 +79,8 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable TestLocalDb.DeleteFiles(path); } + /// <summary>Verifies that buffering an external system message for retry emits a store-and-forward site event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BufferForRetry_ExternalSystem_EmitsStoreAndForwardSiteEvent() { @@ -83,6 +97,8 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable Assert.Empty(_siteLog.OfType("notification")); } + /// <summary>Verifies that a failed notification forward emits a notification site event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ForwardFailure_Notification_EmitsNotificationSiteEvent() { @@ -103,6 +119,8 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable Assert.Empty(_siteLog.OfType("store_and_forward")); } + /// <summary>Verifies that a routine notification enqueue does not emit a site event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RoutineEnqueue_Notification_DoesNotEmitSiteEvent() { @@ -117,6 +135,8 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable Assert.Empty(_siteLog.OfType("notification")); } + /// <summary>Verifies that parking a notification emits an error-severity notification site event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Park_Notification_EmitsErrorNotificationSiteEvent() { @@ -136,6 +156,8 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable r.Message.Contains("parked", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that parking an external system message emits an error-severity store-and-forward site event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Park_ExternalSystem_EmitsErrorStoreAndForwardSiteEvent() { @@ -154,6 +176,8 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable r.Message.Contains("parked", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that a message delivered immediately does not emit a site event.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeliveredImmediately_DoesNotEmitSiteEvent() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs index 8a2f725a..a3b1a26e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// <summary> -/// WP-9: Tests for SQLite persistence layer. +/// Tests for SQLite persistence layer. /// </summary> /// <remarks> /// Backed by a real temp-file LocalDb rather than the shared-cache in-memory database @@ -20,16 +20,22 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; + /// <summary>Creates a temporary LocalDb-backed store for each test.</summary> public StoreAndForwardStorageTests() { _localDb = TestLocalDb.CreateTemp("StorageTests"); _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger<StoreAndForwardStorage>.Instance); } + /// <summary>Initializes the store-and-forward storage schema.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public async Task InitializeAsync() => await _storage.InitializeAsync(); + /// <summary>Completes immediately; the synchronous <c>Dispose</c> releases the resources.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> public Task DisposeAsync() => Task.CompletedTask; + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { // Dispose first — the master connection anchors the WAL, so the sidecars @@ -39,6 +45,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable TestLocalDb.DeleteFiles(path); } + /// <summary>Verifies that enqueuing stores the message.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_StoresMessage() { @@ -52,6 +60,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal("target1", retrieved.Target); } + /// <summary>Verifies that enqueuing works for every message category.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_AllCategories() { @@ -68,6 +78,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal(StoreAndForwardCategory.CachedDbWrite, db!.Category); } + /// <summary>Verifies that removing a message deletes it from the store.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RemoveMessageAsync_RemovesSuccessfully() { @@ -78,6 +90,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Null(retrieved); } + /// <summary>Verifies that updating a message persists the changed fields.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateMessageAsync_UpdatesFields() { @@ -96,6 +110,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal("Connection refused", retrieved.LastError); } + /// <summary>Verifies that the retry query returns only pending messages.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetMessagesForRetryAsync_ReturnsOnlyPendingMessages() { @@ -112,10 +128,12 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.All(forRetry, m => Assert.Equal(StoreAndForwardMessageStatus.Pending, m.Status)); } + /// <summary>Verifies that with a non-zero retry interval the retry query excludes messages not yet due and includes due ones.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetMessagesForRetryAsync_NonZeroInterval_ExcludesNotYetDueIncludesDue() { - // StoreAndForward-013: exercise the julianday elapsed-time comparison with a + // Exercise the julianday elapsed-time comparison with a // non-zero retry interval. A message attempted just now must NOT be due; one // attempted long ago must be due. var notDue = CreateMessage("notdue", StoreAndForwardCategory.ExternalSystem); @@ -141,6 +159,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Contains("never", ids); } + /// <summary>Verifies that the parked query returns only parked messages.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetParkedMessagesAsync_ReturnsParkedOnly() { @@ -154,6 +174,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.All(messages, m => Assert.Equal(StoreAndForwardMessageStatus.Parked, m.Status)); } + /// <summary>Verifies that retrying a parked message moves it back to pending.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryParkedMessageAsync_MovesToPending() { @@ -171,10 +193,12 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal(0, retrieved.RetryCount); } + /// <summary>Verifies that retrying a parked message clears its last attempt time so it is immediately due.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RetryParkedMessageAsync_ClearsLastAttemptAt_SoMessageIsImmediatelyDue() { - // StoreAndForward-010: a re-queued parked message must be unambiguously due + // A re-queued parked message must be unambiguously due // for the next sweep regardless of its (stale) last_attempt_at. Use a large // retry interval so a leftover timestamp would otherwise exclude the message. var msg = CreateMessage("requeue1", StoreAndForwardCategory.ExternalSystem); @@ -196,6 +220,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Contains(due, m => m.Id == "requeue1"); } + /// <summary>Verifies that discarding a parked message removes it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DiscardParkedMessageAsync_RemovesMessage() { @@ -211,6 +237,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Null(retrieved); } + /// <summary>Verifies that the buffer depth query returns the correct count per category.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetBufferDepthByCategoryAsync_ReturnsCorrectCounts() { @@ -222,6 +250,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.True(depth.GetValueOrDefault(StoreAndForwardCategory.ExternalSystem) >= 2); } + /// <summary>Verifies that the origin-instance query returns the correct message count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetMessageCountByOriginInstanceAsync_ReturnsCount() { @@ -237,6 +267,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal(2, count); } + /// <summary>Verifies that parked messages are returned correctly across pages.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetParkedMessagesAsync_Pagination() { @@ -256,10 +288,12 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal(2, page2.Count); } + /// <summary>Verifies that the transactioned parked reads report a count that matches the full result set.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetParkedMessagesAsync_TransactionedReads_CountMatchesFullResultSet() { - // StoreAndForward-006: the COUNT(*) and paged SELECT now run inside one + // The COUNT(*) and paged SELECT now run inside one // transaction so they share a consistent snapshot. This functional check // guards the fix — it verifies the transaction wiring did not break paging: // the reported TotalCount and the rows assembled across all pages agree, and @@ -289,6 +323,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal(25, collected.Distinct().Count()); } + /// <summary>Verifies that the status query returns an accurate message count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetMessageCountByStatusAsync_ReturnsAccurateCount() { @@ -299,8 +335,10 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.True(count >= 1); } - // ── Audit Log #23 (ExecutionId Task 4): execution_id / source_script ── + // ── ExecutionId: execution_id / source_script ── + /// <summary>Verifies that enqueuing round-trips the execution id and source script.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_RoundTripsExecutionIdAndSourceScript() { @@ -320,6 +358,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal("Plant.Pump42/OnTick", retrieved.SourceScript); } + /// <summary>Verifies that a null execution id and source script round-trip as null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_NullExecutionIdAndSourceScript_RoundTripAsNull() { @@ -337,6 +377,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Null(retrieved.SourceScript); } + /// <summary>Verifies that the execution id and source script survive a retry sweep read.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExecutionIdAndSourceScript_SurviveRetrySweepRead() { @@ -357,6 +399,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal("Plant.Tank/OnAlarm", row.SourceScript); } + /// <summary>Verifies that a legacy row without the new columns reads back with null values.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LegacyRowWithoutNewColumns_ReadsBackAsNull() { @@ -401,6 +445,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Null(retrieved.SourceScript); } + /// <summary>Verifies that a malformed execution id reads back as null without aborting the retry sweep.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MalformedExecutionId_ReadsBackAsNull_DoesNotAbortRetrySweep() { @@ -440,6 +486,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Null(retrieved!.ExecutionId); } + /// <summary>Verifies that initialization is idempotent when the columns already exist.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task InitializeAsync_IsIdempotent_WhenColumnsAlreadyExist() { @@ -456,8 +504,10 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal(message.ExecutionId, retrieved!.ExecutionId); } - // ── Audit Log #23 (ParentExecutionId Task 6): parent_execution_id ── + // ── ParentExecutionId: parent_execution_id ── + /// <summary>Verifies that enqueuing round-trips the parent execution id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_RoundTripsParentExecutionId() { @@ -475,6 +525,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal(parentExecutionId, retrieved!.ParentExecutionId); } + /// <summary>Verifies that a null parent execution id round-trips as null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EnqueueAsync_NullParentExecutionId_RoundTripsAsNull() { @@ -490,6 +542,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Null(retrieved!.ParentExecutionId); } + /// <summary>Verifies that the parent execution id survives a retry sweep read.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ParentExecutionId_SurvivesRetrySweepRead() { @@ -508,12 +562,14 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal(parentExecutionId, row.ParentExecutionId); } + /// <summary>Verifies that a legacy row without the parent execution id column reads back with a null value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LegacyRowWithoutParentExecutionIdColumn_ReadsBackAsNull() { // Back-compat: a row persisted by a build that pre-dates the // parent_execution_id column must still deserialize, with - // ParentExecutionId reading back as null. Simulate the pre-Task-6 + // ParentExecutionId reading back as null. Simulate the pre-ParentExecutionId // schema (which already has execution_id / source_script from the // ExecutionId rollout) by recreating the table without // parent_execution_id, inserting directly, then running InitializeAsync @@ -554,6 +610,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Null(retrieved.ParentExecutionId); } + /// <summary>Verifies that a malformed parent execution id reads back as null without aborting the retry sweep.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MalformedParentExecutionId_ReadsBackAsNull_DoesNotAbortRetrySweep() { @@ -589,8 +647,10 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Null(retrieved!.ParentExecutionId); } - // ── Task 7 (arch review 02, Performance): bounded due-rows query ── + // ── Bounded due-rows query ── + /// <summary>Verifies that the retry query honors its limit and returns the oldest messages first.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetMessagesForRetry_HonorsLimit_OldestFirst() { @@ -649,6 +709,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable // library, so the shim is gone — but either way it is not this class's to assert, // and SiteLocalDbDirectoryTests pins the outcome for the site host. + /// <summary>Verifies that initialization leaves the database in WAL journal mode.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Initialize_LeavesTheDatabaseInWalJournalMode() { @@ -675,8 +737,10 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable } } - // ── Task 18: epoch-ms due-check ── + // ── Epoch-ms due-check ── + /// <summary>Verifies that the retry query uses epoch milliseconds and respects the retry interval.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetMessagesForRetry_UsesEpochMs_RespectsInterval() { @@ -701,6 +765,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Single(await _storage.GetMessagesForRetryAsync()); } + /// <summary>Verifies that initialization backfills epoch milliseconds for legacy rows.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Initialize_BackfillsEpochMs_ForLegacyRows() { @@ -738,7 +804,7 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable return result is DBNull ? null : result; } - // ── Task 20: buffer-resync storage primitives ── + // ── Buffer-resync storage primitives ── private static StoreAndForwardMessage NewMsg( string id, @@ -756,6 +822,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Status = status, }; + /// <summary>Verifies that the all-messages query returns every status, oldest first, up to the limit.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetAllMessages_ReturnsEveryStatus_OldestFirst_UpToLimit() { @@ -779,8 +847,10 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable // LocalDbConfigConvergenceTests.ANodeWithNewerLocalRows_KeepsThem_WhenAPeerSnapshotArrives // now covers. - // ── Task 23: oldest-parked-age health signal ── + // ── Oldest-parked-age health signal ── + /// <summary>Verifies that the oldest-parked query returns the oldest parked row's creation time, or null when none are parked.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task GetOldestParkedCreatedAt_ReturnsOldestParkedRow_OrNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/CollisionDetectorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/CollisionDetectorTests.cs index 5f556e05..5fd97d28 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/CollisionDetectorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/CollisionDetectorTests.cs @@ -6,9 +6,10 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests; public class CollisionDetectorTests { // ======================================================================== - // WP-12: Naming Collision Detection + // Naming Collision Detection // ======================================================================== + /// <summary>Verifies that no collisions returns an empty result.</summary> [Fact] public void DetectCollisions_NoCollisions_ReturnsEmpty() { @@ -22,6 +23,7 @@ public class CollisionDetectorTests Assert.Empty(collisions); } + /// <summary>Verifies that different modules with no prefix collision return an empty result.</summary> [Fact] public void DetectCollisions_DifferentModulesNoPrefixCollision_ReturnsEmpty() { @@ -43,6 +45,7 @@ public class CollisionDetectorTests Assert.Empty(collisions); } + /// <summary>Verifies that a collision between a direct and a composed name is reported.</summary> [Fact] public void DetectCollisions_DirectAndComposedNameCollision_ReturnsCollision() { @@ -67,6 +70,7 @@ public class CollisionDetectorTests Assert.Contains(collisions, c => c.Contains("modA.Value")); } + /// <summary>Verifies that nested composition yields the correct canonical names.</summary> [Fact] public void DetectCollisions_NestedComposition_ReturnsCorrectCanonicalNames() { @@ -89,6 +93,7 @@ public class CollisionDetectorTests Assert.Empty(collisions); } + /// <summary>Verifies that inherited members that collide with composed members are reported as a collision.</summary> [Fact] public void DetectCollisions_InheritedMembersCollideWithComposed_ReturnsCollision() { @@ -113,13 +118,14 @@ public class CollisionDetectorTests } // ======================================================================== - // Follow-up #3: inherited placeholder rows must not self-collide with the + // Inherited placeholder rows must not self-collide with the // parent rows the inheritance walk re-adds. Before the fix, every derived // template reported a collision for each IsInherited row (child origin vs // "parent 'X'" origin) — so there was no supported path to add an attribute // or composition to a derived template. // ======================================================================== + /// <summary>Verifies that a derived template with inherited placeholder rows produces no false collision.</summary> [Fact] public void DetectCollisions_DerivedTemplateWithInheritedPlaceholders_NoFalseCollision() { @@ -142,6 +148,7 @@ public class CollisionDetectorTests Assert.Empty(collisions); } + /// <summary>Verifies that multi-level inherited placeholder rows produce no false collision.</summary> [Fact] public void DetectCollisions_MultiLevelInheritedPlaceholders_NoFalseCollision() { @@ -163,6 +170,7 @@ public class CollisionDetectorTests Assert.Empty(collisions); } + /// <summary>Verifies that a genuine collision on a derived template is still detected despite an inherited placeholder.</summary> [Fact] public void DetectCollisions_DerivedTemplate_GenuineCollisionStillDetected_DespiteInheritedPlaceholder() { @@ -190,7 +198,7 @@ public class CollisionDetectorTests } // ======================================================================== - // Task 10: repeated composition blindness. A template composed into TWO + // Repeated composition blindness. A template composed into TWO // different slots was added to the global visited-set on the first slot, so // the SECOND slot's subtree was never collected — the detector went blind to // any collision reachable only through the second slot. The visited-set must @@ -198,6 +206,7 @@ public class CollisionDetectorTests // siblings/other slots can re-descend the same template. // ======================================================================== + /// <summary>Verifies that a template composed twice within a subtree reports the collision under the second slot.</summary> [Fact] public void DetectCollisions_TemplateComposedTwiceWithinSubtree_SeesCollisionUnderSecondSlot() { @@ -230,6 +239,7 @@ public class CollisionDetectorTests Assert.Contains(collisions, c => c.Contains("w.y2.z.Val")); } + /// <summary>Verifies that a cyclic composition does not loop forever.</summary> [Fact] public void DetectCollisions_CyclicComposition_DoesNotLoopForever() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/CycleDetectorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/CycleDetectorTests.cs index 2b29ac3e..84bb9ca1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/CycleDetectorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/CycleDetectorTests.cs @@ -5,9 +5,10 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests; public class CycleDetectorTests { // ======================================================================== - // WP-13: Inheritance cycle detection + // Inheritance cycle detection // ======================================================================== + /// <summary>Verifies that self-inheritance is reported as a cycle.</summary> [Fact] public void DetectInheritanceCycle_SelfInheritance_ReturnsCycle() { @@ -20,6 +21,7 @@ public class CycleDetectorTests Assert.Contains("itself", result); } + /// <summary>Verifies that a direct inheritance cycle is reported.</summary> [Fact] public void DetectInheritanceCycle_DirectCycle_ReturnsCycle() { @@ -34,6 +36,7 @@ public class CycleDetectorTests Assert.Contains("cycle", result, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that a three-node inheritance cycle is reported.</summary> [Fact] public void DetectInheritanceCycle_ThreeNodeCycle_ReturnsCycle() { @@ -49,6 +52,7 @@ public class CycleDetectorTests Assert.Contains("cycle", result, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that an inheritance chain with no cycle returns null.</summary> [Fact] public void DetectInheritanceCycle_NoCycle_ReturnsNull() { @@ -62,9 +66,10 @@ public class CycleDetectorTests } // ======================================================================== - // WP-13: Composition cycle detection + // Composition cycle detection // ======================================================================== + /// <summary>Verifies that self-composition is reported as a cycle.</summary> [Fact] public void DetectCompositionCycle_SelfComposition_ReturnsCycle() { @@ -77,6 +82,7 @@ public class CycleDetectorTests Assert.Contains("compose itself", result); } + /// <summary>Verifies that a direct composition cycle is reported.</summary> [Fact] public void DetectCompositionCycle_DirectCycle_ReturnsCycle() { @@ -92,6 +98,7 @@ public class CycleDetectorTests Assert.Contains("cycle", result, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that a transitive composition cycle is reported.</summary> [Fact] public void DetectCompositionCycle_TransitiveCycle_ReturnsCycle() { @@ -108,6 +115,7 @@ public class CycleDetectorTests Assert.NotNull(result); } + /// <summary>Verifies that a composition graph with no cycle returns null.</summary> [Fact] public void DetectCompositionCycle_NoCycle_ReturnsNull() { @@ -121,9 +129,10 @@ public class CycleDetectorTests } // ======================================================================== - // WP-13: Cross-graph cycle detection (inheritance + composition) + // Cross-graph cycle detection (inheritance + composition) // ======================================================================== + /// <summary>Verifies that a cycle spanning inheritance and composition is reported as a cross-graph cycle.</summary> [Fact] public void DetectCrossGraphCycle_InheritanceCompositionCross_ReturnsCycle() { @@ -141,6 +150,7 @@ public class CycleDetectorTests Assert.Contains("Cross-graph cycle", result); } + /// <summary>Verifies that a graph with no cross-graph cycle returns null.</summary> [Fact] public void DetectCrossGraphCycle_NoCycle_ReturnsNull() { @@ -155,9 +165,10 @@ public class CycleDetectorTests } // ======================================================================== - // TemplateEngine-013: robustness against duplicate Ids and Id 0 + // Robustness against duplicate Ids and Id 0 // ======================================================================== + /// <summary>Verifies that duplicate ids in the list do not cause an exception when detecting an inheritance cycle.</summary> [Fact] public void DetectInheritanceCycle_DuplicateIdsInList_DoesNotThrow() { @@ -173,6 +184,7 @@ public class CycleDetectorTests Assert.Null(ex); } + /// <summary>Verifies that duplicate ids in the list do not cause an exception when detecting a composition cycle.</summary> [Fact] public void DetectCompositionCycle_DuplicateIdsInList_DoesNotThrow() { @@ -185,6 +197,7 @@ public class CycleDetectorTests Assert.Null(ex); } + /// <summary>Verifies that duplicate ids in the list do not cause an exception when detecting a cross-graph cycle.</summary> [Fact] public void DetectCrossGraphCycle_DuplicateIdsInList_DoesNotThrow() { @@ -197,6 +210,7 @@ public class CycleDetectorTests Assert.Null(ex); } + /// <summary>Verifies that an inheritance cycle through a real id of zero is still detected.</summary> [Fact] public void DetectInheritanceCycle_RealIdZero_StillDetectsCycle() { @@ -212,6 +226,7 @@ public class CycleDetectorTests Assert.Contains("itself", result); } + /// <summary>Verifies that a parent chain passing through id zero is detected as a cycle.</summary> [Fact] public void DetectInheritanceCycle_ParentChainThroughIdZero_DetectsCycle() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs index f3db7c93..6c1bd526 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ConnectionBindingOverrideTests.cs @@ -35,6 +35,7 @@ public class ConnectionBindingOverrideTests [id] = new("OPC-Server1", "OpcUa", 1) { Id = id, PrimaryConfiguration = "opc.tcp://localhost:4840" } }; + /// <summary>Verifies that a set override replaces the template's data source reference.</summary> [Fact] public void Override_replaces_template_DataSourceReference_when_set() { @@ -58,6 +59,7 @@ public class ConnectionBindingOverrideTests Assert.Equal("ns=2;s=Pump1.Speed", attr.DataSourceReference); } + /// <summary>Verifies that a null override falls back to the template default.</summary> [Fact] public void Null_override_falls_back_to_template_default() { @@ -81,6 +83,7 @@ public class ConnectionBindingOverrideTests Assert.Equal("TemplateDefault", attr.DataSourceReference); } + /// <summary>Verifies that changing an override changes the revision hash.</summary> [Fact] public void Override_change_changes_revision_hash() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DefaultDataSourceReferenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DefaultDataSourceReferenceTests.cs index 1daf2b2d..dbf13e8d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DefaultDataSourceReferenceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DefaultDataSourceReferenceTests.cs @@ -15,6 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening; /// </summary> public class DefaultDataSourceReferenceTests { + /// <summary>Verifies that the revision hash ignores the default data source reference.</summary> [Fact] public void RevisionHash_IgnoresDefaultDataSourceReference() { @@ -32,6 +33,7 @@ public class DefaultDataSourceReferenceTests Assert.Equal(svc.ComputeHash(without), svc.ComputeHash(with)); } + /// <summary>Verifies that a resolved attribute's default data source reference is null by default.</summary> [Fact] public void ResolvedAttribute_DefaultDataSourceReference_IsNullByDefault() { @@ -39,6 +41,7 @@ public class DefaultDataSourceReferenceTests Assert.Null(a.DefaultDataSourceReference); } + /// <summary>Verifies that flattening populates the default data source reference for both bound and unbound data-sourced attributes.</summary> [Fact] public void Flatten_PopulatesDefault_ForBoundAndUnboundDataSourcedAttributes() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DeploymentPackageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DeploymentPackageTests.cs index 544c96d3..7bef71ba 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DeploymentPackageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DeploymentPackageTests.cs @@ -5,6 +5,8 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening; public class DeploymentPackageTests { + /// <summary>Verifies that a deployment package round-trips through JSON serialization.</summary> + /// <returns>The result.</returns> [Fact] public void DeploymentPackage_JsonSerializable() { @@ -67,6 +69,8 @@ public class DeploymentPackageTests Assert.Equal("Temperature", deserialized.Configuration.Attributes[0].CanonicalName); } + /// <summary>Verifies that a deployment package carrying a configuration diff round-trips through JSON serialization.</summary> + /// <returns>The result.</returns> [Fact] public void DeploymentPackage_WithDiff_Serializable() { @@ -105,6 +109,8 @@ public class DeploymentPackageTests Assert.Equal("sha256:old", deserialized.PreviousRevisionHash); } + /// <summary>Verifies that a new flattened configuration has empty default values.</summary> + /// <returns>The result.</returns> [Fact] public void FlattenedConfiguration_DefaultValues() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DiffServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DiffServiceTests.cs index e1e7e6a3..9c9b516f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DiffServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DiffServiceTests.cs @@ -7,6 +7,7 @@ public class DiffServiceTests { private readonly DiffService _sut = new(); + /// <summary>Verifies that a null old configuration reports everything as added.</summary> [Fact] public void ComputeDiff_NullOldConfig_AllAdded() { @@ -37,6 +38,7 @@ public class DiffServiceTests Assert.Single(diff.ScriptChanges); } + /// <summary>Verifies that identical configurations produce no changes.</summary> [Fact] public void ComputeDiff_IdenticalConfigs_NoChanges() { @@ -54,6 +56,7 @@ public class DiffServiceTests Assert.Empty(diff.AttributeChanges); } + /// <summary>Verifies that a removed attribute is detected as removed.</summary> [Fact] public void ComputeDiff_AttributeRemoved_DetectedAsRemoved() { @@ -80,6 +83,7 @@ public class DiffServiceTests Assert.Equal("Removed", diff.AttributeChanges[0].CanonicalName); } + /// <summary>Verifies that a changed attribute is detected as changed.</summary> [Fact] public void ComputeDiff_AttributeChanged_DetectedAsChanged() { @@ -103,6 +107,7 @@ public class DiffServiceTests Assert.Equal("50", diff.AttributeChanges[0].NewValue?.Value); } + /// <summary>Verifies that the revision hashes are included in the diff.</summary> [Fact] public void ComputeDiff_RevisionHashes_Included() { @@ -113,6 +118,7 @@ public class DiffServiceTests Assert.Equal("sha256:new", diff.NewRevisionHash); } + /// <summary>Verifies that a script code change is detected.</summary> [Fact] public void ComputeDiff_ScriptCodeChange_Detected() { @@ -134,10 +140,11 @@ public class DiffServiceTests Assert.Equal(DiffChangeType.Changed, diff.ScriptChanges[0].ChangeType); } + /// <summary>Verifies that an attribute description change is detected as changed.</summary> [Fact] public void ComputeDiff_AttributeDescriptionChange_DetectedAsChanged() { - // TemplateEngine-017: AttributesEqual must compare Description. + // AttributesEqual must compare Description. var oldConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1", @@ -162,10 +169,11 @@ public class DiffServiceTests Assert.Equal(DiffChangeType.Changed, diff.AttributeChanges[0].ChangeType); } + /// <summary>Verifies that an attribute element data type change is detected as changed.</summary> [Fact] public void ComputeDiff_AttributeElementDataTypeChange_DetectedAsChanged() { - // #290: ElementDataType is part of the revision hash, so AttributesEqual + // ElementDataType is part of the revision hash, so AttributesEqual // must also compare it — a List element-type change (Int32 → Double) with // identical JSON-encoded values must surface as a Changed attribute, // keeping the structured diff in parity with the staleness hash. @@ -193,10 +201,11 @@ public class DiffServiceTests Assert.Equal(DiffChangeType.Changed, diff.AttributeChanges[0].ChangeType); } + /// <summary>Verifies that an alarm description change is detected as changed.</summary> [Fact] public void ComputeDiff_AlarmDescriptionChange_DetectedAsChanged() { - // TemplateEngine-017: AlarmsEqual must compare Description. + // AlarmsEqual must compare Description. var oldConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1", @@ -221,10 +230,11 @@ public class DiffServiceTests Assert.Equal(DiffChangeType.Changed, diff.AlarmChanges[0].ChangeType); } + /// <summary>Verifies that a native alarm source reference change is detected as changed.</summary> [Fact] public void ComputeDiff_NativeAlarmSourceReferenceChange_DetectedAsChanged() { - // PLAN-05 Task 12: native alarm source edits must surface in the diff so + // Native alarm source edits must surface in the diff so // the Deployments diff view is not blind to native-alarm binding changes. var oldConfig = new FlattenedConfiguration { @@ -251,6 +261,7 @@ public class DiffServiceTests Assert.Equal("PumpAlarms", diff.NativeAlarmSourceChanges[0].CanonicalName); } + /// <summary>Verifies that an added native alarm source is detected as added.</summary> [Fact] public void ComputeDiff_NativeAlarmSourceAdded_DetectedAsAdded() { @@ -271,6 +282,7 @@ public class DiffServiceTests Assert.Equal(DiffChangeType.Added, diff.NativeAlarmSourceChanges[0].ChangeType); } + /// <summary>Verifies that a removed native alarm source is detected as removed.</summary> [Fact] public void ComputeDiff_NativeAlarmSourceRemoved_DetectedAsRemoved() { @@ -291,6 +303,7 @@ public class DiffServiceTests Assert.Equal(DiffChangeType.Removed, diff.NativeAlarmSourceChanges[0].ChangeType); } + /// <summary>Verifies that a native alarm source lock change is detected as changed.</summary> [Fact] public void ComputeDiff_NativeAlarmSourceLockChange_DetectedAsChanged() { @@ -317,6 +330,7 @@ public class DiffServiceTests Assert.Equal(DiffChangeType.Changed, diff.NativeAlarmSourceChanges[0].ChangeType); } + /// <summary>Verifies that an unchanged native alarm source produces no diff.</summary> [Fact] public void ComputeDiff_NativeAlarmSourceUnchanged_NoDiff() { @@ -329,12 +343,12 @@ public class DiffServiceTests Assert.Empty(diff.NativeAlarmSourceChanges); } + /// <summary>Verifies that identical connection configurations compare as equal.</summary> [Fact] public void ConnectionsEqual_IdenticalConfigs_ReturnsTrue() { - // TemplateEngine-017: ConnectionsEqual is the comparator callers use - // to detect connection-endpoint drift (the diff-view extension that - // surfaces this in the UI is tracked under TemplateEngine-018). + // ConnectionsEqual is the comparator callers use + // to detect connection-endpoint drift. var a = new ConnectionConfig { Protocol = "OpcUa", @@ -347,10 +361,11 @@ public class DiffServiceTests Assert.True(DiffService.ConnectionsEqual(a, b)); } + /// <summary>Verifies that an endpoint edit makes the connection configurations compare as unequal.</summary> [Fact] public void ConnectionsEqual_EndpointEdit_ReturnsFalse() { - // TemplateEngine-017: primary endpoint JSON edit must surface as a + // A primary endpoint JSON edit must surface as a // change. Without this, deployment redeploys ship a different // ConnectionConfig with no visible drift signal. var a = new ConnectionConfig @@ -364,6 +379,7 @@ public class DiffServiceTests Assert.False(DiffService.ConnectionsEqual(a, b)); } + /// <summary>Verifies that a backup configuration edit makes the connection configurations compare as unequal.</summary> [Fact] public void ConnectionsEqual_BackupConfigurationEdit_ReturnsFalse() { @@ -373,6 +389,7 @@ public class DiffServiceTests Assert.False(DiffService.ConnectionsEqual(a, b)); } + /// <summary>Verifies that a failover retry count edit makes the connection configurations compare as unequal.</summary> [Fact] public void ConnectionsEqual_FailoverRetryCountEdit_ReturnsFalse() { @@ -382,6 +399,7 @@ public class DiffServiceTests Assert.False(DiffService.ConnectionsEqual(a, b)); } + /// <summary>Verifies that a protocol edit makes the connection configurations compare as unequal.</summary> [Fact] public void ConnectionsEqual_ProtocolEdit_ReturnsFalse() { @@ -391,14 +409,15 @@ public class DiffServiceTests Assert.False(DiffService.ConnectionsEqual(a, b)); } - // ── TemplateEngine-018: ComputeConnectionsDiff produces Added/Removed/Changed entries ── + // ── ComputeConnectionsDiff produces Added/Removed/Changed entries ── + /// <summary>Verifies that a newly added binding is reported as added in the connections diff.</summary> [Fact] public void ComputeConnectionsDiff_NewBindingAdded_ReportedAsAdded() { // First-time binding (or instance gains its first data-sourced // attribute) — old config has no Connections map, new config does. - // The pre-018 diff shape silently dropped this so operators saw + // The earlier diff shape silently dropped this so operators saw // "no changes" when the deployment package was structurally larger. var oldConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1" }; var newConfig = new FlattenedConfiguration @@ -425,6 +444,7 @@ public class DiffServiceTests Assert.Equal("OpcUa", diff[0].NewValue!.Protocol); } + /// <summary>Verifies that a null old configuration reports all connections as added.</summary> [Fact] public void ComputeConnectionsDiff_NullOldConfig_AllReportedAsAdded() { @@ -455,6 +475,7 @@ public class DiffServiceTests Assert.Equal("OpcUa", diff[0].NewValue!.Protocol); } + /// <summary>Verifies that a cleared binding is reported as removed in the connections diff.</summary> [Fact] public void ComputeConnectionsDiff_BindingCleared_ReportedAsRemoved() { @@ -479,6 +500,7 @@ public class DiffServiceTests Assert.Null(diff[0].NewValue); } + /// <summary>Verifies that an endpoint edit is reported as changed in the connections diff.</summary> [Fact] public void ComputeConnectionsDiff_EndpointEdit_ReportedAsChanged() { @@ -521,6 +543,7 @@ public class DiffServiceTests Assert.Contains("host-b", diff[0].NewValue!.ConfigurationJson); } + /// <summary>Verifies that identical connections produce no diff entries.</summary> [Fact] public void ComputeConnectionsDiff_IdenticalConnections_NoEntries() { @@ -539,10 +562,11 @@ public class DiffServiceTests Assert.Empty(diff); } - // ── TemplateEngine-018: ComputeDiff wires ComputeConnectionsDiff into the + // ── ComputeDiff wires ComputeConnectionsDiff into the // public ConfigurationDiff.ConnectionChanges slot so standalone connection - // protocol/endpoint/failover drift surfaces in the deployment diff (#10). ── + // protocol/endpoint/failover drift surfaces in the deployment diff. ── + /// <summary>Verifies that protocol, endpoint and failover changes populate the connection changes of the diff.</summary> [Fact] public void ComputeDiff_ConnectionProtocolAndEndpointAndFailoverChange_PopulatesConnectionChanges() { @@ -591,6 +615,7 @@ public class DiffServiceTests Assert.Equal(5, entry.NewValue!.FailoverRetryCount); } + /// <summary>Verifies that a diff that differs only in its connections reports changes.</summary> [Fact] public void ComputeDiff_OnlyConnectionDiffers_HasChangesIsTrue() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceMergeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceMergeTests.cs index ae90b442..be762428 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceMergeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceMergeTests.cs @@ -12,6 +12,7 @@ public class FlatteningServiceMergeTests { // ── MergeHiLoConfig ──────────────────────────────────────────────────── + /// <summary>Verifies that merging prefers derived keys while inherited keys that are not overridden survive.</summary> [Fact] public void MergeHiLoConfig_DerivedKeysWin_InheritedKeysSurvive() { @@ -29,6 +30,7 @@ public class FlatteningServiceMergeTests Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble()); // inherited } + /// <summary>Verifies that a derived config can override the attribute name.</summary> [Fact] public void MergeHiLoConfig_DerivedCanOverrideAttribute() { @@ -42,6 +44,7 @@ public class FlatteningServiceMergeTests Assert.Equal(50, doc.RootElement.GetProperty("hi").GetDouble()); } + /// <summary>Verifies that merging returns the inherited config when the derived config is null.</summary> [Fact] public void MergeHiLoConfig_DerivedNull_ReturnsInherited() { @@ -52,6 +55,7 @@ public class FlatteningServiceMergeTests Assert.Equal(inherited, result); } + /// <summary>Verifies that merging returns the derived config when the inherited config is null.</summary> [Fact] public void MergeHiLoConfig_InheritedNull_ReturnsDerived() { @@ -62,12 +66,14 @@ public class FlatteningServiceMergeTests Assert.Equal(derived, result); } + /// <summary>Verifies that merging returns null when both configs are null.</summary> [Fact] public void MergeHiLoConfig_BothNull_ReturnsNull() { Assert.Null(FlatteningService.MergeHiLoConfig(null, null)); } + /// <summary>Verifies that merging falls back to the derived config when the inherited config is malformed.</summary> [Fact] public void MergeHiLoConfig_MalformedInherited_FallsBackToDerived() { @@ -79,6 +85,7 @@ public class FlatteningServiceMergeTests Assert.Equal(derived, result); } + /// <summary>Verifies that a new derived key is added while the remaining inherited keys are preserved.</summary> [Fact] public void MergeHiLoConfig_DerivedAddsNewKey_PreservesInheritedRest() { @@ -96,6 +103,7 @@ public class FlatteningServiceMergeTests // ── DiffHiLoConfig ───────────────────────────────────────────────────── + /// <summary>Verifies that diffing identical configs returns null.</summary> [Fact] public void DiffHiLoConfig_NoChanges_ReturnsNull() { @@ -103,6 +111,7 @@ public class FlatteningServiceMergeTests Assert.Null(FlatteningService.DiffHiLoConfig(both, both)); } + /// <summary>Verifies that diffing returns only the key that changed.</summary> [Fact] public void DiffHiLoConfig_ChangedKey_ReturnsOnlyChangedKey() { @@ -118,6 +127,7 @@ public class FlatteningServiceMergeTests Assert.Equal(90, prop.Value.GetDouble()); } + /// <summary>Verifies that a newly added key appears in the diff.</summary> [Fact] public void DiffHiLoConfig_NewKey_AddedToDiff() { @@ -132,6 +142,7 @@ public class FlatteningServiceMergeTests Assert.False(doc.RootElement.TryGetProperty("hi", out _)); } + /// <summary>Verifies that diffing against a null inherited config returns the edited config verbatim.</summary> [Fact] public void DiffHiLoConfig_NullInherited_ReturnsEditedVerbatim() { @@ -139,12 +150,14 @@ public class FlatteningServiceMergeTests Assert.Equal(edited, FlatteningService.DiffHiLoConfig(null, edited)); } + /// <summary>Verifies that diffing a null edited config returns null.</summary> [Fact] public void DiffHiLoConfig_NullEdited_ReturnsNull() { Assert.Null(FlatteningService.DiffHiLoConfig(@"{""hi"":80}", null)); } + /// <summary>Verifies that diffing ignores differences in string escaping.</summary> [Fact] public void DiffHiLoConfig_IgnoresStringEscapeDifferences() { @@ -158,6 +171,7 @@ public class FlatteningServiceMergeTests Assert.Null(diff); // no real change once values are decoded } + /// <summary>Verifies that diffing ignores differences in numeric formatting.</summary> [Fact] public void DiffHiLoConfig_IgnoresNumericFormatDifferences() { @@ -167,6 +181,7 @@ public class FlatteningServiceMergeTests Assert.Null(FlatteningService.DiffHiLoConfig(inherited, edited)); } + /// <summary>Verifies that a diff merged back onto the inherited config reproduces the edited config.</summary> [Fact] public void DiffHiLoConfig_RoundTripsThroughMerge() { @@ -227,6 +242,7 @@ public class FlatteningServiceMergeTests return result.Value!; } + /// <summary>Verifies that an instance alarm override merges its high/low setpoints into the flattened alarm.</summary> [Fact] public void Flatten_InstanceAlarmOverride_HiLo_MergesSetpoints() { @@ -250,6 +266,7 @@ public class FlatteningServiceMergeTests Assert.Equal("Override", alarm.Source); } + /// <summary>Verifies that an instance alarm override replaces the alarm priority.</summary> [Fact] public void Flatten_InstanceAlarmOverride_OverridesPriority() { @@ -268,6 +285,7 @@ public class FlatteningServiceMergeTests Assert.Equal("Override", alarm.Source); } + /// <summary>Verifies that an override targeting a locked alarm is silently ignored.</summary> [Fact] public void Flatten_InstanceAlarmOverride_LockedAlarm_OverrideSilentlyIgnored() { @@ -291,6 +309,7 @@ public class FlatteningServiceMergeTests Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble()); // not overridden } + /// <summary>Verifies that an override naming an unknown alarm has no effect.</summary> [Fact] public void Flatten_InstanceAlarmOverride_UnknownName_DoesNothing() { @@ -312,6 +331,7 @@ public class FlatteningServiceMergeTests Assert.NotEqual("Override", alarm.Source); } + /// <summary>Verifies that an override on a binary-trigger alarm replaces the whole trigger config.</summary> [Fact] public void Flatten_InstanceAlarmOverride_BinaryTrigger_ReplacesWholeConfig() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs index dc45198b..eff33db7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs @@ -19,6 +19,7 @@ public class FlatteningServiceTests return t; } + /// <summary>Verifies that flattening an empty template chain returns a failure.</summary> [Fact] public void Flatten_EmptyTemplateChain_ReturnsFailure() { @@ -34,6 +35,7 @@ public class FlatteningServiceTests Assert.Contains("empty", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that flattening a single template resolves its attributes.</summary> [Fact] public void Flatten_SingleTemplate_ResolvesAttributes() { @@ -56,6 +58,7 @@ public class FlatteningServiceTests Assert.Equal("Status", result.Value.Attributes[0].CanonicalName); } + /// <summary>Verifies that in an inheritance chain the derived template's value overrides the base.</summary> [Fact] public void Flatten_InheritanceChain_DerivedOverridesBase() { @@ -83,6 +86,7 @@ public class FlatteningServiceTests Assert.Equal("200.0", speed.Value); // Child's value wins } + /// <summary>Verifies that a locked attribute is not overridden by a derived template.</summary> [Fact] public void Flatten_LockedAttribute_NotOverriddenByDerived() { @@ -105,6 +109,7 @@ public class FlatteningServiceTests Assert.Equal("locked", locked.Value); // Base locked value preserved } + /// <summary>Verifies that an instance override is applied to an unlocked attribute.</summary> [Fact] public void Flatten_InstanceOverride_AppliedToUnlockedAttribute() { @@ -127,6 +132,7 @@ public class FlatteningServiceTests Assert.Equal("Override", attr.Source); } + /// <summary>Verifies that an instance override is skipped for a locked attribute.</summary> [Fact] public void Flatten_InstanceOverride_SkippedForLockedAttribute() { @@ -148,6 +154,7 @@ public class FlatteningServiceTests Assert.Equal("original", attr.Value); // Lock honored } + /// <summary>Verifies that members of a composed module receive path-qualified names.</summary> [Fact] public void Flatten_ComposedModule_PathQualifiedNames() { @@ -185,6 +192,7 @@ public class FlatteningServiceTests Assert.Contains(result.Value.Scripts, s => s.CanonicalName == "MainPump.Start"); } + /// <summary>Verifies that connection bindings are resolved correctly.</summary> [Fact] public void Flatten_ConnectionBindings_ResolvedCorrectly() { @@ -217,6 +225,7 @@ public class FlatteningServiceTests Assert.Equal("OpcUa", attr.BoundDataConnectionProtocol); } + /// <summary>Verifies that alarms are resolved from the template chain.</summary> [Fact] public void Flatten_Alarms_ResolvedFromChain() { @@ -242,6 +251,7 @@ public class FlatteningServiceTests Assert.Equal("RangeViolation", result.Value.Alarms[0].TriggerType); } + /// <summary>Verifies that instance metadata is set correctly on the flattened configuration.</summary> [Fact] public void Flatten_InstanceMetadata_SetCorrectly() { @@ -263,6 +273,7 @@ public class FlatteningServiceTests Assert.Equal(3, result.Value.AreaId); } + /// <summary>Verifies that an inherited attribute on a derived template keeps the base value.</summary> [Fact] public void Flatten_InheritedAttributeOnDerived_BaseValueWins() { @@ -290,6 +301,7 @@ public class FlatteningServiceTests Assert.Equal("100.0", setPoint.Value); } + /// <summary>Verifies that an attribute overridden on a derived template takes the derived value.</summary> [Fact] public void Flatten_OverriddenAttributeOnDerived_DerivedValueWins() { @@ -317,6 +329,7 @@ public class FlatteningServiceTests Assert.Equal("150.0", setPoint.Value); } + /// <summary>Verifies that overriding an attribute locked in the derived template fails.</summary> [Fact] public void Flatten_LockedInDerivedOverride_Fails() { @@ -349,6 +362,7 @@ public class FlatteningServiceTests Assert.Contains("SetPoint", result.Error); } + /// <summary>Verifies that an inherited script on a derived template keeps the base code.</summary> [Fact] public void Flatten_InheritedScriptOnDerived_BaseCodeWins() { @@ -371,8 +385,9 @@ public class FlatteningServiceTests Assert.Equal("return base;", script.Code); } - // ── M2.5 (#9): per-script execution timeout threads to ResolvedScript ─── + // ── Per-script execution timeout threads to ResolvedScript ─── + /// <summary>Verifies that a script's execution timeout carries through to the resolved script.</summary> [Fact] public void Flatten_SingleTemplate_ScriptExecutionTimeoutSecondsThreadsThrough() { @@ -395,6 +410,7 @@ public class FlatteningServiceTests Assert.Null(dflt.ExecutionTimeoutSeconds); } + /// <summary>Verifies that a derived script override's execution timeout follows the winning row.</summary> [Fact] public void Flatten_DerivedScriptOverride_ExecutionTimeoutFollowsWinningRow() { @@ -427,6 +443,7 @@ public class FlatteningServiceTests Assert.Equal(3, script.ExecutionTimeoutSeconds); } + /// <summary>Verifies that an inherited script's execution timeout follows the base row.</summary> [Fact] public void Flatten_InheritedScriptOnDerived_ExecutionTimeoutFollowsBaseRow() { @@ -459,8 +476,9 @@ public class FlatteningServiceTests Assert.Equal(10, script.ExecutionTimeoutSeconds); } - // ── TemplateEngine-002: per-slot alarm override ──────────────────────── + // ── Per-slot alarm override ──────────────────────── + /// <summary>Verifies that an inherited alarm on a derived template keeps the base value.</summary> [Fact] public void Flatten_InheritedAlarmOnDerived_BaseValueWins() { @@ -495,6 +513,7 @@ public class FlatteningServiceTests Assert.Equal("{\"attributeName\":\"Temp\",\"high\":100}", alarm.TriggerConfiguration); } + /// <summary>Verifies that the MES-reportable flag keeps the base value through a derived chain.</summary> [Fact] public void Flatten_MesReportable_DerivedChain_BaseValueWins() { @@ -532,6 +551,7 @@ public class FlatteningServiceTests Assert.True(alarm.IsMesReportable); } + /// <summary>Verifies that an alarm overridden on a derived template takes the derived value.</summary> [Fact] public void Flatten_OverriddenAlarmOnDerived_DerivedValueWins() { @@ -566,6 +586,7 @@ public class FlatteningServiceTests Assert.Equal("{\"attributeName\":\"Temp\",\"high\":120}", alarm.TriggerConfiguration); } + /// <summary>Verifies that overriding an alarm locked in the derived template fails.</summary> [Fact] public void Flatten_LockedInDerivedAlarmOverride_Fails() { @@ -602,6 +623,7 @@ public class FlatteningServiceTests // ── MES reportable flag: template value + instance override ──────────── + /// <summary>Verifies that the template's MES-reportable value is carried to the resolved alarm.</summary> [Fact] public void Flatten_MesReportable_TemplateValueCarried() { @@ -633,6 +655,7 @@ public class FlatteningServiceTests Assert.False(result.Value.Alarms.First(a => a.CanonicalName == "LowTemp").IsMesReportable); } + /// <summary>Verifies that an instance override of false wins for the MES-reportable flag.</summary> [Fact] public void Flatten_MesReportable_OverrideFalseWins() { @@ -664,6 +687,7 @@ public class FlatteningServiceTests Assert.Equal("Override", alarm.Source); } + /// <summary>Verifies that a null instance override inherits the template's MES-reportable value.</summary> [Fact] public void Flatten_MesReportable_OverrideNullInherits() { @@ -695,8 +719,9 @@ public class FlatteningServiceTests Assert.True(alarm.IsMesReportable); } - // ── TemplateEngine-001: deep composition nesting ─────────────────────── + // ── Deep composition nesting ─────────────────────── + /// <summary>Verifies that attributes, alarms and scripts all resolve through a three-level composition.</summary> [Fact] public void Flatten_ThreeLevelComposition_AttributesAlarmsScriptsAllResolved() { @@ -749,6 +774,7 @@ public class FlatteningServiceTests s => s.CanonicalName == "MainPump.DriveMotor.FrontBearing.MonitorBearing"); } + /// <summary>Verifies that a nested composed alarm's trigger attribute is prefixed with the module path.</summary> [Fact] public void Flatten_NestedComposedAlarm_TriggerAttributePrefixed() { @@ -786,8 +812,9 @@ public class FlatteningServiceTests Assert.Contains("MainPump.DriveMotor.FrontBearing.Vibration", alarm.TriggerConfiguration); } - // ── TemplateEngine-004: alarm on-trigger script resolution ───────────── + // ── Alarm on-trigger script resolution ───────────── + /// <summary>Verifies that an alarm's on-trigger script resolves to its canonical name.</summary> [Fact] public void Flatten_AlarmOnTriggerScript_ResolvedToCanonicalName() { @@ -812,6 +839,7 @@ public class FlatteningServiceTests Assert.Equal("HandleAlarm", alarm.OnTriggerScriptCanonicalName); } + /// <summary>Verifies that a composed alarm's on-trigger script resolves with the module prefix.</summary> [Fact] public void Flatten_ComposedAlarmOnTriggerScript_ResolvedWithPrefix() { @@ -845,14 +873,15 @@ public class FlatteningServiceTests Assert.Equal("MainPump.PumpAlarmHandler", alarm.OnTriggerScriptCanonicalName); } - // ── TemplateEngine-016: composed-script ScriptScope.ParentPath ───────── + // ── Composed-script ScriptScope.ParentPath ───────── + /// <summary>Verifies that a nested composed script's scope carries the correct parent path.</summary> [Fact] public void Flatten_NestedComposedScript_ScopeCarriesCorrectParentPath() { // Station composes Pump (level 1); Pump composes Motor (level 2). - // The depth-1 script's parent is the root template (ParentPath ""); - // the depth-2 script's parent is the Pump module (ParentPath "MainPump"). + // The first-level script's parent is the root template (ParentPath ""); + // the second-level script's parent is the Pump module (ParentPath "MainPump"). var motor = CreateTemplate(3, "Motor"); motor.Scripts.Add(new TemplateScript("MonitorMotor", "// m") { Id = 70 }); @@ -883,10 +912,11 @@ public class FlatteningServiceTests var depth2 = result.Value.Scripts.First(s => s.CanonicalName == "MainPump.DriveMotor.MonitorMotor"); Assert.Equal("MainPump.DriveMotor", depth2.Scope.SelfPath); - // Parent module of a depth-2 script is the enclosing Pump module. + // Parent module of a second-level script is the enclosing Pump module. Assert.Equal("MainPump", depth2.Scope.ParentPath); } + /// <summary>Verifies that native alarm sources are resolved from the template.</summary> [Fact] public void Flatten_ResolvesNativeAlarmSources_FromTemplate() { @@ -903,6 +933,7 @@ public class FlatteningServiceTests Assert.Equal("ns=2;s=P1", result.Value.NativeAlarmSources[0].SourceReference); } + /// <summary>Verifies that an instance override replaces a native alarm source reference.</summary> [Fact] public void Flatten_InstanceOverride_ReplacesNativeAlarmSourceReference() { @@ -921,10 +952,11 @@ public class FlatteningServiceTests Assert.Equal("Override", result.Value.NativeAlarmSources[0].Source); } + /// <summary>Verifies that a locked native alarm source ignores an instance override.</summary> [Fact] public void Flatten_LockedNativeAlarmSource_IgnoresInstanceOverride() { - // #05-T13: a native alarm source locked at the template level must not be + // A native alarm source locked at the template level must not be // repointed by an instance override — the resolved source keeps the template // values and its Source stays "Template" (mirrors attribute/alarm lock rules). var template = CreateTemplate(1, "Base"); @@ -947,8 +979,9 @@ public class FlatteningServiceTests Assert.True(src.IsLocked); } - // ── MV-4: ElementDataType carried through flatten/override ──────────── + // ── ElementDataType carried through flatten/override ──────────── + /// <summary>Verifies that a list attribute's element data type is carried to the resolved attribute.</summary> [Fact] public void Flatten_ListAttribute_ElementDataTypeCarriedToResolvedAttribute() { @@ -976,6 +1009,7 @@ public class FlatteningServiceTests Assert.Equal("String", attr.ElementDataType); } + /// <summary>Verifies that a list attribute's element data type is preserved through an instance override.</summary> [Fact] public void Flatten_ListAttributeWithInstanceOverride_ElementDataTypePreservedThroughOverride() { @@ -1012,6 +1046,7 @@ public class FlatteningServiceTests // ── Repeated composition: same template under multiple slots ────────── + /// <summary>Verifies that a template composed twice resolves its nested members under both slots.</summary> [Fact] public void Flatten_SameTemplateComposedTwice_ResolvesNestedMembersUnderBothSlots() { @@ -1076,6 +1111,7 @@ public class FlatteningServiceTests Assert.Contains(result.Value.NativeAlarmSources, s => s.CanonicalName == "x.y2.z.Src"); } + /// <summary>Verifies that flattening a composition cycle still terminates.</summary> [Fact] public void Flatten_CompositionCycle_StillTerminates() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs index 98e2da22..4b64debf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs @@ -7,6 +7,7 @@ public class RevisionHashServiceTests { private readonly RevisionHashService _sut = new(); + /// <summary>Verifies that identical content produces the same hash.</summary> [Fact] public void ComputeHash_SameContent_SameHash() { @@ -19,6 +20,7 @@ public class RevisionHashServiceTests Assert.Equal(hash1, hash2); } + /// <summary>Verifies that different content produces a different hash.</summary> [Fact] public void ComputeHash_DifferentContent_DifferentHash() { @@ -31,6 +33,7 @@ public class RevisionHashServiceTests Assert.NotEqual(hash1, hash2); } + /// <summary>Verifies that the computed hash starts with the sha256 prefix.</summary> [Fact] public void ComputeHash_StartsWithSha256Prefix() { @@ -40,6 +43,7 @@ public class RevisionHashServiceTests Assert.StartsWith("sha256:", hash); } + /// <summary>Verifies that the hash is deterministic across runs.</summary> [Fact] public void ComputeHash_DeterministicAcrossRuns() { @@ -64,12 +68,14 @@ public class RevisionHashServiceTests Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2)); } + /// <summary>Verifies that a null configuration throws an argument-null exception.</summary> [Fact] public void ComputeHash_NullConfig_ThrowsArgumentNull() { Assert.Throws<ArgumentNullException>(() => _sut.ComputeHash(null!)); } + /// <summary>Verifies that attribute order does not affect the hash.</summary> [Fact] public void ComputeHash_AttributeOrder_DoesNotAffectHash() { @@ -99,10 +105,11 @@ public class RevisionHashServiceTests Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2)); } + /// <summary>Verifies that the hashable records declare their properties in alphabetical order.</summary> [Fact] public void HashableRecords_PropertiesDeclaredAlphabetically() { - // TemplateEngine-011: revision-hash determinism depends entirely on the + // Revision-hash determinism depends entirely on the // private Hashable* records declaring their properties in alphabetical // order (System.Text.Json emits properties in CLR declaration order and // does not sort). This guards against a contributor silently changing @@ -131,10 +138,11 @@ public class RevisionHashServiceTests } } + /// <summary>Verifies that editing an attribute description changes the hash.</summary> [Fact] public void ComputeHash_AttributeDescriptionEdit_ChangesHash() { - // TemplateEngine-017: Description must be folded into the hash so that + // Description must be folded into the hash so that // edits to authoring-time documentation (which still travels in the // deployed payload) flow through the staleness indicator. var baseAttr = new ResolvedAttribute @@ -158,10 +166,11 @@ public class RevisionHashServiceTests Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter)); } + /// <summary>Verifies that editing an alarm description changes the hash.</summary> [Fact] public void ComputeHash_AlarmDescriptionEdit_ChangesHash() { - // TemplateEngine-017: same Description contract applies to alarms. + // Same Description contract applies to alarms. var baseAlarm = new ResolvedAlarm { CanonicalName = "HighTemp", @@ -182,10 +191,11 @@ public class RevisionHashServiceTests Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter)); } + /// <summary>Verifies that editing a connection endpoint changes the hash.</summary> [Fact] public void ComputeHash_ConnectionEndpointEdit_ChangesHash() { - // TemplateEngine-017: a Deployment user editing the primary endpoint + // A Deployment user editing the primary endpoint // JSON of a data connection bound to an instance must produce a // different revision hash. The connection's protocol, primary/backup // configuration JSON, and failover retry count are all part of the @@ -220,10 +230,11 @@ public class RevisionHashServiceTests Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter)); } + /// <summary>Verifies that editing a connection protocol changes the hash.</summary> [Fact] public void ComputeHash_ConnectionProtocolEdit_ChangesHash() { - // TemplateEngine-017: changing protocol must change the hash. + // Changing protocol must change the hash. var connectionsBefore = new Dictionary<string, ConnectionConfig> { ["plc1"] = new ConnectionConfig @@ -250,10 +261,11 @@ public class RevisionHashServiceTests Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter)); } + /// <summary>Verifies that connections with the same content produce the same hash.</summary> [Fact] public void ComputeHash_ConnectionsSameContent_SameHash() { - // TemplateEngine-017: equal Connections maps must yield the same hash, + // Equal Connections maps must yield the same hash, // regardless of dictionary iteration order (the SortedDictionary // projection guards this). var connections1 = new Dictionary<string, ConnectionConfig> @@ -279,10 +291,11 @@ public class RevisionHashServiceTests Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2)); } + /// <summary>Verifies that changing a list element data type changes the hash.</summary> [Fact] public void ComputeHash_ListElementDataTypeChange_ChangesHash() { - // #290: a List attribute whose element type changes (Int32 → Double) + // A List attribute whose element type changes (Int32 → Double) // while the JSON-encoded values stay identical MUST produce a different // revision hash, so the deployment is flagged stale. ElementDataType is // folded into the hashable projection precisely for this case. @@ -307,10 +320,11 @@ public class RevisionHashServiceTests Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter)); } + /// <summary>Verifies that an unchanged list element data type produces the same hash.</summary> [Fact] public void ComputeHash_SameListElementDataType_SameHash() { - // #290: two List attributes identical in every field — including + // Two List attributes identical in every field — including // ElementDataType — must hash the same. var attr = new ResolvedAttribute { @@ -332,10 +346,11 @@ public class RevisionHashServiceTests Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2)); } + /// <summary>Verifies that a scalar attribute hashes the same whether its element data type is null or unset.</summary> [Fact] public void ComputeHash_ScalarAttribute_NullVsUnsetElementDataType_SameHash() { - // #290 no-op guard: a scalar attribute has a null ElementDataType, and + // No-op guard: a scalar attribute has a null ElementDataType, and // the canonical serializer omits null properties // (DefaultIgnoreCondition = WhenWritingNull). Explicitly setting // ElementDataType = null must therefore hash identically to leaving it @@ -367,10 +382,11 @@ public class RevisionHashServiceTests Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2)); } + /// <summary>Verifies that changing a native alarm source changes the hash.</summary> [Fact] public void ComputeHash_NativeAlarmSourceChange_ChangesHash() { - // TemplateEngine-011: native alarm source bindings are part of the + // Native alarm source bindings are part of the // deployment package, so an edit to a source's binding (here the // SourceReference) MUST change the revision hash and flag the instance // stale. Before this migration native sources were absent from the hash @@ -397,10 +413,11 @@ public class RevisionHashServiceTests Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter)); } + /// <summary>Verifies that changing a native alarm source's locked flag changes the hash.</summary> [Fact] public void ComputeHash_NativeAlarmSourceIsLockedChange_ChangesHash() { - // TemplateEngine-011: IsLocked participates in the hash too. + // IsLocked participates in the hash too. var baseSource = new ResolvedNativeAlarmSource { CanonicalName = "Boiler.Alarms", @@ -422,10 +439,11 @@ public class RevisionHashServiceTests Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter)); } + /// <summary>Verifies that native alarm source order does not affect the hash.</summary> [Fact] public void ComputeHash_NativeAlarmSourceOrder_DoesNotAffectHash() { - // TemplateEngine-011: sources are sorted by CanonicalName before hashing, + // Sources are sorted by CanonicalName before hashing, // mirroring attributes/alarms/scripts — authoring order is irrelevant. var a = new ResolvedNativeAlarmSource { @@ -448,10 +466,11 @@ public class RevisionHashServiceTests Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2)); } + /// <summary>Verifies that a configuration with no native alarm sources keeps its baseline hash.</summary> [Fact] public void ComputeHash_NoNativeAlarmSources_HashUnchangedFromBaseline() { - // TemplateEngine-011 migration surgicality: adding the (null-when-empty) + // Migration surgicality: adding the (null-when-empty) // NativeAlarmSources property to the hashable projection must NOT change // the hash of any native-source-free configuration. The literal below is // the pre-migration hash of CreateConfig("Instance1", "25.0"); pinning it diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/LockEnforcerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/LockEnforcerTests.cs index 64d5c707..69e1bb6d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/LockEnforcerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/LockEnforcerTests.cs @@ -6,9 +6,10 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests; public class LockEnforcerTests { // ======================================================================== - // WP-8: Override Granularity + // Override Granularity // ======================================================================== + /// <summary>Verifies that overriding a locked attribute returns an error.</summary> [Fact] public void ValidateAttributeOverride_LockedAttribute_ReturnsError() { @@ -27,6 +28,7 @@ public class LockEnforcerTests Assert.Contains("locked", result); } + /// <summary>Verifies that changing the data type in an attribute override returns an error.</summary> [Fact] public void ValidateAttributeOverride_DataTypeChanged_ReturnsError() { @@ -45,6 +47,7 @@ public class LockEnforcerTests Assert.Contains("DataType", result); } + /// <summary>Verifies that changing the data source reference in an attribute override returns an error.</summary> [Fact] public void ValidateAttributeOverride_DataSourceReferenceChanged_ReturnsError() { @@ -63,6 +66,7 @@ public class LockEnforcerTests Assert.Contains("DataSourceReference", result); } + /// <summary>Verifies that changing only the value and description in an attribute override returns null.</summary> [Fact] public void ValidateAttributeOverride_ValueAndDescriptionChanged_ReturnsNull() { @@ -80,10 +84,11 @@ public class LockEnforcerTests Assert.Null(result); } + /// <summary>Verifies that changing the element data type in an attribute override returns an error.</summary> [Fact] public void ValidateAttributeOverride_ElementDataTypeChanged_ReturnsError() { - // MV-10 review fix: ElementDataType is the element scalar type of a List + // ElementDataType is the element scalar type of a List // attribute and is fixed by the defining level, exactly like DataType. // TemplateService.UpdateAttributeAsync never copies it onto the persisted // row, so a mismatch must be rejected before the Value (validated against @@ -103,6 +108,7 @@ public class LockEnforcerTests Assert.Contains("ElementDataType", result); } + /// <summary>Verifies that a matching element data type returns null.</summary> [Fact] public void ValidateAttributeOverride_ElementDataTypeMatches_ReturnsNull() { @@ -120,6 +126,7 @@ public class LockEnforcerTests Assert.Null(result); } + /// <summary>Verifies that both element data types being null returns null.</summary> [Fact] public void ValidateAttributeOverride_ElementDataTypeBothNull_ReturnsNull() { @@ -138,6 +145,7 @@ public class LockEnforcerTests Assert.Null(result); } + /// <summary>Verifies that overriding a locked alarm returns an error.</summary> [Fact] public void ValidateAlarmOverride_LockedAlarm_ReturnsError() { @@ -156,6 +164,7 @@ public class LockEnforcerTests Assert.Contains("locked", result); } + /// <summary>Verifies that changing the trigger type in an alarm override returns an error.</summary> [Fact] public void ValidateAlarmOverride_TriggerTypeChanged_ReturnsError() { @@ -174,6 +183,7 @@ public class LockEnforcerTests Assert.Contains("TriggerType", result); } + /// <summary>Verifies that changing the overridable fields of an alarm returns null.</summary> [Fact] public void ValidateAlarmOverride_OverridableFieldsChanged_ReturnsNull() { @@ -193,6 +203,7 @@ public class LockEnforcerTests Assert.Null(result); } + /// <summary>Verifies that overriding a locked script returns an error.</summary> [Fact] public void ValidateScriptOverride_LockedScript_ReturnsError() { @@ -205,6 +216,7 @@ public class LockEnforcerTests Assert.Contains("locked", result); } + /// <summary>Verifies that changing the name in a script override returns an error.</summary> [Fact] public void ValidateScriptOverride_NameChanged_ReturnsError() { @@ -217,6 +229,7 @@ public class LockEnforcerTests Assert.Contains("Name", result); } + /// <summary>Verifies that changing the overridable fields of a script returns null.</summary> [Fact] public void ValidateScriptOverride_OverridableFieldsChanged_ReturnsNull() { @@ -234,9 +247,10 @@ public class LockEnforcerTests } // ======================================================================== - // WP-9: Locking Rules + // Locking Rules // ======================================================================== + /// <summary>Verifies that unlocking a locked member returns an error.</summary> [Fact] public void ValidateLockChange_UnlockLocked_ReturnsError() { @@ -246,6 +260,7 @@ public class LockEnforcerTests Assert.Contains("cannot be unlocked", result); } + /// <summary>Verifies that locking an unlocked member returns null.</summary> [Fact] public void ValidateLockChange_LockUnlocked_ReturnsNull() { @@ -254,6 +269,7 @@ public class LockEnforcerTests Assert.Null(result); } + /// <summary>Verifies that keeping a member locked returns null.</summary> [Fact] public void ValidateLockChange_KeepLocked_ReturnsNull() { @@ -262,6 +278,7 @@ public class LockEnforcerTests Assert.Null(result); } + /// <summary>Verifies that keeping a member unlocked returns null.</summary> [Fact] public void ValidateLockChange_KeepUnlocked_ReturnsNull() { @@ -271,9 +288,10 @@ public class LockEnforcerTests } // ======================================================================== - // TemplateEngine-022: LockedInDerived one-way ratchet + // LockedInDerived one-way ratchet // ======================================================================== + /// <summary>Verifies that clearing LockedInDerived returns an error.</summary> [Fact] public void ValidateLockedInDerivedChange_ClearLocked_ReturnsError() { @@ -287,6 +305,7 @@ public class LockEnforcerTests Assert.Contains("cannot be cleared", result); } + /// <summary>Verifies that setting LockedInDerived on an unlocked member returns null.</summary> [Fact] public void ValidateLockedInDerivedChange_LockUnlocked_ReturnsNull() { @@ -296,6 +315,7 @@ public class LockEnforcerTests Assert.Null(result); } + /// <summary>Verifies that keeping LockedInDerived set returns null.</summary> [Fact] public void ValidateLockedInDerivedChange_KeepLocked_ReturnsNull() { @@ -304,6 +324,7 @@ public class LockEnforcerTests Assert.Null(result); } + /// <summary>Verifies that keeping LockedInDerived unset returns null.</summary> [Fact] public void ValidateLockedInDerivedChange_KeepUnlocked_ReturnsNull() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/AreaServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/AreaServiceTests.cs index 54e76c24..f14d7145 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/AreaServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/AreaServiceTests.cs @@ -12,11 +12,14 @@ public class AreaServiceTests private readonly Mock<IAuditService> _auditMock = new(); private readonly AreaService _sut; + /// <summary>Initializes the test class with a mocked repository and the service under test.</summary> public AreaServiceTests() { _sut = new AreaService(_repoMock.Object, _auditMock.Object); } + /// <summary>Verifies that creating an area with valid input succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateArea_ValidInput_ReturnsSuccess() { @@ -32,6 +35,8 @@ public class AreaServiceTests Assert.Equal(1, result.Value.SiteId); } + /// <summary>Verifies that creating an area with a duplicate name fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateArea_DuplicateName_ReturnsFailure() { @@ -47,6 +52,8 @@ public class AreaServiceTests Assert.Contains("already exists", result.Error); } + /// <summary>Verifies that creating an area with a parent validates that the parent belongs to the site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateArea_WithParent_ValidatesParentBelongsToSite() { @@ -59,6 +66,8 @@ public class AreaServiceTests Assert.Contains("does not belong", result.Error); } + /// <summary>Verifies that deleting an area with assigned instances fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteArea_WithAssignedInstances_ReturnsFailure() { @@ -78,6 +87,8 @@ public class AreaServiceTests Assert.Contains("instance(s) are assigned", result.Error); } + /// <summary>Verifies that deleting an area with child areas fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteArea_WithChildAreas_ReturnsFailure() { @@ -98,6 +109,8 @@ public class AreaServiceTests Assert.Contains("child areas", result.Error); } + /// <summary>Verifies that deleting an unconstrained area succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteArea_NoConstraints_Success() { @@ -116,6 +129,8 @@ public class AreaServiceTests _repoMock.Verify(r => r.DeleteAreaAsync(1, It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that moving an area under another area succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveArea_ToOtherArea_Succeeds() { @@ -141,6 +156,8 @@ public class AreaServiceTests _auditMock.Verify(a => a.LogAsync("admin", "Move", "Area", "3", "Leaf", It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that moving an area to the site root succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveArea_ToSiteRoot_Succeeds() { @@ -161,6 +178,8 @@ public class AreaServiceTests Assert.Null(result.Value.ParentAreaId); } + /// <summary>Verifies that moving an area under itself fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveArea_ToSelf_Fails() { @@ -173,6 +192,8 @@ public class AreaServiceTests Assert.Contains("its own parent", result.Error); } + /// <summary>Verifies that moving an area under one of its descendants fails with a cycle error.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveArea_ToDescendant_FailsWithCycleError() { @@ -195,6 +216,8 @@ public class AreaServiceTests Assert.Contains("descendants", result.Error); } + /// <summary>Verifies that moving an area to a different site fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveArea_DifferentSite_Fails() { @@ -209,6 +232,8 @@ public class AreaServiceTests Assert.Contains("same site", result.Error); } + /// <summary>Verifies that moving an area fails when its name collides at the new parent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveArea_NameCollidesAtNewParent_Fails() { @@ -232,6 +257,8 @@ public class AreaServiceTests Assert.Contains("already exists", result.Error); } + /// <summary>Verifies that moving an area to its current parent succeeds as a no-op.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveArea_SameParent_NoOpSuccess() { @@ -253,6 +280,8 @@ public class AreaServiceTests _auditMock.Verify(a => a.LogAsync(It.IsAny<string>(), "Move", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that moving an area fails when the target parent does not exist.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveArea_TargetParentMissing_Fails() { @@ -267,6 +296,8 @@ public class AreaServiceTests Assert.Contains("not found", result.Error); } + /// <summary>Verifies that deleting an area is blocked when its descendants have assigned instances.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteArea_InstancesInDescendants_Blocked() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/InstanceServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/InstanceServiceTests.cs index ae38bade..09ef518a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/InstanceServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/InstanceServiceTests.cs @@ -15,11 +15,14 @@ public class InstanceServiceTests private readonly Mock<IAuditService> _auditMock = new(); private readonly InstanceService _sut; + /// <summary>Initializes the test with a mocked repository and audit service and the service under test.</summary> public InstanceServiceTests() { _sut = new InstanceService(_repoMock.Object, _auditMock.Object); } + /// <summary>Verifies that creating an instance with valid input returns success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateInstance_ValidInput_ReturnsSuccess() { @@ -38,6 +41,8 @@ public class InstanceServiceTests _repoMock.Verify(r => r.AddInstanceAsync(It.IsAny<Instance>(), It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that creating an instance with a duplicate name returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateInstance_DuplicateName_ReturnsFailure() { @@ -52,6 +57,8 @@ public class InstanceServiceTests Assert.Contains("already exists", result.Error); } + /// <summary>Verifies that creating an instance from a missing template returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateInstance_MissingTemplate_ReturnsFailure() { @@ -64,6 +71,8 @@ public class InstanceServiceTests Assert.Contains("not found", result.Error); } + /// <summary>Verifies that overriding a locked attribute returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttributeOverride_LockedAttribute_ReturnsFailure() { @@ -82,6 +91,8 @@ public class InstanceServiceTests Assert.Contains("locked", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that overriding a non-existent attribute returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttributeOverride_NonExistentAttribute_ReturnsFailure() { @@ -97,6 +108,8 @@ public class InstanceServiceTests Assert.Contains("does not exist", result.Error); } + /// <summary>Verifies that overriding an unlocked attribute returns success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttributeOverride_UnlockedAttribute_ReturnsSuccess() { @@ -120,7 +133,7 @@ public class InstanceServiceTests Assert.Equal("99", result.Value.OverrideValue); } - // --- TemplateEngine-008 regression: SetAlarmOverrideAsync validation --- + // --- SetAlarmOverrideAsync validation regression --- private static Template TemplateWithAlarms(int id, params TemplateAlarm[] alarms) { @@ -133,6 +146,8 @@ public class InstanceServiceTests return t; } + /// <summary>Verifies that overriding a non-existent alarm returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAlarmOverride_NonExistentAlarm_ReturnsFailure() { @@ -153,6 +168,8 @@ public class InstanceServiceTests It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that overriding a locked alarm on a composed template returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAlarmOverride_ComposedLockedAlarm_ReturnsFailure() { @@ -177,6 +194,8 @@ public class InstanceServiceTests It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that overriding an unlocked alarm on a composed template returns success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAlarmOverride_ComposedUnlockedAlarm_ReturnsSuccess() { @@ -202,6 +221,8 @@ public class InstanceServiceTests It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that overriding a locked alarm declared directly on the template returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAlarmOverride_DirectLockedAlarm_ReturnsFailure() { @@ -220,6 +241,8 @@ public class InstanceServiceTests Assert.Contains("locked", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that setting an alarm override stores the MES-reportable override.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAlarmOverrideAsync_StoresIsMesReportableOverride() { @@ -250,6 +273,8 @@ public class InstanceServiceTests Assert.True(result.Value.IsMesReportableOverride); } + /// <summary>Verifies that a null MES-reportable override clears the existing override.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAlarmOverrideAsync_NullIsMesReportableOverride_ClearsExistingOverride() { @@ -284,6 +309,8 @@ public class InstanceServiceTests It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that setting an alarm override updates the MES-reportable override on an existing override.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAlarmOverrideAsync_UpdatesIsMesReportableOverrideOnExisting() { @@ -315,6 +342,8 @@ public class InstanceServiceTests Assert.Equal(3, existingOverride.PriorityLevelOverride); } + /// <summary>Verifies that enabling an existing instance sets its state to enabled.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Enable_ExistingInstance_SetsEnabled() { @@ -330,6 +359,8 @@ public class InstanceServiceTests Assert.Equal(InstanceState.Enabled, result.Value.State); } + /// <summary>Verifies that disabling an existing instance sets its state to disabled.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Disable_ExistingInstance_SetsDisabled() { @@ -345,6 +376,8 @@ public class InstanceServiceTests Assert.Equal(InstanceState.Disabled, result.Value.State); } + /// <summary>Verifies that a bulk connection-binding assignment succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetConnectionBindings_BulkAssignment_Success() { @@ -364,8 +397,10 @@ public class InstanceServiceTests _repoMock.Verify(r => r.AddInstanceConnectionBindingAsync(It.IsAny<InstanceConnectionBinding>(), It.IsAny<CancellationToken>()), Times.Exactly(2)); } - // --- NJ-2: ElementDataType stamping on SetAttributeOverrideAsync --- + // --- ElementDataType stamping on SetAttributeOverrideAsync --- + /// <summary>Verifies that creating an override for a list attribute stamps the element data type.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttributeOverride_CreatePath_ListAttribute_StampsElementDataType() { @@ -398,6 +433,8 @@ public class InstanceServiceTests Assert.Equal(DataType.String, captured.ElementDataType); } + /// <summary>Verifies that updating an override for a list attribute stamps the element data type.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttributeOverride_UpdatePath_ListAttribute_StampsElementDataType() { @@ -432,6 +469,8 @@ public class InstanceServiceTests Assert.Equal(DataType.String, captured.ElementDataType); } + /// <summary>Verifies that creating an override for a scalar attribute leaves the element data type null.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SetAttributeOverride_CreatePath_ScalarAttribute_ElementDataTypeIsNull() { @@ -464,6 +503,8 @@ public class InstanceServiceTests Assert.Null(captured.ElementDataType); } + /// <summary>Verifies that assigning an instance to an area in a different site returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AssignToArea_AreaInDifferentSite_ReturnsFailure() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/SiteServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/SiteServiceTests.cs index bb6a54dc..9aba6af7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/SiteServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/SiteServiceTests.cs @@ -13,11 +13,14 @@ public class SiteServiceTests private readonly Mock<IAuditService> _auditMock = new(); private readonly SiteService _sut; + /// <summary>Initializes the test class with a mocked repository and the service under test.</summary> public SiteServiceTests() { _sut = new SiteService(_repoMock.Object, _auditMock.Object); } + /// <summary>Verifies that creating a site with valid input succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateSite_ValidInput_ReturnsSuccess() { @@ -33,6 +36,8 @@ public class SiteServiceTests Assert.Equal("SITE-001", result.Value.SiteIdentifier); } + /// <summary>Verifies that creating a site with a duplicate identifier fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateSite_DuplicateIdentifier_ReturnsFailure() { @@ -45,6 +50,8 @@ public class SiteServiceTests Assert.Contains("already exists", result.Error); } + /// <summary>Verifies that creating a site with an empty name fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateSite_EmptyName_ReturnsFailure() { @@ -54,6 +61,8 @@ public class SiteServiceTests Assert.Contains("required", result.Error); } + /// <summary>Verifies that deleting a site that has instances fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteSite_WithInstances_ReturnsFailure() { @@ -72,6 +81,8 @@ public class SiteServiceTests Assert.Contains("2 instance(s)", result.Error); } + /// <summary>Verifies that deleting a site without instances succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteSite_NoInstances_Success() { @@ -87,6 +98,8 @@ public class SiteServiceTests Assert.True(result.IsSuccess); } + /// <summary>Verifies that creating a data connection with valid input succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateDataConnection_ValidInput_Success() { @@ -101,6 +114,8 @@ public class SiteServiceTests Assert.Equal(1, result.Value.SiteId); } + /// <summary>Verifies that updating a site with valid input succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateSite_ValidInput_Success() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateDeletionServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateDeletionServiceTests.cs index 0f5980ba..0e91611e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateDeletionServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateDeletionServiceTests.cs @@ -11,11 +11,14 @@ public class TemplateDeletionServiceTests private readonly Mock<ITemplateEngineRepository> _repoMock = new(); private readonly TemplateDeletionService _sut; + /// <summary>Initializes the test class with a mocked repository and the deletion service under test.</summary> public TemplateDeletionServiceTests() { _sut = new TemplateDeletionService(_repoMock.Object); } + /// <summary>Verifies that a template with no references can be deleted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CanDeleteTemplate_NoReferences_ReturnsSuccess() { @@ -33,6 +36,8 @@ public class TemplateDeletionServiceTests Assert.True(result.IsSuccess); } + /// <summary>Verifies that a template with instances cannot be deleted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CanDeleteTemplate_WithInstances_ReturnsFailure() { @@ -56,6 +61,8 @@ public class TemplateDeletionServiceTests Assert.Contains("Inst1", result.Error); } + /// <summary>Verifies that a template with child templates cannot be deleted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CanDeleteTemplate_WithChildTemplates_ReturnsFailure() { @@ -79,6 +86,8 @@ public class TemplateDeletionServiceTests Assert.Contains("Child", result.Error); } + /// <summary>Verifies that a template composed by other templates cannot be deleted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CanDeleteTemplate_ComposedByOthers_ReturnsFailure() { @@ -103,10 +112,12 @@ public class TemplateDeletionServiceTests Assert.Contains("Composer", result.Error); } + /// <summary>Verifies that checking deletability does not issue a composition query per template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CanDeleteTemplate_DoesNotIssuePerTemplateCompositionQuery() { - // TemplateEngine-009: Check 3 must read the Compositions navigation + // The composition check must read the Compositions navigation // already loaded by GetAllTemplatesAsync rather than issuing one // GetCompositionsByTemplateIdAsync round-trip per template. var templates = new List<Template> @@ -131,6 +142,8 @@ public class TemplateDeletionServiceTests It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that checking a missing template returns a failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CanDeleteTemplate_NotFound_ReturnsFailure() { @@ -143,6 +156,8 @@ public class TemplateDeletionServiceTests Assert.Contains("not found", result.Error); } + /// <summary>Verifies that a template is deleted when all constraints are met.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteTemplate_AllConstraintsMet_Deletes() { @@ -163,6 +178,8 @@ public class TemplateDeletionServiceTests _repoMock.Verify(r => r.DeleteTemplateAsync(1, It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that all errors are reported when multiple constraints are violated.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CanDeleteTemplate_MultipleConstraints_AllErrorsReported() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs index 6b146303..856bb2cb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs @@ -13,11 +13,14 @@ public class TemplateFolderServiceTests private readonly Mock<IAuditService> _auditMock = new(); private readonly TemplateFolderService _sut; + /// <summary>Initializes the test class with a mocked repository and the service under test.</summary> public TemplateFolderServiceTests() { _sut = new TemplateFolderService(_repoMock.Object, _auditMock.Object); } + /// <summary>Verifies that creating a folder with valid input succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateFolder_ValidInput_ReturnsSuccess() { @@ -30,10 +33,12 @@ public class TemplateFolderServiceTests Assert.Equal("Dev", result.Value.Name); Assert.Null(result.Value.ParentFolderId); _repoMock.Verify(r => r.AddFolderAsync(It.IsAny<TemplateFolder>(), It.IsAny<CancellationToken>()), Times.Once); - // Two saves: the folder entity, then the staged audit row (TemplateEngine-024). + // Two saves: the folder entity, then the staged audit row. _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Exactly(2)); } + /// <summary>Verifies that creating a folder with an empty name fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateFolder_EmptyName_ReturnsFailure() { @@ -43,6 +48,8 @@ public class TemplateFolderServiceTests Assert.Contains("required", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that creating a folder whose name duplicates a sibling's, ignoring case, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateFolder_DuplicateSiblingName_CaseInsensitive_ReturnsFailure() { @@ -58,6 +65,8 @@ public class TemplateFolderServiceTests Assert.Contains("already exists", result.Error); } + /// <summary>Verifies that creating a folder under a missing parent fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateFolder_ParentNotFound_ReturnsFailure() { @@ -70,6 +79,8 @@ public class TemplateFolderServiceTests Assert.Contains("not found", result.Error); } + /// <summary>Verifies that renaming a folder with valid input succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RenameFolder_ValidInput_ReturnsSuccess() { @@ -84,6 +95,8 @@ public class TemplateFolderServiceTests Assert.Equal("New", result.Value.Name); } + /// <summary>Verifies that renaming a missing folder fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RenameFolder_NotFound_ReturnsFailure() { @@ -95,6 +108,8 @@ public class TemplateFolderServiceTests Assert.True(result.IsFailure); } + /// <summary>Verifies that renaming a folder to a sibling's name fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RenameFolder_DuplicateSibling_ReturnsFailure() { @@ -110,6 +125,8 @@ public class TemplateFolderServiceTests Assert.Contains("already exists", result.Error); } + /// <summary>Verifies that moving a folder under a valid parent succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveFolder_ValidParent_ReturnsSuccess() { @@ -126,6 +143,8 @@ public class TemplateFolderServiceTests Assert.Equal(2, result.Value.ParentFolderId); } + /// <summary>Verifies that moving a folder onto itself fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveFolder_OntoSelf_ReturnsFailure() { @@ -138,6 +157,8 @@ public class TemplateFolderServiceTests Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that moving a folder onto one of its descendants fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveFolder_OntoDescendant_ReturnsFailure() { @@ -156,6 +177,8 @@ public class TemplateFolderServiceTests Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that moving a folder to the root succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveFolder_ToRoot_ReturnsSuccess() { @@ -170,6 +193,8 @@ public class TemplateFolderServiceTests Assert.Null(result.Value.ParentFolderId); } + /// <summary>Verifies that moving a folder in a graph with a pre-existing cycle fails without looping forever.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveFolder_PreExistingCycleInGraph_ReturnsFailure_DoesNotInfiniteLoop() { @@ -189,6 +214,8 @@ public class TemplateFolderServiceTests Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that deleting an empty folder succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteFolder_Empty_ReturnsSuccess() { @@ -205,6 +232,8 @@ public class TemplateFolderServiceTests _repoMock.Verify(r => r.DeleteFolderAsync(1, It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that deleting a folder with child folders fails and reports the counts.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteFolder_HasChildFolders_ReturnsFailure_WithCounts() { @@ -222,6 +251,8 @@ public class TemplateFolderServiceTests Assert.Contains("1 subfolder", result.Error); } + /// <summary>Verifies that deleting a folder that contains templates fails and reports the counts.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteFolder_HasTemplates_ReturnsFailure_WithCounts() { @@ -243,6 +274,8 @@ public class TemplateFolderServiceTests // ReorderFolderAsync — sibling reorder by SortOrder (Up = lower, Down = higher) // ======================================================================== + /// <summary>Verifies that reordering a folder up swaps its sort order with the previous sibling.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReorderFolder_Up_SwapsSortOrderWithPreviousSibling() { @@ -262,6 +295,8 @@ public class TemplateFolderServiceTests Assert.Equal(2, c.SortOrder); } + /// <summary>Verifies that reordering a folder down swaps its sort order with the next sibling.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReorderFolder_Down_SwapsSortOrderWithNextSibling() { @@ -281,6 +316,8 @@ public class TemplateFolderServiceTests Assert.Equal(1, c.SortOrder); } + /// <summary>Verifies that reordering the top folder up is a no-op.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReorderFolder_UpAtTop_IsNoOp() { @@ -302,6 +339,8 @@ public class TemplateFolderServiceTests It.IsAny<string>(), It.IsAny<string>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that reordering the bottom folder down is a no-op.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReorderFolder_DownAtBottom_IsNoOp() { @@ -320,6 +359,8 @@ public class TemplateFolderServiceTests _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that reordering only affects folders within the same parent.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReorderFolder_OnlyReordersWithinSameParent() { @@ -343,6 +384,8 @@ public class TemplateFolderServiceTests Assert.Equal(1, b.SortOrder); } + /// <summary>Verifies that moving one of three siblings down and then up yields the expected order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReorderFolder_ThreeSiblings_DownThenUp_YieldsExpectedOrder() { @@ -367,6 +410,8 @@ public class TemplateFolderServiceTests Assert.Equal(new[] { "B", "C", "A" }, ordered2); } + /// <summary>Verifies that reordering a missing folder fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReorderFolder_NotFound_ReturnsFailure() { @@ -383,6 +428,8 @@ public class TemplateFolderServiceTests // CreateFolderAsync — distinct SortOrder assignment (max+1 among siblings) // ======================================================================== + /// <summary>Verifies that the first sibling folder gets sort order zero.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateFolder_FirstSibling_GetsSortOrderZero() { @@ -401,6 +448,8 @@ public class TemplateFolderServiceTests Assert.Equal(0, captured!.SortOrder); } + /// <summary>Verifies that a second sibling folder under the same parent gets sort order one.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateFolder_SecondSiblingUnderSameParent_GetsSortOrderOne() { @@ -420,6 +469,8 @@ public class TemplateFolderServiceTests Assert.Equal(1, captured!.SortOrder); } + /// <summary>Verifies that folder sort orders do not interfere across different parents.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateFolder_SortOrdersDoNotInterferAcrossParents() { @@ -442,6 +493,8 @@ public class TemplateFolderServiceTests Assert.Equal(0, captured!.SortOrder); } + /// <summary>Verifies that reordering two sibling folders visibly changes their order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateFolder_TwoSiblingsReorderIsVisiblyEffective() { @@ -471,6 +524,8 @@ public class TemplateFolderServiceTests _repoMock.Verify(r => r.UpdateFolderAsync(second, It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that reordering a folder persists the change and writes an audit entry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReorderFolder_Persists_And_WritesAudit() { @@ -486,7 +541,7 @@ public class TemplateFolderServiceTests // Both swapped siblings persisted. _repoMock.Verify(r => r.UpdateFolderAsync(a, It.IsAny<CancellationToken>()), Times.Once); _repoMock.Verify(r => r.UpdateFolderAsync(b, It.IsAny<CancellationToken>()), Times.Once); - // Two saves: the swapped siblings, then the staged audit row (TemplateEngine-024). + // Two saves: the swapped siblings, then the staged audit row. _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Exactly(2)); // Audit entry written (mirrors Move/Rename audit assertions). _auditMock.Verify(au => au.LogAsync("admin", "Reorder", "TemplateFolder", "2", "B", @@ -494,11 +549,13 @@ public class TemplateFolderServiceTests } // ======================================================================== - // TemplateEngine-024 — each folder mutator must SaveChanges *after* LogAsync + // Each folder mutator must SaveChanges *after* LogAsync // so the staged audit row is persisted (and not discarded when the scope is // disposed). Verified by tracking call order across the mutators. // ======================================================================== + /// <summary>Verifies that creating a folder persists the audit row by saving after logging.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateFolder_PersistsAuditRow_SaveFollowsLog() { @@ -508,6 +565,8 @@ public class TemplateFolderServiceTests .ReturnsAsync(new List<TemplateFolder>()))); } + /// <summary>Verifies that renaming a folder persists the audit row by saving after logging.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RenameFolder_PersistsAuditRow_SaveFollowsLog() { @@ -522,6 +581,8 @@ public class TemplateFolderServiceTests })); } + /// <summary>Verifies that moving a folder persists the audit row by saving after logging.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveFolder_PersistsAuditRow_SaveFollowsLog() { @@ -538,6 +599,8 @@ public class TemplateFolderServiceTests })); } + /// <summary>Verifies that reordering a folder persists the audit row by saving after logging.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReorderFolder_PersistsAuditRow_SaveFollowsLog() { @@ -553,6 +616,8 @@ public class TemplateFolderServiceTests })); } + /// <summary>Verifies that deleting a folder persists the audit row by saving after logging.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteFolder_PersistsAuditRow_SaveFollowsLog() { @@ -593,7 +658,7 @@ public class TemplateFolderServiceTests /// <summary> /// Asserts the mutator logged an audit entry and then issued a /// <c>SaveChangesAsync</c> after it — proving the staged audit row is - /// persisted rather than discarded (TemplateEngine-024). + /// persisted rather than discarded. /// </summary> private static void AssertAuditRowPersisted(List<string> calls) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/SharedScriptServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/SharedScriptServiceTests.cs index ce690088..644fb125 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/SharedScriptServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/SharedScriptServiceTests.cs @@ -11,6 +11,7 @@ public class SharedScriptServiceTests private readonly Mock<IAuditService> _auditMock; private readonly SharedScriptService _service; + /// <summary>Initializes the test class with mocked repository and audit services.</summary> public SharedScriptServiceTests() { _repoMock = new Mock<ITemplateEngineRepository>(); @@ -19,6 +20,9 @@ public class SharedScriptServiceTests _service = new SharedScriptService(_repoMock.Object, _auditMock.Object); } + /// <summary>Verifies that create and update both reject invalid code before saving.</summary> + /// <param name="code">The invalid script code.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData("return System.IO.File.ReadAllText(\"x\");")] [InlineData("return noSuchVariable;")] @@ -34,6 +38,8 @@ public class SharedScriptServiceTests _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that creating a shared script succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateSharedScript_Success() { @@ -48,6 +54,8 @@ public class SharedScriptServiceTests _repoMock.Verify(r => r.AddSharedScriptAsync(It.IsAny<SharedScript>(), It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that creating a shared script with an empty name fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateSharedScript_EmptyName_Fails() { @@ -56,6 +64,8 @@ public class SharedScriptServiceTests Assert.Contains("name is required", result.Error); } + /// <summary>Verifies that creating a shared script with empty code fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateSharedScript_EmptyCode_Fails() { @@ -64,6 +74,8 @@ public class SharedScriptServiceTests Assert.Contains("code is required", result.Error); } + /// <summary>Verifies that creating a shared script with a duplicate name fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateSharedScript_DuplicateName_Fails() { @@ -76,6 +88,8 @@ public class SharedScriptServiceTests Assert.Contains("already exists", result.Error); } + /// <summary>Verifies that creating a shared script with unbalanced braces fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateSharedScript_UnbalancedBraces_Fails() { @@ -88,6 +102,8 @@ public class SharedScriptServiceTests Assert.Contains("Syntax error", result.Error); } + /// <summary>Verifies that updating a shared script succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateSharedScript_Success() { @@ -100,6 +116,8 @@ public class SharedScriptServiceTests Assert.Equal("return 42;", result.Value.Code); } + /// <summary>Verifies that updating a shared script that does not exist fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateSharedScript_NotFound_Fails() { @@ -111,6 +129,8 @@ public class SharedScriptServiceTests Assert.Contains("not found", result.Error); } + /// <summary>Verifies that deleting a shared script succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteSharedScript_Success() { @@ -123,6 +143,8 @@ public class SharedScriptServiceTests _repoMock.Verify(r => r.DeleteSharedScriptAsync(1, It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that deleting a shared script that does not exist fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteSharedScript_NotFound_Fails() { @@ -136,6 +158,9 @@ public class SharedScriptServiceTests // Syntax validation unit tests + /// <summary>Verifies that syntax validation of valid code returns the expected result.</summary> + /// <param name="code">The script code to validate.</param> + /// <param name="expected">The expected validation result, or null when the code is valid.</param> [Theory] [InlineData("return 42;", null)] [InlineData("public void Run() { }", null)] @@ -146,6 +171,8 @@ public class SharedScriptServiceTests Assert.Equal(expected, SharedScriptService.ValidateSyntax(code)); } + /// <summary>Verifies that syntax validation of invalid code returns an error.</summary> + /// <param name="code">The invalid script code.</param> [Theory] [InlineData("public void Run() {")] [InlineData("return a + b);")] @@ -157,8 +184,10 @@ public class SharedScriptServiceTests Assert.Contains("Syntax error", result); } - // --- TemplateEngine-007 regression: string/comment-literal awareness --- + // --- Regression: string/comment-literal awareness --- + /// <summary>Verifies that delimiters inside strings or comments do not cause a syntax validation error.</summary> + /// <param name="code">The script code containing delimiters inside a string or comment.</param> [Theory] [InlineData("var s = \"a } brace\"; { }")] // brace inside a normal string [InlineData("var s = \"a ) paren ] bracket\";")] // paren/bracket inside a string @@ -172,14 +201,16 @@ public class SharedScriptServiceTests Assert.Null(SharedScriptService.ValidateSyntax(code)); } - // --- TemplateEngine-020 regression: audit row carries the real script Id --- + // --- Regression: audit row carries the real script Id --- + /// <summary>Verifies that the create audit row carries the real script id rather than a literal zero.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateSharedScript_AuditRowCarriesRealScriptIdNotLiteralZero() { - // Pre-020: AddSharedScriptAsync → LogAsync("0", ...) → SaveChangesAsync. + // Before the fix: AddSharedScriptAsync → LogAsync("0", ...) → SaveChangesAsync. // The audit row was queued with EntityId = "0" because EF Core had - // not yet populated the auto-generated key. Post-020: save first, + // not yet populated the auto-generated key. After the fix: save first, // then log with the real Id, then save the audit row. _repoMock.Setup(r => r.GetSharedScriptByNameAsync("Helpers", It.IsAny<CancellationToken>())) .ReturnsAsync((SharedScript?)null); diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateInheritanceResolverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateInheritanceResolverTests.cs index be39c767..f90bd533 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateInheritanceResolverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateInheritanceResolverTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests; /// <summary> -/// Tests for the read-only authoring resolve service (M9/T26a). The resolved +/// Tests for the read-only authoring resolve service. The resolved /// inherited member set MUST agree with what <see cref="FlatteningService"/> /// produces on deploy — the precedence is exercised both directly and via a /// cross-check against the flattener. @@ -29,6 +29,7 @@ public class TemplateInheritanceResolverTests // ── (a) A→B→C chain: resolving C returns members inherited transitively from A ── + /// <summary>Verifies that a three-level chain includes a transitively inherited grandparent member.</summary> [Fact] public void Resolve_ThreeLevelChain_IncludesTransitivelyInheritedGrandparentMember() { @@ -62,6 +63,7 @@ public class TemplateInheritanceResolverTests // ── (b) a base member ADDED to A after C was created appears in C's resolved set ── + /// <summary>Verifies that a base member added after the derived template was created appears in the resolved set.</summary> [Fact] public void Resolve_BaseMemberAddedAfterDerivedCreated_AppearsInResolvedSet() { @@ -86,6 +88,7 @@ public class TemplateInheritanceResolverTests // ── (c) a locked member is flagged ── + /// <summary>Verifies that locked and base-locked members are flagged.</summary> [Fact] public void Resolve_LockedAndBaseLockedMembers_Flagged() { @@ -107,6 +110,7 @@ public class TemplateInheritanceResolverTests // ── (d) own override wins over inherited ── + /// <summary>Verifies that an own override wins over an inherited member.</summary> [Fact] public void Resolve_OwnOverride_WinsOverInherited() { @@ -125,6 +129,7 @@ public class TemplateInheritanceResolverTests Assert.False(speed.IsInherited); } + /// <summary>Verifies that an inherited placeholder does not shadow the live base value.</summary> [Fact] public void Resolve_InheritedPlaceholder_DoesNotShadowLiveBaseValue() { @@ -146,6 +151,7 @@ public class TemplateInheritanceResolverTests // ── (e) staleness summary true when stored rows differ; false when in sync ── + /// <summary>Verifies that the staleness summary is true when a base-added member is missing from the stored rows.</summary> [Fact] public void Resolve_StalenessSummary_TrueWhenBaseAddedMemberMissingFromStoredRows() { @@ -163,6 +169,7 @@ public class TemplateInheritanceResolverTests Assert.Equal(1, resolved.Staleness.DifferingMemberCount); } + /// <summary>Verifies that the staleness summary is false when the stored rows are in sync.</summary> [Fact] public void Resolve_StalenessSummary_FalseWhenStoredRowsInSync() { @@ -179,6 +186,7 @@ public class TemplateInheritanceResolverTests Assert.Equal(0, resolved.Staleness.DifferingMemberCount); } + /// <summary>Verifies that the staleness summary is true when a stored inherited value is stale.</summary> [Fact] public void Resolve_StalenessSummary_TrueWhenStoredInheritedValueIsStale() { @@ -196,6 +204,7 @@ public class TemplateInheritanceResolverTests // ── (f) a composition-derived template resolves sanely ── + /// <summary>Verifies that a composition-derived template resolves its own and inherited members.</summary> [Fact] public void Resolve_CompositionDerivedTemplate_ResolvesOwnAndInheritedMembers() { @@ -216,6 +225,7 @@ public class TemplateInheritanceResolverTests Assert.Contains(resolved.Attributes, m => m.Name == "LocalTweak" && !m.IsInherited); } + /// <summary>Verifies that resolving an unknown template returns an empty set.</summary> [Fact] public void Resolve_UnknownTemplate_ReturnsEmptySet() { @@ -226,6 +236,7 @@ public class TemplateInheritanceResolverTests // ── Cross-member-type coverage: alarms, scripts, native alarm sources ── + /// <summary>Verifies that all member types are transitively inherited.</summary> [Fact] public void Resolve_AllMemberTypes_TransitivelyInherited() { @@ -250,6 +261,7 @@ public class TemplateInheritanceResolverTests // output for a no-override instance over the same chain. This is the // guarantee that the editor preview agrees with deploy. ── + /// <summary>Verifies that the resolver agrees with the flattening service for an instance with no overrides.</summary> [Fact] public void Resolve_AgreesWithFlatteningService_ForNoOverrideInstance() { @@ -295,6 +307,7 @@ public class TemplateInheritanceResolverTests // TriggerConfiguration MUST equal the FlatteningService-merged result for // a derived template that overrides only one setpoint (preview = deploy). ── + /// <summary>Verifies that the resolver agrees with the flattening service for a partial high/low override.</summary> [Fact] public void Resolve_AgreesWithFlatteningService_ForPartialHiLoOverride() { @@ -359,6 +372,7 @@ public class TemplateInheritanceResolverTests // must NOT attempt the per-setpoint merge (guard matches the flattener's // both-HiLo gate). Derived config must win verbatim (whole-replace). ── + /// <summary>Verifies that when the base is not high/low and the derived template is, the derived config wins verbatim.</summary> [Fact] public void Resolve_BaseNonHiLo_DerivedHiLo_DerivedConfigWinsVerbatim() { @@ -418,6 +432,7 @@ public class TemplateInheritanceResolverTests // ── I2: staleness must flag drift on the fields that actually matter, not // just one scalar per type. ── + /// <summary>Verifies that the staleness summary is true when a base alarm trigger config changed but the priority did not.</summary> [Fact] public void Resolve_StalenessSummary_TrueWhenBaseAlarmTriggerConfigChanged_PriorityUnchanged() { @@ -451,6 +466,7 @@ public class TemplateInheritanceResolverTests Assert.Equal(1, resolved.Staleness.DifferingMemberCount); } + /// <summary>Verifies that the staleness summary is true when a native source connection name changed but the source reference did not.</summary> [Fact] public void Resolve_StalenessSummary_TrueWhenNativeSourceConnectionNameChanged_SourceReferenceUnchanged() { @@ -481,6 +497,7 @@ public class TemplateInheritanceResolverTests Assert.Equal(1, resolved.Staleness.DifferingMemberCount); } + /// <summary>Verifies that the staleness summary is true when a native source condition filter changed but the source reference did not.</summary> [Fact] public void Resolve_StalenessSummary_TrueWhenNativeSourceConditionFilterChanged_SourceReferenceUnchanged() { @@ -511,6 +528,7 @@ public class TemplateInheritanceResolverTests Assert.Equal(1, resolved.Staleness.DifferingMemberCount); } + /// <summary>Verifies that the staleness summary is false when the alarm and native source are in sync.</summary> [Fact] public void Resolve_StalenessSummary_FalseWhenAlarmAndNativeSourceInSync() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateNamingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateNamingTests.cs index e2a35e3d..8306ba48 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateNamingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateNamingTests.cs @@ -20,6 +20,7 @@ public class TemplateNamingTests return (byId, compById); } + /// <summary>Verifies that a base template's qualified name is just its own name.</summary> [Fact] public void QualifiedName_BaseTemplate_IsJustItsName() { @@ -29,6 +30,7 @@ public class TemplateNamingTests Assert.Equal("Motor Controller", TemplateNaming.QualifiedName(motorController, byId, compById)); } + /// <summary>Verifies that a one-level derived template's qualified name is prefixed with its owner.</summary> [Fact] public void QualifiedName_OneLevelDerived_PrefixesTheOwner() { @@ -45,6 +47,7 @@ public class TemplateNamingTests Assert.Equal("Motor Controller.Pump", TemplateNaming.QualifiedName(derivedPump, byId, compById)); } + /// <summary>Verifies that a nested derived template's qualified name walks the whole chain.</summary> [Fact] public void QualifiedName_NestedDerived_WalksTheWholeChain() { @@ -71,6 +74,7 @@ public class TemplateNamingTests TemplateNaming.QualifiedName(derivedTempSensor, byId, compById)); } + /// <summary>Verifies that a derived template with a missing owner link falls back to its stored name.</summary> [Fact] public void QualifiedName_DerivedWithMissingOwnerLink_FallsBackToStoredName() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateResolverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateResolverTests.cs index c05185ba..2c20a9a3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateResolverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateResolverTests.cs @@ -6,9 +6,10 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests; public class TemplateResolverTests { // ======================================================================== - // WP-7: Path-Qualified Canonical Naming + // Path-Qualified Canonical Naming // ======================================================================== + /// <summary>Verifies that direct members are resolved without a prefix.</summary> [Fact] public void ResolveAllMembers_DirectMembers_NoPrefix() { @@ -26,6 +27,7 @@ public class TemplateResolverTests Assert.Contains(members, m => m.CanonicalName == "OnStart" && m.MemberType == "Script"); } + /// <summary>Verifies that members of a composed module are resolved with prefixed names.</summary> [Fact] public void ResolveAllMembers_ComposedModule_PrefixedNames() { @@ -42,6 +44,7 @@ public class TemplateResolverTests Assert.Equal("sensor1.Pressure", members[0].CanonicalName); } + /// <summary>Verifies that nested composition produces multi-level prefixes.</summary> [Fact] public void ResolveAllMembers_NestedComposition_MultiLevelPrefix() { @@ -62,9 +65,10 @@ public class TemplateResolverTests } // ======================================================================== - // WP-10: Inheritance Override Scope + // Inheritance Override Scope // ======================================================================== + /// <summary>Verifies that inherited members are included.</summary> [Fact] public void ResolveAllMembers_InheritedMembers_Included() { @@ -82,6 +86,7 @@ public class TemplateResolverTests Assert.Contains(members, m => m.CanonicalName == "ExtraAttr"); } + /// <summary>Verifies that a child override of a parent member uses the child's version.</summary> [Fact] public void ResolveAllMembers_ChildOverridesParentMember_UsesChildVersion() { @@ -105,6 +110,7 @@ public class TemplateResolverTests Assert.Equal(2, speedMember.SourceTemplateId); // Child's version } + /// <summary>Verifies that inherited composed modules are included.</summary> [Fact] public void ResolveAllMembers_InheritedComposedModules_Included() { @@ -123,9 +129,10 @@ public class TemplateResolverTests } // ======================================================================== - // WP-11: Composition Override Scope + // Composition Override Scope // ======================================================================== + /// <summary>Verifies that a composed member is found by its canonical name.</summary> [Fact] public void FindMemberByCanonicalName_ComposedMember_Found() { @@ -143,6 +150,7 @@ public class TemplateResolverTests Assert.False(member.IsLocked); } + /// <summary>Verifies that a locked composed member is reported as locked.</summary> [Fact] public void FindMemberByCanonicalName_LockedComposedMember_ReturnsLocked() { @@ -159,6 +167,7 @@ public class TemplateResolverTests Assert.True(member.IsLocked); } + /// <summary>Verifies that a three-level inheritance chain is returned root first.</summary> [Fact] public void BuildInheritanceChain_ThreeLevel_RootFirst() { @@ -175,16 +184,17 @@ public class TemplateResolverTests Assert.Equal("C", chain[2].Name); } - // ── TemplateEngine-019: a real Id of 0 must walk the inheritance chain ── + // ── A real Id of 0 must walk the inheritance chain ── + /// <summary>Verifies that a real template id of zero is treated as a parent reference rather than as no parent.</summary> [Fact] public void BuildInheritanceChain_RealIdZero_IsTreatedAsParentReferenceNotAsNoParent() { - // TemplateEngine-013 removed the 0-as-no-parent sentinel from - // CycleDetector; the same fix had not propagated into the resolver, + // The 0-as-no-parent sentinel was removed from + // CycleDetector, but the same fix had not propagated into the resolver, // so seeding BuildInheritanceChain with templateId == 0 returned an // empty chain even when a template with Id 0 existed (e.g. an - // import-staging row, or any in-memory test setup). Post-019: only a + // import-staging row, or any in-memory test setup). Now only a // null ParentTemplateId means "no parent", and an Id of 0 walks the // chain like any other node. var orphaned = new Template("OrphanedId0") { Id = 0 }; @@ -198,19 +208,20 @@ public class TemplateResolverTests var lookup = new Dictionary<int, Template> { [0] = orphaned }; var chain = TemplateResolver.BuildInheritanceChain(0, lookup); - // Pre-019: while (currentId != 0 && ...) was false on the first + // Previously, while (currentId != 0 && ...) was false on the first // iteration, so the chain was empty and the orphaned template's // members were silently dropped from every flatten/resolve through - // it. Post-019: the orphan is the chain. + // it. Now the orphan is the chain. Assert.Single(chain); Assert.Equal("OrphanedId0", chain[0].Name); } + /// <summary>Verifies that a parent chain passing through id zero is not truncated at zero.</summary> [Fact] public void BuildInheritanceChain_ParentChainThroughIdZero_DoesNotTruncateChainAtZero() { // A template whose real parent has Id 0 must include the Id 0 parent - // in the chain. Pre-019: `current.ParentTemplateId ?? 0` paired with + // in the chain. Previously `current.ParentTemplateId ?? 0` paired with // `currentId != 0` exited the loop as soon as the walk reached a real // Id of 0 — silently truncating the inheritance contribution from the // root template. @@ -231,12 +242,13 @@ public class TemplateResolverTests Assert.Equal("Child", chain[1].Name); } + /// <summary>Verifies that a template with a real id of zero still resolves its members.</summary> [Fact] public void ResolveAllMembers_TemplateWithRealIdZero_StillResolvesItsMembers() { // End-to-end: ResolveAllMembers piggybacks on BuildInheritanceChain, // so the chain truncation regression also dropped every member of an - // Id-0 template from the resolved-member set. Lock this in too. + // template with an Id of 0 from the resolved-member set. Lock this in too. var orphan = new Template("OrphanedId0") { Id = 0 }; orphan.Attributes.Add(new TemplateAttribute("Speed") { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs index 17d273f2..3f45fb2c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs @@ -14,6 +14,7 @@ public class TemplateServiceTests private readonly Mock<IAuditService> _auditMock; private readonly TemplateService _service; + /// <summary>Initializes the test with mocked repositories and the service under test.</summary> public TemplateServiceTests() { _repoMock = new Mock<ITemplateEngineRepository>(); @@ -23,9 +24,11 @@ public class TemplateServiceTests } // ======================================================================== - // WP-1: Template CRUD with Inheritance + // Template CRUD with Inheritance // ======================================================================== + /// <summary>Verifies that create template, success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateTemplate_Success() { @@ -34,7 +37,7 @@ public class TemplateServiceTests Assert.True(result.IsSuccess); Assert.Equal("Pump", result.Value.Name); _repoMock.Verify(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once); - // TemplateEngine-020: SaveChangesAsync runs twice — once after the + // SaveChangesAsync runs twice — once after the // AddTemplateAsync to populate the auto-generated key (so the audit // row can carry the real Id), and once after LogAsync to persist the // audit row itself. @@ -42,6 +45,8 @@ public class TemplateServiceTests _auditMock.Verify(a => a.LogAsync("admin", "Create", "Template", It.IsAny<string>(), "Pump", It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that create template, empty name, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateTemplate_EmptyName_Fails() { @@ -50,6 +55,8 @@ public class TemplateServiceTests Assert.Contains("required", result.Error); } + /// <summary>Verifies that create template, with parent, success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateTemplate_WithParent_Success() { @@ -66,6 +73,8 @@ public class TemplateServiceTests Assert.Equal(1, result.Value.ParentTemplateId); } + /// <summary>Verifies that create template, with parent, does not run dead collision query.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateTemplate_WithParent_DoesNotRunDeadCollisionQuery() { @@ -87,6 +96,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that create template, nonexistent parent, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateTemplate_NonexistentParent_Fails() { @@ -97,6 +108,8 @@ public class TemplateServiceTests Assert.Contains("not found", result.Error); } + /// <summary>Verifies that create template, with folder id, sets folder id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateTemplate_WithFolderId_SetsFolderId() { @@ -109,6 +122,8 @@ public class TemplateServiceTests Assert.Equal(7, result.Value.FolderId); } + /// <summary>Verifies that delete template, success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteTemplate_Success() { @@ -127,6 +142,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.DeleteTemplateAsync(1, It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that delete template, referenced by instances, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteTemplate_ReferencedByInstances_Fails() { @@ -145,6 +162,8 @@ public class TemplateServiceTests Assert.Contains("instance(s) reference it", result.Error); } + /// <summary>Verifies that delete template, has children, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteTemplate_HasChildren_Fails() { @@ -164,6 +183,8 @@ public class TemplateServiceTests Assert.Contains("child template(s) inherit from it", result.Error); } + /// <summary>Verifies that delete template, composed by other, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteTemplate_ComposedByOther_Fails() { @@ -185,10 +206,12 @@ public class TemplateServiceTests Assert.Contains("template(s) compose it", result.Error); } + /// <summary>Verifies that delete template, multiple constraints, reports all not just first.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteTemplate_MultipleConstraints_ReportsAllNotJustFirst() { - // TemplateEngine-014: DeleteTemplateAsync delegates its constraint check + // DeleteTemplateAsync delegates its constraint check // to the single TemplateDeletionService implementation, which accumulates // every blocking reason instead of returning on the first failing category. var template = new Template("Busy") { Id = 1 }; @@ -222,9 +245,11 @@ public class TemplateServiceTests } // ======================================================================== - // WP-2: Attribute Definitions with Lock Flags + // Attribute Definitions with Lock Flags // ======================================================================== + /// <summary>Verifies that add attribute, success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAttribute_Success() { @@ -243,10 +268,12 @@ public class TemplateServiceTests Assert.Equal(1, result.Value.TemplateId); } + /// <summary>Verifies that add attribute, to derived template with inherited placeholders, succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAttribute_ToDerivedTemplateWithInheritedPlaceholders_Succeeds() { - // Follow-up #3 (end-to-end). Adding a member to a derived template used to + // End-to-end: adding a member to a derived template used to // fail: CloneTemplateWithNewAttribute carries the child's IsInherited // placeholder rows into DetectCollisions, which counted each one twice (child // origin + the parent the inheritance walk re-adds) and reported a spurious @@ -276,6 +303,8 @@ public class TemplateServiceTests Assert.Equal(8, result.Value.TemplateId); } + /// <summary>Verifies that add attribute, duplicate name, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAttribute_DuplicateName_Fails() { @@ -290,10 +319,12 @@ public class TemplateServiceTests Assert.Contains("already exists", result.Error); } - // ── #92: (DataType, ElementDataType, Value) validation hoisted into the + // ── (DataType, ElementDataType, Value) validation hoisted into the // service so the Central UI's direct write path shares the same // server-side guard as ManagementActor.ValidateAttributeTypes. ── + /// <summary>Verifies that add attribute, invalid data type, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAttribute_InvalidDataType_Fails() { @@ -312,6 +343,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that add attribute, list missing element type, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAttribute_ListMissingElementType_Fails() { @@ -326,6 +359,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that add attribute, list invalid element type, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAttribute_ListInvalidElementType_Fails() { @@ -341,6 +376,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that add attribute, scalar with element type, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAttribute_ScalarWithElementType_Fails() { @@ -355,6 +392,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that add attribute, list value does not decode, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAttribute_ListValueDoesNotDecode_Fails() { @@ -373,6 +412,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that add attribute, scalar value does not parse, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAttribute_ScalarValueDoesNotParse_Fails() { @@ -396,6 +437,8 @@ public class TemplateServiceTests Assert.True(result.IsSuccess); } + /// <summary>Verifies that add attribute, valid typed list, succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAttribute_ValidTypedList_Succeeds() { @@ -418,6 +461,8 @@ public class TemplateServiceTests Assert.Equal(DataType.Int32, result.Value.ElementDataType); } + /// <summary>Verifies that update attribute, invalid list value, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAttribute_InvalidListValue_Fails() { @@ -444,6 +489,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that update attribute, valid typed list value, succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAttribute_ValidTypedListValue_Succeeds() { @@ -466,9 +513,11 @@ public class TemplateServiceTests } // ======================================================================== - // WP-3: Alarm Definitions + // Alarm Definitions // ======================================================================== + /// <summary>Verifies that add alarm, success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAlarm_Success() { @@ -491,6 +540,8 @@ public class TemplateServiceTests Assert.Equal("HighTemp", result.Value.Name); } + /// <summary>Verifies that add alarm, invalid priority, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAlarm_InvalidPriority_Fails() { @@ -504,6 +555,8 @@ public class TemplateServiceTests Assert.Contains("priority", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that update alarm, trigger type fixed, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAlarm_TriggerTypeFixed_Fails() { @@ -530,6 +583,8 @@ public class TemplateServiceTests Assert.Contains("TriggerType", result.Error); } + /// <summary>Verifies that update alarm async, copies is mes reportable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAlarmAsync_CopiesIsMesReportable() { @@ -561,6 +616,8 @@ public class TemplateServiceTests It.Is<TemplateAlarm>(a => a.IsMesReportable), It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that update alarm async, clears is mes reportable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAlarmAsync_ClearsIsMesReportable() { @@ -590,9 +647,11 @@ public class TemplateServiceTests } // ======================================================================== - // WP-4: Script Definitions + // Script Definitions // ======================================================================== + /// <summary>Verifies that add script, success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddScript_Success() { @@ -610,6 +669,8 @@ public class TemplateServiceTests Assert.Equal("OnStart", result.Value.Name); } + /// <summary>Verifies that update script, name fixed, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateScript_NameFixed_Fails() { @@ -625,10 +686,12 @@ public class TemplateServiceTests Assert.Contains("Name", result.Error); } + /// <summary>Verifies that update script, ui edit path, preserves existing execution timeout seconds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateScript_UiEditPath_PreservesExistingExecutionTimeoutSeconds() { - // #54: the UI now has an ExecutionTimeoutSeconds input, but it still loads + // The UI now has an ExecutionTimeoutSeconds input, but it still loads // (round-trips) the existing value into that input on edit, so leaving it // untouched re-sends the same value rather than null. This test proves the // service preserves the timeout end-to-end when the loaded value is re-sent @@ -655,10 +718,12 @@ public class TemplateServiceTests Assert.Equal(30, result.Value.ExecutionTimeoutSeconds); } + /// <summary>Verifies that update script, explicit null timeout, clears execution timeout seconds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateScript_ExplicitNullTimeout_ClearsExecutionTimeoutSeconds() { - // M2.5 (#9): a deliberate clear (e.g. via Transport or CLI setting null + // A deliberate clear (e.g. via Transport or CLI setting null // explicitly) must still work — the service must not guard against null. var existing = new TemplateScript("OnStart", "return true;") { @@ -683,13 +748,15 @@ public class TemplateServiceTests } // ======================================================================== - // WP-5: Shared Script CRUD (see SharedScriptServiceTests) + // Shared Script CRUD (see SharedScriptServiceTests) // ======================================================================== // ======================================================================== - // WP-6: Composition with Recursive Nesting + // Composition with Recursive Nesting // ======================================================================== + /// <summary>Verifies that add composition, success, derives template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddComposition_Success_DerivesTemplate() { @@ -726,6 +793,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that add composition, cascades child compositions.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddComposition_CascadesChildCompositions() { @@ -771,6 +840,8 @@ public class TemplateServiceTests Assert.Equal("Probe1", capturedCompositions[1].InstanceName); } + /// <summary>Verifies that add composition, base is derived, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddComposition_BaseIsDerived_Fails() { @@ -786,6 +857,8 @@ public class TemplateServiceTests Assert.Contains("derived template", result.Error); } + /// <summary>Verifies that add composition, derived contained name, need not be globally unique.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddComposition_DerivedContainedName_NeedNotBeGloballyUnique() { @@ -816,6 +889,8 @@ public class TemplateServiceTests Assert.Equal("myModule", captured[0].Name); } + /// <summary>Verifies that rename composition, renames slot and derived template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RenameComposition_RenamesSlotAndDerivedTemplate() { @@ -837,6 +912,8 @@ public class TemplateServiceTests Assert.Equal("NewSlot", derived.Name); } + /// <summary>Verifies that rename composition, renames only the slots own derived template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RenameComposition_RenamesOnlyTheSlotsOwnDerivedTemplate() { @@ -867,6 +944,8 @@ public class TemplateServiceTests It.Is<Template>(t => t.Id == 78), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that rename composition, duplicate name, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RenameComposition_DuplicateName_Fails() { @@ -885,6 +964,8 @@ public class TemplateServiceTests Assert.Contains("already exists", result.Error); } + /// <summary>Verifies that delete composition, cascades nested derived templates.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteComposition_CascadesNestedDerivedTemplates() { @@ -912,6 +993,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.DeleteTemplateAsync(78, It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that delete composition, cascades derived template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteComposition_CascadesDerivedTemplate() { @@ -928,6 +1011,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.DeleteTemplateAsync(77, It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that delete composition, legacy direct base, does not cascade.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteComposition_LegacyDirectBase_DoesNotCascade() { @@ -944,6 +1029,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.DeleteTemplateAsync(5, It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that delete template, derived template, direct delete blocked.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteTemplate_DerivedTemplate_DirectDeleteBlocked() { @@ -956,6 +1043,8 @@ public class TemplateServiceTests Assert.Contains("derived template", result.Error); } + /// <summary>Verifies that update attribute, locked in derived base, rejects on derived.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAttribute_LockedInDerivedBase_RejectsOnDerived() { @@ -979,6 +1068,8 @@ public class TemplateServiceTests Assert.Contains("locked by base template 'Sensor'", result.Error); } + /// <summary>Verifies that update script, locked in derived base, rejects on derived.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateScript_LockedInDerivedBase_RejectsOnDerived() { @@ -1002,6 +1093,8 @@ public class TemplateServiceTests Assert.Contains("locked by base template 'Sensor'", result.Error); } + /// <summary>Verifies that add composition, copies alarms as inherited.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddComposition_CopiesAlarmsAsInherited() { @@ -1043,6 +1136,8 @@ public class TemplateServiceTests Assert.Equal("Too hot", copied.Description); } + /// <summary>Verifies that update alarm, locked in derived base, rejects on derived.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAlarm_LockedInDerivedBase_RejectsOnDerived() { @@ -1084,6 +1179,8 @@ public class TemplateServiceTests Assert.Contains("locked by base template 'Sensor'", result.Error); } + /// <summary>Verifies that update alarm, derived override, persists is inherited false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAlarm_DerivedOverride_PersistsIsInheritedFalse() { @@ -1125,6 +1222,8 @@ public class TemplateServiceTests Assert.Equal(99, result.Value.PriorityLevel); } + /// <summary>Verifies that update attribute, derived override, persists is inherited false.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAttribute_DerivedOverride_PersistsIsInheritedFalse() { @@ -1149,6 +1248,8 @@ public class TemplateServiceTests Assert.Equal("99", result.Value.Value); } + /// <summary>Verifies that delete template, base with derivatives, blocked with slot names.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteTemplate_BaseWithDerivatives_BlockedWithSlotNames() { @@ -1172,6 +1273,8 @@ public class TemplateServiceTests Assert.Contains("'Pump' (as 'TempSensor')", result.Error); } + /// <summary>Verifies that add composition, duplicate instance name, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddComposition_DuplicateInstanceName_Fails() { @@ -1188,6 +1291,8 @@ public class TemplateServiceTests Assert.Contains("already exists", result.Error); } + /// <summary>Verifies that add composition, self composition, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddComposition_SelfComposition_Fails() { @@ -1205,9 +1310,11 @@ public class TemplateServiceTests } // ======================================================================== - // WP-9: Locking Rules + // Locking Rules // ======================================================================== + /// <summary>Verifies that update attribute, locked member, cannot unlock.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAttribute_LockedMember_CannotUnlock() { @@ -1229,6 +1336,8 @@ public class TemplateServiceTests Assert.Contains("cannot be unlocked", result.Error); } + /// <summary>Verifies that update attribute, lock unlocked member, succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAttribute_LockUnlockedMember_Succeeds() { @@ -1250,6 +1359,8 @@ public class TemplateServiceTests Assert.True(result.Value.IsLocked); } + /// <summary>Verifies that update attribute, unlocked attribute, data type change rejected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAttribute_UnlockedAttribute_DataTypeChangeRejected() { @@ -1274,6 +1385,8 @@ public class TemplateServiceTests Assert.Equal(DataType.Float, existing.DataType); } + /// <summary>Verifies that update attribute, unlocked attribute, data source reference change rejected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAttribute_UnlockedAttribute_DataSourceReferenceChangeRejected() { @@ -1298,6 +1411,8 @@ public class TemplateServiceTests Assert.Equal("/Motor/Temp", existing.DataSourceReference); } + /// <summary>Verifies that update attribute, parent locked, cannot override.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAttribute_ParentLocked_CannotOverride() { @@ -1334,9 +1449,11 @@ public class TemplateServiceTests } // ======================================================================== - // WP-10: Inheritance Override Scope — Cannot remove parent members + // Inheritance Override Scope — Cannot remove parent members // ======================================================================== + /// <summary>Verifies that delete attribute, inherited from parent, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteAttribute_InheritedFromParent_Fails() { @@ -1366,6 +1483,8 @@ public class TemplateServiceTests Assert.Contains("inherited from parent", result.Error); } + /// <summary>Verifies that delete attribute, own member, succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeleteAttribute_OwnMember_Succeeds() { @@ -1385,9 +1504,11 @@ public class TemplateServiceTests } // ======================================================================== - // WP-13: Graph Acyclicity + // Graph Acyclicity // ======================================================================== + /// <summary>Verifies that update template, change parent, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateTemplate_ChangeParent_Fails() { @@ -1403,10 +1524,12 @@ public class TemplateServiceTests _repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that update template, omitted parent and description, leaves unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateTemplate_OmittedParentAndDescription_LeavesUnchanged() { - // Followup #5: a null parent/description means "leave unchanged" (an omitted + // A null parent/description means "leave unchanged" (an omitted // CLI/API field), not an attempt to clear. Renaming without re-passing the // description must not wipe it, and must not trip the parent-immutability guard. var child = new Template("Child") { Id = 2, ParentTemplateId = 1, Description = "keep me" }; @@ -1425,6 +1548,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that update template, empty description, clears it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateTemplate_EmptyDescription_ClearsIt() { @@ -1443,6 +1568,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that update template, same parent, succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateTemplate_SameParent_Succeeds() { @@ -1463,6 +1590,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once); } + /// <summary>Verifies that add composition, circular chain, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddComposition_CircularChain_Fails() { @@ -1490,6 +1619,8 @@ public class TemplateServiceTests // Move template between folders // ======================================================================== + /// <summary>Verifies that move template, to folder, returns success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveTemplate_ToFolder_ReturnsSuccess() { @@ -1508,6 +1639,8 @@ public class TemplateServiceTests Assert.Equal(7, result.Value.FolderId); } + /// <summary>Verifies that move template, to root, returns success.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveTemplate_ToRoot_ReturnsSuccess() { @@ -1524,6 +1657,8 @@ public class TemplateServiceTests Assert.Null(result.Value.FolderId); } + /// <summary>Verifies that move template, target folder missing, returns failure.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveTemplate_TargetFolderMissing_ReturnsFailure() { @@ -1537,10 +1672,12 @@ public class TemplateServiceTests Assert.Contains("not found", result.Error); } + /// <summary>Verifies that move template, name collision at destination, fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveTemplate_NameCollisionAtDestination_Fails() { - // TemplateEngine-021: moving "Pump" into a folder that already contains a + // Moving "Pump" into a folder that already contains a // template named "Pump" (case-insensitive) must be rejected before the // FolderId is persisted. Mirrors TemplateFolderService.MoveFolderAsync // sibling-name uniqueness, and pins the design rule that naming @@ -1564,10 +1701,12 @@ public class TemplateServiceTests _repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that move template, no collision at destination, succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task MoveTemplate_NoCollisionAtDestination_Succeeds() { - // TemplateEngine-021 companion: a sibling with the same name in a + // Companion case: a sibling with the same name in a // *different* folder is not a collision. The move must succeed. var moving = new Template("Pump") { Id = 1, FolderId = null }; var unrelated = new Template("Pump") { Id = 2, FolderId = 8 }; // different folder @@ -1585,10 +1724,12 @@ public class TemplateServiceTests Assert.Equal(7, result.Value.FolderId); } + /// <summary>Verifies that update attribute, locked in derived downgrade, on base, rejected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task UpdateAttribute_LockedInDerivedDowngrade_OnBase_Rejected() { - // TemplateEngine-022: LockedInDerived is a one-way ratchet on base + // LockedInDerived is a one-way ratchet on base // templates. Once true, it cannot be cleared — otherwise existing // derived overrides that were previously blocked would become // retroactively legal. @@ -1616,20 +1757,22 @@ public class TemplateServiceTests } // ======================================================================== - // TemplateEngine-020: Create* audit rows must carry the real entity Id, + // Create* audit rows must carry the real entity Id, // not the literal "0" that EF Core leaves on the entity until // SaveChangesAsync populates the auto-generated key. // ======================================================================== + /// <summary>Verifies that create template, audit row carries real template id not literal zero.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateTemplate_AuditRowCarriesRealTemplateIdNotLiteralZero() { // Simulate EF Core's identity-key population: the repository's - // AddTemplateAsync stamps Id during SaveChangesAsync. The pre-020 + // AddTemplateAsync stamps Id during SaveChangesAsync. The previous // order (Add → LogAsync → Save) queued the audit row before the Id // existed, so every Create audit entry persisted with EntityId = "0" // and the audit trail lost its link back to the created template. - // Post-020: save first, then log with the real Id, then save the + // Current order: save first, then log with the real Id, then save the // audit row. Template? added = null; _repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>())) @@ -1663,6 +1806,8 @@ public class TemplateServiceTests Assert.NotEqual("0", auditedEntityId); } + /// <summary>Verifies that add attribute, audit row carries real attribute id not literal zero.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAttribute_AuditRowCarriesRealAttributeIdNotLiteralZero() { @@ -1705,6 +1850,8 @@ public class TemplateServiceTests Assert.NotEqual("0", auditedEntityId); } + /// <summary>Verifies that add alarm, audit row carries real alarm id not literal zero.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAlarm_AuditRowCarriesRealAlarmIdNotLiteralZero() { @@ -1752,6 +1899,8 @@ public class TemplateServiceTests Assert.NotEqual("0", auditedEntityId); } + /// <summary>Verifies that add script, audit row carries real script id not literal zero.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddScript_AuditRowCarriesRealScriptIdNotLiteralZero() { @@ -1795,17 +1944,19 @@ public class TemplateServiceTests } // ======================================================================== - // Follow-up #1/#2: inherited-member propagation & resync + // Inherited-member propagation & resync // ======================================================================== private static Template BaseTemplate(int id, string name) => new(name) { Id = id }; private static Template DerivedTemplate(int id, string name, int parentId) => new(name) { Id = id, ParentTemplateId = parentId, IsDerived = true }; + /// <summary>Verifies that resync, materializes missing inherited attribute.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resync_MaterializesMissingInheritedAttribute() { - // The reported #1/#2 shape: a base member added after the child was derived + // The reported shape: a base member added after the child was derived // never reached the child's stored rows. Resync materializes it. var baseT = BaseTemplate(7, "Base"); baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" }); @@ -1836,6 +1987,8 @@ public class TemplateServiceTests Assert.Equal(DataType.String, b.DataType); } + /// <summary>Verifies that resync, removes orphaned inherited row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resync_RemovesOrphanedInheritedRow() { @@ -1863,6 +2016,8 @@ public class TemplateServiceTests Assert.Equal(81, Assert.Single(deleted)); } + /// <summary>Verifies that resync, updates drifted inherited row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resync_UpdatesDriftedInheritedRow() { @@ -1890,6 +2045,8 @@ public class TemplateServiceTests Assert.Equal("NEW", childA.Value); // re-synced in place to the live base value } + /// <summary>Verifies that resync, leaves authored overrides untouched.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resync_LeavesAuthoredOverridesUntouched() { @@ -1915,6 +2072,8 @@ public class TemplateServiceTests _repoMock.Verify(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never); } + /// <summary>Verifies that resync, on base, cascades to all derived children.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resync_OnBase_CascadesToAllDerivedChildren() { @@ -1946,6 +2105,8 @@ public class TemplateServiceTests Assert.Contains(added, a => a.TemplateId == 9 && a.Name == "B" && a.IsInherited); } + /// <summary>Verifies that resync, materializes missing inherited script and native source.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resync_MaterializesMissingInheritedScriptAndNativeSource() { @@ -1984,6 +2145,8 @@ public class TemplateServiceTests Assert.Equal(8, src.TemplateId); } + /// <summary>Verifies that reconcile descendants, propagates new base member to child.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReconcileDescendants_PropagatesNewBaseMemberToChild() { @@ -2012,11 +2175,13 @@ public class TemplateServiceTests Assert.True(c.IsInherited); } + /// <summary>Verifies that add attribute, propagates new member to derived child.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AddAttribute_PropagatesNewMemberToDerivedChild() { // End-to-end: adding a member to a base auto-propagates an inherited - // placeholder to its derived children (#1/#2). + // placeholder to its derived children. var baseT = BaseTemplate(7, "Base"); baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" }); var child = DerivedTemplate(8, "Child", 7); @@ -2047,6 +2212,8 @@ public class TemplateServiceTests Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "C" && a.IsInherited); // child placeholder } + /// <summary>Verifies that create derived template async, copies is mes reportable onto placeholder.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CreateDerivedTemplateAsync_CopiesIsMesReportableOntoPlaceholder() { @@ -2088,6 +2255,8 @@ public class TemplateServiceTests Assert.True(copied.IsMesReportable); } + /// <summary>Verifies that reconcile descendants, propagates is mes reportable change.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ReconcileDescendants_PropagatesIsMesReportableChange() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SchemaRefValidationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SchemaRefValidationTests.cs index c9ac2013..d5949b3f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SchemaRefValidationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SchemaRefValidationTests.cs @@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation; /// <summary> -/// M9-T32b deploy-time validation: a dangling <c>{"$ref":"lib:Name"}</c> in any +/// Deploy-time validation: a dangling <c>{"$ref":"lib:Name"}</c> in any /// validated script parameter/return schema must BLOCK deployment (a /// <see cref="ValidationCategory.SchemaReference"/> error naming the missing ref). /// A valid ref resolved through the supplied seam passes. The check is inert for @@ -15,6 +15,7 @@ public class SchemaRefValidationTests { private readonly ValidationService _sut = new(); + /// <summary>Verifies that a dangling reference in the parameter definitions blocks deployment.</summary> [Fact] public void Validate_DanglingRefInParameterDefinitions_BlocksDeploy() { @@ -40,6 +41,7 @@ public class SchemaRefValidationTests Assert.Contains("MissingLib", error.Message, StringComparison.Ordinal); } + /// <summary>Verifies that a dangling reference in the return definition blocks deployment.</summary> [Fact] public void Validate_DanglingRefInReturnDefinition_BlocksDeploy() { @@ -65,6 +67,7 @@ public class SchemaRefValidationTests && e.Message.Contains("GoneReturn", StringComparison.Ordinal)); } + /// <summary>Verifies that a valid schema reference passes validation.</summary> [Fact] public void Validate_ValidRef_Passes() { @@ -91,6 +94,7 @@ public class SchemaRefValidationTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.SchemaReference); } + /// <summary>Verifies that a schema without references produces no schema reference error.</summary> [Fact] public void Validate_NoRefSchema_NoSchemaReferenceError() { @@ -114,6 +118,7 @@ public class SchemaRefValidationTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.SchemaReference); } + /// <summary>Verifies that validating without a schema resolver causes no regression.</summary> [Fact] public void Validate_NoResolverSupplied_NoRegression() { @@ -140,6 +145,7 @@ public class SchemaRefValidationTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.SchemaReference); } + /// <summary>Verifies that a cyclic reference blocks deployment without overflowing the stack.</summary> [Fact] public void Validate_CyclicRef_BlocksDeployWithoutOverflow() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompileVerdictCacheEvictionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompileVerdictCacheEvictionTests.cs index ea021251..cb675890 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompileVerdictCacheEvictionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompileVerdictCacheEvictionTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation; /// <summary> -/// WP2.5: the verdict cache's eviction policy. Overflow used to <c>Clear()</c> the +/// The verdict cache's eviction policy. Overflow used to <c>Clear()</c> the /// whole cache, which re-opened the non-collectible <c>InteractiveAssemblyLoader</c> /// leak the cache exists to bound — every hot script had to be recompiled, and every /// recompile loads another assembly that can never be unloaded. These tests pin the @@ -26,6 +26,7 @@ public class ScriptCompileVerdictCacheEvictionTests private static (bool Ok, string? Error) Lookup(string code, Func<(bool, string?)> factory) => ScriptCompileVerdictCache.GetOrAdd(Surface, code, factory); + /// <summary>Verifies that an overflow keeps the hot entry instead of clearing the whole cache.</summary> [Fact] public void Overflow_KeepsHotEntry_AndDoesNotClearEverything() { @@ -66,6 +67,7 @@ public class ScriptCompileVerdictCacheEvictionTests Assert.Equal(compilesBefore, hotCompiles); } + /// <summary>Verifies that an overflow retains recent entries rather than dropping all of them.</summary> [Fact] public void Overflow_RetainsRecentEntries_RatherThanDroppingAll() { @@ -91,6 +93,7 @@ public class ScriptCompileVerdictCacheEvictionTests $"cache retained only {ScriptCompileVerdictCache.Count} entries after eviction"); } + /// <summary>Verifies that an overflow keeps the cache within its size bound.</summary> [Fact] public void Overflow_KeepsCacheBounded() { @@ -135,6 +138,7 @@ public class ScriptCompileVerdictCacheEvictionTests $"cache grew to {ScriptCompileVerdictCache.Count} entries under promotion, exceeding its two-generation bound"); } + /// <summary>Verifies that the compile surface remains part of the cache key across eviction.</summary> [Fact] public void SurfaceIsPartOfTheKey_AcrossEviction() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs index 7990a9bb..a308b960 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation; /// <summary> -/// M3.2: the design-time deploy gate now delegates to the shared +/// The design-time deploy gate now delegates to the shared /// <c>ZB.MOM.WW.ScadaBridge.ScriptAnalysis</c> analyzer — a real Roslyn semantic /// forbidden-API verdict plus a real CSharpScript compile against the /// runtime-mirroring <c>ScriptCompileSurface</c>. These tests assert the @@ -18,6 +18,7 @@ public class ScriptCompilerTests { private readonly ScriptCompiler _sut = new(); + /// <summary>Verifies that compiling empty code returns a failure.</summary> [Fact] public void TryCompile_EmptyCode_ReturnsFailure() { @@ -26,6 +27,7 @@ public class ScriptCompilerTests Assert.Contains("empty", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that compiling whitespace-only code returns a failure.</summary> [Fact] public void TryCompile_WhitespaceOnly_ReturnsFailure() { @@ -36,6 +38,7 @@ public class ScriptCompilerTests // --- Forbidden-API gate (authoritative) --- + /// <summary>Verifies that compiling code that uses a forbidden API returns a failure.</summary> [Fact] public void TryCompile_ForbiddenApi_ReturnsFailure() { @@ -44,10 +47,11 @@ public class ScriptCompilerTests Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that compiling code that uses several forbidden APIs reports all of them.</summary> [Fact] public void TryCompile_MultipleForbiddenApis_ReportsAll() { - // #05-T24: the failure message must enumerate EVERY forbidden API, not + // The failure message must enumerate EVERY forbidden API, not // just the first, so an operator fixing one isn't surprised by the next. var result = _sut.TryCompile( "System.IO.File.ReadAllText(\"x\"); System.Diagnostics.Process.Start(\"y\");", @@ -57,6 +61,8 @@ public class ScriptCompilerTests Assert.Contains("Process", result.Error, StringComparison.Ordinal); } + /// <summary>Verifies that adversarial attempts to bypass the forbidden-API check are still rejected.</summary> + /// <param name="code">The adversarial script source under test.</param> [Theory] // Namespace alias — the old substring scan never saw "System.IO." spelled out. [InlineData("using IO = System.IO; IO.File.ReadAllText(\"x\");")] @@ -73,6 +79,7 @@ public class ScriptCompilerTests Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that a forbidden type in an otherwise allowed namespace is rejected as forbidden.</summary> [Fact] public void TryCompile_ForbiddenTypeInAllowedNamespace_RejectedAsForbidden() { @@ -87,6 +94,7 @@ public class ScriptCompilerTests Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that using the stopwatch from the allowed diagnostics namespace compiles successfully.</summary> [Fact] public void TryCompile_StopwatchInAllowedDiagnostics_ReturnsSuccess() { @@ -100,6 +108,7 @@ public class ScriptCompilerTests // --- Real-compile gate (the win over the old structural-only scan) --- + /// <summary>Verifies that compiling code with an undefined symbol returns a failure.</summary> [Fact] public void TryCompile_UndefinedSymbol_ReturnsFailure() { @@ -111,6 +120,7 @@ public class ScriptCompilerTests Assert.Contains("compile", result.Error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that compiling code with a syntax error returns a failure.</summary> [Fact] public void TryCompile_SyntaxError_ReturnsFailure() { @@ -121,6 +131,7 @@ public class ScriptCompilerTests // --- Valid real script against the script API surface --- + /// <summary>Verifies that a valid script using the API surface compiles successfully.</summary> [Fact] public void TryCompile_ValidScriptUsingApiSurface_ReturnsSuccess() { @@ -137,6 +148,7 @@ public class ScriptCompilerTests // --- Trigger-expression gate via ValidationService.CheckExpressionSyntax --- + /// <summary>Verifies that checking a valid expression returns null.</summary> [Fact] public void CheckExpressionSyntax_ValidExpression_ReturnsNull() { @@ -145,6 +157,7 @@ public class ScriptCompilerTests Assert.Null(error); } + /// <summary>Verifies that checking an expression that uses a forbidden API returns an error message.</summary> [Fact] public void CheckExpressionSyntax_ForbiddenApi_ReturnsMessage() { @@ -153,6 +166,7 @@ public class ScriptCompilerTests Assert.Contains("forbidden", error, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that checking the same expression twice serves the second call from the cache.</summary> [Fact] public void CheckExpressionSyntax_SameExpressionTwice_SecondCallIsCacheHit() { @@ -164,6 +178,7 @@ public class ScriptCompilerTests Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits); // FAILS today: no cache use } + /// <summary>Verifies that a verdict cached for the script surface is not reused for the trigger surface.</summary> [Fact] public void CheckExpressionSyntax_ScriptSurfaceVerdict_NotReusedForTriggerSurface() { @@ -176,6 +191,7 @@ public class ScriptCompilerTests Assert.NotNull(error); // green today (no cache), and MUST STAY green after T3 — the T2 key makes it structural } + /// <summary>Verifies that checking an expression with several forbidden APIs reports all of them.</summary> [Fact] public void CheckExpressionSyntax_MultipleForbiddenApis_ReportsAll() { @@ -186,6 +202,7 @@ public class ScriptCompilerTests Assert.Contains("Process", error); } + /// <summary>Verifies that checking an expression with several compile errors reports all of them.</summary> [Fact] public void CheckExpressionSyntax_MultipleCompileErrors_ReportsAll() { @@ -197,6 +214,7 @@ public class ScriptCompilerTests // --- Compile-verdict cache: unchanged code compiles once per process --- + /// <summary>Verifies that compiling the same code twice serves the second call from the cache.</summary> [Fact] public void TryCompile_SameCodeTwice_SecondCallIsCacheHit() { @@ -209,6 +227,7 @@ public class ScriptCompilerTests Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits); } + /// <summary>Verifies that the same code on different surfaces computes separate verdicts.</summary> [Fact] public void GetOrAdd_SameCodeDifferentSurface_ComputesSeparateVerdicts() { @@ -223,6 +242,7 @@ public class ScriptCompilerTests Assert.Equal(2, ScriptCompileVerdictCache.Count); } + /// <summary>Verifies that a cached failure formats the script name separately for each caller.</summary> [Fact] public void TryCompile_CachedFailure_FormatsNameForEachCaller() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs index c10c57e5..9630fc41 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs @@ -7,6 +7,7 @@ public class SemanticValidatorTests { private readonly SemanticValidator _sut = new(); + /// <summary>Verifies that a native alarm source referencing an unknown connection returns an error.</summary> [Fact] public void Validate_NativeAlarmSource_UnknownConnection_ReturnsError() { @@ -19,6 +20,7 @@ public class SemanticValidatorTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid); } + /// <summary>Verifies that a native alarm source with an empty source reference returns an error.</summary> [Fact] public void Validate_NativeAlarmSource_EmptySourceRef_ReturnsError() { @@ -31,6 +33,7 @@ public class SemanticValidatorTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid); } + /// <summary>Verifies that a native alarm source with a valid binding produces no error.</summary> [Fact] public void Validate_NativeAlarmSource_ValidBinding_NoError() { @@ -43,6 +46,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid); } + /// <summary>Verifies that a CallScript target that cannot be found returns an error.</summary> [Fact] public void Validate_CallScriptTargetNotFound_ReturnsError() { @@ -65,6 +69,7 @@ public class SemanticValidatorTests e.Message.Contains("NonExistent")); } + /// <summary>Verifies that a CallScript target that exists produces no error.</summary> [Fact] public void Validate_CallScriptTargetExists_NoError() { @@ -82,6 +87,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound); } + /// <summary>Verifies that a CallScript to a composition script referenced by its leaf name produces no error.</summary> [Fact] public void Validate_CallScriptCompositionDelegatedByLeafName_NoError() { @@ -105,10 +111,11 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound); } + /// <summary>Verifies that a CallScript to a composition script referenced by its leaf name emits an advisory warning.</summary> [Fact] public void Validate_CallScriptCompositionDelegatedByLeafName_EmitsAdvisoryWarning() { - // #05-T24: a call target accepted ONLY by the composed leaf-name fallback + // A call target accepted ONLY by the composed leaf-name fallback // (dynamic child path, not statically verifiable) must surface an advisory // warning rather than being silently accepted. var config = new FlattenedConfiguration @@ -128,6 +135,7 @@ public class SemanticValidatorTests && w.Message.Contains("leaf name only", StringComparison.Ordinal)); } + /// <summary>Verifies that a CallScript to a sibling in the same scope emits no leaf-name warning.</summary> [Fact] public void Validate_CallScriptSameScopeSibling_EmitsNoLeafWarning() { @@ -149,6 +157,7 @@ public class SemanticValidatorTests w.Message.Contains("leaf name only", StringComparison.Ordinal)); } + /// <summary>Verifies that a CallScript to an unknown leaf name still returns an error.</summary> [Fact] public void Validate_CallScriptUnknownLeafName_StillReturnsError() { @@ -169,6 +178,7 @@ public class SemanticValidatorTests e.Category == ValidationCategory.CallTargetNotFound && e.Message.Contains("Typo")); } + /// <summary>Verifies that a CallShared target that cannot be found returns an error.</summary> [Fact] public void Validate_CallSharedTargetNotFound_ReturnsError() { @@ -191,6 +201,7 @@ public class SemanticValidatorTests e.Message.Contains("MissingShared")); } + /// <summary>Verifies that a CallShared target that exists produces no error.</summary> [Fact] public void Validate_CallSharedTargetExists_NoError() { @@ -212,6 +223,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound); } + /// <summary>Verifies that a parameter count mismatch returns an error.</summary> [Fact] public void Validate_ParameterCountMismatch_ReturnsError() { @@ -238,8 +250,9 @@ public class SemanticValidatorTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } - // ── #21 Argument-type validation ──────────────────────────────────────── + // ── Argument-type validation ──────────────────────────────────────── + /// <summary>Verifies that passing a string for an integer parameter returns an error.</summary> [Fact] public void Validate_ArgumentTypeMismatch_StringForInteger_ReturnsError() { @@ -269,6 +282,7 @@ public class SemanticValidatorTests e.Message.Contains("type", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that passing a number for a string parameter returns an error.</summary> [Fact] public void Validate_ArgumentTypeMismatch_NumberForString_ReturnsError() { @@ -298,6 +312,7 @@ public class SemanticValidatorTests e.Message.Contains("type", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that passing a boolean for an integer parameter returns an error.</summary> [Fact] public void Validate_ArgumentTypeMismatch_BooleanForInteger_ReturnsError() { @@ -326,6 +341,7 @@ public class SemanticValidatorTests e.Message.Contains("type", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that literals of the correct types produce no error.</summary> [Fact] public void Validate_ArgumentTypeMatch_CorrectLiterals_NoError() { @@ -354,6 +370,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } + /// <summary>Verifies that passing an integer literal for a float parameter produces no error.</summary> [Fact] public void Validate_ArgumentType_IntegerLiteralForFloat_NoError() { @@ -381,6 +398,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } + /// <summary>Verifies that an expression of unknown type does not produce a false positive.</summary> [Fact] public void Validate_ArgumentType_UnknownExpression_NoFalsePositive() { @@ -409,6 +427,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } + /// <summary>Verifies that an object initializer argument does not produce a false positive.</summary> [Fact] public void Validate_ArgumentType_ObjectInitializerArgument_NoFalsePositive() { @@ -437,6 +456,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } + /// <summary>Verifies that an untyped parameter does not produce a false positive.</summary> [Fact] public void Validate_ArgumentType_UntypedParameter_NoFalsePositive() { @@ -464,6 +484,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } + /// <summary>Verifies that a compound expression starting with a literal does not produce a false positive.</summary> [Fact] public void Validate_ArgumentType_CompoundExpressionStartingWithLiteral_NoFalsePositive() { @@ -492,6 +513,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } + /// <summary>Verifies that a concatenated string expression does not produce a false positive.</summary> [Fact] public void Validate_ArgumentType_ConcatenatedStringExpression_NoFalsePositive() { @@ -520,8 +542,9 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } - // ── #20 Return-type validation ────────────────────────────────────────── + // ── Return-type validation ────────────────────────────────────────── + /// <summary>Verifies that assigning a boolean result to an integer returns an error.</summary> [Fact] public void Validate_ReturnTypeMismatch_BooleanResultIntoInt_ReturnsError() { @@ -549,6 +572,7 @@ public class SemanticValidatorTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } + /// <summary>Verifies that assigning a string result to a boolean returns an error.</summary> [Fact] public void Validate_ReturnTypeMismatch_StringResultIntoBool_ReturnsError() { @@ -575,6 +599,7 @@ public class SemanticValidatorTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } + /// <summary>Verifies that a compatible assignment of a return value produces no error.</summary> [Fact] public void Validate_ReturnTypeMatch_CompatibleAssignment_NoError() { @@ -602,6 +627,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } + /// <summary>Verifies that assigning a return value to a var local does not produce a false positive.</summary> [Fact] public void Validate_ReturnType_VarAssignment_NoFalsePositive() { @@ -629,6 +655,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } + /// <summary>Verifies that an unused return value does not produce a false positive.</summary> [Fact] public void Validate_ReturnType_UnusedResult_NoFalsePositive() { @@ -656,6 +683,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } + /// <summary>Verifies that a call to a script with no declared return type does not produce a false positive.</summary> [Fact] public void Validate_ReturnType_UndeclaredReturn_NoFalsePositive() { @@ -678,11 +706,12 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } + /// <summary>Verifies that a return type mismatch on a qualified instance call returns an error.</summary> [Fact] public void Validate_ReturnTypeMismatch_QualifiedInstanceCall_ReturnsError() { // Real-code form: `Instance.CallScript(...)`. The receiver prefix must - // be skipped so #20 still sees the typed-local assignment. + // be skipped so return-type validation still sees the typed-local assignment. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", @@ -706,6 +735,7 @@ public class SemanticValidatorTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } + /// <summary>Verifies that a return type mismatch on a qualified shared call returns an error.</summary> [Fact] public void Validate_ReturnTypeMismatch_QualifiedSharedCall_ReturnsError() { @@ -737,6 +767,7 @@ public class SemanticValidatorTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } + /// <summary>Verifies that a cast expression on a return value does not produce a false positive.</summary> [Fact] public void Validate_ReturnType_CastExpression_NoFalsePositive() { @@ -765,6 +796,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } + /// <summary>Verifies that a range violation on a non-numeric attribute returns an error.</summary> [Fact] public void Validate_RangeViolationOnNonNumeric_ReturnsError() { @@ -790,6 +822,7 @@ public class SemanticValidatorTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType); } + /// <summary>Verifies that a range violation on a numeric attribute produces no error.</summary> [Fact] public void Validate_RangeViolationOnNumeric_NoError() { @@ -815,6 +848,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType); } + /// <summary>Verifies that an on-trigger script that cannot be found returns an error.</summary> [Fact] public void Validate_OnTriggerScriptNotFound_ReturnsError() { @@ -837,6 +871,7 @@ public class SemanticValidatorTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.OnTriggerScriptNotFound); } + /// <summary>Verifies that an instance script that calls an alarm on-trigger script returns an error.</summary> [Fact] public void Validate_InstanceScriptCallsAlarmOnTrigger_ReturnsError() { @@ -867,6 +902,7 @@ public class SemanticValidatorTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.CrossCallViolation); } + /// <summary>Verifies that call target extraction handles multiple call types.</summary> [Fact] public void ExtractCallTargets_MultipleCallTypes() { @@ -902,6 +938,7 @@ public class SemanticValidatorTests ] }; + /// <summary>Verifies that a hi/lo alarm on a non-numeric attribute returns an error.</summary> [Fact] public void Validate_HiLoOnNonNumericAttribute_ReturnsError() { @@ -916,6 +953,7 @@ public class SemanticValidatorTests && e.Message.Contains("non-numeric")); } + /// <summary>Verifies that a hi/lo alarm on a numeric attribute produces no operand type error.</summary> [Fact] public void Validate_HiLoOnNumericAttribute_NoOperandTypeError() { @@ -927,6 +965,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType); } + /// <summary>Verifies that a hi/lo alarm with no setpoints returns a warning.</summary> [Fact] public void Validate_HiLoNoSetpoints_ReturnsWarning() { @@ -941,6 +980,7 @@ public class SemanticValidatorTests && w.Message.Contains("no setpoints")); } + /// <summary>Verifies that a hi/lo alarm whose LoLo setpoint exceeds its Lo setpoint returns an error.</summary> [Fact] public void Validate_HiLoLoLoGreaterThanLo_ReturnsError() { @@ -955,6 +995,7 @@ public class SemanticValidatorTests && e.Message.Contains("Lo")); } + /// <summary>Verifies that a hi/lo alarm whose Hi setpoint exceeds its HiHi setpoint returns an error.</summary> [Fact] public void Validate_HiLoHiGreaterThanHiHi_ReturnsError() { @@ -969,6 +1010,7 @@ public class SemanticValidatorTests && e.Message.Contains("HiHi")); } + /// <summary>Verifies that a hi/lo alarm whose low side overlaps its high side returns an error.</summary> [Fact] public void Validate_HiLoLowSideOverlapsHighSide_ReturnsError() { @@ -983,6 +1025,7 @@ public class SemanticValidatorTests && e.Message.Contains("overlap")); } + /// <summary>Verifies that a hi/lo alarm with only the high side configured produces no ordering error.</summary> [Fact] public void Validate_HiLoOnlyHighSideConfigured_NoOrderingError() { @@ -996,6 +1039,7 @@ public class SemanticValidatorTests e => e.Category == ValidationCategory.TriggerOperandType); } + /// <summary>Verifies that a hi/lo alarm with a negative deadband returns an error.</summary> [Fact] public void Validate_HiLoNegativeDeadband_ReturnsError() { @@ -1010,6 +1054,7 @@ public class SemanticValidatorTests && e.Message.Contains("non-negative")); } + /// <summary>Verifies that a hi/lo alarm with a zero deadband produces no error.</summary> [Fact] public void Validate_HiLoZeroDeadband_NoError() { @@ -1023,6 +1068,7 @@ public class SemanticValidatorTests e => e.Category == ValidationCategory.TriggerOperandType); } + /// <summary>Verifies that a hi/lo alarm with valid setpoint ordering produces no errors.</summary> [Fact] public void Validate_HiLoValidOrdering_NoErrors() { @@ -1038,8 +1084,9 @@ public class SemanticValidatorTests w => w.Category == ValidationCategory.TriggerOperandType); } - // ── M2.7 review nits — comment-aware arg tokenizer ───────────────────── + // ── Review nits — comment-aware arg tokenizer ───────────────────── + /// <summary>Verifies that a line comment containing a comma inside the arguments does not produce a false positive.</summary> [Fact] public void Validate_ArgSplit_LineCommentWithCommaInsideArgs_NoFalsePositive() { @@ -1068,6 +1115,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } + /// <summary>Verifies that a block comment containing a comma inside the arguments does not produce a false positive.</summary> [Fact] public void Validate_ArgSplit_BlockCommentWithCommaInsideArgs_NoFalsePositive() { @@ -1096,8 +1144,9 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } - // ── M2.7 review nits — stricter numeric-literal inference ─────────────── + // ── Review nits — stricter numeric-literal inference ─────────────── + /// <summary>Verifies that an identifier with a leading underscore does not produce a false positive.</summary> [Fact] public void Validate_ArgumentType_UnderscoreLeadingIdentifier_NoFalsePositive() { @@ -1126,8 +1175,9 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } - // ── MV-5 List attribute semantics ──────────────────────────────────────── + // ── List attribute semantics ──────────────────────────────────────── + /// <summary>Verifies that a list attribute with a valid element type and default produces no error.</summary> [Fact] public void Validate_ListAttribute_ValidElementTypeAndDefault_NoError() { @@ -1151,6 +1201,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.MissingMetadata); } + /// <summary>Verifies that a list attribute with a null element type returns an error.</summary> [Fact] public void Validate_ListAttribute_NullElementType_ReturnsError() { @@ -1171,6 +1222,7 @@ public class SemanticValidatorTests e.Message.Contains("element type", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that a scalar attribute that has an element type returns an error.</summary> [Fact] public void Validate_ScalarAttribute_WithElementType_ReturnsError() { @@ -1191,6 +1243,7 @@ public class SemanticValidatorTests e.Message.Contains("element type", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that a list attribute with a non-scalar element type returns an error.</summary> [Fact] public void Validate_ListAttribute_NonScalarElementType_ReturnsError() { @@ -1213,6 +1266,7 @@ public class SemanticValidatorTests } } + /// <summary>Verifies that a list attribute with a malformed JSON default returns an error.</summary> [Fact] public void Validate_ListAttribute_MalformedJsonDefault_ReturnsError() { @@ -1239,6 +1293,7 @@ public class SemanticValidatorTests e.Message.Contains("default", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that a list attribute whose default contains an unparseable element returns an error.</summary> [Fact] public void Validate_ListAttribute_DefaultElementNotParseable_ReturnsError() { @@ -1265,6 +1320,7 @@ public class SemanticValidatorTests e.Message.Contains("Int32")); } + /// <summary>Verifies that a list attribute with an empty default produces no default error.</summary> [Fact] public void Validate_ListAttribute_EmptyDefault_NoDefaultError() { @@ -1282,6 +1338,7 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.MissingMetadata); } + /// <summary>Verifies that a list attribute used as a range violation operand returns an error.</summary> [Fact] public void Validate_ListAttribute_AsRangeViolationOperand_ReturnsError() { @@ -1311,6 +1368,7 @@ public class SemanticValidatorTests e.Message.Contains("non-numeric")); } + /// <summary>Verifies that a list attribute used as a hi/lo operand returns an error.</summary> [Fact] public void Validate_ListAttribute_AsHiLoOperand_ReturnsError() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ValidationServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ValidationServiceTests.cs index bdd4719c..3466a300 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ValidationServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ValidationServiceTests.cs @@ -7,6 +7,7 @@ public class ValidationServiceTests { private readonly ValidationService _sut = new(); + /// <summary>Verifies that a valid configuration returns success.</summary> [Fact] public void Validate_ValidConfig_ReturnsSuccess() { @@ -21,6 +22,7 @@ public class ValidationServiceTests Assert.True(result.IsValid); } + /// <summary>Verifies that an empty instance name returns an error.</summary> [Fact] public void Validate_EmptyInstanceName_ReturnsError() { @@ -35,6 +37,7 @@ public class ValidationServiceTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.FlatteningFailure); } + /// <summary>Verifies that a naming collision returns an error.</summary> [Fact] public void Validate_NamingCollision_ReturnsError() { @@ -53,6 +56,7 @@ public class ValidationServiceTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.NamingCollision); } + /// <summary>Verifies that a forbidden API returns a compilation error.</summary> [Fact] public void Validate_ForbiddenApi_ReturnsCompilationError() { @@ -74,6 +78,7 @@ public class ValidationServiceTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ScriptCompilation); } + /// <summary>Verifies that mismatched braces return a compilation error.</summary> [Fact] public void Validate_MismatchedBraces_ReturnsCompilationError() { @@ -91,6 +96,7 @@ public class ValidationServiceTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ScriptCompilation); } + /// <summary>Verifies that an alarm referencing a missing attribute returns an error.</summary> [Fact] public void Validate_AlarmReferencesMissingAttribute_ReturnsError() { @@ -114,6 +120,7 @@ public class ValidationServiceTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.AlarmTriggerReference); } + /// <summary>Verifies that an alarm referencing an existing attribute produces no error.</summary> [Fact] public void Validate_AlarmReferencesExistingAttribute_NoError() { @@ -137,6 +144,7 @@ public class ValidationServiceTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.AlarmTriggerReference); } + /// <summary>Verifies that a script trigger referencing a missing attribute returns an error.</summary> [Fact] public void Validate_ScriptTriggerReferencesMissingAttribute_ReturnsError() { @@ -160,10 +168,11 @@ public class ValidationServiceTests Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ScriptTriggerReference); } + /// <summary>Verifies that an unbound data-sourced attribute at design time returns a warning rather than an error.</summary> [Fact] public void Validate_UnboundDataSourceAttribute_DesignTime_ReturnsWarningNotError() { - // M2.8 (#23): at template design time (the default, enforceConnectionBindings:false) + // At template design time (the default, enforceConnectionBindings:false) // a data-sourced attribute is legitimately unbound — bindings are set later at // instance/deploy time. So this must stay a non-blocking WARNING and IsValid true. var config = new FlattenedConfiguration @@ -187,10 +196,11 @@ public class ValidationServiceTests Assert.True(result.IsValid); } + /// <summary>Verifies that an unbound data-sourced attribute at deploy time returns an error and blocks.</summary> [Fact] public void Validate_UnboundDataSourceAttribute_DeployTime_ReturnsErrorAndBlocks() { - // M2.8 (#23): the deploy path opts in (enforceConnectionBindings:true). A data-sourced + // The deploy path opts in (enforceConnectionBindings:true). A data-sourced // attribute with no binding now gates the deployment as an ERROR (IsValid false). var config = new FlattenedConfiguration { @@ -212,10 +222,11 @@ public class ValidationServiceTests Assert.False(result.IsValid); } + /// <summary>Verifies that a static attribute without a binding produces no binding error at deploy time.</summary> [Fact] public void Validate_StaticAttributeWithoutBinding_DeployTime_NoBindingError() { - // M2.8 (#23): only DATA-SOURCED attributes require a binding. A static attribute + // Only DATA-SOURCED attributes require a binding. A static attribute // (DataSourceReference == null) must remain OK even under deploy-time enforcement. var config = new FlattenedConfiguration { @@ -238,10 +249,11 @@ public class ValidationServiceTests Assert.True(result.IsValid); } + /// <summary>Verifies that an attribute bound to an existing site connection produces no binding error at deploy time.</summary> [Fact] public void Validate_BoundToExistingSiteConnection_DeployTime_NoBindingError() { - // M2.8 (#23): a data-sourced attribute bound to a connection that exists at the + // A data-sourced attribute bound to a connection that exists at the // target site passes the binding gate. var config = new FlattenedConfiguration { @@ -268,10 +280,11 @@ public class ValidationServiceTests Assert.True(result.IsValid); } + /// <summary>Verifies that an attribute bound to a non-existent site connection returns an error at deploy time.</summary> [Fact] public void Validate_BoundToNonExistentSiteConnection_DeployTime_ReturnsError() { - // M2.8 (#23): a binding pointing at a connection that does NOT exist on the + // A binding pointing at a connection that does NOT exist on the // target site is an ERROR that blocks deployment. var config = new FlattenedConfiguration { @@ -298,10 +311,11 @@ public class ValidationServiceTests Assert.False(result.IsValid); } + /// <summary>Verifies that the exists-at-site check is inert for a bound attribute when no site is set at deploy time.</summary> [Fact] public void Validate_BoundAttributeWithNoSiteSet_DeployTime_ExistsAtSiteCheckIsInert() { - // M2.8 (#23): when siteConnectionNames is null the "exists at site" half of the + // When siteConnectionNames is null the "exists at site" half of the // binding check stays inert — a properly-bound data-sourced attribute must NOT // produce a ConnectionBinding error, even under deploy-time enforcement. // This pins the contract: passing enforce:true + siteConnectionNames:null is safe @@ -328,6 +342,7 @@ public class ValidationServiceTests Assert.True(result.IsValid); } + /// <summary>Verifies that an empty configuration returns a warning.</summary> [Fact] public void Validate_EmptyConfig_ReturnsWarning() { @@ -337,12 +352,13 @@ public class ValidationServiceTests Assert.Contains(result.Warnings, w => w.Category == ValidationCategory.FlatteningFailure); } - // --- M9-T28a: per-trigger AnalysisKind (Advisory default | Strict escalates) --- + // --- Per-trigger AnalysisKind (Advisory default | Strict escalates) --- // The only currently-advisory finding in CheckExpressionTrigger is the blank/empty // expression (which "will never fire"). Advisory keeps it a non-blocking warning; // Strict promotes it to a deploy-blocking error. The kind rides the existing // trigger-config JSON ({"expression":"...","analysisKind":"Strict"}) — no migration. + /// <summary>Verifies that a blank expression trigger under the default advisory kind warns but passes.</summary> [Fact] public void Validate_ExpressionTrigger_BlankExpression_AdvisoryDefault_WarnsButPasses() { @@ -368,6 +384,7 @@ public class ValidationServiceTests Assert.True(result.IsValid); } + /// <summary>Verifies that a blank expression trigger under the strict kind fails with an error.</summary> [Fact] public void Validate_ExpressionTrigger_BlankExpression_StrictKind_FailsWithError() { @@ -392,6 +409,7 @@ public class ValidationServiceTests Assert.False(result.IsValid); } + /// <summary>Verifies that a blank script expression trigger under the strict kind fails with an error.</summary> [Fact] public void Validate_ScriptExpressionTrigger_BlankExpression_StrictKind_FailsWithError() { @@ -416,6 +434,7 @@ public class ValidationServiceTests Assert.False(result.IsValid); } + /// <summary>Verifies that a valid expression trigger passes under both kinds.</summary> [Fact] public void Validate_ExpressionTrigger_ValidExpression_PassesUnderBothKinds() { @@ -463,6 +482,7 @@ public class ValidationServiceTests Assert.DoesNotContain(strictResult.Warnings, w => w.Category == ValidationCategory.AlarmTriggerReference); } + /// <summary>Verifies that skipping compilation skips the expression trigger syntax check.</summary> [Fact] public void Validate_SkipCompilation_SkipsExpressionTriggerSyntaxCheck() { @@ -473,6 +493,7 @@ public class ValidationServiceTests Assert.Contains(full.Errors, e => e.Message.Contains("failed validation")); // deploy gate unchanged } + /// <summary>Verifies that skipping compilation still checks the expression attribute references.</summary> [Fact] public void Validate_SkipCompilation_StillChecksExpressionAttributeReferences() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TestSupport/TestLocalDb.cs b/tests/ZB.MOM.WW.ScadaBridge.TestSupport/TestLocalDb.cs index 46a68f1f..f60e6e62 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TestSupport/TestLocalDb.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TestSupport/TestLocalDb.cs @@ -43,6 +43,8 @@ public sealed class TestLocalDb : IDisposable public ILocalDb Db { get; } /// <summary>Opens a local database at <paramref name="databasePath"/>.</summary> + /// <param name="databasePath">The path of the database file to open.</param> + /// <returns>The opened test local database.</returns> public static TestLocalDb Create(string databasePath) { var configuration = new ConfigurationBuilder() @@ -63,6 +65,8 @@ public sealed class TestLocalDb : IDisposable /// Creates a local database at a fresh temp path. Dispose the instance, then call /// <see cref="DeleteFiles"/> with <see cref="Path"/> to clean up. /// </summary> + /// <param name="prefix">The prefix of the temp file name.</param> + /// <returns>The opened test local database, with <see cref="Path"/> set.</returns> public static TestLocalDb CreateTemp(string prefix = "localdb") { var path = System.IO.Path.Combine( @@ -79,6 +83,7 @@ public sealed class TestLocalDb : IDisposable /// Deletes <paramref name="databasePath"/> and its WAL sidecars. Call only after the /// owning <see cref="TestLocalDb"/> is disposed — the master connection anchors the WAL. /// </summary> + /// <param name="databasePath">The path of the database file whose files are deleted.</param> public static void DeleteFiles(string databasePath) { foreach (var suffix in new[] { "", "-wal", "-shm" }) @@ -87,5 +92,6 @@ public sealed class TestLocalDb : IDisposable } } + /// <summary>Disposes the underlying service provider.</summary> public void Dispose() => _provider.Dispose(); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/CompositionImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/CompositionImportTests.cs index e5042db6..05bbee2e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/CompositionImportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/CompositionImportTests.cs @@ -18,8 +18,8 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests; /// <summary> /// FU-B — integration coverage for the post-flush second-pass rewire in -/// <c>BundleImporter.ApplyAsync</c>: composition edges (<c>#39</c>) and -/// alarm-script FKs (remainder of <c>#37</c>). All three scenarios drive the +/// <c>BundleImporter.ApplyAsync</c>: composition edges and +/// alarm-script FKs. All three scenarios drive the /// full export → load → apply pipeline so the wire-level DTO carries the /// name-keyed references the importer is expected to resolve. /// </summary> @@ -27,6 +27,7 @@ public sealed class CompositionImportTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Initializes the test class with an in-memory database and the Transport services.</summary> public CompositionImportTests() { var services = new ServiceCollection(); @@ -43,7 +44,7 @@ public sealed class CompositionImportTests : IDisposable services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>(); services.AddScoped<INotificationRepository, NotificationRepository>(); services.AddScoped<IInboundApiRepository, InboundApiRepository>(); - // M8: DependencyResolver now injects ISiteRepository to walk the + // DependencyResolver now injects ISiteRepository to walk the // site/data-connection/instance closure; register it or activation fails. services.AddScoped<ISiteRepository, SiteRepository>(); services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>(); @@ -53,6 +54,7 @@ public sealed class CompositionImportTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() => _provider.Dispose(); /// <summary> @@ -97,6 +99,8 @@ public sealed class CompositionImportTests : IDisposable await ctx.SaveChangesAsync(); } + /// <summary>Verifies that composition edges are restored on import.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Composition_edges_are_restored_on_import() { @@ -163,6 +167,8 @@ public sealed class CompositionImportTests : IDisposable } } + /// <summary>Verifies that a composition referencing a skipped template emits an unresolved audit entry and skips the edge.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Composition_referencing_skipped_template_emits_unresolved_audit_and_skips_edge() { @@ -241,6 +247,8 @@ public sealed class CompositionImportTests : IDisposable } } + /// <summary>Verifies that the on-trigger script alarm link is restored on import.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task OnTriggerScript_alarm_link_is_restored_on_import() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ConflictResolutionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ConflictResolutionTests.cs index 77b8b109..680b9946 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ConflictResolutionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ConflictResolutionTests.cs @@ -25,6 +25,7 @@ public sealed class ConflictResolutionTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Initializes the test with an in-memory configuration database and the Transport services.</summary> public ConflictResolutionTests() { var services = new ServiceCollection(); @@ -41,7 +42,7 @@ public sealed class ConflictResolutionTests : IDisposable services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>(); services.AddScoped<INotificationRepository, NotificationRepository>(); services.AddScoped<IInboundApiRepository, InboundApiRepository>(); - // M8: DependencyResolver now injects ISiteRepository to walk the + // DependencyResolver now injects ISiteRepository to walk the // site/data-connection/instance closure; register it or activation fails. services.AddScoped<ISiteRepository, SiteRepository>(); services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>(); @@ -51,6 +52,7 @@ public sealed class ConflictResolutionTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() => _provider.Dispose(); /// <summary> @@ -94,6 +96,8 @@ public sealed class ConflictResolutionTests : IDisposable return session.SessionId; } + /// <summary>Verifies that the Overwrite resolution replaces the existing template's description.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Overwrite_replaces_existing_template_description() { @@ -140,6 +144,8 @@ public sealed class ConflictResolutionTests : IDisposable Assert.Equal(1, result.Overwritten); } + /// <summary>Verifies that the Skip resolution leaves the existing template unchanged.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Skip_leaves_existing_template_unchanged() { @@ -186,6 +192,8 @@ public sealed class ConflictResolutionTests : IDisposable Assert.Equal(0, result.Overwritten); } + /// <summary>Verifies that the Rename resolution creates a new template alongside the existing one.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Rename_creates_new_template_alongside_existing() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Export/BundleExporterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Export/BundleExporterTests.cs index 66f482ab..4b43a7b5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Export/BundleExporterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Export/BundleExporterTests.cs @@ -35,6 +35,7 @@ public sealed class BundleExporterTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Initializes the test class with an in-memory database and the exporter under test.</summary> public BundleExporterTests() { var services = new ServiceCollection(); @@ -64,7 +65,7 @@ public sealed class BundleExporterTests : IDisposable sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(), sp.GetRequiredService<IDataProtectionProvider>())); - // Repositories the resolver pulls from. M8 (B4): the resolver now injects + // Repositories the resolver pulls from. The resolver now injects // ISiteRepository to walk the site/data-connection/instance closure, so it // must be registered or the BuildServiceProvider-time graph resolution for // DependencyResolver fails. @@ -87,8 +88,11 @@ public sealed class BundleExporterTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Releases the resources held by this instance.</summary> public void Dispose() => _provider.Dispose(); + /// <summary>Verifies that exporting writes an audit event and returns a valid bundle.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_writes_audit_event_and_returns_valid_bundle() { @@ -214,6 +218,8 @@ public sealed class BundleExporterTests : IDisposable } } + /// <summary>Verifies that exporting emits the selected SMS config with its secret and the summary count.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_emits_selected_sms_config_with_secret_and_summary_count() { @@ -286,6 +292,8 @@ public sealed class BundleExporterTests : IDisposable Assert.Equal("super-secret-token", token); } + /// <summary>Verifies that exporting with a passphrase produces an encrypted bundle.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_with_passphrase_produces_encrypted_bundle() { @@ -369,6 +377,8 @@ public sealed class BundleExporterTests : IDisposable } } + /// <summary>Verifies that exporting with a site selection packs the sites, data connections and instances.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_with_site_selection_packs_sites_dataconnections_and_instances() { @@ -464,7 +474,7 @@ public sealed class BundleExporterTests : IDisposable Assert.Equal("NORTH-01.Pump1", content.Instances[0].UniqueName); Assert.Equal("PumpStation", content.Instances[0].TemplateName); // The instance carries its binding, and the manifest dep-edge resolves the - // owning site by identifier (I1) — Site:NORTH-01, never Site:<rawId>. + // owning site by identifier (I1) — the site's identifier string, never the raw id. Assert.Single(content.Instances[0].ConnectionBindings); var instanceEntry = Assert.Single( manifest.Contents, @@ -473,6 +483,8 @@ public sealed class BundleExporterTests : IDisposable Assert.DoesNotContain(instanceEntry.DependsOn, d => d == $"Site:{siteId}"); } + /// <summary>Verifies that a directly selected instance records its owning site by identifier.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ExportAsync_directly_selected_instance_records_owning_site_by_identifier() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/AreaTransportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/AreaTransportTests.cs index 4a60a091..92061514 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/AreaTransportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/AreaTransportTests.cs @@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// <summary> -/// Integration tests for Area-by-name transport (Task 7): a source instance +/// Integration tests for Area-by-name transport: a source instance /// assigned to an <see cref="Area"/> under its site must carry that area's NAME /// through the exported bundle so the importer's <c>ResolveOrCreateAreaIdAsync</c> /// machinery can resolve-or-create the equivalent area under the target site. This @@ -36,6 +36,7 @@ public sealed class AreaTransportTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Builds the service provider with an in-memory database and the Transport services.</summary> public AreaTransportTests() { var services = new ServiceCollection(); @@ -60,12 +61,15 @@ public sealed class AreaTransportTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Releases the resources used by this instance.</summary> public void Dispose() => _provider.Dispose(); + /// <summary>Verifies that an instance's area travels by name and resolves under the target site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Instance_Area_travels_by_name_and_resolves_under_target_site() { - // ---- Seed a site + instance assigned to an Area "Line-1" ---- + // ---- Seed a site + instance assigned to an Area "Line 1" ---- await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>(); @@ -161,7 +165,7 @@ public sealed class AreaTransportTests : IDisposable } // ---- Assert: the imported instance's AreaId resolves to an Area named - // "Line-1" under the target site (NOT null). ---- + // "Line 1" under the target site (NOT null). ---- await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs index d8bdaa92..8716b988 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs @@ -48,6 +48,7 @@ public sealed class BundleImporterApplyTests : IDisposable private readonly ServiceProvider _provider; private readonly RecordingScriptArtifactChangeBus _artifactBus = new(); + /// <summary>Initializes a new instance of the <c>BundleImporterApplyTests</c> class.</summary> public BundleImporterApplyTests() { var services = new ServiceCollection(); @@ -82,11 +83,11 @@ public sealed class BundleImporterApplyTests : IDisposable services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>(); services.AddScoped<INotificationRepository, NotificationRepository>(); services.AddScoped<IInboundApiRepository, InboundApiRepository>(); - // M8: DependencyResolver now injects ISiteRepository to walk the + // DependencyResolver now injects ISiteRepository to walk the // site/data-connection/instance closure; register it or activation fails. services.AddScoped<ISiteRepository, SiteRepository>(); services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>(); - // M9-T32: FlatteningPipeline gained an ISharedSchemaRepository constructor + // FlatteningPipeline gained an ISharedSchemaRepository constructor // dependency for schema-library $ref resolution; register the real repo so // AddDeploymentManager()'s IFlatteningPipeline → FlatteningPipeline activates. // SharedSchemaRepository only needs ScadaBridgeDbContext, which is already @@ -95,11 +96,11 @@ public sealed class BundleImporterApplyTests : IDisposable services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>(); services.AddScoped<IAuditService, AuditService>(); services.AddTransport(); - // #05-T14: a recording bus so the post-commit ScriptArtifactsChanged + // A recording bus so the post-commit ScriptArtifactsChanged // publish is observable. BundleImporter takes IScriptArtifactChangeBus? // optionally, so this registration flows in via DI activation. services.AddSingleton<IScriptArtifactChangeBus>(_artifactBus); - // #16 (M8 D2): the stale-instance probe is implemented in DeploymentManager + // The stale-instance probe is implemented in DeploymentManager // and the flatten/hash primitives in TemplateEngine — register both so // BundleImporter resolves a real IStaleInstanceProbe and ApplyAsync can // compute StaleInstanceIds against genuine revision hashes. @@ -109,6 +110,7 @@ public sealed class BundleImporterApplyTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Disposes the underlying service provider.</summary> public void Dispose() => _provider.Dispose(); // ---- helpers ---- @@ -168,6 +170,8 @@ public sealed class BundleImporterApplyTests : IDisposable // ---- tests ---- + /// <summary>Verifies that applying a bundle adds new artifacts in a single transaction.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_adds_new_artifacts_in_single_transaction() { @@ -210,6 +214,8 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.NotEqual(Guid.Empty, result.BundleImportId); } + /// <summary>Verifies that applying a bundle overwrites an existing artifact when the resolution is Overwrite.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_overwrites_artifact_when_resolution_is_Overwrite() { @@ -251,6 +257,8 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.Equal(1, result.Overwritten); } + /// <summary>Verifies that applying a bundle leaves an existing artifact untouched when the resolution is Skip.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_skips_artifact_when_resolution_is_Skip() { @@ -287,6 +295,8 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.Equal(0, result.Overwritten); } + /// <summary>Verifies that applying a bundle imports an artifact under a new name when the resolution is Rename.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_renames_artifact_when_resolution_is_Rename() { @@ -321,13 +331,15 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.Equal(1, result.Renamed); } + /// <summary>Verifies that applying a bundle rolls back all changes when semantic validation fails.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails() { // Arrange: seed an ApiMethod whose script body calls MissingHelper(). // No SharedScript by that name exists in source or (after wipe) in the // target. ApiMethod name-resolution findings are HARD errors (unlike - // template-script findings, which are advisory as of #05-T19), so this + // template-script findings, which are advisory), so this // must reject the apply and roll everything back. await using (var scope = _provider.CreateAsyncScope()) { @@ -364,6 +376,8 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.Null(sessionStore.Get(sessionId)); } + /// <summary>Verifies that applying a bundle removes the import session on the success path as well.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_removes_session_on_success_path_too() { @@ -392,6 +406,8 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.Null(sessionStore.Get(sessionId)); } + /// <summary>Verifies that applying a bundle stamps the bundle import id on every audit row it emits.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_writes_BundleImportId_on_every_emitted_audit_row() { @@ -444,6 +460,8 @@ public sealed class BundleImporterApplyTests : IDisposable } } + /// <summary>Verifies that applying a bundle writes the BundleImported summary row inside the transaction.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_writes_BundleImported_summary_row_inside_transaction() { @@ -482,6 +500,8 @@ public sealed class BundleImporterApplyTests : IDisposable } } + /// <summary>Verifies that a failed apply writes the BundleImportFailed row outside the rolled-back transaction.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_writes_BundleImportFailed_outside_rolled_back_transaction() { @@ -521,6 +541,8 @@ public sealed class BundleImporterApplyTests : IDisposable } } + /// <summary>Verifies that overwriting a template synchronizes its attributes, alarms and scripts to the bundle.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle() { @@ -664,6 +686,8 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.Equal(1, result.Overwritten); } + /// <summary>Verifies that overwriting an external system synchronizes its methods to the bundle.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_Overwrite_synchronises_external_system_methods_to_bundle() { @@ -789,10 +813,12 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.Equal(1, result.Overwritten); } + /// <summary>Verifies that adding a new external system carries over its timeout seconds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_adds_new_external_system_with_TimeoutSeconds() { - // arch-review 2026-08-19 P7-1 (row-27 shape): TimeoutSeconds is the + // TimeoutSeconds shape: TimeoutSeconds is the // per-system HTTP timeout ExternalSystemClient reads at call time — it must // survive Add, not just the retry policy (T-002 above covers the sibling // Methods-drop regression on Overwrite; this is the Add-path fidelity gap). @@ -831,6 +857,8 @@ public sealed class BundleImporterApplyTests : IDisposable } } + /// <summary>Verifies that overwriting an external system restores its timeout seconds to the bundle value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_Overwrite_restores_TimeoutSeconds_to_bundle_value() { @@ -876,9 +904,9 @@ public sealed class BundleImporterApplyTests : IDisposable } // ───────────────────────────────────────────────────────────────────── - // arch-review 2026-08-19 P7-1: old-bundle semantics for TimeoutSeconds. Hand-packs + // Old-bundle semantics for TimeoutSeconds. Hand-packs // a BundleContentDto with an ExternalSystemDto whose TimeoutSeconds is left at its - // default (null) — the same in-memory shape a pre-P7-1 bundle deserializes to, + // default (null) — the same in-memory shape a pre-TimeoutSeconds bundle deserializes to, // since the field is simply absent from that JSON text. // ───────────────────────────────────────────────────────────────────── private async Task<Guid> PackAndLoadExternalSystemBundleAsync(params ExternalSystemDto[] externalSystems) @@ -916,6 +944,8 @@ public sealed class BundleImporterApplyTests : IDisposable return session.SessionId; } + /// <summary>Verifies that adding from an old bundle without timeout seconds uses the entity default.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_Add_with_old_bundle_missing_TimeoutSeconds_uses_entity_default() { @@ -940,6 +970,8 @@ public sealed class BundleImporterApplyTests : IDisposable } } + /// <summary>Verifies that overwriting from an old bundle without timeout seconds preserves the target's value.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_Overwrite_with_old_bundle_missing_TimeoutSeconds_preserves_target_value() { @@ -1022,6 +1054,8 @@ public sealed class BundleImporterApplyTests : IDisposable return session.SessionId; } + /// <summary>Verifies that applying a bundle creates a new SMS config with its secret decrypted.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_creates_new_sms_config_with_decrypted_secret() { @@ -1060,6 +1094,8 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.Equal(1, result.Added); } + /// <summary>Verifies that applying a bundle overwrites an existing SMS config matched by account SID.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_overwrites_existing_sms_config_matched_by_account_sid() { @@ -1119,6 +1155,8 @@ public sealed class BundleImporterApplyTests : IDisposable // NOT fail to parse, must NOT create any ApiKey rows, and must surface the // ignored-key count on the result (so the operator knows to re-issue keys). // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that applying a bundle ignores legacy API keys without failing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_ignores_legacy_api_keys_in_bundle_without_failing() { @@ -1214,6 +1252,8 @@ public sealed class BundleImporterApplyTests : IDisposable // surfaces no ApiKey/key preview items, and DOES surface the ApiMethod // preview item. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that previewing a legacy bundle does not surface API key items.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_on_legacy_bundle_does_not_surface_key_items() { @@ -1290,7 +1330,7 @@ public sealed class BundleImporterApplyTests : IDisposable } // ───────────────────────────────────────────────────────────────────── - // #16 (M8 D2): StaleInstanceIds enumeration. Overwriting a template changes + // StaleInstanceIds enumeration. Overwriting a template changes // its flattened-config hash, so deployed instances of that template drift // from their DeployedConfigSnapshot.RevisionHash and must surface as stale // in the import result. NotDeployed (incl. freshly-imported) instances are @@ -1356,6 +1396,8 @@ public sealed class BundleImporterApplyTests : IDisposable return site.Id; } + /// <summary>Verifies that overwriting a template marks its deployed instance as stale.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_overwriting_template_marks_deployed_instance_stale() { @@ -1405,6 +1447,8 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.Contains(deployedInstanceId, result.StaleInstanceIds); } + /// <summary>Verifies that overwriting a template does not mark instances of an unchanged template as stale.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_overwriting_template_does_not_mark_unchanged_template_instance_stale() { @@ -1461,6 +1505,8 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.DoesNotContain(valveInstanceId, result.StaleInstanceIds); } + /// <summary>Verifies that a freshly imported NotDeployed instance is never reported as stale.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_freshly_imported_NotDeployed_instance_is_never_stale() { @@ -1545,6 +1591,7 @@ public sealed class BundleImporterApplyTests : IDisposable /// depends entirely on a FRESH hash rather than on the leftover drift. /// </para> /// </summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_second_overwrite_of_same_template_still_marks_instance_stale() { @@ -1587,7 +1634,7 @@ public sealed class BundleImporterApplyTests : IDisposable } var instanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed"); - // Import #1 — Overwrite to shape A. Drifts the instance off its snapshot. + // First import — Overwrite to shape A. Drifts the instance off its snapshot. ImportResult first; await using (var scope = _provider.CreateAsyncScope()) { @@ -1598,11 +1645,11 @@ public sealed class BundleImporterApplyTests : IDisposable } Assert.Contains(instanceId, first.StaleInstanceIds); - // The operator redeploys: the snapshot now matches the post-import-#1 config, - // so the instance is genuinely up to date going into import #2. + // The operator redeploys: the snapshot now matches the post-first-import config, + // so the instance is genuinely up to date going into the second import. await RefreshDeployedSnapshotAsync(instanceId); - // Import #2 — Overwrite the SAME template to shape B. The instance drifts + // Second import — Overwrite the SAME template to shape B. The instance drifts // again and must be reported again. ImportResult second; await using (var scope = _provider.CreateAsyncScope()) @@ -1639,8 +1686,10 @@ public sealed class BundleImporterApplyTests : IDisposable await ctx.SaveChangesAsync(); } - // ============ #05-T14: post-commit ScriptArtifactsChanged publish ============ + // ============ Post-commit ScriptArtifactsChanged publish ============ + /// <summary>Verifies that applying a bundle publishes a script-artifacts-changed notification per kind after commit.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_publishes_ScriptArtifactsChanged_per_kind_after_commit() { @@ -1703,6 +1752,8 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source)); } + /// <summary>Verifies that applying a bundle publishes script-artifacts-changed notifications for Add resolutions.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_publishes_ScriptArtifactsChanged_for_Add_resolutions() { @@ -1758,6 +1809,8 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.Contains("DelmiaRecipeDownload", notification.Names); // FAILS today: Adds are filtered out } + /// <summary>Verifies that a failed apply publishes no notifications.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_failed_apply_publishes_nothing() { @@ -1809,10 +1862,13 @@ public sealed class BundleImporterApplyTests : IDisposable /// <summary>Records every published notification for assertion.</summary> private sealed class RecordingScriptArtifactChangeBus : IScriptArtifactChangeBus { + /// <summary>Gets the notifications received by this bus.</summary> public List<ScriptArtifactsChanged> Received { get; } = new(); + /// <inheritdoc /> public void Publish(ScriptArtifactsChanged notification) => Received.Add(notification); + /// <inheritdoc /> public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler) => throw new NotSupportedException("The recording bus does not support subscriptions."); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs index 0d0d2797..d35b138a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs @@ -33,6 +33,7 @@ public sealed class BundleImporterPreviewTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Initializes a new instance of the <c>BundleImporterPreviewTests</c> class.</summary> public BundleImporterPreviewTests() { var services = new ServiceCollection(); @@ -52,7 +53,7 @@ public sealed class BundleImporterPreviewTests : IDisposable services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>(); services.AddScoped<INotificationRepository, NotificationRepository>(); services.AddScoped<IInboundApiRepository, InboundApiRepository>(); - // M8: DependencyResolver now injects ISiteRepository to walk the + // DependencyResolver now injects ISiteRepository to walk the // site/data-connection/instance closure; register it or activation fails. services.AddScoped<ISiteRepository, SiteRepository>(); services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>(); @@ -62,6 +63,7 @@ public sealed class BundleImporterPreviewTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Releases the resources used by this instance.</summary> public void Dispose() => _provider.Dispose(); private async Task<Stream> ExportTemplatesAsync() @@ -224,6 +226,8 @@ public sealed class BundleImporterPreviewTests : IDisposable NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(), ConnectionBindings: bindings ?? Array.Empty<InstanceConnectionBindingDto>()); + /// <summary>Verifies that PreviewAsync classifies an artifact as identical when its fields match.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_classifies_artifact_as_Identical_when_fields_match() { @@ -255,6 +259,8 @@ public sealed class BundleImporterPreviewTests : IDisposable Assert.Null(pumpItem.FieldDiffJson); } + /// <summary>Verifies that PreviewAsync classifies an artifact as modified and reports the field diff.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_classifies_artifact_as_Modified_with_field_diff() { @@ -299,6 +305,8 @@ public sealed class BundleImporterPreviewTests : IDisposable Assert.Contains("Description", pumpItem.FieldDiffJson!, StringComparison.Ordinal); } + /// <summary>Verifies that PreviewAsync classifies an artifact as new when it is absent from the target.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_classifies_artifact_as_New_when_absent_from_target() { @@ -338,10 +346,12 @@ public sealed class BundleImporterPreviewTests : IDisposable Assert.Null(pumpItem.FieldDiffJson); } + /// <summary>Verifies that PreviewAsync classifies an unchanged structured template as identical.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_unchanged_structured_template_classifies_Identical() { - // #05-T23 end-to-end: a template with a base (ParentTemplateId) and a + // End-to-end: a template with a base (ParentTemplateId) and a // composition, re-imported against an UNCHANGED target, must classify // Identical. Before T23, PreviewAsync compared "<id:N>" placeholders for // BaseTemplateName / composition target against the bundle's real names, @@ -383,6 +393,8 @@ public sealed class BundleImporterPreviewTests : IDisposable Assert.Equal(ConflictKind.Identical, pumpItem.Kind); } + /// <summary>Verifies that PreviewAsync emits a warning when a template script dependency is missing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_emits_Warning_when_template_script_dependency_missing() { @@ -392,7 +404,7 @@ public sealed class BundleImporterPreviewTests : IDisposable // selection that only pulls the template — the bundle won't carry // MissingHelper (it doesn't exist anywhere) so the preview must flag it. // - // Under #05-T19 a TEMPLATE-script name-resolution finding is ADVISORY + // A TEMPLATE-script name-resolution finding is ADVISORY // (the design-time deploy gate re-validates it), so the preview surfaces // it as ConflictKind.Warning — NOT a Blocker. await using (var scope = _provider.CreateAsyncScope()) @@ -431,10 +443,12 @@ public sealed class BundleImporterPreviewTests : IDisposable Assert.DoesNotContain(preview.Items, i => i.Name == "HelperFn"); } + /// <summary>Verifies that PreviewAsync emits a blocker when an API method dependency is missing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_emits_Blocker_when_ApiMethod_dependency_missing() { - // #05-T19 severity split: an ApiMethod script referencing a missing + // Severity split: an ApiMethod script referencing a missing // dependency has no downstream deploy gate, so the preview must surface // it as a hard Blocker (parity with the apply-time SemanticValidationException). await using (var scope = _provider.CreateAsyncScope()) @@ -479,10 +493,12 @@ public sealed class BundleImporterPreviewTests : IDisposable && i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal)); } + /// <summary>Verifies that PreviewAsync emits a blocker when a template script uses a forbidden API.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_emits_Blocker_when_template_script_has_forbidden_api() { - // #05-T20 — the script trust gate runs in preview too, so a forbidden-API + // The script trust gate runs in preview too, so a forbidden-API // script surfaces as a Blocker row (parity with the apply-time // SemanticValidationException). Trust violations block regardless of kind. await using (var scope = _provider.CreateAsyncScope()) @@ -511,6 +527,8 @@ public sealed class BundleImporterPreviewTests : IDisposable && i.BlockerReason.Contains("Process", StringComparison.Ordinal)); } + /// <summary>Verifies that PreviewAsync does not flag OPC UA tag paths in a data source reference as blockers.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_does_not_flag_opcua_tag_paths_in_DataSourceReference_as_blockers() { @@ -550,6 +568,8 @@ public sealed class BundleImporterPreviewTests : IDisposable i.Kind == ConflictKind.Blocker && i.Name == "Tank"); } + /// <summary>Verifies that PreviewAsync does not flag standard-library or runtime member accesses as blockers.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_does_not_flag_stdlib_or_runtime_member_accesses_as_blockers() { @@ -599,10 +619,12 @@ public sealed class BundleImporterPreviewTests : IDisposable } } + /// <summary>Verifies that PreviewAsync diffs each of several templates with children correctly.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_multiple_templates_with_children_diffs_each_correctly() { - // Transport-008 regression: PreviewAsync previously fetched each matching + // Regression: PreviewAsync previously fetched each matching // template's children via a per-name GetTemplateWithChildrenAsync call // (N+1). The bulk variant returns every match in a single query — this // test seeds three templates with distinct child collections and asserts @@ -647,8 +669,10 @@ public sealed class BundleImporterPreviewTests : IDisposable Assert.Equal(ConflictKind.Identical, tankItem.Kind); } - // ---- M8 C2: site/connection/instance preview + required-mapping detection + blockers ---- + // ---- Site/connection/instance preview + required-mapping detection + blockers ---- + /// <summary>Verifies that PreviewAsync into a fresh target surfaces the required mappings with a null auto-match.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_into_fresh_target_surfaces_required_mappings_with_null_auto_match() { @@ -694,6 +718,8 @@ public sealed class BundleImporterPreviewTests : IDisposable Assert.Contains(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "plant-1/OpcUaPrimary" && i.Kind == ConflictKind.New); } + /// <summary>Verifies that PreviewAsync into a populated target auto-matches the site and connection.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_into_populated_target_auto_matches_site_and_connection() { @@ -730,10 +756,12 @@ public sealed class BundleImporterPreviewTests : IDisposable Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.EntityType == "Instance"); } + /// <summary>Verifies that PreviewAsync emits two distinct site-qualified items for two sites sharing a connection name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_two_sites_same_connection_name_emit_two_distinct_site_qualified_items() { - // C2: a bundle with plant-1/OpcUaPrimary + plant-2/OpcUaPrimary must surface + // A bundle with two sites (plant 1 and plant 2) each holding OpcUaPrimary must surface // TWO distinct DataConnection preview items, each site-qualified // ({site}/{name}) — NOT a single collapsed "OpcUaPrimary" item. This is what // lets the operator (and the apply path) resolve each site's connection @@ -782,6 +810,8 @@ public sealed class BundleImporterPreviewTests : IDisposable Assert.DoesNotContain(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary"); } + /// <summary>Verifies that PreviewAsync for a modified instance against a hydrated target shows a child diff rather than everything as added.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_modified_instance_against_hydrated_target_shows_child_diff_not_all_added() { @@ -823,6 +853,8 @@ public sealed class BundleImporterPreviewTests : IDisposable Assert.DoesNotContain("\"ConnectionBindings\"", instItem.FieldDiffJson!, StringComparison.Ordinal); } + /// <summary>Verifies that PreviewAsync emits a blocker for an instance whose template is missing.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_instance_with_missing_template_emits_blocker() { @@ -854,6 +886,8 @@ public sealed class BundleImporterPreviewTests : IDisposable && i.BlockerReason.Contains("NoSuchTemplate", StringComparison.Ordinal)); } + /// <summary>Verifies that PreviewAsync emits a blocker when a referenced connection is absent from both the bundle and the target.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task PreviewAsync_referenced_connection_absent_from_bundle_and_target_emits_blocker() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterRetryingStrategyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterRetryingStrategyTests.cs index 0eec7184..dade6a76 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterRetryingStrategyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterRetryingStrategyTests.cs @@ -23,7 +23,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// <summary> -/// Regression guard for Gitea #28: <see cref="BundleImporter.ApplyAsync"/> must +/// Regression guard: <see cref="BundleImporter.ApplyAsync"/> must /// run its multi-<c>SaveChanges</c> apply through the context's execution /// strategy, because the production central <c>ScadaBridgeDbContext</c> is /// configured with <c>EnableRetryOnFailure</c> and @@ -50,6 +50,7 @@ public sealed class BundleImporterRetryingStrategyTests : IDisposable private readonly ServiceProvider _provider; private readonly DbConnection _sharedConnection; + /// <summary>Initializes a new instance of the <c>BundleImporterRetryingStrategyTests</c> class.</summary> public BundleImporterRetryingStrategyTests() { var services = new ServiceCollection(); @@ -104,12 +105,15 @@ public sealed class BundleImporterRetryingStrategyTests : IDisposable ctx.Database.EnsureCreated(); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() { _provider.Dispose(); _sharedConnection.Dispose(); } + /// <summary>Verifies that applying a bundle succeeds under a retrying execution strategy.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_succeeds_under_a_retrying_execution_strategy() { @@ -211,11 +215,14 @@ public sealed class BundleImporterRetryingStrategyTests : IDisposable /// </summary> private sealed class TestRetryingExecutionStrategy : ExecutionStrategy { + /// <summary>Initializes a new instance of the <c>TestRetryingExecutionStrategy</c> class.</summary> + /// <param name="dependencies">The execution strategy dependencies.</param> public TestRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies) : base(dependencies, maxRetryCount: 3, maxRetryDelay: TimeSpan.FromMilliseconds(1)) { } + /// <inheritdoc /> protected override bool ShouldRetryOn(Exception exception) => false; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterRollbackFailureTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterRollbackFailureTests.cs index 766e7996..b05ba22c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterRollbackFailureTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterRollbackFailureTests.cs @@ -50,6 +50,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable private readonly DbConnection _sharedConnection; private readonly ThrowingRollbackInterceptor _interceptor = new(); + /// <summary>Initializes the in-memory SQLite database and service provider used by the tests.</summary> public BundleImporterRollbackFailureTests() { var services = new ServiceCollection(); @@ -87,7 +88,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>(); services.AddScoped<INotificationRepository, NotificationRepository>(); services.AddScoped<IInboundApiRepository, InboundApiRepository>(); - // M8: DependencyResolver now injects ISiteRepository to walk the + // DependencyResolver now injects ISiteRepository to walk the // site/data-connection/instance closure; register it or activation fails. services.AddScoped<ISiteRepository, SiteRepository>(); services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>(); @@ -102,12 +103,15 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable ctx.Database.EnsureCreated(); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { _provider.Dispose(); _sharedConnection.Dispose(); } + /// <summary>Verifies that applying a bundle still writes a BundleImportFailed audit entry when the transaction rollback itself throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_writes_BundleImportFailed_even_when_RollbackAsync_throws() { @@ -225,8 +229,10 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable { public const string RollbackErrorMarker = "simulated rollback failure"; + /// <summary>Gets or sets whether the interceptor throws when a transaction rolls back.</summary> public bool ThrowOnRollback { get; set; } + /// <inheritdoc /> public override ValueTask<InterceptionResult> TransactionRollingBackAsync( DbTransaction transaction, TransactionEventData eventData, @@ -240,6 +246,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable return base.TransactionRollingBackAsync(transaction, eventData, result, cancellationToken); } + /// <inheritdoc /> public override InterceptionResult TransactionRollingBack( DbTransaction transaction, TransactionEventData eventData, @@ -263,6 +270,9 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable /// </summary> internal sealed class SqliteCompatibleScadaBridgeDbContext : ScadaBridgeDbContext { + /// <summary>Initializes the context with the given options and data protection provider.</summary> + /// <param name="options">The DbContext options.</param> + /// <param name="dataProtectionProvider">The data protection provider used for encrypted columns.</param> public SqliteCompatibleScadaBridgeDbContext( DbContextOptions<ScadaBridgeDbContext> options, IDataProtectionProvider dataProtectionProvider) @@ -270,6 +280,7 @@ internal sealed class SqliteCompatibleScadaBridgeDbContext : ScadaBridgeDbContex { } + /// <inheritdoc /> protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/CreateMissingSiteRelationalTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/CreateMissingSiteRelationalTests.cs index 25d95bbd..f83f4ee7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/CreateMissingSiteRelationalTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/CreateMissingSiteRelationalTests.cs @@ -33,6 +33,7 @@ public sealed class CreateMissingSiteRelationalTests : IDisposable private readonly SqliteConnection _connection; private readonly ServiceProvider _provider; + /// <summary>Initializes the test class with a temporary database.</summary> public CreateMissingSiteRelationalTests() { // One open in-memory SQLite connection for the fixture's lifetime — the @@ -69,12 +70,15 @@ public sealed class CreateMissingSiteRelationalTests : IDisposable scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Database.EnsureCreated(); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() { _provider.Dispose(); _connection.Dispose(); } + /// <summary>Verifies that importing with create-missing for a site and connection wires the materialized site id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Import_create_missing_site_and_connection_wires_the_materialised_site_id() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/FleetWideUnlockThrottleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/FleetWideUnlockThrottleTests.cs index ddd8dd31..a7e78fac 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/FleetWideUnlockThrottleTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/FleetWideUnlockThrottleTests.cs @@ -23,6 +23,7 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable private readonly SqliteConnection _connection; private readonly ServiceProvider _provider; + /// <summary>Initializes a new instance of the <c>FleetWideUnlockThrottleTests</c> class.</summary> public FleetWideUnlockThrottleTests() { // One open in-memory SQLite connection for the fixture's lifetime — the database @@ -40,6 +41,7 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Database.EnsureCreated(); } + /// <summary>Disposes the service provider and the shared connection.</summary> public void Dispose() { _provider.Dispose(); @@ -61,10 +63,12 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable _provider.GetRequiredService<IServiceScopeFactory>(), clock), logger: null); + /// <summary>Verifies that two nodes sharing one store enforce a single combined throttle window.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TwoNodesSharingOneStore_EnforceASingleCombinedWindow() { - // The headline row-52 contract: attempts spread across both central nodes count + // The headline contract: attempts spread across both central nodes count // against ONE budget. Before this, five attempts per node were both allowed. var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero)); var nodeA = NewNode(clock); @@ -81,6 +85,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable Assert.False(await nodeB.TryRegisterAttemptAsync("10.0.0.1", 10)); } + /// <summary>Verifies that denied attempts do not consume any of the throttle budget.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task DeniedAttempts_BurnNoBudget() { @@ -108,6 +114,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable Assert.Equal(3, recorded); } + /// <summary>Verifies that the throttle window survives a restart.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TheWindowIsRestartDurable() { @@ -125,6 +133,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable Assert.False(await afterRestart.TryRegisterAttemptAsync("10.0.0.1", 3)); } + /// <summary>Verifies that the throttle window slides so an aged-out budget is released.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TheWindowSlides_SoAnAgedOutBudgetIsReleased() { @@ -142,6 +152,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable Assert.True(await node.TryRegisterAttemptAsync("10.0.0.1", 3)); } + /// <summary>Verifies that throttle buckets are isolated per client.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task BucketsAreIsolatedPerClient() { @@ -156,6 +168,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable Assert.True(await node.TryRegisterAttemptAsync("<unattributed>", 1)); } + /// <summary>Verifies that a store failure fails open and does not block the unlock.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task StoreFailure_FailsOpen_AndDoesNotBlockTheUnlock() { @@ -176,6 +190,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable } } + /// <summary>Verifies that caller cancellation is not swallowed by the fail-open path.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task CallerCancellation_IsNotSwallowedByTheFailOpenPath() { @@ -197,10 +213,12 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable () => limiter.TryRegisterAttemptAsync("10.0.0.1", 10, cts.Token)); } + /// <summary>Verifies that without a shared window the limiter still enforces a process-local cap.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task WithoutASharedWindow_TheLimiterStillEnforcesAProcessLocalCap() { - // Hosts that register Transport without a shared window keep the pre-row-52 + // Hosts that register Transport without a shared window keep the pre-shared-window // behaviour rather than losing the cap entirely. var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero)); var limiter = new BundleUnlockRateLimiter(clock, BundleUnlockRateLimiter.DefaultWindow); @@ -210,6 +228,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable Assert.False(await limiter.TryRegisterAttemptAsync("10.0.0.1", 1)); } + /// <summary>Verifies that aged rows are pruned opportunistically once the prune interval has elapsed.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task AgedRowsArePrunedOpportunistically_OnceThePruneIntervalHasElapsed() { @@ -233,6 +253,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable Assert.Equal("10.0.0.2", Assert.Single(remaining).ClientKey); } + /// <summary>Verifies that recent rows survive a prune pass.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RecentRowsSurviveAPrunePass() { @@ -252,6 +274,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable Assert.Equal(2, await ctx.BundleUnlockAttempts.AsNoTracking().CountAsync()); } + /// <summary>Verifies that the scheduled prune clears aged rows even when there is no unlock traffic.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ScheduledPrune_ClearsAgedRows_WithNoUnlockTrafficAtAll() { @@ -275,6 +299,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable Assert.Empty(await ctx.BundleUnlockAttempts.AsNoTracking().ToListAsync()); } + /// <summary>Verifies that the scheduled prune respects the retention horizon and the cadence claim.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ScheduledPrune_RespectsTheRetentionHorizonAndTheCadenceClaim() { @@ -300,6 +326,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable Assert.Equal(1, await ctx.BundleUnlockAttempts.AsNoTracking().CountAsync()); } + /// <summary>Verifies that the scheduled prune does not throw when the store is unavailable.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ScheduledPrune_DoesNotThrow_WhenTheStoreIsUnavailable() { @@ -320,8 +348,13 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable private sealed class TestClock : TimeProvider { private DateTimeOffset _now; + /// <summary>Initializes a new instance of the <c>TestClock</c> class.</summary> + /// <param name="start">The initial time of the clock.</param> public TestClock(DateTimeOffset start) { _now = start; } + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => _now; + /// <summary>Moves the test clock forward by the given amount.</summary> + /// <param name="by">The amount to advance the clock by.</param> public void Advance(TimeSpan by) => _now = _now.Add(by); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/InheritanceImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/InheritanceImportTests.cs index 33a07858..26064cec 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/InheritanceImportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/InheritanceImportTests.cs @@ -28,6 +28,7 @@ public sealed class InheritanceImportTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Initializes a new instance of the <see cref="InheritanceImportTests"/> class.</summary> public InheritanceImportTests() { var services = new ServiceCollection(); @@ -52,6 +53,7 @@ public sealed class InheritanceImportTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() => _provider.Dispose(); private async Task<byte[]> ExportAllTemplatesAsync() @@ -110,6 +112,8 @@ public sealed class InheritanceImportTests : IDisposable return session.SessionId; } + /// <summary>Verifies that importing a derived template wires its parent template id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Import_DerivedTemplate_WiresParentTemplateId() { @@ -137,6 +141,8 @@ public sealed class InheritanceImportTests : IDisposable } } + /// <summary>Verifies that an overwrite import updates the parent edge and honours a renamed base.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Import_Overwrite_UpdatesParentEdge_AndRenamedBaseIsHonoured() { @@ -167,6 +173,8 @@ public sealed class InheritanceImportTests : IDisposable } } + /// <summary>Verifies that importing a derived template whose base was skipped leaves the parent null and audits it as unresolved.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Import_DerivedWithSkippedBase_LeavesNullAndAuditsUnresolved() { @@ -218,6 +226,8 @@ public sealed class InheritanceImportTests : IDisposable await ctx.SaveChangesAsync(); } + /// <summary>Verifies that an overwrite import that closes an inheritance cycle throws and rolls back.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Import_Overwrite_ThatClosesInheritanceCycle_Throws_AndRollsBack() { @@ -254,6 +264,8 @@ public sealed class InheritanceImportTests : IDisposable } } + /// <summary>Verifies that an overwrite import that closes a composition cycle throws and rolls back.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Import_Overwrite_ThatClosesCompositionCycle_Throws_AndRollsBack() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/NativeAlarmSourceImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/NativeAlarmSourceImportTests.cs index f5833bb8..cca4e05b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/NativeAlarmSourceImportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/NativeAlarmSourceImportTests.cs @@ -22,7 +22,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// <summary> -/// #05-T5: a template's <see cref="TemplateNativeAlarmSource"/> collection must +/// A template's <see cref="TemplateNativeAlarmSource"/> collection must /// survive an export→import round-trip (Add + Overwrite sync), diff in Preview, /// and — crucially — carry across environments so an instance-level override of a /// native alarm source no longer dangles in the target. Before this fix the @@ -34,6 +34,7 @@ public sealed class NativeAlarmSourceImportTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Initializes the in-memory host used by the import tests.</summary> public NativeAlarmSourceImportTests() { var services = new ServiceCollection(); @@ -67,6 +68,7 @@ public sealed class NativeAlarmSourceImportTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Disposes the underlying service provider.</summary> public void Dispose() => _provider.Dispose(); private async Task<Guid> ExportAllTemplatesAndLoadAsync() @@ -118,6 +120,8 @@ public sealed class NativeAlarmSourceImportTests : IDisposable LockedInDerived = lockedInDerived, }; + /// <summary>Verifies that an Add import carries the template's native alarm sources.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Import_Add_CarriesTemplateNativeAlarmSources() { @@ -165,6 +169,8 @@ public sealed class NativeAlarmSourceImportTests : IDisposable } } + /// <summary>Verifies that an Overwrite import with no change emits no native source audit.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Import_Overwrite_NoChange_EmitsNoNativeSourceAudit() { @@ -196,6 +202,8 @@ public sealed class NativeAlarmSourceImportTests : IDisposable } } + /// <summary>Verifies that an Overwrite import synchronizes the native alarm sources.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Import_Overwrite_SyncsNativeAlarmSources() { @@ -253,6 +261,8 @@ public sealed class NativeAlarmSourceImportTests : IDisposable } } + /// <summary>Verifies that Preview diffs the native alarm sources.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Preview_DiffsNativeAlarmSources() { @@ -286,6 +296,8 @@ public sealed class NativeAlarmSourceImportTests : IDisposable } } + /// <summary>Verifies that an imported instance override no longer dangles.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Import_InstanceOverride_NoLongerDangles() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs index 0eef3513..24b0bc28 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs @@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// <summary> -/// Integration tests for the M8 D1 site/instance-scoped apply path of +/// Integration tests for the site/instance-scoped apply path of /// <see cref="ZB.MOM.WW.ScadaBridge.Transport.Import.BundleImporter.ApplyAsync"/>: /// resolve-or-create target sites + data connections from a <see cref="BundleNameMap"/>, /// upsert instances, and rewire every cross-environment FK (connection-binding @@ -38,6 +38,7 @@ public sealed class SiteInstanceImportTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Initializes the test class with a service provider backed by an in-memory database.</summary> public SiteInstanceImportTests() { var services = new ServiceCollection(); @@ -68,6 +69,7 @@ public sealed class SiteInstanceImportTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Releases the resources used by this instance.</summary> public void Dispose() => _provider.Dispose(); // ────────────────────────────────────────────────────────────────────── @@ -246,6 +248,8 @@ public sealed class SiteInstanceImportTests : IDisposable // CreateNew into a fresh target // ────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that creating into a fresh target creates the site, connection and instance with rewired foreign keys.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_CreateNew_into_fresh_target_creates_site_connection_instance_with_rewired_FKs() { @@ -327,7 +331,7 @@ public sealed class SiteInstanceImportTests : IDisposable Assert.Equal(3, result.Added); Assert.Equal(1, result.Skipped); // No template was overwritten and the imported instance is NotDeployed, so - // the D2 (#16) stale-instance enumeration finds nothing. + // the stale-instance enumeration finds nothing. Assert.Empty(result.StaleInstanceIds); } @@ -335,11 +339,13 @@ public sealed class SiteInstanceImportTests : IDisposable // MapToExisting into a populated target (FK remap to existing ids) // ────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that mapping to an existing connection remaps the binding to the existing target connection id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_MapToExisting_remaps_binding_to_existing_target_connection_id() { - // Source env: plant-1 / OpcUaPrimary. Build a bundle from it, then set up - // a DISTINCT target env that already has plant-1 + OpcUaPrimary with their + // Source env: plant 1 / OpcUaPrimary. Build a bundle from it, then set up + // a DISTINCT target env that already has plant 1 + OpcUaPrimary with their // OWN surrogate ids (achieved by wiping + reseeding so ids differ from the // source's). The import must MapToExisting and rebind to the TARGET ids. await SeedSiteClosureAsync(); @@ -420,6 +426,8 @@ public sealed class SiteInstanceImportTests : IDisposable // Rename an instance on import // ────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that applying renames the instance unique name on import.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_renames_instance_unique_name_on_import() { @@ -467,6 +475,8 @@ public sealed class SiteInstanceImportTests : IDisposable // Cross-site rebind: source site maps to a DIFFERENTLY-named target site // ────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that applying maps the source site to a differently named target site and rebinds the connections.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_maps_source_site_to_differently_named_target_site_and_rebinds_connections() { @@ -475,7 +485,7 @@ public sealed class SiteInstanceImportTests : IDisposable await WipeSiteClosureAsync(); // Target env has a DIFFERENTLY-identified site (plant-west) carrying a - // same-named connection. The operator maps plant-1 → plant-west. + // same-named connection. The operator maps plant 1 → plant-west. int westSiteId; int westConnId; await using (var scope = _provider.CreateAsyncScope()) @@ -514,7 +524,7 @@ public sealed class SiteInstanceImportTests : IDisposable await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>(); - // No plant-1 site was created — the bundle's site mapped onto plant-west. + // No plant 1 site was created — the bundle's site mapped onto plant-west. Assert.Equal(0, await ctx.Sites.CountAsync(s => s.SiteIdentifier == "plant-1")); var inst = await ctx.Instances @@ -532,6 +542,8 @@ public sealed class SiteInstanceImportTests : IDisposable // Rollback: a mid-apply failure persists NOTHING // ────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that applying rolls back the site and connection when the instance pass throws.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_rolls_back_site_and_connection_when_instance_pass_throws() { @@ -612,6 +624,9 @@ public sealed class SiteInstanceImportTests : IDisposable // Overwrite an existing instance: child rows replaced from the bundle // ────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that an overwrite cannot reassign a deployed instance.</summary> + /// <param name="state">The state of the existing instance being overwritten.</param> + /// <returns>A task that represents the asynchronous operation.</returns> [Theory] [InlineData(InstanceState.Enabled)] [InlineData(InstanceState.NotDeployed)] @@ -662,6 +677,8 @@ public sealed class SiteInstanceImportTests : IDisposable } } + /// <summary>Verifies that overwriting an existing instance replaces its child rows and remaps the binding.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_Overwrite_existing_instance_replaces_child_rows_and_remaps_binding() { @@ -729,13 +746,15 @@ public sealed class SiteInstanceImportTests : IDisposable // C1 — binding references a TARGET connection the bundle did NOT carry // ────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a binding to a target connection omitted from the bundle resolves to the existing id rather than zero.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_binding_to_target_connection_omitted_from_bundle_resolves_to_existing_id_not_zero() { // C1 regression: a valid bundle can carry an instance whose binding (and // native-alarm-source override) references a connection that exists in the // TARGET but was NOT carried in the bundle's DataConnections. Preview does - // NOT block it (it auto-matches the target). Before the C1 Pass-2 fix the + // NOT block it (it auto-matches the target). Before the second-pass fix the // connection map MISSED for that binding → DataConnectionId defaulted to 0 // (an invalid FK). After the fix the map resolves the EXISTING target // connection's id and both the binding + the native-alarm override rewrite @@ -806,7 +825,7 @@ public sealed class SiteInstanceImportTests : IDisposable var nameMap = new BundleNameMap( Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") }, // No explicit connection mapping — the connection auto-matches the - // existing target connection within plant-1 (the C1 Pass-2 path). + // existing target connection within plant 1 (the second-pass path). Connections: Array.Empty<ConnectionMapping>()); var result = await ApplyAsync( @@ -844,6 +863,8 @@ public sealed class SiteInstanceImportTests : IDisposable Assert.Equal(1, result.Added); } + /// <summary>Verifies that a binding to a connection in neither the bundle nor the target fails instead of writing a zero id.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_binding_to_connection_in_neither_bundle_nor_target_fails_instead_of_writing_zero() { @@ -924,6 +945,8 @@ public sealed class SiteInstanceImportTests : IDisposable // M-4 — native-alarm-source ConnectionNameOverride RENAME-REDIRECT // ────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a native alarm override's connection name is rewritten to the redirected target name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_native_alarm_override_connection_name_rewritten_to_redirected_target_name() { @@ -1049,14 +1072,16 @@ public sealed class SiteInstanceImportTests : IDisposable // C2 — two sites with same-named connections resolve independently // ────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that two sites sharing a connection name are resolved independently per site.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_two_sites_same_connection_name_apply_per_site_resolution_independently() { // C2 regression: connection names are unique only WITHIN a site, so a bundle - // with plant-1/OpcUaPrimary + plant-2/OpcUaPrimary must NOT collapse onto a + // with plant 1/OpcUaPrimary + plant 2/OpcUaPrimary must NOT collapse onto a // single resolution. Both target connections exist; the operator Overwrites - // plant-1's and Skips plant-2's. The site-qualified resolution key routes the - // Overwrite to plant-1's connection ONLY and leaves plant-2's untouched. + // plant 1's and Skips plant 2's. The site-qualified resolution key routes the + // Overwrite to plant 1's connection ONLY and leaves plant 2's untouched. int plant1ConnId, plant2ConnId; await using (var scope = _provider.CreateAsyncScope()) { @@ -1143,13 +1168,13 @@ public sealed class SiteInstanceImportTests : IDisposable { var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>(); - // plant-1's connection was Overwritten with the bundle's config. + // plant 1's connection was Overwritten with the bundle's config. var c1 = await ctx.DataConnections.SingleAsync(c => c.Id == plant1ConnId); Assert.Contains("p1-from-bundle", c1.PrimaryConfiguration!); Assert.Equal(9, c1.FailoverRetryCount); - // plant-2's same-named connection was LEFT UNTOUCHED by the Skip — the - // bare-name collision bug would have applied plant-1's Overwrite here too. + // plant 2's same-named connection was LEFT UNTOUCHED by the Skip — the + // bare-name collision bug would have applied plant 1's Overwrite here too. var c2 = await ctx.DataConnections.SingleAsync(c => c.Id == plant2ConnId); Assert.Contains("p2-existing", c2.PrimaryConfiguration!); Assert.Equal(2, c2.FailoverRetryCount); @@ -1158,15 +1183,17 @@ public sealed class SiteInstanceImportTests : IDisposable Assert.Equal(2, await ctx.DataConnections.CountAsync(c => c.Name == "OpcUaPrimary")); } - // One Overwrite (plant-1 conn), three Skips (two sites + plant-2 conn). + // One Overwrite (plant 1 conn), three Skips (two sites + plant 2 conn). Assert.Equal(1, result.Overwritten); Assert.Equal(3, result.Skipped); } // ────────────────────────────────────────────────────────────────────── - // #229 — empty-ConnectionName binding is silently SKIPPED (not written) + // Empty-ConnectionName binding is silently SKIPPED (not written) // ────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that a binding with an empty connection name is skipped rather than written.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task ApplyAsync_empty_ConnectionName_binding_is_skipped_not_written() { @@ -1277,9 +1304,11 @@ public sealed class SiteInstanceImportTests : IDisposable } // ────────────────────────────────────────────────────────────────────── - // N4 (plan R2-05 T7): warn on overrides of LOCKED template members + // Warn on overrides of LOCKED template members // ────────────────────────────────────────────────────────────────────── + /// <summary>Verifies that an override on a locked attribute emits a warning and the import still succeeds.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Import_OverrideOnLockedAttribute_EmitsWarning_ImportStillSucceeds() { @@ -1323,6 +1352,8 @@ public sealed class SiteInstanceImportTests : IDisposable } } + /// <summary>Verifies that a preview of an override on a locked native alarm source shows a warning row.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Preview_OverrideOnLockedNativeAlarmSource_ShowsWarningRow() { @@ -1344,6 +1375,8 @@ public sealed class SiteInstanceImportTests : IDisposable && b.BlockerReason!.Contains("locked", StringComparison.OrdinalIgnoreCase)); } + /// <summary>Verifies that an override on an unlocked attribute produces no lock warning.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Import_OverrideOnUnlockedAttribute_NoLockWarning() { @@ -1378,7 +1411,7 @@ public sealed class SiteInstanceImportTests : IDisposable } /// <summary> - /// Seeds a template ("Pump") + site ("plant-1") + instance ("Pump-01") whose + /// Seeds a template ("Pump") + site ("plant 1") + instance ("Pump 01") whose /// overrides target the given members; <paramref name="lockAttribute"/> / /// <paramref name="lockNativeSource"/> toggle the template member's IsLocked flag. /// The attribute override is always present; the native-source override (and its diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs index 0f3fba48..c1ab41d7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs @@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// <summary> -/// #05-T3: a template script's <see cref="TemplateScript.MinTimeBetweenRuns"/> and +/// A template script's <see cref="TemplateScript.MinTimeBetweenRuns"/> and /// <see cref="TemplateScript.ExecutionTimeoutSeconds"/> must survive an export→import /// round-trip. The exporter serializes both fields (EntitySerializer) and the DTO /// carries them; before this fix the importer's <c>BuildTemplate</c> (Add) and @@ -33,6 +33,7 @@ public sealed class TemplateScriptFidelityTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Initializes the test class with a fresh service provider and database.</summary> public TemplateScriptFidelityTests() { var services = new ServiceCollection(); @@ -66,6 +67,7 @@ public sealed class TemplateScriptFidelityTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Disposes the service provider.</summary> public void Dispose() => _provider.Dispose(); private async Task<Guid> ExportAndLoadAsync() @@ -99,6 +101,8 @@ public sealed class TemplateScriptFidelityTests : IDisposable return session.SessionId; } + /// <summary>Verifies that adding a template script preserves its minimum time between runs and execution timeout.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Add_preserves_MinTimeBetweenRuns_and_ExecutionTimeoutSeconds() { @@ -147,6 +151,8 @@ public sealed class TemplateScriptFidelityTests : IDisposable } } + /// <summary>Verifies that overwriting updates the execution timeout, shows it as the only diff, and emits an audit entry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Overwrite_updates_ExecutionTimeoutSeconds_only_diff_and_emits_audit() { @@ -200,10 +206,12 @@ public sealed class TemplateScriptFidelityTests : IDisposable } } + /// <summary>Verifies that adding preserves the locked-in-derived flag on attributes, alarms and scripts.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Add_preserves_LockedInDerived_on_attribute_alarm_script() { - // #05-T4: LockedInDerived must survive export→import on all three child kinds. + // LockedInDerived must survive export→import on all three child kinds. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs index 2b4e984c..862b4234 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs @@ -26,7 +26,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests; /// <summary> -/// #05-T8 — per-entity <b>structural</b> round-trip guard. +/// Per-entity <b>structural</b> round-trip guard. /// <para> /// For every transported entity type, seed a MAXIMALLY populated entity (every /// writable data property set to a non-default value), export it through the real @@ -78,6 +78,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Initializes a new instance of <c>RoundTripEquivalenceTests</c>.</summary> public RoundTripEquivalenceTests() { var services = new ServiceCollection(); @@ -111,6 +112,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Releases the resources held by the test fixture.</summary> public void Dispose() => _provider.Dispose(); // ────────────────────────────────────────────────────────────────────── @@ -187,6 +189,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable // Template + its five child kinds + folders // ══════════════════════════════════════════════════════════════════════ + /// <summary>Verifies that round trip template closure preserves every writable property.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RoundTrip_template_closure_preserves_every_writable_property() { @@ -336,6 +340,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable .Include(t => t.Compositions) .SingleAsync(t => t.Name == name); + /// <summary>Verifies that round trip template folders preserve hierarchy and sort order.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RoundTrip_template_folders_preserve_hierarchy_and_sort_order() { @@ -386,7 +392,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable AssertRoundTripEqual(srcChild, impChild, nameof(TemplateFolder.Id), nameof(TemplateFolder.ParentFolderId)); - // (RED) HOLE #5 — folder hierarchy is flattened on import Add: the folder + // (RED) Folder hole — folder hierarchy is flattened on import Add: the folder // Add pass builds `new TemplateFolder(dto.Name) { SortOrder }` and never // consults the DTO's ParentName, so the child lands parent-less. The // serializer-level Roundtrip_template_folder_preserves_hierarchy test is @@ -399,6 +405,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable // SharedScript // ══════════════════════════════════════════════════════════════════════ + /// <summary>Verifies that round trip shared script preserves every writable property.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RoundTrip_shared_script_preserves_every_writable_property() { @@ -440,6 +448,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable // ExternalSystemDefinition + ExternalSystemMethod // ══════════════════════════════════════════════════════════════════════ + /// <summary>Verifies that round trip external system and method preserve every writable property.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RoundTrip_external_system_and_method_preserve_every_writable_property() { @@ -450,7 +460,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable AuthConfiguration = "{\"apiKey\":\"super-secret\"}", // rides SecretsBlock MaxRetries = 6, RetryDelay = TimeSpan.FromSeconds(17), - // arch-review 2026-08-19 P7-1: a default-0 fixture value made this + // A default (zero) fixture value made this // reflection guard vacuous for TimeoutSeconds (comparing default against // default survives even a serializer that drops the field entirely) — a // non-default value makes the guard actually exercise the field. @@ -495,14 +505,14 @@ public sealed class RoundTripEquivalenceTests : IDisposable { var impSys = await ctx.ExternalSystemDefinitions.AsNoTracking().SingleAsync(); - // (RED) HOLES #1/#2 — MaxRetries + RetryDelay are dropped on import Add: + // (RED) MaxRetries + RetryDelay are dropped on import Add: // BuildExternalSystem builds `new ExternalSystemDefinition(name, url, authType) // { AuthConfiguration }` and never copies the DTO's retry config. The // serializer-level Roundtrip_external_system_preserves_retry_config test is // GREEN — the loss is purely in BundleImporter's Add path. AssertRoundTripEqual(srcSys, impSys, nameof(ExternalSystemDefinition.Id)); - // (RED) HOLE #3 — ExternalSystemMethod rows are dropped on import Add: + // (RED) ExternalSystemMethod rows are dropped on import Add: // only the Overwrite path calls SyncExternalSystemMethodsAsync; the Add path // carries no methods (the definition entity has no Methods navigation to // cascade). The serializer-level round-trip carries them; the pipeline loses @@ -521,6 +531,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable // DatabaseConnectionDefinition (External-System DB connection) // ══════════════════════════════════════════════════════════════════════ + /// <summary>Verifies that round trip database connection preserves every writable property.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RoundTrip_database_connection_preserves_every_writable_property() { @@ -564,6 +576,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable // NotificationList + NotificationRecipient // ══════════════════════════════════════════════════════════════════════ + /// <summary>Verifies that round trip notification list and recipient preserve every writable property.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RoundTrip_notification_list_and_recipient_preserve_every_writable_property() { @@ -609,7 +623,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable // List scalars (Name, Type) round-trip (GREEN). AssertRoundTripEqual(srcList, impList, nameof(NotificationList.Id)); - // (RED) HOLE #4 — recipients are dropped on EXPORT: the dependency + // (RED) Recipients are dropped on EXPORT: the dependency // resolver loads each selected list via GetNotificationListByIdAsync, which // uses FindAsync(id) with NO Include(Recipients), so the exported DTO carries // an empty Recipients collection and the import adds a recipient-less list. @@ -629,6 +643,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable // SmtpConfiguration // ══════════════════════════════════════════════════════════════════════ + /// <summary>Verifies that round trip smtp configuration preserves every writable property.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RoundTrip_smtp_configuration_preserves_every_writable_property() { @@ -681,6 +697,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable // SmsConfiguration // ══════════════════════════════════════════════════════════════════════ + /// <summary>Verifies that round trip sms configuration preserves every writable property.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RoundTrip_sms_configuration_preserves_every_writable_property() { @@ -726,6 +744,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable // ApiMethod // ══════════════════════════════════════════════════════════════════════ + /// <summary>Verifies that round trip api method preserves every writable property.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RoundTrip_api_method_preserves_every_writable_property() { @@ -770,6 +790,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable // Site + DataConnection + Instance + all four instance-child kinds // ══════════════════════════════════════════════════════════════════════ + /// <summary>Verifies that round trip site closure preserves every writable property.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task RoundTrip_site_closure_preserves_every_writable_property() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripTests.cs index 976f69f1..b5d8093f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripTests.cs @@ -39,6 +39,7 @@ public sealed class RoundTripTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Initializes a new instance of the <see cref="RoundTripTests"/> class with an in-memory database and the Transport services.</summary> public RoundTripTests() { var services = new ServiceCollection(); @@ -66,7 +67,7 @@ public sealed class RoundTripTests : IDisposable services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>(); services.AddScoped<INotificationRepository, NotificationRepository>(); services.AddScoped<IInboundApiRepository, InboundApiRepository>(); - // M8: DependencyResolver now injects ISiteRepository to walk the + // DependencyResolver injects ISiteRepository to walk the // site/data-connection/instance closure; register it or activation fails. services.AddScoped<ISiteRepository, SiteRepository>(); services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>(); @@ -76,8 +77,11 @@ public sealed class RoundTripTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Disposes the underlying service provider.</summary> public void Dispose() => _provider.Dispose(); + /// <summary>Verifies that exporting, wiping and re-importing a bundle restores the original state.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Export_then_wipe_then_import_restores_state() { @@ -236,7 +240,7 @@ public sealed class RoundTripTests : IDisposable .Include(t => t.Compositions) .SingleAsync(t => t.Name == "Pump"); Assert.Equal("composes BaseDevice", pump.Description); - // FU-B / #39 — composition graph IS restored on import. The bundle + // Composition graph IS restored on import. The bundle // carried Pump composing BaseDevice via InstanceName="base"; the // importer's second pass (ResolveCompositionEdgesAsync) re-resolved // ComposedTemplateName to BaseDevice's new id after the template @@ -302,6 +306,8 @@ public sealed class RoundTripTests : IDisposable Assert.NotEqual(Guid.Empty, result.BundleImportId); } + /// <summary>Verifies that exporting and re-importing a bundle restores the SMS configuration with its secret intact.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Export_then_import_restores_sms_config_with_secret_intact() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/SemanticValidatorImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/SemanticValidatorImportTests.cs index 5bae7130..e6b456f7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/SemanticValidatorImportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/SemanticValidatorImportTests.cs @@ -39,6 +39,7 @@ public sealed class SemanticValidatorImportTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Initializes the test class with an in-memory service provider wired for Transport.</summary> public SemanticValidatorImportTests() { var services = new ServiceCollection(); @@ -55,7 +56,7 @@ public sealed class SemanticValidatorImportTests : IDisposable services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>(); services.AddScoped<INotificationRepository, NotificationRepository>(); services.AddScoped<IInboundApiRepository, InboundApiRepository>(); - // M8: DependencyResolver now injects ISiteRepository to walk the + // DependencyResolver now injects ISiteRepository to walk the // site/data-connection/instance closure; register it or activation fails. services.AddScoped<ISiteRepository, SiteRepository>(); services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>(); @@ -65,6 +66,7 @@ public sealed class SemanticValidatorImportTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() => _provider.Dispose(); /// <summary> @@ -81,7 +83,7 @@ public sealed class SemanticValidatorImportTests : IDisposable var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>(); var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>(); var ids = await ctx.Templates.Select(t => t.Id).ToListAsync(); - // #05-T19: also carry any seeded ApiMethods so the severity-split + // Also carry any seeded ApiMethods so the severity-split // tests (template-script = warning, ApiMethod = hard error) exercise // both source kinds through the same export→load→apply path. var apiIds = await ctx.ApiMethods.Select(m => m.Id).ToListAsync(); @@ -198,10 +200,12 @@ public sealed class SemanticValidatorImportTests : IDisposable } } + /// <summary>Verifies that a template script with an unresolved reference produces a warning but does not block the import.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task TemplateScript_with_unresolved_reference_warns_but_does_not_block_import() { - // #05-T19 — a template whose script body calls UnknownHelper(): a + // A template whose script body calls UnknownHelper(): a // PascalCase identifier that doesn't resolve to any SharedScript or // ExternalSystem in the bundle or the target. Under the severity split, // template-script name-resolution findings are ADVISORY (the design-time @@ -244,10 +248,12 @@ public sealed class SemanticValidatorImportTests : IDisposable } } + /// <summary>Verifies that a template script with a local PascalCase method does not hard-block the import.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Apply_TemplateScriptWithLocalPascalCaseMethod_DoesNotHardBlock() { - // #05-T19 — a template script that DECLARES and calls its own local + // A template script that DECLARES and calls its own local // PascalCase function. The identifier scan would otherwise flag // ComputeRate as a missing SharedScript reference; the local-declaration // exclusion (Roslyn-parsed local functions/methods) removes it, so the @@ -284,10 +290,12 @@ public sealed class SemanticValidatorImportTests : IDisposable w => w.Contains("ComputeRate", StringComparison.Ordinal)); } + /// <summary>Verifies that a template script using Regex and StringBuilder does not hard-block the import.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Apply_TemplateScriptWithRegexAndStringBuilder_DoesNotHardBlock() { - // #05-T19 — a template script using stdlib helpers that the extended + // A template script using stdlib helpers that the extended // denylist now recognizes (Regex, StringBuilder, …). None resolve to a // user SharedScript/ExternalSystem, and none should be flagged. await using (var scope = _provider.CreateAsyncScope()) @@ -326,10 +334,12 @@ public sealed class SemanticValidatorImportTests : IDisposable || w.Contains("StringBuilder", StringComparison.Ordinal)); } + /// <summary>Verifies that an API method with an unresolved reference still hard-blocks the import.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Apply_ApiMethodWithUnresolvedReference_StillHardBlocks() { - // #05-T19 — an ApiMethod script referencing a genuinely-missing + // An ApiMethod script referencing a genuinely-missing // ExternalSystem. ApiMethod findings have no downstream deploy gate, so // they remain HARD errors: the apply throws SemanticValidationException. await using (var scope = _provider.CreateAsyncScope()) @@ -368,10 +378,12 @@ public sealed class SemanticValidatorImportTests : IDisposable } } + /// <summary>Verifies that a template script using a forbidden API hard-blocks the import.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Apply_TemplateScriptWithForbiddenApi_HardBlocks() { - // #05-T20 — bundle import is the fifth script-trust call site. A template + // Bundle import is the fifth script-trust call site. A template // script that reaches a forbidden API (Process.Start) must be rejected at // import review, NOT left to fail at runtime. Unlike the name-resolution // heuristic, a trust verdict is authoritative → hard error for ALL kinds. @@ -411,10 +423,12 @@ public sealed class SemanticValidatorImportTests : IDisposable } } + /// <summary>Verifies that an API method using a forbidden API hard-blocks the import.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Apply_ApiMethodWithForbiddenApi_HardBlocks() { - // #05-T20 — same trust gate over ApiMethod script bodies. + // Same trust gate over ApiMethod script bodies. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>(); @@ -443,10 +457,12 @@ public sealed class SemanticValidatorImportTests : IDisposable Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal)); } + /// <summary>Verifies that an alarm Expression trigger using a forbidden API hard-blocks the import.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Apply_AlarmExpressionTriggerWithForbiddenApi_HardBlocks() { - // #05-T20 (code-review fix): an alarm's Expression trigger compiles and + // An alarm's Expression trigger compiles and // executes at the site, so a forbidden API in the trigger expression must // be caught at import — not just in template/shared/ApiMethod script bodies. await using (var scope = _provider.CreateAsyncScope()) @@ -482,6 +498,8 @@ public sealed class SemanticValidatorImportTests : IDisposable Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal)); } + /// <summary>Verifies that an instance alarm override expression using a forbidden API hard-blocks the import.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Apply_InstanceAlarmOverrideExpressionWithForbiddenApi_HardBlocks() { @@ -518,6 +536,8 @@ public sealed class SemanticValidatorImportTests : IDisposable // pre-exist, but no new import ran — the throw is pre-write at Pass 0). } + /// <summary>Verifies that previewing an instance alarm override expression with a forbidden API surfaces a blocker.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Preview_InstanceAlarmOverrideExpressionWithForbiddenApi_SurfacesBlocker() { @@ -538,6 +558,8 @@ public sealed class SemanticValidatorImportTests : IDisposable && b.BlockerReason!.Contains("trust violation", StringComparison.Ordinal)); } + /// <summary>Verifies that a clean instance alarm override expression imports successfully.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Apply_InstanceAlarmOverrideExpression_Clean_Imports() { @@ -587,10 +609,12 @@ public sealed class SemanticValidatorImportTests : IDisposable } } + /// <summary>Verifies that an API method reference is not suppressed by a template-local function of the same name.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Apply_ApiMethodReference_NotSuppressedByTemplateLocalFunction() { - // #05-T19 (code-review fix): a local function declared in a TEMPLATE script + // A local function declared in a TEMPLATE script // must not suppress a genuinely-missing reference of the same name in an // ApiMethod — the ApiMethod finding stays a HARD error. A pre-fix global // locallyDeclared set silently defeated this. @@ -628,6 +652,8 @@ public sealed class SemanticValidatorImportTests : IDisposable Assert.Contains(ex.Errors, err => err.Contains("SharedHelper", StringComparison.Ordinal)); } + /// <summary>Verifies that the semantic validator catches an alarm trigger type mismatch at import.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task SemanticValidator_catches_alarm_trigger_type_mismatch_at_import() { @@ -685,6 +711,8 @@ public sealed class SemanticValidatorImportTests : IDisposable } } + /// <summary>Verifies that a valid bundle passes semantic validation.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Valid_bundle_passes_semantic_validation() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ValidationFailureTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ValidationFailureTests.cs index 0d84bb89..1e79aab4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ValidationFailureTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ValidationFailureTests.cs @@ -28,6 +28,7 @@ public sealed class ValidationFailureTests : IDisposable { private readonly ServiceProvider _provider; + /// <summary>Initializes a new instance of the <c>ValidationFailureTests</c> class.</summary> public ValidationFailureTests() { var services = new ServiceCollection(); @@ -44,7 +45,7 @@ public sealed class ValidationFailureTests : IDisposable services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>(); services.AddScoped<INotificationRepository, NotificationRepository>(); services.AddScoped<IInboundApiRepository, InboundApiRepository>(); - // M8: DependencyResolver now injects ISiteRepository to walk the + // DependencyResolver now injects ISiteRepository to walk the // site/data-connection/instance closure; register it or activation fails. services.AddScoped<ISiteRepository, SiteRepository>(); services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>(); @@ -54,8 +55,11 @@ public sealed class ValidationFailureTests : IDisposable _provider = services.BuildServiceProvider(); } + /// <summary>Releases the resources held by this test class.</summary> public void Dispose() => _provider.Dispose(); + /// <summary>Verifies that a semantic validation failure rolls back all writes.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Semantic_validation_failure_rolls_back_all_writes() { @@ -64,8 +68,7 @@ public sealed class ValidationFailureTests : IDisposable // a SharedScript that is NOT in the bundle (we don't seed one) and // also NOT in the target after the wipe. ApiMethod name-resolution // findings are HARD errors (no downstream deploy gate re-validates - // them — unlike template-script findings, which are advisory as of - // #05-T19), so this is the vehicle that still forces a rollback. + // them — unlike template-script findings, which are advisory), so this is the vehicle that still forces a rollback. // - Export the ApiMethod only (no helper alongside). // - Wipe the target so the apply runs through Add-paths. // - Snapshot row counts so the rollback assertion is unambiguous. diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Encryption/BundleSecretEncryptorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Encryption/BundleSecretEncryptorTests.cs index 9e3b6627..c643fade 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Encryption/BundleSecretEncryptorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Encryption/BundleSecretEncryptorTests.cs @@ -7,11 +7,12 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Encryption; public sealed class BundleSecretEncryptorTests { - // Commons-015 sets the documented PBKDF2 floor to 100_000 (OWASP minimum); + // The documented PBKDF2 floor is 100_000 (OWASP minimum); // the production value is 600_000. Using the floor keeps the test fast while // remaining a valid EncryptionMetadata.Iterations. private const int TestIterations = EncryptionMetadata.MinPbkdf2Iterations; + /// <summary>Verifies that encrypting and then decrypting arbitrary bytes returns the original bytes.</summary> [Fact] public void Encrypt_then_Decrypt_roundtrips_arbitrary_bytes() { @@ -24,6 +25,7 @@ public sealed class BundleSecretEncryptorTests Assert.Equal(plaintext, recovered); } + /// <summary>Verifies that decrypting with the wrong passphrase throws a <see cref="System.Security.Cryptography.CryptographicException"/>.</summary> [Fact] public void Decrypt_with_wrong_passphrase_throws_CryptographicException() { @@ -35,6 +37,7 @@ public sealed class BundleSecretEncryptorTests Assert.ThrowsAny<CryptographicException>(() => sut.Decrypt(ciphertext, metadata, "wrong-pass")); } + /// <summary>Verifies that decrypting tampered ciphertext throws a <see cref="System.Security.Cryptography.CryptographicException"/>.</summary> [Fact] public void Decrypt_with_tampered_ciphertext_throws_CryptographicException() { @@ -47,6 +50,7 @@ public sealed class BundleSecretEncryptorTests Assert.ThrowsAny<CryptographicException>(() => sut.Decrypt(ciphertext, metadata, "pass")); } + /// <summary>Verifies that encrypting the same input twice produces different ciphertext because of the random IV.</summary> [Fact] public void Encrypt_produces_distinct_ciphertext_for_same_input_due_to_random_iv() { @@ -61,6 +65,7 @@ public sealed class BundleSecretEncryptorTests Assert.NotEqual(meta1.SaltB64, meta2.SaltB64); } + /// <summary>Verifies that decrypting with mismatched associated data throws a <see cref="System.Security.Cryptography.CryptographicException"/>.</summary> [Fact] public void Decrypt_with_mismatched_AAD_throws_CryptographicException() { @@ -78,6 +83,7 @@ public sealed class BundleSecretEncryptorTests () => sut.Decrypt(ciphertext, metadata, "pass", aadOnDecrypt)); } + /// <summary>Verifies that decrypting with matching associated data recovers the plaintext.</summary> [Fact] public void Decrypt_with_matching_AAD_recovers_plaintext() { @@ -92,6 +98,7 @@ public sealed class BundleSecretEncryptorTests Assert.Equal(plaintext, recovered); } + /// <summary>Verifies that encryption emits metadata matching the inputs needed for decryption.</summary> [Fact] public void Encrypt_emits_metadata_matching_decryption_inputs() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs index 130adf4b..7101eb2c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs @@ -132,6 +132,8 @@ public sealed class DependencyResolverTests .Returns(instance.ConnectionBindings.ToList()); } + /// <summary>Verifies that resolving includes the base template for a composed template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_includes_base_template_for_composed_template() { @@ -152,6 +154,8 @@ public sealed class DependencyResolverTests Assert.Contains(result.Templates, t => t.Id == 11); } + /// <summary>Verifies that resolving includes the shared script referenced by a template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_includes_shared_script_referenced_by_template() { @@ -170,6 +174,8 @@ public sealed class DependencyResolverTests Assert.Equal("UtilHelper", result.SharedScripts[0].Name); } + /// <summary>Verifies that resolving includes the external system referenced by a template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_includes_external_system_referenced_by_template() { @@ -188,6 +194,8 @@ public sealed class DependencyResolverTests Assert.Equal("ErpSystem", result.ExternalSystems[0].Name); } + /// <summary>Verifies that resolving includes the shared script dependency of an API method.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_includes_api_method_shared_script_dependency() { @@ -206,6 +214,8 @@ public sealed class DependencyResolverTests Assert.Equal("Validator", result.SharedScripts[0].Name); } + /// <summary>Verifies that resolving includes the selected SMS configurations.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_includes_selected_sms_configs() { @@ -226,6 +236,8 @@ public sealed class DependencyResolverTests e => e.Type == "SmsConfiguration" && e.Name == "AC_test_sid"); } + /// <summary>Verifies that resolving handles a diamond dependency without duplication.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_handles_diamond_dependency_without_duplication() { @@ -253,6 +265,8 @@ public sealed class DependencyResolverTests Assert.Single(result.Templates, t => t.Id == 4); } + /// <summary>Verifies that resolving includes the template folder for each selected template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_includes_template_folder_for_each_selected_template() { @@ -276,6 +290,8 @@ public sealed class DependencyResolverTests Assert.Equal("Grand", result.TemplateFolders[2].Name); } + /// <summary>Verifies that resolving returns a topological order with base templates before derived ones.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_returns_topological_order_base_before_derived() { @@ -302,9 +318,11 @@ public sealed class DependencyResolverTests } // --------------------------------------------------------------------------- - // M8 (B1): site/instance-scoped expansion. + // Site/instance-scoped expansion. // --------------------------------------------------------------------------- + /// <summary>Verifies that resolving a site pulls in its connections and instances.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_site_pulls_its_connections_and_instances() { @@ -331,6 +349,8 @@ public sealed class DependencyResolverTests Assert.Single(result.Templates, t => t.Id == 9); } + /// <summary>Verifies that resolving an instance with dependencies pulls in its site connections and template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_instance_with_deps_pulls_site_connections_and_template() { @@ -370,6 +390,8 @@ public sealed class DependencyResolverTests Assert.Single(resolvedInstance.NativeAlarmSourceOverrides); } + /// <summary>Verifies that resolving an instance without dependencies does not pull in its site or template.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_instance_without_deps_does_not_pull_site_or_template() { @@ -395,6 +417,8 @@ public sealed class DependencyResolverTests Assert.Empty(result.Templates); } + /// <summary>Verifies that resolving deduplicates an instance when both the site and the instance are selected.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_dedups_instance_when_site_and_instance_both_selected() { @@ -422,6 +446,8 @@ public sealed class DependencyResolverTests await _templates.Received(1).GetInstanceByIdAsync(instance.Id, Arg.Any<CancellationToken>()); } + /// <summary>Verifies that resolving orders sites, connections and instances deterministically.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_orders_sites_connections_and_instances_deterministically() { @@ -462,6 +488,8 @@ public sealed class DependencyResolverTests Assert.Equal(new[] { "Alpha", "Zulu" }, result.Instances.Select(i => i.UniqueName).ToArray()); } + /// <summary>Verifies that resolving emits manifest entries with dependency edges for site scope.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Resolve_emits_manifest_entries_with_dependency_edges_for_site_scope() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs index 1a21e3bc..e9716775 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs @@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import; /// <summary> /// Tests for <see cref="ArtifactDiff"/>: the structured per-line Myers code diff -/// embedded in <c>FieldDiffJson</c> (T20) and the M8 Site / DataConnection / +/// embedded in <c>FieldDiffJson</c> (T20) and the Site / DataConnection / /// Instance compares. The diff records are private, so assertions parse the /// serialized <see cref="ImportPreviewItem.FieldDiffJson"/>. /// </summary> @@ -61,6 +61,7 @@ public sealed class ArtifactDiffTests // ============ T20: structured per-line code diff ============ + /// <summary>Verifies that a modified shared script emits a structured line diff.</summary> [Fact] public void SharedScript_ModifiedCode_EmitsStructuredLineDiff() { @@ -94,6 +95,7 @@ public sealed class ArtifactDiffTests Assert.DoesNotContain(hunks, h => h.Text.Contains("lines>")); } + /// <summary>Verifies that a shared script with identical code reports no change.</summary> [Fact] public void SharedScript_IdenticalCode_NoChange() { @@ -156,7 +158,7 @@ public sealed class ArtifactDiffTests Assert.Equal(ConflictKind.Identical, item.Kind); } - // ============ arch-review 2026-08-19 P7-1: TimeoutSeconds in the diff (row-27 shape) ============ + // ============ TimeoutSeconds in the diff ============ /// <summary> /// The bundle has always CARRIED TimeoutSeconds and BundleImporter has always APPLIED it @@ -220,6 +222,7 @@ public sealed class ArtifactDiffTests Assert.Equal(ConflictKind.Identical, item.Kind); } + /// <summary>Verifies that a modified API method script emits a structured line diff.</summary> [Fact] public void ApiMethod_ModifiedScript_EmitsStructuredLineDiff() { @@ -244,6 +247,7 @@ public sealed class ArtifactDiffTests Assert.Contains(("add", "log(\"b\");"), hunks); } + /// <summary>Verifies that modified template script code emits a structured line diff.</summary> [Fact] public void TemplateScript_ModifiedCode_EmitsStructuredLineDiff() { @@ -265,8 +269,9 @@ public sealed class ArtifactDiffTests Assert.Contains(("add", "new2"), hunks); } - // ============ #05-T23: placeholder-identity resolution via name maps ============ + // ============ placeholder-identity resolution via name maps ============ + /// <summary>Verifies that templates with matching folder, base and composition names compare as identical.</summary> [Fact] public void CompareTemplate_MatchingFolderBaseAndComposition_IsIdentical() { @@ -295,6 +300,7 @@ public sealed class ArtifactDiffTests Assert.Equal(ConflictKind.Identical, item.Kind); } + /// <summary>Verifies that a divergent base template name makes the template compare as modified.</summary> [Fact] public void CompareTemplate_DivergentBaseTemplateName_IsModified() { @@ -311,8 +317,9 @@ public sealed class ArtifactDiffTests Assert.Equal(ConflictKind.Modified, item.Kind); } - // ============ #05-T6: single-source child equality — diff sees the full field set ============ + // ============ single-source child equality — diff sees the full field set ============ + /// <summary>Verifies that a change to an attribute's element data type makes the template compare as modified.</summary> [Fact] public void CompareTemplate_AttributeElementDataTypeChange_IsModified() { @@ -334,6 +341,7 @@ public sealed class ArtifactDiffTests ChangeFor(item, "Attributes.L"); // ElementDataType diverged — previously missed. } + /// <summary>Verifies that a change to the locked-in-derived flag makes the template compare as modified.</summary> [Fact] public void CompareTemplate_LockedInDerivedChange_IsModified() { @@ -355,6 +363,7 @@ public sealed class ArtifactDiffTests ChangeFor(item, "Attributes.L"); // LockedInDerived diverged — previously missed. } + /// <summary>Verifies that a change to a script's execution timeout makes the template compare as modified.</summary> [Fact] public void CompareTemplate_ScriptExecutionTimeoutChange_IsModified() { @@ -372,6 +381,7 @@ public sealed class ArtifactDiffTests ChangeFor(item, "Scripts.S"); // ExecutionTimeoutSeconds diverged — previously missed. } + /// <summary>Verifies that a change to an alarm's on-trigger script makes the template compare as modified.</summary> [Fact] public void CompareTemplate_AlarmOnTriggerScriptChange_IsModified() { @@ -396,6 +406,7 @@ public sealed class ArtifactDiffTests ChangeFor(item, "Alarms.A"); // OnTriggerScriptName diverged — previously missed. } + /// <summary>Verifies that a change to an alarm's MES-reportable flag makes the template compare as modified.</summary> [Fact] public void CompareTemplate_AlarmIsMesReportableChange_IsModified() { @@ -418,6 +429,7 @@ public sealed class ArtifactDiffTests ChangeFor(item, "Alarms.A"); // IsMesReportable diverged. } + /// <summary>Verifies that a change to an instance alarm override's MES-reportable flag makes the instance compare as modified.</summary> [Fact] public void CompareInstance_AlarmOverrideIsMesReportableChange_IsModified() { @@ -443,8 +455,9 @@ public sealed class ArtifactDiffTests Assert.Equal("<modified>", modified.GetProperty("newValue").GetString()); } - // ============ M8: CompareSite ============ + // ============ CompareSite ============ + /// <summary>Verifies that comparing a site against no existing site yields a new result.</summary> [Fact] public void CompareSite_ExistingNull_New() { @@ -457,6 +470,7 @@ public sealed class ArtifactDiffTests Assert.Null(item.FieldDiffJson); } + /// <summary>Verifies that a site with a differing field compares as modified.</summary> [Fact] public void CompareSite_FieldDiffers_Modified() { @@ -471,6 +485,7 @@ public sealed class ArtifactDiffTests Assert.Equal("new", change.GetProperty("newValue").GetString()); } + /// <summary>Verifies that a site with no changes compares as identical.</summary> [Fact] public void CompareSite_NoChanges_Identical() { @@ -490,8 +505,9 @@ public sealed class ArtifactDiffTests Assert.Null(item.FieldDiffJson); } - // ============ M8: CompareDataConnection ============ + // ============ CompareDataConnection ============ + /// <summary>Verifies that comparing a data connection against no existing connection yields a new result.</summary> [Fact] public void CompareDataConnection_ExistingNull_New() { @@ -503,6 +519,7 @@ public sealed class ArtifactDiffTests Assert.Equal("opc1", item.Name); } + /// <summary>Verifies that a data connection with a different protocol compares as modified.</summary> [Fact] public void CompareDataConnection_ProtocolDiffers_Modified() { @@ -517,6 +534,7 @@ public sealed class ArtifactDiffTests Assert.Equal("MxAccess", change.GetProperty("newValue").GetString()); } + /// <summary>Verifies that a changed secret value with preserved presence is reported without echoing the secret.</summary> [Fact] public void CompareDataConnection_SecretValueChangesButPresencePreserved_NoSecretEcho() { @@ -541,6 +559,7 @@ public sealed class ArtifactDiffTests Assert.DoesNotContain("4840", item.FieldDiffJson ?? string.Empty); } + /// <summary>Verifies that a flip in secret presence is shown only as a present marker, without the secret value.</summary> [Fact] public void CompareDataConnection_SecretPresenceFlips_ShowsPresentMarkerOnly() { @@ -577,6 +596,7 @@ public sealed class ArtifactDiffTests ? new SecretsBlock(new Dictionary<string, string> { ["AuthToken"] = "wire-token" }) : null); + /// <summary>Verifies that comparing an SMS configuration against no existing configuration yields a new result.</summary> [Fact] public void CompareSmsConfiguration_ExistingNull_New() { @@ -587,6 +607,7 @@ public sealed class ArtifactDiffTests Assert.Equal("AC1", item.Name); } + /// <summary>Verifies that an SMS configuration whose fields and secret presence all match compares as identical.</summary> [Fact] public void CompareSmsConfiguration_AllFieldsAndPresenceMatch_Identical() { @@ -596,6 +617,7 @@ public sealed class ArtifactDiffTests Assert.Null(item.FieldDiffJson); } + /// <summary>Verifies that an SMS configuration with a different from-number compares as modified.</summary> [Fact] public void CompareSmsConfiguration_FromNumberDiffers_Modified() { @@ -607,6 +629,7 @@ public sealed class ArtifactDiffTests Assert.Equal("+15559999999", change.GetProperty("newValue").GetString()); } + /// <summary>Verifies that a changed auth token value with preserved presence is reported without echoing the secret.</summary> [Fact] public void CompareSmsConfiguration_AuthTokenValueChangesButPresencePreserved_NoSecretEcho() { @@ -620,6 +643,7 @@ public sealed class ArtifactDiffTests Assert.DoesNotContain("wire-token", json); } + /// <summary>Verifies that a flip in auth token presence is shown only as a present marker, without the secret value.</summary> [Fact] public void CompareSmsConfiguration_AuthTokenPresenceFlips_ShowsPresentMarkerOnly() { @@ -634,8 +658,9 @@ public sealed class ArtifactDiffTests Assert.DoesNotContain("stored-token", item.FieldDiffJson!); } - // ============ M8: CompareInstance ============ + // ============ CompareInstance ============ + /// <summary>Verifies that comparing an instance against no existing instance yields a new result.</summary> [Fact] public void CompareInstance_ExistingNull_New() { @@ -647,6 +672,7 @@ public sealed class ArtifactDiffTests Assert.Equal("inst-1", item.Name); } + /// <summary>Verifies that an instance with a differing state compares as modified.</summary> [Fact] public void CompareInstance_StateDiffers_Modified() { @@ -661,6 +687,7 @@ public sealed class ArtifactDiffTests Assert.Equal("Enabled", change.GetProperty("newValue").GetString()); } + /// <summary>Verifies that an instance with no changes compares as identical.</summary> [Fact] public void CompareInstance_NoChanges_Identical() { @@ -673,6 +700,7 @@ public sealed class ArtifactDiffTests Assert.Null(item.FieldDiffJson); } + /// <summary>Verifies that differing template and site names make the instance compare as modified.</summary> [Fact] public void CompareInstance_TemplateAndSiteNamesDiffer_Modified() { @@ -689,6 +717,7 @@ public sealed class ArtifactDiffTests Assert.Equal("site-b", ChangeFor(item, "SiteIdentifier").GetProperty("newValue").GetString()); } + /// <summary>Verifies that added, removed and modified child overrides are reported for an instance.</summary> [Fact] public void CompareInstance_ChildOverridesAddedRemovedModified() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs index d901fa92..a0bbad00 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs @@ -69,7 +69,10 @@ public sealed class BundleImporterLoadTests private sealed class TestTimeProvider : TimeProvider { private DateTimeOffset _now; + /// <summary>Initializes the provider at the given start time.</summary> + /// <param name="start">The initial UTC time.</param> public TestTimeProvider(DateTimeOffset start) { _now = start; } + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => _now; } @@ -159,6 +162,8 @@ public sealed class BundleImporterLoadTests return serializer.Pack(content, manifest, passphrase, encryptor); } + /// <summary>Verifies that loading an unencrypted bundle returns a session.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_returns_session_for_unencrypted_bundle() { @@ -176,6 +181,8 @@ public sealed class BundleImporterLoadTests Assert.NotNull(rig.SessionStore.Get(session.SessionId)); } + /// <summary>Verifies that loading an encrypted bundle with the correct passphrase returns a session.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_returns_session_for_encrypted_bundle_with_correct_passphrase() { @@ -197,6 +204,8 @@ public sealed class BundleImporterLoadTests Assert.Equal("Pump", roundTripped.Templates[0].Name); } + /// <summary>Verifies that loading throws when the passphrase is wrong.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_throws_when_passphrase_wrong() { @@ -210,6 +219,8 @@ public sealed class BundleImporterLoadTests () => rig.Importer.LoadAsync(stream, passphrase: "wrong")); } + /// <summary>Verifies that the bundle is locked after three wrong passphrases, even across callers.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_locks_bundle_after_three_wrong_passphrases_even_across_callers() { @@ -245,6 +256,8 @@ public sealed class BundleImporterLoadTests () => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "correct")); } + /// <summary>Verifies that loading rejects a bundle with too many entries.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_rejects_bundle_with_too_many_entries() { @@ -261,6 +274,8 @@ public sealed class BundleImporterLoadTests () => rig.Importer.LoadAsync(new MemoryStream(paddedBytes), passphrase: null)); } + /// <summary>Verifies that loading rejects a bundle with an oversized entry.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_rejects_bundle_with_oversized_entry() { @@ -277,6 +292,8 @@ public sealed class BundleImporterLoadTests () => rig.Importer.LoadAsync(new MemoryStream(oversizedBytes), passphrase: null)); } + /// <summary>Verifies that loading rejects a bundle whose entry exceeds the compression ratio.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_rejects_bundle_whose_entry_exceeds_compression_ratio() { @@ -352,13 +369,15 @@ public sealed class BundleImporterLoadTests return output.ToArray(); } + /// <summary>Verifies that loading rejects a bundle with a tampered manifest field even with the correct passphrase.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_rejects_bundle_with_tampered_manifest_field_even_with_correct_passphrase() { // T-005: a stolen bundle whose plaintext manifest fields (SourceEnvironment, // ExportedBy, …) have been edited must fail decryption with a tag mismatch. // Without AAD an attacker could rewrite the SourceEnvironment label and slip - // past the Step-4 typo-resistant confirmation gate. We tamper the field by + // past the typo-resistant confirmation gate. We tamper the field by // re-packing the manifest (with everything else, including the original // ciphertext, unchanged) into a fresh ZIP and verify decrypt fails. var rig = BuildRig(); @@ -405,6 +424,8 @@ public sealed class BundleImporterLoadTests () => rig.Importer.LoadAsync(tamperedZipBytes, passphrase: "correct")); } + /// <summary>Verifies that loading resets the unlock counter on the correct passphrase.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_resets_unlock_counter_on_correct_passphrase() { @@ -429,6 +450,8 @@ public sealed class BundleImporterLoadTests Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(session.Manifest.ContentHash)); } + /// <summary>Verifies that loading throws a not-supported error when the bundle format version is unsupported.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_throws_NotSupportedException_when_bundleFormatVersion_unsupported() { @@ -458,6 +481,8 @@ public sealed class BundleImporterLoadTests () => rig.Importer.LoadAsync(bundleStream, passphrase: null)); } + /// <summary>Verifies that loading throws an invalid-data error when the content hash does not match.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_throws_InvalidDataException_when_content_hash_mismatch() { @@ -488,6 +513,8 @@ public sealed class BundleImporterLoadTests Assert.Contains("hash", ex.Message, StringComparison.OrdinalIgnoreCase); } + /// <summary>Verifies that loading throws an invalid-operation error when the bundle exceeds the size cap.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_throws_InvalidOperationException_when_bundle_exceeds_size_cap() { @@ -508,6 +535,8 @@ public sealed class BundleImporterLoadTests // invoked, so these all passed-by-doing-nothing before the wiring. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that loading rejects unlock attempts past the per-IP hourly cap.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task LoadAsync_rejects_unlock_attempts_past_the_per_ip_hourly_cap() { @@ -542,6 +571,8 @@ public sealed class BundleImporterLoadTests new MemoryStream(bundleBytes), passphrase: "correct", clientKey: "10.1.1.5")); } + /// <summary>Verifies that a throttled unlock is not counted as a passphrase failure and burns no strike.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Throttled_unlock_is_not_a_passphrase_failure_and_burns_no_strike() { @@ -571,6 +602,8 @@ public sealed class BundleImporterLoadTests Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(contentHash)); } + /// <summary>Verifies that the unlock throttle is isolated per client.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Unlock_throttle_is_isolated_per_client() { @@ -598,6 +631,8 @@ public sealed class BundleImporterLoadTests new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.8")); } + /// <summary>Verifies that unattributed callers share one throttle bucket rather than bypassing it.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Unattributed_callers_share_one_throttle_bucket_rather_than_bypassing_it() { @@ -623,6 +658,8 @@ public sealed class BundleImporterLoadTests Assert.Equal(BundleImporter.UnattributedClientKey, throttled.ClientKey); } + /// <summary>Verifies that unencrypted bundles do not consume the unlock budget.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Unencrypted_bundles_do_not_consume_the_unlock_budget() { @@ -641,6 +678,8 @@ public sealed class BundleImporterLoadTests } } + /// <summary>Verifies that an already-locked bundle does not spend the client's hourly budget.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task An_already_locked_bundle_does_not_spend_the_clients_hourly_budget() { @@ -683,6 +722,8 @@ public sealed class BundleImporterLoadTests // Register row 43 — abandoned-session release. // ───────────────────────────────────────────────────────────────────── + /// <summary>Verifies that abandoning a session releases its slot and zeroes its decrypted content.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Abandon_releases_the_session_slot_and_zeroes_its_decrypted_content() { @@ -708,6 +749,8 @@ public sealed class BundleImporterLoadTests Assert.All(decrypted, b => Assert.Equal(0, b)); } + /// <summary>Verifies that abandoning a session is idempotent and tolerates unknown ids.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Abandon_is_idempotent_and_tolerates_unknown_ids() { @@ -724,6 +767,8 @@ public sealed class BundleImporterLoadTests Assert.Null(rig.SessionStore.Get(session.SessionId)); } + /// <summary>Verifies that abandoning a session does not release another caller's session.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Abandon_does_not_release_another_callers_session() { @@ -743,6 +788,8 @@ public sealed class BundleImporterLoadTests Assert.NotEmpty(survivor!.DecryptedContent); } + /// <summary>Verifies that abandoning a session frees a concurrent import session slot.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task Abandon_frees_a_concurrent_import_session_slot() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs index 88b24938..36311c3c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs @@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import; /// <summary> /// Register row 53: <c>MaxConcurrentImportSessions</c> had no observability — nothing -/// counted open sessions and nothing logged a rejection at the cap, so the register-row-43 +/// counted open sessions and nothing logged a rejection at the cap, so the earlier /// session leak could only be inferred from rotating test failures. These tests pin the /// two signals that close that gap: the /// <c>scadabridge.central.transport.import_sessions.open</c> gauge and the Warning-level @@ -25,6 +25,7 @@ public sealed class BundleSessionObservabilityTests // Warning log on cap rejection // ------------------------------------------------------------------ + /// <summary>Verifies that opening a session at the cap logs a warning with the session count and the cap.</summary> [Fact] public void Open_AtCap_LogsWarningWithCountAndCap() { @@ -50,6 +51,7 @@ public sealed class BundleSessionObservabilityTests Assert.Contains("MaxConcurrentImportSessions", warning.Message, StringComparison.Ordinal); } + /// <summary>Verifies that opening a session under the cap logs nothing.</summary> [Fact] public void Open_UnderCap_LogsNothing() { @@ -64,6 +66,7 @@ public sealed class BundleSessionObservabilityTests Assert.Empty(logger.Entries); } + /// <summary>Verifies that opening at a cap filled by expired sessions reclaims them and logs nothing.</summary> [Fact] public void Open_AtCapOfExpiredSessions_ReclaimsAndLogsNothing() { @@ -88,6 +91,7 @@ public sealed class BundleSessionObservabilityTests // Rejection counter (register row 66) // ------------------------------------------------------------------ + /// <summary>Verifies that opening a session at the cap increments the rejection counter.</summary> [Fact] public void Open_AtCap_IncrementsTheRejectionCounter() { @@ -109,6 +113,7 @@ public sealed class BundleSessionObservabilityTests Assert.Equal(1, delta); } + /// <summary>Verifies that repeated cap hits accumulate on the rejection counter.</summary> [Fact] public void Open_RepeatedCapHits_AccumulateOnTheRejectionCounter() { @@ -131,6 +136,7 @@ public sealed class BundleSessionObservabilityTests Assert.Equal(3, delta); } + /// <summary>Verifies that opening a session under the cap does not increment the rejection counter.</summary> [Fact] public void Open_UnderCap_DoesNotIncrementTheRejectionCounter() { @@ -145,6 +151,7 @@ public sealed class BundleSessionObservabilityTests Assert.Equal(0, delta); } + /// <summary>Verifies that reclaiming expired sessions at the cap does not increment the rejection counter.</summary> [Fact] public void Open_AtCapOfExpiredSessions_ReclaimsWithoutIncrementingTheRejectionCounter() { @@ -166,6 +173,7 @@ public sealed class BundleSessionObservabilityTests Assert.Equal(0, delta); } + /// <summary>Verifies that reopening an existing session id at the cap does not increment the rejection counter.</summary> [Fact] public void Open_ReopeningAnExistingSessionIdAtCap_DoesNotIncrementTheRejectionCounter() { @@ -190,6 +198,7 @@ public sealed class BundleSessionObservabilityTests // OpenSessionCount / gauge // ------------------------------------------------------------------ + /// <summary>Verifies that the open session count excludes sessions that expired but have not been swept.</summary> [Fact] public void OpenSessionCount_ExcludesExpiredButUnsweptSessions() { @@ -211,6 +220,8 @@ public sealed class BundleSessionObservabilityTests Assert.Equal(1, sut.OpenSessionCount); } + /// <summary>Verifies that the eviction service registers the gauge provider and clears it on stop.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvictionService_RegistersTheGaugeProvider_AndClearsItOnStop() { @@ -250,6 +261,8 @@ public sealed class BundleSessionObservabilityTests Assert.Equal(0, afterClear); } + /// <summary>Verifies that stopping an eviction service does not clobber a newer instance's gauge provider.</summary> + /// <returns>A task that represents the asynchronous operation.</returns> [Fact] public async Task EvictionService_StopDoesNotClobberANewerInstancesProvider() { @@ -418,8 +431,13 @@ public sealed class BundleSessionObservabilityTests private sealed class TestClock : TimeProvider { private DateTimeOffset _now; + /// <summary>Initializes the clock at the given start time.</summary> + /// <param name="start">The initial time.</param> public TestClock(DateTimeOffset start) { _now = start; } + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => _now; + /// <summary>Moves the clock forward.</summary> + /// <param name="by">The amount of time to advance.</param> public void Advance(TimeSpan by) => _now = _now.Add(by); } @@ -434,22 +452,39 @@ public sealed class BundleSessionObservabilityTests { public static readonly InertUnlockWindow Instance = new(); + /// <inheritdoc /> public Task<bool> TryRegisterAttemptAsync( string clientKey, int maxAttemptsPerWindow, TimeSpan window, CancellationToken ct = default) => Task.FromResult(true); + /// <inheritdoc /> public Task PruneAsync(TimeSpan window, CancellationToken ct = default) => Task.CompletedTask; } /// <summary>Captures formatted log entries so the cap-rejection Warning can be asserted.</summary> private sealed class CapturingLogger : ILogger<BundleSessionStore> { + /// <summary>Gets the captured log entries in emission order.</summary> public List<(LogLevel Level, string Message)> Entries { get; } = []; + /// <summary>Starts no scope; the fake does not track scopes.</summary> + /// <typeparam name="TState">The type of the scope state.</typeparam> + /// <param name="state">The scope state, ignored.</param> + /// <returns>No scope token; the fake does not track scopes.</returns> public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + /// <summary>Reports every log level as enabled.</summary> + /// <param name="logLevel">The log level being queried.</param> + /// <returns>Always <see langword="true"/>.</returns> public bool IsEnabled(LogLevel logLevel) => true; + /// <summary>Records the log entry for later assertions.</summary> + /// <typeparam name="TState">The type of the log state.</typeparam> + /// <param name="logLevel">The severity of the entry.</param> + /// <param name="eventId">The event id of the entry.</param> + /// <param name="state">The state object being logged.</param> + /// <param name="exception">The exception associated with the entry, if any.</param> + /// <param name="formatter">Formats the state and exception into the message text.</param> public void Log<TState>( LogLevel logLevel, EventId eventId, diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionStoreTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionStoreTests.cs index de4e64e4..624263c5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionStoreTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionStoreTests.cs @@ -29,10 +29,11 @@ public sealed class BundleSessionStoreTests Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0), Contents: Array.Empty<ManifestContentEntry>()); + /// <summary>Verifies that opening beyond the maximum concurrent import sessions throws, and removing one admits a new session.</summary> [Fact] public void Open_beyond_MaxConcurrentImportSessions_throws_and_removing_one_admits_a_new_session() { - // #05-T24: the store bounds concurrently-open sessions (each pins a + // The store bounds concurrently-open sessions (each pins a // decrypted bundle in memory). The (cap+1)th Open is rejected; freeing a // slot re-admits a new session. var clock = new TestTimeProvider(DateTimeOffset.UtcNow); @@ -59,6 +60,7 @@ public sealed class BundleSessionStoreTests Assert.NotNull(sut.Get(overflow.SessionId)); } + /// <summary>Verifies that opening a session and then getting it returns the session.</summary> [Fact] public void Open_then_Get_returns_session() { @@ -73,6 +75,7 @@ public sealed class BundleSessionStoreTests Assert.Same(session, fetched); } + /// <summary>Verifies that getting a session after its time-to-live returns null and evicts it.</summary> [Fact] public void Get_after_TTL_returns_null_and_evicts() { @@ -90,6 +93,7 @@ public sealed class BundleSessionStoreTests Assert.Null(sut.Get(session.SessionId)); } + /// <summary>Verifies that removing a session evicts it.</summary> [Fact] public void Remove_evicts_session() { @@ -103,6 +107,7 @@ public sealed class BundleSessionStoreTests Assert.Null(sut.Get(session.SessionId)); } + /// <summary>Verifies that eviction removes every session past its time-to-live.</summary> [Fact] public void EvictExpired_removes_all_past_ttl() { @@ -125,6 +130,7 @@ public sealed class BundleSessionStoreTests Assert.Null(sut.Get(dropB.SessionId)); } + /// <summary>Verifies that the legacy failed-unlock-attempts field still round-trips through the shared reference.</summary> [Fact] public void Legacy_FailedUnlockAttempts_field_still_round_trips_via_shared_reference() { @@ -155,6 +161,7 @@ public sealed class BundleSessionStoreTests Assert.True(refetch!.Locked); } + /// <summary>Verifies that the unlock failure count is tracked per bundle and resets.</summary> [Fact] public void UnlockFailureCount_TracksPerBundleAndResets() { @@ -179,6 +186,7 @@ public sealed class BundleSessionStoreTests Assert.Equal(0, sut.GetUnlockFailureCount(contentHashA)); } + /// <summary>Verifies that unlock failures expire on the time-to-live and get returns zero.</summary> [Fact] public void UnlockFailures_ExpireOnTtlAndGetReturnsZero() { @@ -197,6 +205,7 @@ public sealed class BundleSessionStoreTests Assert.Equal(0, sut.GetUnlockFailureCount(contentHash)); } + /// <summary>Verifies that evicting expired entries clears stale unlock failure entries.</summary> [Fact] public void UnlockFailures_EvictExpired_ClearsStaleEntries() { @@ -220,8 +229,13 @@ public sealed class BundleSessionStoreTests private sealed class TestTimeProvider : TimeProvider { private DateTimeOffset _now; + /// <summary>Initializes the provider with the given start time.</summary> + /// <param name="start">The initial UTC time.</param> public TestTimeProvider(DateTimeOffset start) { _now = start; } + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => _now; + /// <summary>Advances the provider's current time.</summary> + /// <param name="by">The amount of time to advance.</param> public void Advance(TimeSpan by) => _now = _now.Add(by); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleUnlockRateLimiterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleUnlockRateLimiterTests.cs index f480bdcf..642d5777 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleUnlockRateLimiterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleUnlockRateLimiterTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Transport.Import; namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import; /// <summary> -/// Transport-004: <see cref="BundleUnlockRateLimiter"/> must enforce a per-key cap +/// <see cref="BundleUnlockRateLimiter"/> must enforce a per-key cap /// over a trailing window — the design doc's "per-IP-per-hour" cap (§11). The /// limiter accepts any opaque caller key (typically a remote IP); these tests use /// IP-style strings to mirror the documented intent. @@ -13,11 +13,18 @@ public sealed class BundleUnlockRateLimiterTests private sealed class TestClock : TimeProvider { private DateTimeOffset _now; + /// <summary>Initializes a new instance of the <c>TestClock</c> class.</summary> + /// <param name="start">The start.</param> public TestClock(DateTimeOffset start) { _now = start; } + /// <inheritdoc /> public override DateTimeOffset GetUtcNow() => _now; + /// <summary>Advances the test clock by the given amount.</summary> + /// <param name="delta">The delta.</param> public void Advance(TimeSpan delta) { _now += delta; } } + /// <summary>Verifies that registering an attempt under the limit returns true.</summary> + /// <returns>The result.</returns> [Fact] public void TryRegisterAttempt_UnderLimit_ReturnsTrue() { @@ -36,6 +43,8 @@ public sealed class BundleUnlockRateLimiterTests Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1")); } + /// <summary>Verifies that registering an attempt once the limit is reached is rejected.</summary> + /// <returns>The result.</returns> [Fact] public void TryRegisterAttempt_AtLimit_RejectsNextAttempt() { @@ -60,6 +69,8 @@ public sealed class BundleUnlockRateLimiterTests Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1")); } + /// <summary>Verifies that recorded attempts expire after the window elapses.</summary> + /// <returns>The result.</returns> [Fact] public void TryRegisterAttempt_EntriesExpireAfterWindow() { @@ -83,6 +94,8 @@ public sealed class BundleUnlockRateLimiterTests Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10)); } + /// <summary>Verifies that a partial expiry releases only the oldest slot.</summary> + /// <returns>The result.</returns> [Fact] public void TryRegisterAttempt_PartialExpiry_ReleasesOldestSlotOnly() { @@ -118,6 +131,8 @@ public sealed class BundleUnlockRateLimiterTests Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10)); } + /// <summary>Verifies that attempts are counted independently per key.</summary> + /// <returns>The result.</returns> [Fact] public void TryRegisterAttempt_PerKeyIsolation() { @@ -137,6 +152,9 @@ public sealed class BundleUnlockRateLimiterTests Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1")); } + /// <summary>Verifies that registering an attempt with a blank key throws.</summary> + /// <param name="key">The key.</param> + /// <returns>The result.</returns> [Theory] [InlineData(null)] [InlineData("")] @@ -148,6 +166,9 @@ public sealed class BundleUnlockRateLimiterTests () => limiter.TryRegisterAttempt(key!, maxAttemptsPerWindow: 10)); } + /// <summary>Verifies that registering an attempt with a non-positive limit throws.</summary> + /// <param name="limit">The limit.</param> + /// <returns>The result.</returns> [Theory] [InlineData(0)] [InlineData(-1)] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/LineDifferTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/LineDifferTests.cs index cd655b05..bc7bf26b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/LineDifferTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/LineDifferTests.cs @@ -6,6 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import; public sealed class LineDifferTests { + /// <summary>Verifies that identical text produces no additions or removals and is not truncated.</summary> [Fact] public void IdenticalText_NoAddsOrRemoves_NotTruncated() { @@ -18,6 +19,7 @@ public sealed class LineDifferTests Assert.All(result.Lines, l => Assert.Equal(LineDiffOp.Context, l.Op)); } + /// <summary>Verifies that diffing an empty old text against three new lines yields three additions numbered 1 to 3.</summary> [Fact] public void OldEmpty_NewThreeLines_ThreeAdds_NewLineNos1To3() { @@ -34,6 +36,7 @@ public sealed class LineDifferTests Assert.All(adds, a => Assert.Null(a.OldLineNo)); } + /// <summary>Verifies that diffing two old lines against an empty new text yields two removals numbered 1 to 2.</summary> [Fact] public void NewEmpty_OldTwoLines_TwoRemoves_OldLineNos1To2() { @@ -50,6 +53,7 @@ public sealed class LineDifferTests Assert.All(removes, r => Assert.Null(r.NewLineNo)); } + /// <summary>Verifies that changing one middle line yields one removal and one addition with the surrounding context preserved.</summary> [Fact] public void SingleMiddleLineChange_OneRemoveOneAdd_ContextPreserved() { @@ -83,6 +87,7 @@ public sealed class LineDifferTests Assert.Equal(3, ctxC.NewLineNo); } + /// <summary>Verifies that differing CRLF and LF line endings are normalised and produce no changes.</summary> [Fact] public void CrlfNormalization_NoChanges() { @@ -93,6 +98,7 @@ public sealed class LineDifferTests Assert.False(result.Truncated); } + /// <summary>Verifies that lone carriage returns are normalised and produce no changes.</summary> [Fact] public void LoneCarriageReturnNormalization_NoChanges() { @@ -103,6 +109,7 @@ public sealed class LineDifferTests Assert.False(result.Truncated); } + /// <summary>Verifies that a null old text and a single new line yield one addition.</summary> [Fact] public void NullOld_NewSingleLine_OneAdd() { @@ -115,6 +122,7 @@ public sealed class LineDifferTests Assert.Equal(1, add.NewLineNo); } + /// <summary>Verifies that a null new text and a single old line yield one removal.</summary> [Fact] public void NullNew_OldSingleLine_OneRemove() { @@ -127,6 +135,7 @@ public sealed class LineDifferTests Assert.Equal(1, remove.OldLineNo); } + /// <summary>Verifies that two null texts produce no changes.</summary> [Fact] public void BothNull_NoChanges() { @@ -137,6 +146,7 @@ public sealed class LineDifferTests Assert.False(result.Truncated); } + /// <summary>Verifies that a thousand new lines against an empty old text are truncated to the maximum line count.</summary> [Fact] public void SizeCap_OldEmpty_ThousandNewLines_TruncatedToMax() { @@ -150,6 +160,7 @@ public sealed class LineDifferTests Assert.Equal(0, result.RemovedCount); } + /// <summary>Verifies that an interleaved change preserves the longest-common-subsequence context.</summary> [Fact] public void InterleavedCase_LcsContextPreserved() { @@ -182,6 +193,7 @@ public sealed class LineDifferTests Assert.True(idxCtx1 < idxRem2 && idxRem2 < idxCtx3); } + /// <summary>Verifies that a trailing newline produces a trailing empty line.</summary> [Fact] public void TrailingNewline_ProducesTrailingEmptyLine() { @@ -192,6 +204,7 @@ public sealed class LineDifferTests Assert.Equal(0, result.RemovedCount); } + /// <summary>Verifies that oversized inputs with no common lines degrade to a summary without running the Myers algorithm.</summary> [Fact] public void OversizedInputs_ZeroCommonLines_DegradesToSummary_WithoutRunningMyers() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleDtoSerializationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleDtoSerializationTests.cs index 6c754063..93b5a0dc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleDtoSerializationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleDtoSerializationTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Transport.Serialization; namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization; /// <summary> -/// Round-trip + forward-compat coverage for the M8 site/instance-scoped bundle +/// Round-trip + forward-compat coverage for the site/instance-scoped bundle /// DTOs (<see cref="SiteDto"/>, <see cref="DataConnectionDto"/>, /// <see cref="InstanceDto"/> and the extended <see cref="BundleContentDto"/>). /// </summary> @@ -90,6 +90,7 @@ public sealed class BundleDtoSerializationTests }, }; + /// <summary>Verifies that full content round-trips with deep equality.</summary> [Fact] public void FullContent_RoundTrips_WithDeepEquality() { @@ -136,6 +137,7 @@ public sealed class BundleDtoSerializationTests Assert.Equal(original.Instances[0].ConnectionBindings[0], inst.ConnectionBindings[0]); } + /// <summary>Verifies that enums serialize as strings for a human-readable wire format.</summary> [Fact] public void EnumsSerializeAsStrings_ForHumanReadableWire() { @@ -147,10 +149,11 @@ public sealed class BundleDtoSerializationTests Assert.Contains("\"Double\"", json, StringComparison.Ordinal); } + /// <summary>Verifies that legacy content missing the new arrays deserializes to empty collections rather than null.</summary> [Fact] public void LegacyContent_MissingNewArrays_DeserializesToEmpty_NotNull() { - // A pre-M8 bundle content blob: only the original central-config arrays + // A legacy bundle content blob: only the original central-config arrays // are present; sites/dataConnections/instances fields are entirely absent // from the JSON text. const string legacyJson = """ @@ -178,10 +181,11 @@ public sealed class BundleDtoSerializationTests Assert.Empty(content.Instances); } + /// <summary>Verifies that a default-constructed content has empty, non-null new arrays.</summary> [Fact] public void DefaultConstructed_NewArrays_AreEmpty_NotNull() { - // Existing positional callers that never set the M8 members still get + // Existing positional callers that never set the site/instance members still get // empty (not null) collections, so consumers can enumerate unconditionally. var content = new BundleContentDto( TemplateFolders: Array.Empty<TemplateFolderDto>(), @@ -201,7 +205,7 @@ public sealed class BundleDtoSerializationTests Assert.Empty(content.Instances); } - // --- arch-review 2026-08-19 P7-1: ExternalSystem TimeoutSeconds wire format --- + // --- ExternalSystem TimeoutSeconds wire format --- /// <summary> /// Pins the old-bundle semantics: a bundle written before <c>TimeoutSeconds</c> @@ -249,6 +253,7 @@ public sealed class BundleDtoSerializationTests // --- S10: SMS recipient PhoneNumber + SmsConfig wire format --------------- + /// <summary>Verifies that a legacy recipient missing a phone number deserializes to null.</summary> [Fact] public void LegacyRecipient_MissingPhoneNumber_DeserializesToNull() { @@ -283,11 +288,12 @@ public sealed class BundleDtoSerializationTests Assert.Null(recipient.PhoneNumber); } + /// <summary>Verifies that legacy content missing the SMS configs array deserializes to an empty collection rather than null.</summary> [Fact] public void LegacyContent_MissingSmsConfigsArray_DeserializesToEmpty_NotNull() { // A pre-SMS bundle has no smsConfigs field; the importer must see an empty - // (never null) collection — same forward-compat invariant as the M8 arrays. + // (never null) collection — same forward-compat invariant as the site/instance arrays. const string legacyJson = """ { "TemplateFolders": [], @@ -308,6 +314,7 @@ public sealed class BundleDtoSerializationTests Assert.Empty(content.SmsConfigs); } + /// <summary>Verifies that a default-constructed content has an empty, non-null SMS configs collection.</summary> [Fact] public void DefaultConstructed_SmsConfigs_IsEmpty_NotNull() { @@ -325,6 +332,7 @@ public sealed class BundleDtoSerializationTests Assert.Empty(content.SmsConfigs); } + /// <summary>Verifies that the SMS config auth token rides the secrets block rather than cleartext public fields.</summary> [Fact] public void SmsConfig_AuthToken_RidesSecretsBlock_NotCleartextPublicFields() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleSerializerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleSerializerTests.cs index e8931cdd..916961fa 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleSerializerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleSerializerTests.cs @@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization; public sealed class BundleSerializerTests { - // Commons-015 sets the documented PBKDF2 floor to 100_000 (OWASP minimum). + // The documented PBKDF2 floor is 100_000 (OWASP minimum). // Using the floor keeps the suite fast while passing EncryptionMetadata // validation. private const int TestIterations = EncryptionMetadata.MinPbkdf2Iterations; @@ -33,6 +33,7 @@ public sealed class BundleSerializerTests contents: Array.Empty<ManifestContentEntry>(), contentBytes: contentBytes); + /// <summary>Verifies that packing without a passphrase emits a manifest and plain JSON content.</summary> [Fact] public void Pack_emits_manifest_and_content_json_when_no_passphrase() { @@ -49,6 +50,7 @@ public sealed class BundleSerializerTests Assert.Null(archive.GetEntry("content.enc")); } + /// <summary>Verifies that packing with a passphrase emits a manifest and encrypted content.</summary> [Fact] public void Pack_emits_manifest_and_content_enc_when_passphrase_supplied() { @@ -67,6 +69,7 @@ public sealed class BundleSerializerTests Assert.NotNull(archive.GetEntry("content.enc")); } + /// <summary>Verifies that a round trip through a temporary stream recovers identical content.</summary> [Fact] public void Roundtrip_through_temp_stream_recovers_identical_content() { @@ -91,6 +94,7 @@ public sealed class BundleSerializerTests Assert.Equal("return 42;", shared.Code); } + /// <summary>Verifies that reading the manifest throws when the archive has no manifest file.</summary> [Fact] public void ReadManifest_throws_when_zip_missing_manifest_json() { @@ -107,6 +111,7 @@ public sealed class BundleSerializerTests Assert.Throws<InvalidDataException>(() => sut.ReadManifest(ms)); } + /// <summary>Verifies that unpacking throws a CryptographicException when the passphrase is wrong.</summary> [Fact] public void UnpackContent_throws_CryptographicException_on_wrong_passphrase() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs index da36cda3..05177e89 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs @@ -24,6 +24,8 @@ public sealed class EntitySerializerTests SmtpConfigurations: Array.Empty<SmtpConfiguration>(), ApiMethods: Array.Empty<ApiMethod>()); + /// <summary>Verifies that converting an external system to a DTO carves its credentials into the secrets block.</summary> + /// <returns>The result.</returns> [Fact] public void ToDto_carves_external_system_credentials_into_secrets_block() { @@ -47,6 +49,8 @@ public sealed class EntitySerializerTests Assert.Equal("ApiKey", dtoSys.AuthType); } + /// <summary>Verifies that converting an SMTP config to a DTO carves its password into the secrets block.</summary> + /// <returns>The result.</returns> [Fact] public void ToDto_carves_smtp_password_into_secrets_block() { @@ -67,6 +71,8 @@ public sealed class EntitySerializerTests Assert.Equal(587, dtoSmtp.Port); } + /// <summary>Verifies that a template round trip preserves its attributes, alarms, scripts and compositions.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_template_preserves_attributes_alarms_scripts_composition() { @@ -99,7 +105,7 @@ public sealed class EntitySerializerTests TriggerType = AlarmTriggerType.RangeViolation, TriggerConfiguration = "{\"threshold\":100}", IsLocked = false, - // FU-B / #37 — alarm fires "OnUpdate" script when triggered. The FK is + // Alarm fires "OnUpdate" script when triggered. The FK is // an in-aggregate script id; the DTO carries the name and the // importer resolves the FK on the way back in. OnTriggerScriptId = 1, @@ -122,7 +128,7 @@ public sealed class EntitySerializerTests var sut = new EntitySerializer(); var dto = sut.ToBundleContent(aggregate); - // FU-B / #37 — verify the DTO carries OnTriggerScriptName by NAME (not id). + // Verify the DTO carries OnTriggerScriptName by NAME (not id). // The bundle is portable across environments so a script-id FK can't // survive a round-trip; resolution back to a script id is the importer's // job once the parent template's scripts have been re-persisted. @@ -159,6 +165,8 @@ public sealed class EntitySerializerTests Assert.Equal("MotorA", rtComp.InstanceName); } + /// <summary>Verifies that a template round trip preserves the locked-in-derived flag on attributes, alarms and scripts.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_template_preserves_LockedInDerived_on_attribute_alarm_script() { @@ -202,6 +210,8 @@ public sealed class EntitySerializerTests Assert.True(Assert.Single(rtBasic.Scripts).LockedInDerived); } + /// <summary>Verifies that a template round trip preserves its native alarm sources.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_template_preserves_NativeAlarmSources() { @@ -244,6 +254,8 @@ public sealed class EntitySerializerTests Assert.True(rtSrc.LockedInDerived); } + /// <summary>Verifies that old-form bundle JSON without the locked-in-derived field deserializes it as false.</summary> + /// <returns>The result.</returns> [Fact] public void OldForm_bundle_json_without_LockedInDerived_deserializes_to_false() { @@ -294,6 +306,8 @@ public sealed class EntitySerializerTests Assert.False(Assert.Single(template.Scripts).LockedInDerived); } + /// <summary>Verifies that a round trip preserves the MES-reportable flag on alarms and instance overrides.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_preserves_IsMesReportable_on_alarm_and_instance_override() { @@ -332,6 +346,8 @@ public sealed class EntitySerializerTests Assert.False(Assert.Single(Assert.Single(restored.Instances).AlarmOverrides).IsMesReportableOverride); } + /// <summary>Verifies that old-form bundle JSON without the MES-reportable field deserializes it as false on alarms and null on overrides.</summary> + /// <returns>The result.</returns> [Fact] public void OldForm_bundle_json_without_IsMesReportable_deserializes_to_false_and_null() { @@ -394,6 +410,8 @@ public sealed class EntitySerializerTests Assert.Null(over.IsMesReportableOverride); } + /// <summary>Verifies that a template folder round trip preserves the folder hierarchy.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_template_folder_preserves_hierarchy() { @@ -419,6 +437,8 @@ public sealed class EntitySerializerTests Assert.Equal(rtRoot.Id, rtChild.ParentFolderId); } + /// <summary>Verifies that an external system round trip preserves its retry configuration.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_external_system_preserves_retry_config() { @@ -441,7 +461,7 @@ public sealed class EntitySerializerTests } /// <summary> - /// arch-review 2026-08-19 P7-1 (row-27 shape): <c>TimeoutSeconds</c> is operative at + /// <c>TimeoutSeconds</c> is operative at /// runtime (the per-system HTTP timeout) but was silently dropped by the export/import /// DTO — same defect class as the earlier <c>MaxRetries</c>/<c>RetryDelay</c> holes this /// test's sibling covers. @@ -494,6 +514,8 @@ public sealed class EntitySerializerTests Assert.Equal(0, sys.TimeoutSeconds); } + /// <summary>Verifies that converting a DTO with a null secrets block yields an entity with default empty secrets.</summary> + /// <returns>The result.</returns> [Fact] public void FromDto_with_null_SecretsBlock_yields_entity_with_default_empty_secrets() { @@ -516,6 +538,8 @@ public sealed class EntitySerializerTests Assert.Null(sys.AuthConfiguration); } + /// <summary>Verifies that a list attribute round trip preserves its element data type.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_List_attribute_preserves_ElementDataType() { @@ -585,6 +609,8 @@ public sealed class EntitySerializerTests ApiMethods: Array.Empty<ApiMethodDto>()); } + /// <summary>Verifies that import normalizes an old-form Int32 list value to native JSON.</summary> + /// <returns>The result.</returns> [Fact] public void Import_normalizes_old_form_Int32_list_value_to_native_json() { @@ -600,6 +626,8 @@ public sealed class EntitySerializerTests Assert.Equal("[10,20]", attr.Value); } + /// <summary>Verifies that import leaves a string list value quoted.</summary> + /// <returns>The result.</returns> [Fact] public void Import_leaves_string_list_value_quoted() { @@ -614,6 +642,8 @@ public sealed class EntitySerializerTests Assert.Equal("[\"a\",\"b\"]", attr.Value); } + /// <summary>Verifies that import leaves a malformed list value unchanged without throwing.</summary> + /// <returns>The result.</returns> [Fact] public void Import_leaves_malformed_list_value_unchanged_without_throwing() { @@ -628,6 +658,8 @@ public sealed class EntitySerializerTests Assert.Equal("[\"a\"", attr.Value); } + /// <summary>Verifies that import leaves a scalar attribute value unchanged.</summary> + /// <returns>The result.</returns> [Fact] public void Import_leaves_scalar_attribute_value_unchanged() { @@ -667,6 +699,8 @@ public sealed class EntitySerializerTests Assert.Equal("42.0", attr.Value); } + /// <summary>Verifies that a scalar attribute round trip keeps a null element data type as null.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_scalar_attribute_with_null_ElementDataType_remains_null() { @@ -699,7 +733,7 @@ public sealed class EntitySerializerTests Assert.Null(rtAttr.ElementDataType); } - // --- M8 (B2): site / data-connection / instance mapping ------------------ + // --- Site / data-connection / instance mapping ------------------ /// <summary> /// Builds a populated aggregate: one template (so the instance's TemplateName @@ -768,6 +802,8 @@ public sealed class EntitySerializerTests }; } + /// <summary>Verifies that converting a site, connection and instance to DTOs maps secrets and name links.</summary> + /// <returns>The result.</returns> [Fact] public void ToDto_maps_site_connection_instance_with_secrets_and_name_links() { @@ -831,6 +867,8 @@ public sealed class EntitySerializerTests Assert.Equal("ns=2;s=Pump1.P", dtoBinding.DataSourceReferenceOverride); } + /// <summary>Verifies that converting a connection to a DTO omits the secret key when the backup configuration is empty.</summary> + /// <returns>The result.</returns> [Fact] public void ToDto_omits_secret_key_when_backup_configuration_is_empty() { @@ -857,6 +895,8 @@ public sealed class EntitySerializerTests Assert.False(dtoConn.Secrets.Values.ContainsKey("BackupConfiguration")); } + /// <summary>Verifies that a site, connection and instance round trip restores configuration from the secrets and binding names.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_site_connection_instance_restores_config_from_secrets_and_binding_name() { @@ -911,6 +951,8 @@ public sealed class EntitySerializerTests // --- S10: SMS recipient PhoneNumber + SmsConfiguration round-trip --------- + /// <summary>Verifies that converting an SMS recipient to a DTO carries its phone number.</summary> + /// <returns>The result.</returns> [Fact] public void ToDto_carries_sms_recipient_phone_number() { @@ -930,6 +972,8 @@ public sealed class EntitySerializerTests Assert.Null(dtoRecipient.EmailAddress); } + /// <summary>Verifies that an SMS recipient round trip preserves the phone number.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_sms_recipient_preserves_phone_number() { @@ -948,6 +992,8 @@ public sealed class EntitySerializerTests Assert.Null(rtRecipient.EmailAddress); } + /// <summary>Verifies that an email recipient round trip preserves the email address and leaves the phone number null.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_email_recipient_preserves_email_and_leaves_phone_null() { @@ -969,6 +1015,8 @@ public sealed class EntitySerializerTests Assert.Null(rtRecipient.PhoneNumber); } + /// <summary>Verifies that a recipient round trip preserves both the email address and the phone number.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_recipient_with_both_contacts_preserves_both() { @@ -989,6 +1037,8 @@ public sealed class EntitySerializerTests Assert.Equal("+15559876543", rtRecipient.PhoneNumber); } + /// <summary>Verifies that converting a recipient DTO without a phone number yields a null phone number.</summary> + /// <returns>The result.</returns> [Fact] public void FromDto_recipient_without_phone_number_yields_null_phone() { @@ -1017,6 +1067,8 @@ public sealed class EntitySerializerTests Assert.Null(recipient.PhoneNumber); } + /// <summary>Verifies that converting an SMS config to a DTO carves its auth token into the secrets block.</summary> + /// <returns>The result.</returns> [Fact] public void ToDto_carves_sms_auth_token_into_secrets_block() { @@ -1045,6 +1097,8 @@ public sealed class EntitySerializerTests Assert.DoesNotContain("twilio-super-secret", dtoSms.FromNumber); } + /// <summary>Verifies that an SMS config round trip restores the auth token from the secrets block.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_sms_config_restores_auth_token_from_secrets_block() { @@ -1075,6 +1129,8 @@ public sealed class EntitySerializerTests Assert.Equal("twilio-super-secret", rtSms.AuthToken); } + /// <summary>Verifies that converting an SMS config to a DTO omits the secret key when the auth token is null.</summary> + /// <returns>The result.</returns> [Fact] public void ToDto_omits_sms_secret_key_when_auth_token_is_null() { @@ -1095,6 +1151,8 @@ public sealed class EntitySerializerTests // --- EWS: Transport + the pre-existing OAuth2 authority/scope drop --------- + /// <summary>Verifies that an SMTP config round trip preserves the transport and OAuth2 fields.</summary> + /// <returns>The result.</returns> [Fact] public void Roundtrip_smtp_config_preserves_transport_and_oauth2_fields() { @@ -1143,6 +1201,8 @@ public sealed class EntitySerializerTests Assert.Equal(TimeSpan.FromSeconds(90), rtSmtp.RetryDelay); } + /// <summary>Verifies that converting an SMTP config DTO without transport or OAuth2 fields yields nulls.</summary> + /// <returns>The result.</returns> [Fact] public void FromDto_smtp_config_without_transport_or_oauth2_fields_yields_nulls() { @@ -1182,6 +1242,8 @@ public sealed class EntitySerializerTests Assert.Equal("smtp.example.com", smtp.Host); } + /// <summary>Verifies that an SMTP config DTO omits null transport and OAuth2 fields from its JSON.</summary> + /// <returns>The result.</returns> [Fact] public void SmtpConfigDto_omits_null_transport_and_oauth2_fields_from_json() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/ManifestBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/ManifestBuilderTests.cs index e2d98333..0ff53bda 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/ManifestBuilderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/ManifestBuilderTests.cs @@ -10,11 +10,12 @@ public sealed class ManifestBuilderTests private static BundleSummary EmptySummary => new(0, 0, 0, 0, 0, 0, 0, 0); private static IReadOnlyList<ManifestContentEntry> NoContents => Array.Empty<ManifestContentEntry>(); + /// <summary>Verifies that building a manifest populates the summary from the contents.</summary> [Fact] public void Build_populates_summary_from_contents() { var sut = new ManifestBuilder(); - // M8 (B3): summary now carries trailing Sites/DataConnections/Instances counts. + // Summary now carries trailing Sites/DataConnections/Instances counts. var summary = new BundleSummary(2, 1, 0, 0, 0, 0, 0, 0, Sites: 3, DataConnections: 4, Instances: 5); var contents = new[] { @@ -26,7 +27,7 @@ public sealed class ManifestBuilderTests var manifest = sut.Build("env", "user", "1.0.0", encryption: null, summary, contents, contentBytes: new byte[] { 1, 2, 3 }); Assert.Equal(summary, manifest.Summary); - // New M8 summary counts flow through the builder verbatim. + // New summary counts flow through the builder verbatim. Assert.Equal(3, manifest.Summary.Sites); Assert.Equal(4, manifest.Summary.DataConnections); Assert.Equal(5, manifest.Summary.Instances); @@ -35,11 +36,12 @@ public sealed class ManifestBuilderTests Assert.Equal("user", manifest.ExportedBy); Assert.Equal("1.0.0", manifest.ScadaBridgeVersion); Assert.Equal(1, manifest.BundleFormatVersion); - // M8 (B3): schema minor bumped 1.0 → 1.1; format version stays 1. + // Schema minor bumped 1.0 → 1.1; format version stays 1. Assert.Equal("1.1", manifest.SchemaVersion); Assert.Null(manifest.Encryption); } + /// <summary>Verifies that building a manifest computes a content hash with the sha256 prefix.</summary> [Fact] public void Build_computes_content_hash_with_sha256_prefix() { @@ -52,6 +54,7 @@ public sealed class ManifestBuilderTests Assert.Equal("sha256:" + expectedHashHex, manifest.ContentHash); } + /// <summary>Verifies that validation rejects an unsupported bundle format version.</summary> [Fact] public void Validate_rejects_unsupported_bundleFormatVersion() { @@ -74,6 +77,7 @@ public sealed class ManifestBuilderTests Assert.Equal(ManifestValidationResult.UnsupportedFormatVersion, result); } + /// <summary>Verifies that validation rejects a manifest whose content hash does not match.</summary> [Fact] public void Validate_rejects_when_contentHash_mismatch() { @@ -95,6 +99,7 @@ public sealed class ManifestBuilderTests Assert.Equal(ManifestValidationResult.ContentHashMismatch, result); } + /// <summary>Verifies that validation accepts a well-formed version 1 manifest.</summary> [Fact] public void Validate_accepts_well_formed_v1_manifest() { @@ -107,10 +112,11 @@ public sealed class ManifestBuilderTests Assert.Equal(ManifestValidationResult.Ok, result); } + /// <summary>Verifies that validation accepts an older schema version 1.0 manifest.</summary> [Fact] public void Validate_accepts_older_schemaVersion_1_0_manifest() { - // An older "1.0" bundle (pre-M8) must still import: schema minor differences + // An older "1.0" bundle must still import: schema minor differences // are additive and the validator gates only on BundleFormatVersion. var bytes = Encoding.UTF8.GetBytes("legacy-bundle"); var hash = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); @@ -131,6 +137,7 @@ public sealed class ManifestBuilderTests Assert.Equal(ManifestValidationResult.Ok, result); } + /// <summary>Verifies that validation refuses bundle format version 2.</summary> [Fact] public void Validate_refuses_bundleFormatVersion_2() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/TransportOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/TransportOptionsValidatorTests.cs index 47a3a213..552a691b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/TransportOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/TransportOptionsValidatorTests.cs @@ -15,6 +15,7 @@ public class TransportOptionsValidatorTests private static ValidateOptionsResult Validate(TransportOptions options) => new TransportOptionsValidator().Validate(Options.DefaultName, options); + /// <summary>Verifies that the default options are valid.</summary> [Fact] public void DefaultOptions_AreValid() { @@ -22,6 +23,7 @@ public class TransportOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that a zero bundle session TTL is rejected.</summary> [Fact] public void ZeroBundleSessionTtlMinutes_IsRejected() { @@ -30,6 +32,7 @@ public class TransportOptionsValidatorTests Assert.Contains("BundleSessionTtlMinutes", result.FailureMessage); } + /// <summary>Verifies that a zero maximum bundle size is rejected.</summary> [Fact] public void ZeroMaxBundleSizeMb_IsRejected() { @@ -38,6 +41,7 @@ public class TransportOptionsValidatorTests Assert.Contains("MaxBundleSizeMb", result.FailureMessage); } + /// <summary>Verifies that a zero maximum of concurrent import sessions is rejected.</summary> [Fact] public void ZeroMaxConcurrentImportSessions_IsRejected() { @@ -46,6 +50,7 @@ public class TransportOptionsValidatorTests Assert.Contains("MaxConcurrentImportSessions", result.FailureMessage); // FAILS today: validates clean } + /// <summary>Verifies that a negative maximum of concurrent import sessions is rejected.</summary> [Fact] public void NegativeMaxConcurrentImportSessions_IsRejected() { @@ -54,6 +59,7 @@ public class TransportOptionsValidatorTests Assert.Contains("MaxConcurrentImportSessions", result.FailureMessage); } + /// <summary>Verifies that zero PBKDF2 iterations is rejected.</summary> [Fact] public void ZeroPbkdf2Iterations_IsRejected() { @@ -62,6 +68,7 @@ public class TransportOptionsValidatorTests Assert.Contains("Pbkdf2Iterations", result.FailureMessage); } + /// <summary>Verifies that a PBKDF2 iteration count below the security floor is rejected.</summary> [Fact] public void Pbkdf2Iterations_BelowSecurityFloor_IsRejected() { @@ -72,6 +79,7 @@ public class TransportOptionsValidatorTests Assert.Contains("Pbkdf2Iterations", result.FailureMessage); } + /// <summary>Verifies that a PBKDF2 iteration count at the security floor is valid.</summary> [Fact] public void Pbkdf2Iterations_AtSecurityFloor_IsValid() { @@ -79,6 +87,7 @@ public class TransportOptionsValidatorTests Assert.True(result.Succeeded, result.FailureMessage); } + /// <summary>Verifies that zero unlock attempts per session is rejected.</summary> [Fact] public void ZeroMaxUnlockAttemptsPerSession_IsRejected() { @@ -87,6 +96,7 @@ public class TransportOptionsValidatorTests Assert.Contains("MaxUnlockAttemptsPerSession", result.FailureMessage); } + /// <summary>Verifies that a zero maximum decompressed bundle entry size is rejected.</summary> [Fact] public void ZeroMaxBundleEntryDecompressedMb_IsRejected() { @@ -95,6 +105,7 @@ public class TransportOptionsValidatorTests Assert.Contains("MaxBundleEntryDecompressedMb", result.FailureMessage); } + /// <summary>Verifies that a zero maximum bundle entry count is rejected.</summary> [Fact] public void ZeroMaxBundleEntryCount_IsRejected() { @@ -103,6 +114,7 @@ public class TransportOptionsValidatorTests Assert.Contains("MaxBundleEntryCount", result.FailureMessage); } + /// <summary>Verifies that a zero maximum bundle entry compression ratio is rejected.</summary> [Fact] public void ZeroMaxBundleEntryCompressionRatio_IsRejected() { @@ -111,6 +123,7 @@ public class TransportOptionsValidatorTests Assert.Contains("MaxBundleEntryCompressionRatio", result.FailureMessage); } + /// <summary>Verifies that zero unlock attempts per IP per hour is rejected.</summary> [Fact] public void ZeroMaxUnlockAttemptsPerIpPerHour_IsRejected() { @@ -119,6 +132,7 @@ public class TransportOptionsValidatorTests Assert.Contains("MaxUnlockAttemptsPerIpPerHour", result.FailureMessage); } + /// <summary>Verifies that a zero major schema version is rejected.</summary> [Fact] public void ZeroSchemaVersionMajor_IsRejected() { @@ -127,6 +141,8 @@ public class TransportOptionsValidatorTests Assert.Contains("SchemaVersionMajor", result.FailureMessage); } + /// <summary>Verifies that a blank source environment is rejected.</summary> + /// <param name="value">The blank source environment value.</param> [Theory] [InlineData("")] [InlineData(" ")]