Repository: shini4i/argo-watcher
Pull request #168 (2023-08-12 03:34:17)

chore: argo status updater changes
We talked about simplifying this class, since it's too bulky and non-trivial.

This PR is an attempt to make it more straight forward.

Notable changes:
1. Instead of fetching application extra time, we now re-use last application object we fetched
2. We don't do retries anymore in cases when application is not found
3. Error messages are standardized, with application deployment status in logs and reasons

sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; [![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarcloud.io/dashboard?id=shini4i_argo-watcher&pullRequest=168)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=CODE_SMELL)

[![54.4%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/50-16px.png '54.4%')](https://sonarcloud.io/component_measures?id=shini4i_argo-watcher&pullRequest=168&metric=new_coverage&view=list) [54.4% Coverage](https://sonarcloud.io/component_measures?id=shini4i_argo-watcher&pullRequest=168&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=shini4i_argo-watcher&pullRequest=168&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=shini4i_argo-watcher&pullRequest=168&metric=new_duplicated_lines_density&view=list)

![warning](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/message_warning-16px.png 'warning') The version of Java (11.0.20) you have used to run this analysis is deprecated and we will stop accepting it soon. Please update to at least Java 17.
Read more [here](https://docs.sonarcloud.io/appendices/scanner-environment/)




![idea](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/light_bulb-16px.png 'idea') Catch issues before they fail your Quality Gate with our IDE extension ![sonarlint](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/sonarlint-16px.png 'sonarlint') [SonarLint](https://www.sonarsource.com/products/sonarlint/features/connected-mode/?referrer=sonarcloud-welcome)
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=shini4i_argo-watcher&pullRequest=168)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=CODE_SMELL)

[![100.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/100-16px.png '100.0%')](https://sonarcloud.io/component_measures?id=shini4i_argo-watcher&pullRequest=168&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=shini4i_argo-watcher&pullRequest=168&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=shini4i_argo-watcher&pullRequest=168&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=shini4i_argo-watcher&pullRequest=168&metric=new_duplicated_lines_density&view=list)

![warning](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/message_warning-16px.png 'warning') The version of Java (11.0.20) you have used to run this analysis is deprecated and we will stop accepting it soon. Please update to at least Java 17.
Read more [here](https://docs.sonarcloud.io/appendices/scanner-environment/)



sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=shini4i_argo-watcher&pullRequest=168)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=shini4i_argo-watcher&pullRequest=168&resolved=false&types=CODE_SMELL)

[![100.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/100-16px.png '100.0%')](https://sonarcloud.io/component_measures?id=shini4i_argo-watcher&pullRequest=168&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=shini4i_argo-watcher&pullRequest=168&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=shini4i_argo-watcher&pullRequest=168&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=shini4i_argo-watcher&pullRequest=168&metric=new_duplicated_lines_density&view=list)

![warning](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/message_warning-16px.png 'warning') The version of Java (11.0.20) you have used to run this analysis is deprecated and we will stop accepting it soon. Please update to at least Java 17.
Read more [here](https://docs.sonarcloud.io/appendices/scanner-environment/)




Patch:
diff --git a/cmd/argo-watcher/argo.go b/cmd/argo-watcher/argo.go
index 136a53e..12e4c28 100644
--- a/cmd/argo-watcher/argo.go
+++ b/cmd/argo-watcher/argo.go
@@ -12,8 +12,7 @@ import (
 )
 
 var (
-	argoSyncRetryDelay    = 15 * time.Second
-	errorArgoPlannedRetry = fmt.Errorf("planned retry")
+	argoSyncRetryDelay = 15 * time.Second
 )
 
 const (
diff --git a/cmd/argo-watcher/argo_status_updater.go b/cmd/argo-watcher/argo_status_updater.go
index e2ce857..84bd81f 100644
--- a/cmd/argo-watcher/argo_status_updater.go
+++ b/cmd/argo-watcher/argo_status_updater.go
@@ -8,254 +8,121 @@ import (
 
 	"github.com/avast/retry-go/v4"
 	"github.com/rs/zerolog/log"
-	"github.com/shini4i/argo-watcher/internal/helpers"
 	"github.com/shini4i/argo-watcher/internal/models"
 )
 
-const defaultErrorMessage string = "could not retrieve details"
 const failedToUpdateTaskStatusTemplate string = "Failed to change task status: %s"
 
 type ArgoStatusUpdater struct {
 	argo             Argo
-	retryAttempts    uint
-	retryDelay       time.Duration
 	registryProxyUrl string
+	retryOptions     []retry.Option
 }
 
 func (updater *ArgoStatusUpdater) Init(argo Argo, retryAttempts uint, retryDelay time.Duration, registryProxyUrl string) {
 	updater.argo = argo
-	updater.retryAttempts = retryAttempts
-	updater.retryDelay = retryDelay
 	updater.registryProxyUrl = registryProxyUrl
+	updater.retryOptions = []retry.Option{
+		retry.DelayType(retry.FixedDelay),
+		retry.Attempts(retryAttempts),
+		retry.Delay(retryDelay),
+		retry.LastErrorOnly(true),
+	}
 }
 
 func (updater *ArgoStatusUpdater) WaitForRollout(task models.Task) {
-	// continuously check for application status change
-	status, err := updater.checkWithRetry(task)
-
-	// application synced successfully
-	if status == ArgoAppSuccess {
-		updater.handleDeploymentSuccess(task)
-		return
-	}
-
-	// we had some unexpected error with ArgoCD API
-	if status == ArgoAppFailed {
+	// wait for application to get into deployed status or timeout
+	application, err := updater.waitForApplicationDeployment(task)
+
+	// handle application failure
+	if err != nil {
+		// deployment failed
+		updater.argo.metrics.AddFailedDeployment(task.App)
+		// update task status regarding failure
 		updater.handleArgoAPIFailure(task, err)
 		return
 	}
 
-	// fetch application details
-	app, err := updater.argo.api.GetApplication(task.App)
-
-	// handle application sync failure
-	switch status {
-	// not all images were deployed to the application
-	case ArgoAppNotAvailable:
-		// show list of missing images
-		var message string
-		// define details
-		if err != nil {
-			message = defaultErrorMessage
-		} else {
-			message = fmt.Sprintf(
-				"List of current images (last app check):\n"+
-					"\t%s\n\n"+
-					"List of expected images:\n"+
-					"\t%s",
-				strings.Join(app.Status.Summary.Images, "\n\t"),
-				strings.Join(task.ListImages(), "\n\t"),
-			)
+	// get application status
+	status := application.GetRolloutStatus(task.ListImages(), updater.registryProxyUrl)
+	if application.IsFinalRolloutStatus(status) {
+		log.Info().Str("id", task.Id).Msg("App is running on the excepted version.")
+		// deployment success
+		updater.argo.metrics.ResetFailedDeployment(task.App)
+		// update task status
+		errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusDeployedMessage, "")
+		if errStatusChange != nil {
+			log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange)
 		}
-		// handle error
-		updater.handleAppNotAvailable(task, errors.New(message))
-	// application sync status wasn't valid
-	case ArgoAppNotSynced:
-		// display sync status and last sync message
-		var message string
-		// define details
-		if err != nil {
-			message = defaultErrorMessage
-		} else {
-			message = fmt.Sprintf(
-				"App status \"%s\"\n"+
-					"App message \"%s\"\n"+
-					"Resources:\n"+
-					"\t%s",
-				app.Status.OperationState.Phase,
-				app.Status.OperationState.Message,
-				strings.Join(app.ListSyncResultResources(), "\n\t"),
-			)
-		}
-		// handle error
-		updater.handleAppOutOfSync(task, errors.New(message))
-	// application is not in a healthy status
-	case ArgoAppNotHealthy:
-		// display current health of pods
-		var message string
-		// define details
-		if err != nil {
-			message = defaultErrorMessage
-		} else {
-			message = fmt.Sprintf(
-				"App sync status \"%s\"\n"+
-					"App health status \"%s\"\n"+
-					"Resources:\n"+
-					"\t%s",
-				app.Status.Sync.Status,
-				app.Status.Health.Status,
-				strings.Join(app.ListUnhealthyResources(), "\n\t"),
-			)
+	} else {
+		log.Info().Str("id", task.Id).Msg("App deployment failed.")
+		// deployment failed
+		updater.argo.metrics.AddFailedDeployment(task.App)
+		// generate failure reason
+		reason := fmt.Sprintf(
+			"Application deployment failed. Rollout status \"%s\"\n\n%s",
+			status,
+			application.GetRolloutMessage(status, task.ListImages()),
+		)
+		// update task status
+		errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusFailedMessage, reason)
+		if errStatusChange != nil {
+			log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange)
 		}
-		// handle error
-		updater.handleAppNotHealthy(task, errors.New(message))
-	// handle unexpected status
-	default:
-		updater.handleDeploymentUnexpectedStatus(task, fmt.Errorf("received unexpected status \"%d\"", status))
 	}
 }
 
-func (updater *ArgoStatusUpdater) checkWithRetry(task models.Task) (int, error) {
-	var lastStatus int
-
-	err := retry.Do(
-		func() error {
-			app, err := updater.argo.api.GetApplication(task.App)
-
-			if err != nil {
-				log.Warn().Str("app", task.App).Msg(err.Error())
-				lastStatus = ArgoAppFailed
-				return err
-			}
-
-			for _, image := range task.Images {
-				expected := fmt.Sprintf("%s:%s", image.Image, image.Tag)
-				if !helpers.ImagesContains(app.Status.Summary.Images, expected, updater.registryProxyUrl) {
-					log.Debug().Str("app", task.App).Str("id", task.Id).Msgf("%s is not available yet", expected)
-					lastStatus = ArgoAppNotAvailable
-					return errorArgoPlannedRetry
-				} else {
-					log.Debug().Str("app", task.App).Str("id", task.Id).Msgf("Expected image is in the app summary")
-				}
-			}
+func (updater *ArgoStatusUpdater) waitForApplicationDeployment(task models.Task) (*models.Application, error) {
+	var application *models.Application
+	var err error
 
-			if app.Status.Sync.Status != "Synced" {
-				log.Debug().Str("id", task.Id).Msgf("%s is not synced yet", task.App)
-				lastStatus = ArgoAppNotSynced
-				return errorArgoPlannedRetry
-			}
-
-			if app.Status.Health.Status != "Healthy" {
-				log.Debug().Str("id", task.Id).Msgf("%s is not healthy yet", task.App)
-				lastStatus = ArgoAppNotHealthy
-				return errorArgoPlannedRetry
+	// wait for application to get into deployed status or timeout
+	log.Debug().Str("id", task.Id).Msg("Waiting for rollout")
+	_ = retry.Do(func() error {
+		application, err = updater.argo.api.GetApplication(task.App)
+		if err != nil {
+			// check if ArgoCD didn't have the app
+			if task.IsAppNotFoundError(err) {
+				// no need to retry in such cases
+				return retry.Unrecoverable(err)
 			}
+			// print application api failure here
+			log.Debug().Str("id", task.Id).Msgf("Failed fetching application status. Error: %s", err.Error())
+			return err
+		}
+		// print application debug here
+		status := application.GetRolloutStatus(task.ListImages(), updater.registryProxyUrl)
+		if !application.IsFinalRolloutStatus(status) {
+			// print status debug here
+			log.Debug().Str("id", task.Id).Msgf("Application status is not final. Status received \"%s\"", status)
+			return errors.New("force retry")
+		}
+		// all good
+		log.Debug().Str("id", task.Id).Msgf("Application rollout finished")
+		return nil
+	}, updater.retryOptions...)
 
-			lastStatus = ArgoAppSuccess
-			return nil
-		},
-		retry.DelayType(retry.FixedDelay),
-		retry.Delay(updater.retryDelay),
-		retry.Attempts(updater.retryAttempts),
-		retry.RetryIf(func(err error) bool {
-			return errors.Is(err, errorArgoPlannedRetry)
-		}),
-		retry.LastErrorOnly(true),
-	)
-
-	return lastStatus, err
+	// return application and latest error
+	return application, err
 }
 
 func (updater *ArgoStatusUpdater) handleArgoAPIFailure(task models.Task, err error) {
-	// notify user that app wasn't found
-	appNotFoundError := fmt.Sprintf("applications.argoproj.io \"%s\" not found", task.App)
-	if strings.Contains(err.Error(), appNotFoundError) {
-		updater.handleAppNotFound(task, err)
-		return
-	}
-	// notify user that ArgoCD API isn't available
-	if strings.Contains(err.Error(), argoUnavailableErrorMessage) {
-		updater.handleArgoUnavailable(task, err)
-		return
-	}
-
-	// notify of unexpected error
-	updater.handleDeploymentFailed(task, err)
-}
+	var apiFailureStatus string = models.StatusFailedMessage
 
-func (updater *ArgoStatusUpdater) handleAppNotFound(task models.Task, err error) {
-	log.Info().Str("id", task.Id).Msgf("Application %s does not exist.", task.App)
-	reason := fmt.Sprintf(ArgoAPIErrorTemplate, err.Error())
-	errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusAppNotFoundMessage, reason)
-	if errStatusChange != nil {
-		log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange)
+	// check if ArgoCD didn't have the app
+	if task.IsAppNotFoundError(err) {
+		apiFailureStatus = models.StatusAppNotFoundMessage
 	}
-}
-
-func (updater *ArgoStatusUpdater) handleArgoUnavailable(task models.Task, err error) {
-	log.Error().Str("id", task.Id).Msg("ArgoCD is not available. Aborting.")
-	reason := fmt.Sprintf(ArgoAPIErrorTemplate, err.Error())
-	errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusAborted, reason)
-	if errStatusChange != nil {
-		log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange)
+	// check if ArgoCD was unavailable
+	if strings.Contains(err.Error(), argoUnavailableErrorMessage) {
+		apiFailureStatus = models.StatusAborted
 	}
-}
 
-func (updater *ArgoStatusUpdater) handleDeploymentFailed(task models.Task, err error) {
-	log.Warn().Str("id", task.Id).Msgf("Deployment failed. Aborting with error: %s", err)
-	updater.argo.metrics.AddFailedDeployment(task.App)
+	// write debug reason
 	reason := fmt.Sprintf(ArgoAPIErrorTemplate, err.Error())
-	errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusFailedMessage, reason)
-	if errStatusChange != nil {
-		log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange)
-	}
-}
-
-func (updater *ArgoStatusUpdater) handleDeploymentSuccess(task models.Task) {
-	log.Info().Str("id", task.Id).Msg("App is running on the excepted version.")
-	updater.argo.metrics.ResetFailedDeployment(task.App)
-	errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusDeployedMessage, "")
-	if errStatusChange != nil {
-		log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange)
-	}
-}
-
-func (updater *ArgoStatusUpdater) handleAppNotAvailable(task models.Task, err error) {
-	log.Warn().Str("id", task.Id).Msgf("Deployment failed. Application not available\n%s", err.Error())
-	updater.argo.metrics.AddFailedDeployment(task.App)
-	reason := fmt.Sprintf("Application not available\n\n%s", err.Error())
-	errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusFailedMessage, reason)
-	if errStatusChange != nil {
-		log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange)
-	}
-}
-
-func (updater *ArgoStatusUpdater) handleAppNotHealthy(task models.Task, err error) {
-	log.Warn().Str("id", task.Id).Msgf("Deployment failed. Application not healthy\n%s", err.Error())
-	updater.argo.metrics.AddFailedDeployment(task.App)
-	reason := fmt.Sprintf("Application not healthy\n\n%s", err.Error())
-	errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusFailedMessage, reason)
-	if errStatusChange != nil {
-		log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange)
-	}
-}
-
-func (updater *ArgoStatusUpdater) handleAppOutOfSync(task models.Task, err error) {
-	log.Warn().Str("id", task.Id).Msgf("Deployment failed. Application out of sync\n%s", err.Error())
-	updater.argo.metrics.AddFailedDeployment(task.App)
-	reason := fmt.Sprintf("Application out of sync\n\n%s", err.Error())
-	errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusFailedMessage, reason)
-	if errStatusChange != nil {
-		log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange)
-	}
-}
+	log.Warn().Str("id", task.Id).Msgf("Deployment failed with status \"%s\". Aborting with error: %s", apiFailureStatus, reason)
 
