@@ -2,6 +2,7 @@ package cmd
22
33import (
44 "bytes"
5+ "net/http"
56 "testing"
67
78 renderapi "github.com/render-oss/cli/internal/fakes/renderapi"
@@ -11,6 +12,8 @@ import (
1112 "github.com/render-oss/cli/pkg/command"
1213 "github.com/render-oss/cli/pkg/config"
1314 "github.com/render-oss/cli/pkg/dependencies"
15+ "github.com/render-oss/cli/pkg/pointers"
16+ servicetypes "github.com/render-oss/cli/pkg/types/service"
1417 "github.com/spf13/cobra"
1518 "github.com/stretchr/testify/assert"
1619 "github.com/stretchr/testify/require"
@@ -194,7 +197,7 @@ func (h serviceUpdateHarness) setupCmd() (*cobra.Command, *bytes.Buffer, *bytes.
194197 return root , & stdout , & stderr
195198}
196199
197- func TestServiceUpdate_JSONOutput_ReturnsServiceOutEnvelope (t * testing.T ) {
200+ func TestServiceUpdate_JSONOutput_UpdatesTopLevelFieldsAndReturnsEnvelope (t * testing.T ) {
198201 harness := newServiceUpdateHarness (t )
199202 project := harness .server .CreateProject (
200203 renderapi.ProjectAttrs {Name : "Website" , OwnerId : serviceTestWorkspaceID },
@@ -209,19 +212,22 @@ func TestServiceUpdate_JSONOutput_ReturnsServiceOutEnvelope(t *testing.T) {
209212 },
210213 }))
211214
215+ // Per-flag PATCH-body wiring is covered by TestServiceUpdate_FlagWiresIntoPatchBody.
216+ // This test owns the JSON output envelope shape, so it sets a single flag.
212217 result , err := harness .execute (svc .Id ,
213218 "--name" , "json-service-renamed" ,
214219 "--output" , "json" ,
215220 )
216221 require .NoError (t , err )
217222
218223 assert .True (t , harness .server .HasRequest ("PATCH" , "/services/" + svc .Id ))
224+ assert .Equal (t , "json-service-renamed" , svc .Name )
219225
220226 body := testrequire .ParseJSONMap (t , result .Stdout )
221227 testassert .MapContains (t , body , map [string ]any {
222228 "data" : map [string ]any {
223229 "id" : svc .Id ,
224- "name" : "json-service" ,
230+ "name" : "json-service-renamed " ,
225231 "type" : string (client .WebService ),
226232 "projectId" : project .Project .Id ,
227233 "environmentId" : envID ,
@@ -233,3 +239,215 @@ func TestServiceUpdate_JSONOutput_ReturnsServiceOutEnvelope(t *testing.T) {
233239 })
234240 assert .NotContains (t , body , "id" )
235241}
242+
243+ // addWebService seeds a web service under a new project and returns it.
244+ func (h serviceUpdateHarness ) addWebService (name string ) * client.Service {
245+ h .t .Helper ()
246+
247+ project := h .server .CreateProject (
248+ renderapi.ProjectAttrs {Name : "Website" , OwnerId : serviceTestWorkspaceID },
249+ renderapi.EnvAttrs {Name : "production" },
250+ )
251+ return h .server .Services .Add (renderapi .NewWebService (renderapi.WebServiceAttrs {
252+ Service : renderapi.CommonServiceAttrs {
253+ Name : name ,
254+ OwnerID : serviceTestWorkspaceID ,
255+ EnvironmentID : project .Env ("production" ).Id ,
256+ },
257+ }))
258+ }
259+
260+ // TestServiceUpdate_ParseCommand_HappyPath verifies that every flag the update command
261+ // registers binds to the expected ServiceUpdateInput field with the expected
262+ // type. It drives the real command's flag set through ParseCommand in memory —
263+ // but does not invoke the command.
264+ // It exhaustively covers the CLI flag input <-> `cli:` tag seam that the e2e tests would only exercise indirectly.
265+ // The input->PATCH-body transform is covered separately in pkg/service/update_test.go.
266+ func TestServiceUpdate_ParseCommand_HappyPath (t * testing.T ) {
267+ cmd := newServiceUpdateTestCmd ()
268+ require .NoError (t , cmd .ParseFlags ([]string {
269+ "--name" , "svc-name" ,
270+ "--repo" , "https://github.com/example/repo" ,
271+ "--branch" , "main" ,
272+ "--image" , "docker.io/library/nginx:alpine" ,
273+ "--plan" , "starter" ,
274+ "--runtime" , string (servicetypes .ServiceRuntimeGo ),
275+ "--root-directory" , "app" ,
276+ "--build-command" , "make build" ,
277+ "--start-command" , "./run" ,
278+ "--pre-deploy-command" , "bin/migrate" ,
279+ "--health-check-path" , "/ready" ,
280+ "--publish-directory" , "public" ,
281+ "--cron-command" , "echo hi" ,
282+ "--cron-schedule" , "0 12 * * *" ,
283+ "--registry-credential" , "rc-123" ,
284+ "--auto-deploy=false" ,
285+ "--build-filter-path" , "src/**" ,
286+ "--build-filter-path" , "go.mod" ,
287+ "--build-filter-ignored-path" , "docs/**" ,
288+ "--num-instances" , "3" ,
289+ "--max-shutdown-delay" , "42" ,
290+ "--previews" , string (servicetypes .PreviewsGenerationManual ),
291+ "--maintenance-mode=true" ,
292+ "--maintenance-mode-uri" , "https://status.example.com" ,
293+ "--ip-allow-list" , "cidr=203.0.113.5/32,description=office" ,
294+ }))
295+
296+ var in servicetypes.ServiceUpdateInput
297+ require .NoError (t , command .ParseCommand (cmd , []string {"srv-abc123" }, & in ))
298+
299+ previews := servicetypes .PreviewsGenerationManual
300+ runtime := servicetypes .ServiceRuntimeGo
301+ assert .Equal (t , servicetypes.ServiceUpdateInput {
302+ Name : "svc-name" ,
303+ Repo : pointers .From ("https://github.com/example/repo" ),
304+ Branch : pointers .From ("main" ),
305+ Image : pointers .From ("docker.io/library/nginx:alpine" ),
306+ Plan : pointers .From ("starter" ),
307+ Runtime : & runtime ,
308+ RootDirectory : pointers .From ("app" ),
309+ BuildCommand : pointers .From ("make build" ),
310+ StartCommand : pointers .From ("./run" ),
311+ PreDeployCommand : pointers .From ("bin/migrate" ),
312+ HealthCheckPath : pointers .From ("/ready" ),
313+ PublishDirectory : pointers .From ("public" ),
314+ CronCommand : pointers .From ("echo hi" ),
315+ CronSchedule : pointers .From ("0 12 * * *" ),
316+ RegistryCredential : pointers .From ("rc-123" ),
317+ AutoDeploy : pointers .From (false ),
318+ BuildFilterPaths : []string {"src/**" , "go.mod" },
319+ BuildFilterIgnoredPaths : []string {"docs/**" },
320+ NumInstances : pointers .From (3 ),
321+ MaxShutdownDelay : pointers .From (42 ),
322+ Previews : & previews ,
323+ MaintenanceMode : pointers .From (true ),
324+ MaintenanceModeURI : pointers .From ("https://status.example.com" ),
325+ IPAllowList : []string {"cidr=203.0.113.5/32,description=office" },
326+ ServiceIDOrName : "srv-abc123" ,
327+ }, in )
328+ }
329+
330+ // TestServiceUpdate_FlagWiresIntoPatchBody verifies end-to-end that flags reach
331+ // the serialized PATCH body and that nothing unspecified comes along for the
332+ // ride. Each case asserts the *entire* decoded body by equality, so an extra or
333+ // misplaced field fails the test — stronger than checking individual keys.
334+ // Cases cover a single top-level field, a single serviceDetails field, a mix of
335+ // top-level and serviceDetails fields, and multiple serviceDetails fields.
336+ func TestServiceUpdate_FlagWiresIntoPatchBody (t * testing.T ) {
337+ tests := []struct {
338+ name string
339+ args []string
340+ wantBody map [string ]any
341+ }{
342+ {
343+ name : "name maps to top-level name only" ,
344+ args : []string {"--name" , "renamed-service" },
345+ wantBody : map [string ]any {"name" : "renamed-service" },
346+ },
347+ {
348+ name : "plan maps into serviceDetails only" ,
349+ args : []string {"--plan" , "starter" },
350+ wantBody : map [string ]any {
351+ "serviceDetails" : map [string ]any {"plan" : "starter" },
352+ },
353+ },
354+ {
355+ name : "top-level and detail fields coexist without leaking" ,
356+ args : []string {"--name" , "renamed-service" , "--health-check-path" , "/ready" },
357+ wantBody : map [string ]any {
358+ "name" : "renamed-service" ,
359+ "serviceDetails" : map [string ]any {"healthCheckPath" : "/ready" },
360+ },
361+ },
362+ {
363+ name : "multiple serviceDetails fields land together" ,
364+ args : []string {"--health-check-path" , "/ready" , "--plan" , "starter" },
365+ wantBody : map [string ]any {
366+ "serviceDetails" : map [string ]any {
367+ "healthCheckPath" : "/ready" ,
368+ "plan" : "starter" ,
369+ },
370+ },
371+ },
372+ }
373+
374+ for _ , tt := range tests {
375+ t .Run (tt .name , func (t * testing.T ) {
376+ harness := newServiceUpdateHarness (t )
377+ svc := harness .addWebService ("wire-service" )
378+
379+ args := append ([]string {svc .Id }, tt .args ... )
380+ args = append (args , "--output" , "json" )
381+ _ , err := harness .execute (args ... )
382+ require .NoError (t , err )
383+
384+ rec , ok := harness .server .LastRequest ("PATCH" , "/services/" + svc .Id )
385+ require .True (t , ok , "expected a PATCH request to be recorded" )
386+ body := testrequire .ParseJSONMap (t , string (rec .Body ))
387+ assert .Equal (t , tt .wantBody , body )
388+ })
389+ }
390+ }
391+
392+ func TestServiceUpdate_TypeIncompatibleFlag_FailsBeforePatch (t * testing.T ) {
393+ harness := newServiceUpdateHarness (t )
394+ svc := harness .addWebService ("web-service" )
395+
396+ // --cron-schedule is rejected only by ValidateForServiceType, which runs
397+ // against the fetched service type. The command must fail before writing.
398+ _ , err := harness .execute (svc .Id , "--cron-schedule" , "0 12 * * *" , "--output" , "json" )
399+ require .Error (t , err )
400+ assert .Contains (t , err .Error (), "--cron-schedule is not supported for web_service" )
401+ assert .False (t , harness .server .HasRequest ("PATCH" , "/services/" + svc .Id ))
402+ }
403+
404+ func TestServiceUpdate_DefaultTextOutput (t * testing.T ) {
405+ harness := newServiceUpdateHarness (t )
406+ svc := harness .addWebService ("text-service" )
407+
408+ result , err := harness .execute (svc .Id , "--name" , "text-service-renamed" )
409+ require .NoError (t , err )
410+
411+ assert .True (t , harness .server .HasRequest ("PATCH" , "/services/" + svc .Id ))
412+ assert .Contains (t , result .Stdout , "Updated this service" )
413+ assert .Contains (t , result .Stdout , "text-service-renamed" )
414+ }
415+
416+ func TestServiceUpdate_ServiceDetailsPatchReachesAPI (t * testing.T ) {
417+ harness := newServiceUpdateHarness (t )
418+ svc := harness .addWebService ("plan-service" )
419+
420+ // --plan flows into the ServiceDetails union; this exercises that the union
421+ // serializes and is accepted end-to-end (the fake decodes the PATCH body).
422+ _ , err := harness .execute (svc .Id , "--plan" , "starter" , "--output" , "json" )
423+ require .NoError (t , err )
424+
425+ assert .True (t , harness .server .HasRequest ("PATCH" , "/services/" + svc .Id ))
426+ }
427+
428+ func TestServiceUpdate_UnknownService_FailsToResolveBeforePatch (t * testing.T ) {
429+ harness := newServiceUpdateHarness (t )
430+
431+ // No service is seeded, so resolving the name finds nothing. The command
432+ // must surface a resolve error and never attempt a PATCH.
433+ _ , err := harness .execute ("does-not-exist" , "--name" , "renamed" , "--output" , "json" )
434+ require .Error (t , err )
435+ assert .Contains (t , err .Error (), `failed to resolve service "does-not-exist"` )
436+ assert .False (t , harness .server .HasRequest ("PATCH" , "/services/" ))
437+ }
438+
439+ func TestServiceUpdate_APIError_SurfacesWithoutWriting (t * testing.T ) {
440+ harness := newServiceUpdateHarness (t )
441+ svc := harness .addWebService ("api-error-service" )
442+
443+ // The fake drains its error queue FIFO across all service requests, so a
444+ // single queued error lands on the first GET the command issues to resolve
445+ // and load the service — before any PATCH. This asserts the command
446+ // propagates an unexpected API error instead of swallowing it, and does not
447+ // write when a pre-PATCH step fails.
448+ harness .server .Services .RespondWith (http .StatusInternalServerError )
449+
450+ _ , err := harness .execute (svc .Id , "--name" , "renamed" , "--output" , "json" )
451+ require .Error (t , err )
452+ assert .False (t , harness .server .HasRequest ("PATCH" , "/services/" + svc .Id ))
453+ }
0 commit comments