@@ -95,13 +95,13 @@ public async Task AzureCliStyleParameters_AreAcceptedCorrectly(string command, p
9595 }
9696
9797 [ Fact ]
98- public async Task ServiceIntegration_PublishCommand_AcceptsAllNamedParameters ( )
98+ public async Task PublishCommand_AcceptsAllNamedParameters_InDryRun ( )
9999 {
100- // Verifies the publish CLI parses every documented flag without error. The publish flow now
101- // orchestrates Entra app creation + redirect-URI back-fill via GraphApiService (mirroring
102- // register-external-mcp-server), so end-to-end "params flow to PublishServerAsync" can't be
103- // exercised here without mocking Graph too — that path is covered by the
104- // <see cref="DryRunMode_NeverCallsActualServices"/> regression test and by manual E2E testing .
100+ // Verifies the publish CLI parses every documented flag without error. Dry-run
101+ // short-circuits before any platform call, so this is a pure CLI parsing test —
102+ // it does NOT verify that the parsed values flow into PublishServerAsync.
103+ // That contract is covered by PublishCommand_ForwardsParsedParametersToToolingService
104+ // (which mocks Graph + tenant detection so the non-dry-run path can be exercised) .
105105
106106 // Arrange
107107 var testEnvId = "test-environment-123" ;
@@ -121,8 +121,140 @@ public async Task ServiceIntegration_PublishCommand_AcceptsAllNamedParameters()
121121 } ) ;
122122
123123 // Assert — successful parse + dispatch, no service calls.
124- result . Should ( ) . Be ( 0 ) ;
125- await _mockToolingService . DidNotReceive ( ) . PublishServerAsync ( Arg . Any < string > ( ) , Arg . Any < string > ( ) , Arg . Any < PublishMcpServerRequest > ( ) ) ;
124+ result . Should ( ) . Be (
125+ 0 ,
126+ because : "dry-run should never trigger a non-zero exit code when all flags parse cleanly." ) ;
127+ await _mockToolingService . DidNotReceive ( ) . PublishServerAsync (
128+ Arg . Any < string > ( ) , Arg . Any < string > ( ) , Arg . Any < PublishMcpServerRequest > ( ) ) ;
129+ }
130+
131+ /// <summary>
132+ /// Strengthened contract test: verifies that every parsed publish flag flows into the actual
133+ /// <see cref="IAgent365ToolingService.PublishServerAsync"/> call. Exercises the non-dry-run
134+ /// path by (a) using <c>--yes</c> to bypass the interactive confirmation prompt, (b) mocking
135+ /// <see cref="GraphApiService"/> so Entra app creation succeeds without a real tenant, and
136+ /// (c) subclassing <see cref="PublishCommandExecutor"/> to stub out tenant auto-detection so
137+ /// the executor doesn't shell out to Azure CLI in CI. Catches breakage of the CLI-to-service
138+ /// contract (alias/display-name/publisher-name mapping, request shaping, and selecting the
139+ /// correct service method) that the dry-run-only test above can't catch.
140+ /// </summary>
141+ [ Fact ]
142+ public async Task PublishCommand_ForwardsParsedParametersToToolingService ( )
143+ {
144+ // Arrange
145+ const string TestTenantId = "test-tenant-99999" ;
146+ const string TestEnvironmentId = "test-env-forward" ;
147+ const string TestServerName = "msdyn_TestServer" ;
148+ const string TestAlias = "test-alias-forward" ;
149+ const string TestDisplayName = "Test Display Forward" ;
150+ const string TestPublisherName = "Contoso Forward" ;
151+ const string TestPublicClientsObjectId = "public-clients-object-id" ;
152+ const string TestPublicClientsClientId = "public-clients-client-id" ;
153+
154+ var logger = Substitute . For < ILogger > ( ) ;
155+ var toolingService = Substitute . For < IAgent365ToolingService > ( ) ;
156+ var graphApiService = Substitute . For < GraphApiService > ( ) ;
157+
158+ // Mock Graph so CreateEntraAppsAsync → factory.CreatePublicClientsAppAsync succeeds.
159+ graphApiService . CreateEntraAppAsync (
160+ TestTenantId , Arg . Any < string > ( ) , serviceTreeId : Arg . Any < string ? > ( ) , Arg . Any < CancellationToken > ( ) )
161+ . Returns ( Task . FromResult < ( string ObjectId , string ClientId ) ? > (
162+ ( TestPublicClientsObjectId , TestPublicClientsClientId ) ) ) ;
163+ graphApiService . UpdateAppPublicClientRedirectUrisAsync (
164+ TestTenantId , TestPublicClientsObjectId , Arg . Any < string [ ] > ( ) , Arg . Any < CancellationToken > ( ) )
165+ . Returns ( Task . FromResult ( true ) ) ;
166+
167+ // Mock Graph for ConfigureEntraAppsAsync → required-resource-access grant on Public Clients.
168+ graphApiService . GetOAuth2PermissionScopeIdAsync (
169+ TestTenantId , Arg . Any < string > ( ) , Arg . Any < string > ( ) , Arg . Any < CancellationToken > ( ) )
170+ . Returns ( Task . FromResult < Guid ? > ( Guid . NewGuid ( ) ) ) ;
171+ graphApiService . AddRequiredResourceAccessAsync (
172+ TestTenantId , Arg . Any < string > ( ) , Arg . Any < string > ( ) , Arg . Any < Guid > ( ) , Arg . Any < CancellationToken > ( ) )
173+ . Returns ( Task . FromResult ( true ) ) ;
174+
175+ // Capture what gets forwarded to PublishServerAsync.
176+ string ? capturedEnvId = null ;
177+ string ? capturedServerName = null ;
178+ PublishMcpServerRequest ? capturedRequest = null ;
179+ toolingService . PublishServerAsync (
180+ Arg . Do < string > ( e => capturedEnvId = e ) ,
181+ Arg . Do < string > ( s => capturedServerName = s ) ,
182+ Arg . Do < PublishMcpServerRequest > ( r => capturedRequest = r ) ,
183+ Arg . Any < CancellationToken > ( ) )
184+ . Returns ( new PublishMcpServerResponse
185+ {
186+ Status = "Success" ,
187+ McpServerAppId = Guid . NewGuid ( ) . ToString ( ) ,
188+ McpServerScope = "Tools.ListInvoke.All" ,
189+ } ) ;
190+
191+ var executor = new TestablePublishCommandExecutor (
192+ logger , toolingService , graphApiService , TestTenantId ) ;
193+
194+ var args = new RawPublishArgs (
195+ EnvironmentId : TestEnvironmentId ,
196+ ServerName : TestServerName ,
197+ Alias : TestAlias ,
198+ DisplayName : TestDisplayName ,
199+ PublisherName : TestPublisherName ,
200+ Yes : true ,
201+ DryRun : false ) ;
202+
203+ // Act
204+ var result = await executor . ExecuteAsync ( args ) ;
205+
206+ // Assert
207+ result . Should ( ) . BeTrue (
208+ because : "the happy path with all dependencies mocked must return true so the publish " +
209+ "handler exits 0." ) ;
210+ capturedRequest . Should ( ) . NotBeNull (
211+ because : "PublishServerAsync must be invoked once the prompt is skipped, the tenant " +
212+ "is detected, and the Public Clients app is created." ) ;
213+ capturedEnvId . Should ( ) . Be (
214+ TestEnvironmentId ,
215+ because : "--environment-id must flow unchanged into PublishServerAsync's first positional arg." ) ;
216+ capturedServerName . Should ( ) . Be (
217+ TestServerName ,
218+ because : "--server-name must flow unchanged into PublishServerAsync's second positional arg." ) ;
219+ capturedRequest ! . Alias . Should ( ) . Be (
220+ TestAlias ,
221+ because : "--alias must populate request.Alias; this is the platform's `name` for the published row." ) ;
222+ capturedRequest . DisplayName . Should ( ) . Be (
223+ TestDisplayName ,
224+ because : "--display-name must populate request.DisplayName; the platform's v2 validator " +
225+ "requires it and surfaces it in MOS." ) ;
226+ capturedRequest . PublisherName . Should ( ) . Be (
227+ TestPublisherName ,
228+ because : "--publisher-name must populate request.PublisherName so the MOS manifest's " +
229+ "developer field is set for custom servers (the platform rejects empty values " +
230+ "for non-1p servers)." ) ;
231+ capturedRequest . PublicClientsAppId . Should ( ) . Be (
232+ TestPublicClientsClientId ,
233+ because : "the just-created Public Clients Entra app's clientId must be carried to the " +
234+ "platform so it can be echoed back and the CLI can grant the PPMI scope on it " +
235+ "post-publish." ) ;
236+ }
237+
238+ /// <summary>
239+ /// Test-only subclass of <see cref="PublishCommandExecutor"/> that stubs out
240+ /// <see cref="PublishCommandExecutor.DetectTenantIdAsync"/> with a known value, so the
241+ /// strengthened contract test doesn't need to shell out to <c>az account show</c> in CI.
242+ /// </summary>
243+ private sealed class TestablePublishCommandExecutor : PublishCommandExecutor
244+ {
245+ private readonly string ? _tenantId ;
246+
247+ public TestablePublishCommandExecutor (
248+ ILogger logger ,
249+ IAgent365ToolingService toolingService ,
250+ GraphApiService ? graphApiService ,
251+ string ? tenantId )
252+ : base ( logger , toolingService , graphApiService )
253+ {
254+ _tenantId = tenantId ;
255+ }
256+
257+ protected override Task < string ? > DetectTenantIdAsync ( ) => Task . FromResult ( _tenantId ) ;
126258 }
127259
128260 [ Fact ]
0 commit comments