-func (updater *ArgoStatusUpdater) handleDeploymentUnexpectedStatus(task models.Task, err error) {
-	log.Error().Str("id", task.Id).Msg("Deployment timed out with unexpected status. Aborting.")
-	log.Error().Str("id", task.Id).Msgf("Deployment error\n%s", err.Error())
-	updater.argo.metrics.AddFailedDeployment(task.App)
-	reason := fmt.Sprintf("Deployment timeout\n\n%s", err.Error())
-	errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusFailedMessage, reason)
+	errStatusChange := updater.argo.state.SetTaskStatus(task.Id, apiFailureStatus, reason)
 	if errStatusChange != nil {
 		log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange)
 	}
diff --git a/internal/models/argo.go b/internal/models/argo.go
index f427727..9d02dc1 100644
--- a/internal/models/argo.go
+++ b/internal/models/argo.go
@@ -1,6 +1,39 @@
 package models
 
-import "fmt"
+import (
+	"fmt"
+	"strings"
+
+	"github.com/shini4i/argo-watcher/internal/helpers"
+)
+
+const (
+	ArgoRolloutAppSuccess      = "success"
+	ArgoRolloutAppNotSynced    = "not synced"
+	ArgoRolloutAppNotAvailable = "not available"
+	ArgoRolloutAppNotHealthy   = "not healthy"
+)
+
+type ApplicationOperationResource struct {
+	HookPhase string `json:"hookPhase"` // example: Failed
+	HookType  string `json:"hookType"`  // example: PreSync
+	Kind      string `json:"kind"`      // example: Pod | Job
+	Message   string `json:"message"`   // example: Job has reached the specified backoff limit
+	Status    string `json:"status"`    // example: Synced
+	SyncPhase string `json:"syncPhase"` // example: PreSync
+	Name      string `json:"name"`      // example: app-migrations
+	Namespace string `json:"namespace"` // example: app
+}
+
+type ApplicationResource struct {
+	Kind      string `json:"kind"`      // example: Pod | Job
+	Name      string `json:"name"`      // example: app-migrations
+	Namespace string `json:"namespace"` // example: app
+	Health    struct {
+		Message string `json:"message"` // example: Job has reached the specified backoff limit
+		Status  string `json:"status"`  // example: Synced
+	} `json:"health"`
+}
 
 type Application struct {
 	Status struct {
@@ -11,28 +44,11 @@ type Application struct {
 			Phase      string `json:"phase"`
 			Message    string `json:"message"`
 			SyncResult struct {
-				Resources []struct {
-					HookPhase string `json:"hookPhase"` // example: Failed
-					HookType  string `json:"hookType"`  // example: PreSync
-					Kind      string `json:"kind"`      // example: Pod | Job
-					Message   string `json:"message"`   // example: Job has reached the specified backoff limit
-					Status    string `json:"status"`    // example: Synced
-					SyncPhase string `json:"syncPhase"` // example: PreSync
-					Name      string `json:"name"`      // example: app-migrations
-					Namespace string `json:"namespace"` // example: app
-				} `json:"resources"`
+				Resources []ApplicationOperationResource `json:"resources"`
 			} `json:"syncResult"`
 		} `json:"operationState"`
-		Resources []struct {
-			Kind      string `json:"kind"`      // example: Pod | Job
-			Name      string `json:"name"`      // example: app-migrations
-			Namespace string `json:"namespace"` // example: app
-			Health    struct {
-				Message string `json:"message"` // example: Job has reached the specified backoff limit
-				Status  string `json:"status"`  // example: Synced
-			} `json:"health"`
-		} `json:"resources"`
-		Summary struct {
+		Resources []ApplicationResource `json:"resources"`
+		Summary   struct {
 			Images []string `json:"images"`
 		}
 		Sync struct {
@@ -41,6 +57,82 @@ type Application struct {
 	} `json:"status"`
 }
 
+// Calculates application rollout status depending on the expected images and proxy configuration.
+func (app *Application) GetRolloutStatus(rolloutImages []string, registryProxyUrl string) string {
+	// check if all the images rolled out
+	for _, image := range rolloutImages {
+		if !helpers.ImagesContains(app.Status.Summary.Images, image, registryProxyUrl) {
+			return ArgoRolloutAppNotAvailable
+		}
+	}
+
+	// verify app sync status
+	if app.Status.Sync.Status != "Synced" {
+		return ArgoRolloutAppNotSynced
+	}
+
+	// verify app health status
+	if app.Status.Health.Status != "Healthy" {
+		return ArgoRolloutAppNotHealthy
+	}
+
+	// all good
+	return ArgoRolloutAppSuccess
+}
+
+// Generate rollout failure message.
+func (app *Application) GetRolloutMessage(status string, rolloutImages []string) string {
+	// handle application sync failure
+	switch status {
+	// not all images were deployed to the application
+	case ArgoRolloutAppNotAvailable:
+		// define details
+		return fmt.Sprintf(
+			"List of current images (last app check):\n"+
+				"\t%s\n\n"+
+				"List of expected images:\n"+
+				"\t%s",
+			strings.Join(app.Status.Summary.Images, "\n\t"),
+			strings.Join(rolloutImages, "\n\t"),
+		)
+	// application sync status wasn't valid
+	case ArgoRolloutAppNotSynced:
+		// display sync status and last sync message
+		return fmt.Sprintf(
+			"App status \"%s\"\n"+
+				"App message \"%s\"\n"+
+				"Resources:\n"+
+				"\t%s",
+			app.Status.OperationState.Phase,
+			app.Status.OperationState.Message,
+			strings.Join(app.ListSyncResultResources(), "\n\t"),
+		)
+	// application is not in a healthy status
+	case ArgoRolloutAppNotHealthy:
+		// display current health of pods
+		return fmt.Sprintf(
+			"App sync status \"%s\"\n"+
+				"App health status \"%s\"\n"+
+				"Resources:\n"+
+				"\t%s",
+			app.Status.Sync.Status,
+			app.Status.Health.Status,
+			strings.Join(app.ListUnhealthyResources(), "\n\t"),
+		)
+	}
+
+	// handle unexpected status
+	return fmt.Sprintf(
+		"received unexpected rollout status \"%s\"",
+		status,
+	)
+}
+
+// Check if rollout status is final.
+func (app *Application) IsFinalRolloutStatus(status string) bool {
+	return status == ArgoRolloutAppSuccess
+}
+
 // ListSyncResultResources returns a list of strings representing the sync result resources of the application.
 // Each string in the list contains information about the resource's kind, name, hook type, hook phase, and message.
 // The information is formatted as "{kind}({name}) {hookType} {hookPhase} with message {message}".
diff --git a/internal/models/task.go b/internal/models/task.go
index cded6b0..eed893b 100644
--- a/internal/models/task.go
+++ b/internal/models/task.go
@@ -1,6 +1,9 @@
 package models
 
-import "fmt"
+import (
+	"fmt"
+	"strings"
+)
 
 type Image struct {
 	Image string `json:"image" example:"ghcr.io/shini4i/argo-watcher"`
@@ -30,6 +33,12 @@ func (task *Task) ListImages() []string {
 	return list
 }
 
+// Check if app not found error.
+func (task *Task) IsAppNotFoundError(err error) bool {
+	var appNotFoundError string = fmt.Sprintf("applications.argoproj.io \"%s\" not found", task.App)
+	return strings.Contains(err.Error(), appNotFoundError)
+}
+
 type TasksResponse struct {
 	Tasks []Task `json:"tasks"`
 	Error string `json:"error,omitempty"`


Test patch:
diff --git a/cmd/argo-watcher/argo_status_updater_test.go b/cmd/argo-watcher/argo_status_updater_test.go
index ae1050e..b532461 100644
--- a/cmd/argo-watcher/argo_status_updater_test.go
+++ b/cmd/argo-watcher/argo_status_updater_test.go
@@ -177,10 +177,10 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 		application.Status.Health.Status = "Healthy"
 
 		// mock calls
-		apiMock.EXPECT().GetApplication(task.App).Return(&application, nil).Times(2)
+		apiMock.EXPECT().GetApplication(task.App).Return(&application, nil)
 		metricsMock.EXPECT().AddFailedDeployment(task.App)
 		stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusFailedMessage,
-			"Application not available\n\nList of current images (last app check):\n\ttest-registry/ghcr.io/shini4i/argo-watcher:dev\n\nList of expected images:\n\tghcr.io/shini4i/argo-watcher:dev")
+			"Application deployment failed. Rollout status \"not available\"\n\nList of current images (last app check):\n\ttest-registry/ghcr.io/shini4i/argo-watcher:dev\n\nList of expected images:\n\tghcr.io/shini4i/argo-watcher:dev")
 
 		// run the rollout
 		updater.WaitForRollout(task)
@@ -208,6 +208,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 
 		// mock calls
 		apiMock.EXPECT().GetApplication(task.App).Return(nil, fmt.Errorf("applications.argoproj.io \"test-app\" not found"))
+		metricsMock.EXPECT().AddFailedDeployment(task.App)
 		stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusAppNotFoundMessage, "ArgoCD API Error: applications.argoproj.io \"test-app\" not found")
 
 		// run the rollout
@@ -236,6 +237,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 
 		// mock calls
 		apiMock.EXPECT().GetApplication(task.App).Return(nil, fmt.Errorf(argoUnavailableErrorMessage))
+		metricsMock.EXPECT().AddFailedDeployment(task.App)
 		stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusAborted, "ArgoCD API Error: connect: connection refused")
 
 		// run the rollout
@@ -302,10 +304,10 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 		application.Status.Summary.Images = []string{"test-image:v0.0.1"}
 
 		// mock calls
-		apiMock.EXPECT().GetApplication(task.App).Return(&application, nil).Times(2)
+		apiMock.EXPECT().GetApplication(task.App).Return(&application, nil)
 		metricsMock.EXPECT().AddFailedDeployment(task.App)
 		stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusFailedMessage,
-			"Application not available\n\nList of current images (last app check):\n\ttest-image:v0.0.1\n\nList of expected images:\n\tghcr.io/shini4i/argo-watcher:dev")
+			"Application deployment failed. Rollout status \"not available\"\n\nList of current images (last app check):\n\ttest-image:v0.0.1\n\nList of expected images:\n\tghcr.io/shini4i/argo-watcher:dev")
 
 		// run the rollout
 		updater.WaitForRollout(task)
