@@ -396,6 +396,59 @@ func TestStackPromote_CreatesNewTarget(t *testing.T) {
396396 assert .Equal (t , http .StatusAccepted , resp .StatusCode )
397397}
398398
399+ // TestStackPromote_InPlaceUpdate — a pre-existing target stack in the same
400+ // family is re-used (action="updated_existing"), exercising the in-place
401+ // branch: updating an existing target service's image_ref AND creating a
402+ // target service the source has but the target lacks.
403+ func TestStackPromote_InPlaceUpdate (t * testing.T ) {
404+ gapCovNeedsDB (t )
405+ db , cleanDB := testhelpers .SetupTestDB (t )
406+ defer cleanDB ()
407+ ensureStackTables (t , db )
408+
409+ teamID := testhelpers .MustCreateTeamDB (t , db , "pro" )
410+ jwt := testhelpers .MustSignSessionJWT (t , uuid .NewString (), teamID , "inplace@example.com" )
411+
412+ // Source (staging) is the family root. Two services with image_refs.
413+ srcSlug , srcID := seedPromoteSourceStackNoImageRef (t , db , teamID , "staging" , "ip-src" )
414+ for _ , svc := range []string {"api" , "worker" } {
415+ _ , err := db .ExecContext (context .Background (), `
416+ INSERT INTO stack_services (stack_id, name, expose, port, image_ref, status)
417+ VALUES ($1::uuid, $2, true, 8080, $3, 'healthy')
418+ ` , srcID , svc , "registry.local/" + svc + ":v2" )
419+ require .NoError (t , err )
420+ }
421+
422+ // Pre-existing target (development) in the SAME family (parent = source).
423+ tgtSlug := "stk-iptgt-" + randHex (t , 4 )
424+ var tgtID string
425+ require .NoError (t , db .QueryRowContext (context .Background (), `
426+ INSERT INTO stacks (team_id, name, slug, namespace, status, tier, env, parent_stack_id)
427+ VALUES ($1, 'ip-tgt', $2, $3, 'healthy', 'pro', 'development', $4::uuid)
428+ RETURNING id::text
429+ ` , teamID , tgtSlug , "instant-stack-" + tgtSlug , srcID ).Scan (& tgtID ))
430+ // Target has only "api" (old image_ref) — "worker" is missing so the
431+ // create-new-service branch fires.
432+ _ , err := db .ExecContext (context .Background (), `
433+ INSERT INTO stack_services (stack_id, name, expose, port, image_ref, status)
434+ VALUES ($1::uuid, 'api', true, 8080, 'registry.local/api:v1', 'healthy')
435+ ` , tgtID )
436+ require .NoError (t , err )
437+
438+ app := newStackTestApp (t , db )
439+ resp := postPromote (t , app , jwt , srcSlug , map [string ]any {"from" : "staging" , "to" : "development" })
440+ defer resp .Body .Close ()
441+ // Existing target -> 200 updated_existing.
442+ assert .Equal (t , http .StatusOK , resp .StatusCode )
443+
444+ // The target's "worker" service must have been created with the source image.
445+ var n int
446+ require .NoError (t , db .QueryRowContext (context .Background (),
447+ `SELECT count(*) FROM stack_services WHERE stack_id = $1::uuid AND name = 'worker'` ,
448+ tgtID ).Scan (& n ))
449+ assert .Equal (t , 1 , n , "missing target service must be created during in-place promote" )
450+ }
451+
399452// ── copyVaultRefsForPromote — direct unit coverage ───────────────────────────
400453
401454// TestCopyVaultRefsForPromote_NoSourceKeys returns nil,nil (the no-op branch).
0 commit comments