@@ -2,7 +2,9 @@ package azure
22
33import (
44 "context"
5+ "errors"
56 "testing"
7+ "time"
68
79 "github.com/Azure/azure-sdk-for-go/sdk/azcore"
810 "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
@@ -379,3 +381,164 @@ func TestConvertAdvisorRecommendation_UnknownService(t *testing.T) {
379381func strPtr (s string ) * string {
380382 return & s
381383}
384+
385+ // fakeServiceClient is a test double for serviceRecsGetter. It sleeps for
386+ // sleepDur to simulate network latency and then returns the fixed recs slice
387+ // (or err when non-nil). sleep is done inside GetRecommendations so that
388+ // mock latency is isolated to the mock body — the test's assert path never
389+ // sleeps (see memory feedback_no_sleep_in_tests).
390+ type fakeServiceClient struct {
391+ sleepDur time.Duration
392+ recs []common.Recommendation
393+ err error
394+ }
395+
396+ func (f * fakeServiceClient ) GetRecommendations (ctx context.Context , _ common.RecommendationParams ) ([]common.Recommendation , error ) {
397+ select {
398+ case <- time .After (f .sleepDur ):
399+ case <- ctx .Done ():
400+ return nil , ctx .Err ()
401+ }
402+ return f .recs , f .err
403+ }
404+
405+ // newFakeFn returns a constructor compatible with the newXxxClientFn signature
406+ // that ignores the credential/subscription/region and always returns fake.
407+ func newFakeFn (fake serviceRecsGetter ) func (azcore.TokenCredential , string , string ) serviceRecsGetter {
408+ return func (_ azcore.TokenCredential , _ , _ string ) serviceRecsGetter { return fake }
409+ }
410+
411+ const fakeServiceSleep = 100 * time .Millisecond
412+
413+ // TestGetRecommendations_Parallelism proves that all four service goroutines
414+ // run concurrently: total wall-clock time must be well under 2× per-service
415+ // sleep (i.e. less than 200ms) rather than near 4× (400ms sequential).
416+ // Advisor always calls getAdvisorRecommendations which skips the DI seam, so
417+ // we only count the four injectable services.
418+ func TestGetRecommendations_Parallelism (t * testing.T ) {
419+ origCompute := newComputeClientFn
420+ origDatabase := newDatabaseClientFn
421+ origCache := newCacheClientFn
422+ origCosmos := newCosmosDBClientFn
423+ t .Cleanup (func () {
424+ newComputeClientFn = origCompute
425+ newDatabaseClientFn = origDatabase
426+ newCacheClientFn = origCache
427+ newCosmosDBClientFn = origCosmos
428+ })
429+
430+ rec := func (svc common.ServiceType ) common.Recommendation {
431+ return common.Recommendation {Provider : common .ProviderAzure , Service : svc }
432+ }
433+
434+ newComputeClientFn = newFakeFn (& fakeServiceClient {sleepDur : fakeServiceSleep , recs : []common.Recommendation {rec (common .ServiceCompute )}})
435+ newDatabaseClientFn = newFakeFn (& fakeServiceClient {sleepDur : fakeServiceSleep , recs : []common.Recommendation {rec (common .ServiceRelationalDB )}})
436+ newCacheClientFn = newFakeFn (& fakeServiceClient {sleepDur : fakeServiceSleep , recs : []common.Recommendation {rec (common .ServiceCache )}})
437+ newCosmosDBClientFn = newFakeFn (& fakeServiceClient {sleepDur : fakeServiceSleep , recs : []common.Recommendation {rec (common .ServiceNoSQL )}})
438+
439+ adapter := & RecommendationsClientAdapter {
440+ cred : & mockAzureTokenCredential {},
441+ subscriptionID : "sub-parallelism" ,
442+ }
443+
444+ start := time .Now ()
445+ _ , err := adapter .GetRecommendations (context .Background (), common.RecommendationParams {})
446+ elapsed := time .Since (start )
447+
448+ require .NoError (t , err )
449+ // With 4 services sleeping 100ms in parallel the wall-clock must be
450+ // substantially less than 2× per-service sleep. We allow 190ms headroom
451+ // for scheduler jitter; if the calls were serial this would take ~400ms.
452+ assert .Less (t , elapsed , 2 * fakeServiceSleep ,
453+ "expected parallel dispatch: elapsed %v >= 2× per-service sleep %v — services may be running serially" ,
454+ elapsed , fakeServiceSleep )
455+ }
456+
457+ // TestGetRecommendations_OrderPreservation verifies that the merged slice
458+ // follows the canonical order compute → database → cache → cosmosdb regardless
459+ // of which fake goroutine returns first (staggered sleeps force an
460+ // out-of-start-order completion).
461+ func TestGetRecommendations_OrderPreservation (t * testing.T ) {
462+ origCompute := newComputeClientFn
463+ origDatabase := newDatabaseClientFn
464+ origCache := newCacheClientFn
465+ origCosmos := newCosmosDBClientFn
466+ t .Cleanup (func () {
467+ newComputeClientFn = origCompute
468+ newDatabaseClientFn = origDatabase
469+ newCacheClientFn = origCache
470+ newCosmosDBClientFn = origCosmos
471+ })
472+
473+ makeRec := func (svc common.ServiceType ) common.Recommendation {
474+ return common.Recommendation {Provider : common .ProviderAzure , Service : svc , ResourceType : string (svc )}
475+ }
476+
477+ // Stagger sleeps so goroutines complete in reverse order: cosmosdb
478+ // finishes first (~10ms), compute last (~40ms). The merged slice must
479+ // still reflect the canonical order.
480+ newComputeClientFn = newFakeFn (& fakeServiceClient {sleepDur : 40 * time .Millisecond , recs : []common.Recommendation {makeRec (common .ServiceCompute )}})
481+ newDatabaseClientFn = newFakeFn (& fakeServiceClient {sleepDur : 30 * time .Millisecond , recs : []common.Recommendation {makeRec (common .ServiceRelationalDB )}})
482+ newCacheClientFn = newFakeFn (& fakeServiceClient {sleepDur : 20 * time .Millisecond , recs : []common.Recommendation {makeRec (common .ServiceCache )}})
483+ newCosmosDBClientFn = newFakeFn (& fakeServiceClient {sleepDur : 10 * time .Millisecond , recs : []common.Recommendation {makeRec (common .ServiceNoSQL )}})
484+
485+ adapter := & RecommendationsClientAdapter {
486+ cred : & mockAzureTokenCredential {},
487+ subscriptionID : "sub-order" ,
488+ }
489+
490+ recs , err := adapter .GetRecommendations (context .Background (), common.RecommendationParams {})
491+ require .NoError (t , err )
492+
493+ // Advisor runs via getAdvisorRecommendations (no DI seam) and returns
494+ // nothing with a mock credential, so we expect exactly 4 recs.
495+ require .Len (t , recs , 4 , "expected one rec per injectable service" )
496+ assert .Equal (t , common .ServiceCompute , recs [0 ].Service , "slot 0 must be compute" )
497+ assert .Equal (t , common .ServiceRelationalDB , recs [1 ].Service , "slot 1 must be database" )
498+ assert .Equal (t , common .ServiceCache , recs [2 ].Service , "slot 2 must be cache" )
499+ assert .Equal (t , common .ServiceNoSQL , recs [3 ].Service , "slot 3 must be cosmosdb" )
500+ }
501+
502+ // TestGetRecommendations_ErrorIsolation asserts that a single service error
503+ // does not prevent the other services' results from appearing in the merged
504+ // slice (no sibling cancellation).
505+ func TestGetRecommendations_ErrorIsolation (t * testing.T ) {
506+ origCompute := newComputeClientFn
507+ origDatabase := newDatabaseClientFn
508+ origCache := newCacheClientFn
509+ origCosmos := newCosmosDBClientFn
510+ t .Cleanup (func () {
511+ newComputeClientFn = origCompute
512+ newDatabaseClientFn = origDatabase
513+ newCacheClientFn = origCache
514+ newCosmosDBClientFn = origCosmos
515+ })
516+
517+ makeRec := func (svc common.ServiceType ) common.Recommendation {
518+ return common.Recommendation {Provider : common .ProviderAzure , Service : svc }
519+ }
520+
521+ // database returns an error; the other three must still contribute recs.
522+ newComputeClientFn = newFakeFn (& fakeServiceClient {sleepDur : fakeServiceSleep , recs : []common.Recommendation {makeRec (common .ServiceCompute )}})
523+ newDatabaseClientFn = newFakeFn (& fakeServiceClient {sleepDur : fakeServiceSleep , err : errors .New ("db unavailable" )})
524+ newCacheClientFn = newFakeFn (& fakeServiceClient {sleepDur : fakeServiceSleep , recs : []common.Recommendation {makeRec (common .ServiceCache )}})
525+ newCosmosDBClientFn = newFakeFn (& fakeServiceClient {sleepDur : fakeServiceSleep , recs : []common.Recommendation {makeRec (common .ServiceNoSQL )}})
526+
527+ adapter := & RecommendationsClientAdapter {
528+ cred : & mockAzureTokenCredential {},
529+ subscriptionID : "sub-isolation" ,
530+ }
531+
532+ recs , err := adapter .GetRecommendations (context .Background (), common.RecommendationParams {})
533+ require .NoError (t , err , "a per-service error must not surface as a GetRecommendations error" )
534+ require .Len (t , recs , 3 , "expected recs from the 3 healthy injectable services" )
535+
536+ services := make ([]common.ServiceType , len (recs ))
537+ for i , r := range recs {
538+ services [i ] = r .Service
539+ }
540+ assert .Contains (t , services , common .ServiceCompute , "compute recs must be present despite db error" )
541+ assert .Contains (t , services , common .ServiceCache , "cache recs must be present despite db error" )
542+ assert .Contains (t , services , common .ServiceNoSQL , "cosmosdb recs must be present despite db error" )
543+ assert .NotContains (t , services , common .ServiceRelationalDB , "db recs must be absent when db errors" )
544+ }
0 commit comments