@@ -346,10 +348,10 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 		application.Status.OperationState.Message = "Not working test app"
 
 		// mock calls
-		apiMock.EXPECT().GetApplication(task.App).Return(&application, nil).Times(2)
+		apiMock.EXPECT().GetApplication(task.App).Return(&application, nil)
 		metricsMock.EXPECT().AddFailedDeployment(task.App)
 		stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusFailedMessage,
-			"Application out of sync\n\nApp status \"NotWorking\"\nApp message \"Not working test app\"\nResources:\n\t")
+			"Application deployment failed. Rollout status \"not synced\"\n\nApp status \"NotWorking\"\nApp message \"Not working test app\"\nResources:\n\t")
 
 		// run the rollout
 		updater.WaitForRollout(task)
@@ -388,10 +390,10 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 		application.Status.Health.Status = "NotHealthy"
 
 		// mock calls
-		apiMock.EXPECT().GetApplication(task.App).Return(&application, nil).Times(2)
+		apiMock.EXPECT().GetApplication(task.App).Return(&application, nil)
 		metricsMock.EXPECT().AddFailedDeployment(task.App)
 		stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusFailedMessage,
-			"Application not healthy\n\nApp sync status \"Synced\"\nApp health status \"NotHealthy\"\nResources:\n\t")
+			"Application deployment failed. Rollout status \"not healthy\"\n\nApp sync status \"Synced\"\nApp health status \"NotHealthy\"\nResources:\n\t")
 
 		// run the rollout
 		updater.WaitForRollout(task)
diff --git a/internal/models/argo_test.go b/internal/models/argo_test.go
index 2b4361a..943af66 100644
--- a/internal/models/argo_test.go
+++ b/internal/models/argo_test.go
@@ -61,3 +61,147 @@ func TestListUnhealthyResources(t *testing.T) {
 	// Assert that the result matches the expected unhealthy resources
 	assert.Equal(t, expectedResources, unhealthyResources)
 }
+
+func TestArgoRolloutStatus(t *testing.T) {
+	t.Run("Rollout status - ArgoRolloutAppNotAvailable", func(t *testing.T) {
+		// define application
+		application := Application{}
+		application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		// define expected images
+		images := []string{"ghcr.io/shini4i/argo-watcher:version2"}
+		registryProxyUrl := ""
+		// test status
+		assert.Equal(t, ArgoRolloutAppNotAvailable, application.GetRolloutStatus(images, registryProxyUrl))
+	})
+
+	t.Run("Rollout status - ArgoRolloutAppNotSynced", func(t *testing.T) {
+		// define application
+		application := Application{}
+		application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		application.Status.Sync.Status = "Syncing"
+		// define expected images
+		images := []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		registryProxyUrl := ""
+		// test status
+		assert.Equal(t, ArgoRolloutAppNotSynced, application.GetRolloutStatus(images, registryProxyUrl))
+	})
+
+	t.Run("Rollout status - ArgoRolloutAppNotHealthy", func(t *testing.T) {
+		// define application
+		application := Application{}
+		application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		application.Status.Sync.Status = "Synced"
+		application.Status.Health.Status = "NotHealthy"
+		// define expected images
+		images := []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		registryProxyUrl := ""
+		// test status
+		assert.Equal(t, ArgoRolloutAppNotHealthy, application.GetRolloutStatus(images, registryProxyUrl))
+	})
+
+	t.Run("Rollout status - ArgoRolloutAppSuccess", func(t *testing.T) {
+		// define application
+		application := Application{}
+		application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		application.Status.Sync.Status = "Synced"
+		application.Status.Health.Status = "Healthy"
+		// define expected images
+		images := []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		registryProxyUrl := ""
+		// test status
+		assert.Equal(t, ArgoRolloutAppSuccess, application.GetRolloutStatus(images, registryProxyUrl))
+	})
+}
+
+func TestArgoRolloutMessage(t *testing.T) {
+
+	t.Run("Rollout message - ArgoRolloutAppNotAvailable", func(t *testing.T) {
+		// define application
+		application := Application{}
+		application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		// define expected images
+		images := []string{"ghcr.io/shini4i/argo-watcher:version2"}
+		assert.Equal(t,
+			"List of current images (last app check):\n\tghcr.io/shini4i/argo-watcher:version1\n\nList of expected images:\n\tghcr.io/shini4i/argo-watcher:version2",
+			application.GetRolloutMessage(ArgoRolloutAppNotAvailable, images))
+	})
+
+	t.Run("Rollout message - ArgoRolloutAppNotSynced", func(t *testing.T) {
+		// define application
+		application := Application{}
+		application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		application.Status.Sync.Status = "Syncing"
+		application.Status.Health.Status = "Healthy"
+		application.Status.OperationState.Phase = "NotWorking"
+		application.Status.OperationState.Message = "Not working test app"
+		application.Status.OperationState.SyncResult.Resources = make([]ApplicationOperationResource, 2)
+		// first resource
+		application.Status.OperationState.SyncResult.Resources[0].HookPhase = "Succeeded"
+		application.Status.OperationState.SyncResult.Resources[0].HookType = "PreSync"
+		application.Status.OperationState.SyncResult.Resources[0].Kind = "Pod"
+		application.Status.OperationState.SyncResult.Resources[0].Message = ""
+		application.Status.OperationState.SyncResult.Resources[0].Status = "Synced"
+		application.Status.OperationState.SyncResult.Resources[0].SyncPhase = "PreSync"
+		application.Status.OperationState.SyncResult.Resources[0].Name = "app-migrations"
+		application.Status.OperationState.SyncResult.Resources[0].Namespace = "app"
+		// second resource
+		application.Status.OperationState.SyncResult.Resources[1].HookPhase = "Failed"
+		application.Status.OperationState.SyncResult.Resources[1].HookType = "PostSync"
+		application.Status.OperationState.SyncResult.Resources[1].Kind = "Job"
+		application.Status.OperationState.SyncResult.Resources[1].Message = "Job has reached the specified backoff limit"
+		application.Status.OperationState.SyncResult.Resources[1].Status = "Synced"
+		application.Status.OperationState.SyncResult.Resources[1].SyncPhase = "PostSync"
+		application.Status.OperationState.SyncResult.Resources[1].Name = "app-job"
+		application.Status.OperationState.SyncResult.Resources[1].Namespace = "app"
+		// define expected images
+		images := []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		assert.Equal(t,
+			"App status \"NotWorking\"\nApp message \"Not working test app\"\nResources:\n\tPod(app-migrations) PreSync Succeeded with message \n\tJob(app-job) PostSync Failed with message Job has reached the specified backoff limit",
+			application.GetRolloutMessage(ArgoRolloutAppNotSynced, images))
+	})
+
+	t.Run("Rollout message - ArgoRolloutAppNotHealthy", func(t *testing.T) {
+		// define application
+		application := Application{}
+		application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		application.Status.Sync.Status = "Syncing"
+		application.Status.Health.Status = "NotHealthy"
+		application.Status.OperationState.Phase = "NotWorking"
+		application.Status.OperationState.Message = "Not working test app"
+		application.Status.Resources = make([]ApplicationResource, 2)
+		// first resource
+		application.Status.Resources[0].Kind = "Pod"
+		application.Status.Resources[0].Name = "app-pod"
+		application.Status.Resources[0].Namespace = "app"
+		application.Status.Resources[0].Health.Message = ""
+		application.Status.Resources[0].Health.Status = "Synced"
+		// second resource
+		application.Status.Resources[1].Kind = "Job"
+		application.Status.Resources[1].Name = "app-job"
+		application.Status.Resources[1].Namespace = "app"
+		application.Status.Resources[1].Health.Message = "Job has reached the specified backoff limit"
+		application.Status.Resources[1].Health.Status = "Unhealthy"
+		// define expected images
+		images := []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		assert.Equal(t,
+			"App sync status \"Syncing\"\nApp health status \"NotHealthy\"\nResources:\n\tPod(app-pod) Synced\n\tJob(app-job) Unhealthy with message Job has reached the specified backoff limit",
+			application.GetRolloutMessage(ArgoRolloutAppNotHealthy, images))
+	})
+
+	t.Run("Rollout message - default", func(t *testing.T) {
+		// define application
+		application := Application{}
+		application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"}
+		// define expected images
+		images := []string{"ghcr.io/shini4i/argo-watcher:version2"}
+		assert.Equal(t, "received unexpected rollout status \"unexpected status\"", application.GetRolloutMessage("unexpected status", images))
+	})
+}
+
+func TestApplication_IsFinalRollout(t *testing.T) {
+	application := Application{}
+	assert.Equal(t, true, application.IsFinalRolloutStatus(ArgoRolloutAppSuccess))
+	assert.Equal(t, false, application.IsFinalRolloutStatus(ArgoRolloutAppNotAvailable))
+	assert.Equal(t, false, application.IsFinalRolloutStatus(ArgoRolloutAppNotHealthy))
+	assert.Equal(t, false, application.IsFinalRolloutStatus(ArgoRolloutAppNotSynced))
+}
diff --git a/internal/models/task_test.go b/internal/models/task_test.go
index ca19d85..1aa03c4 100644
--- a/internal/models/task_test.go
+++ b/internal/models/task_test.go
@@ -1,6 +1,7 @@
 package models
 
 import (
+	"errors"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -25,3 +26,28 @@ func TestTask_ListImages(t *testing.T) {
 
 	assert.Equal(t, expected, result, "List of images does not match")
 }
+
+func TestTask_ListImages_Empty(t *testing.T) {
+	task := Task{
+		Images: []Image{},
+	}
+
+	expected := []string{}
+	result := task.ListImages()
+
+	assert.Equal(t, expected, result, "List of images does not match")
+}
+
+func TestTask_IsAppNotFoundError(t *testing.T) {
+	task := Task{
+		App: "test",
+	}
+	assert.Equal(t, true, task.IsAppNotFoundError(errors.New("applications.argoproj.io \"test\" not found")))
+}
+
+func TestTask_IsAppNotFoundError_Fail(t *testing.T) {
+	task := Task{
+		App: "test",
+	}
+	assert.Equal(t, false, task.IsAppNotFoundError(errors.New("random but very important error")))
+}