Repository: TwiN/gatus
Pull request #214 (2021-12-07 22:44:19)

Opsgenie Alert Provider
## Opsgenie Alert Provider

New alert provider with [Opsgenie](https://www.atlassian.com/software/opsgenie)
Ref: https://docs.opsgenie.com/docs/alert-api

It turns Gatus able to open and close alerts in [Opsgenie API](https://docs.opsgenie.com/docs/alert-api)

#### Config

```yml
opsgenie:
    api-key: api-key
    priority: P1
    source: gatus
    entity-prefix: gatus-
    alias-prefix: gatus-healthcheck-
    tags:
      - my:extra:tag
````

#### Todo

- [x] Implementation
- [x] Tests
- [x] Doc


Patch:
diff --git a/README.md b/README.md
index c1c98b2a..19b2ad29 100644
--- a/README.md
+++ b/README.md
@@ -90,7 +90,7 @@ Gatus, on the other hand, allows you to configure health checks for each of your
 monitor these features and potentially alert you before any clients are impacted.
 
 A sign you may want to look into Gatus is by simply asking yourself whether you'd receive an alert if your load balancer
-was to go down right now. Will any of your existing alerts by triggered? Your metrics won’t report an increase in errors
+was to go down right now. Will any of your existing alerts be triggered? Your metrics won’t report an increase in errors
 if there’s no traffic that makes it to your applications. This puts you in a situation where your clients are the ones
 that will notify you about the degradation of your services rather than you reassuring them that you're working on
 fixing the issue before they even know about it.
@@ -311,8 +311,10 @@ ignored.
 | Parameter              | Description                                                                                                            | Default        |
 |:-----------------------|:---------------------------------------------------------------------------------------------------------------------- |:-------|
 | `alerting.discord`     | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts).             | `{}`   |
-| `alerting.mattermost`  | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts).   | `{}`   |
+| `alerting.email`       | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts).                   | `{}`   |
+| `alerting.mattermost`  | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts).    | `{}`   |
 | `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}`   |
+| `alerting.opsgenie`    | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts).          | `{}`   |
 | `alerting.pagerduty`   | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts).       | `{}`   |
 | `alerting.slack`       | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts).                   | `{}`   |
 | `alerting.teams`       | Configuration for alerts of type `teams`. <br />See [Configuring Teams alerts](#configuring-teams-alerts).                   | `{}`   |
@@ -455,6 +457,24 @@ endpoints:
         description: "healthcheck failed"
 ```
 
+#### Configuring Opsgenie alerts
+| Parameter                                              | Description                                                                   | Default              |
+|:------------------------------------------------------ |:----------------------------------------------------------------------------- |:-------------------- |
+| `alerting.opsgenie`                                    | Configuration for alerts of type `opsgenie`                                   | `{}`                 |
+| `alerting.opsgenie.api-key`                            | Opsgenie API Key                                                              |  Required `""`       |
+| `alerting.opsgenie.priority`                           | Priority level of the alert.                                                  | `P1`                 |
+| `alerting.opsgenie.source`                             | Source field of the alert.                                                    | `gatus`              |
+| `alerting.opsgenie.entity-prefix`                      | Entity field prefix.                                                          | `gatus-`             |
+| `alerting.opsgenie.alias-prefix`                       | Alias field prefix.                                                           | `gatus-healthcheck-` |
+| `alerting.opsgenie.tags`                               | Tags of alert.                                                                | `[]`                 |
+
+Opsgenie provider will automatically open and close alerts.
+
+```yaml
+alerting:
+  opsgenie:
+    api-key: "00000000-0000-0000-0000-000000000000"
+```
 
 #### Configuring PagerDuty alerts
 | Parameter                                              | Description                                                                   | Default        |
@@ -680,7 +700,7 @@ If you have an alert using the `custom` provider with `send-on-resolved` set to
 The aforementioned placeholder will be replaced by `TRIGGERED` or `RESOLVED` accordingly, though it can be modified
 (details at the end of this section).
 
-For all intents and purpose, we'll configure the custom alert with a Slack webhook, but you can call anything you want.
+For all intents and purposes, we'll configure the custom alert with a Slack webhook, but you can call anything you want.
 ```yaml
 alerting:
   custom:
@@ -1085,7 +1105,7 @@ technically, if you create 100 endpoints with a 1 seconds interval, Gatus will s
 
 
 ### Reloading configuration on the fly
-For the sake on convenience, Gatus automatically reloads the configuration on the fly if the loaded configuration file
+For the sake of convenience, Gatus automatically reloads the configuration on the fly if the loaded configuration file
 is updated while Gatus is running.
 
 By default, the application will exit if the updating configuration is invalid, but you can configure
diff --git a/alerting/alert/type.go b/alerting/alert/type.go
index 8ca233df..b921338e 100644
--- a/alerting/alert/type.go
+++ b/alerting/alert/type.go
@@ -34,4 +34,7 @@ const (
 
 	// TypeTwilio is the Type for the twilio alerting provider
 	TypeTwilio Type = "twilio"
+
+	// TypeOpsgenie is the Type for the opsgenie alerting provider
+	TypeOpsgenie Type = "opsgenie"
 )
diff --git a/alerting/config.go b/alerting/config.go
index 6554a380..6e614619 100644
--- a/alerting/config.go
+++ b/alerting/config.go
@@ -8,6 +8,7 @@ import (
 	"github.com/TwiN/gatus/v3/alerting/provider/email"
 	"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
 	"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
+	"github.com/TwiN/gatus/v3/alerting/provider/opsgenie"
 	"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
 	"github.com/TwiN/gatus/v3/alerting/provider/slack"
 	"github.com/TwiN/gatus/v3/alerting/provider/teams"
@@ -46,6 +47,9 @@ type Config struct {
 
 	// Twilio is the configuration for the twilio alerting provider
 	Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
+
+	// Opsgenie is the configuration for the opsgenie alerting provider
+	Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
 }
 
 // GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
@@ -81,6 +85,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
 			return nil
 		}
 		return config.Messagebird
+	case alert.TypeOpsgenie:
+		if config.Opsgenie == nil {
+			// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
+			return nil
+		}
+		return config.Opsgenie
 	case alert.TypePagerDuty:
 		if config.PagerDuty == nil {
 			// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
diff --git a/alerting/provider/opsgenie/opsgenie.go b/alerting/provider/opsgenie/opsgenie.go
new file mode 100644
index 00000000..a24eff2b
--- /dev/null
+++ b/alerting/provider/opsgenie/opsgenie.go
@@ -0,0 +1,267 @@
+package opsgenie
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"github.com/TwiN/gatus/v3/alerting/alert"
+	"github.com/TwiN/gatus/v3/client"
+	"github.com/TwiN/gatus/v3/core"
+	"io"
+	"net/http"
+	"strconv"
+	"strings"
+)
+
+const (
+	restAPI = "https://api.opsgenie.com/v2/alerts"
+)
+
+type opsgenieAlertCreateRequest struct {
+	Message     string            `json:"message"`
+	Priority    string            `json:"priority"`
+	Source      string            `json:"source"`
+	Entity      string            `json:"entity"`
+	Alias       string            `json:"alias"`
+	Description string            `json:"description"`
+	Tags        []string          `json:"tags,omitempty"`
+	Details     map[string]string `json:"details"`
+}
+
+type opsgenieAlertCloseRequest struct {
+	Source string `json:"source"`
+	Note   string `json:"note"`
+}
+
+type AlertProvider struct {
+	APIKey string `yaml:"api-key"`
+
+	//Priority define priority to be used in opsgenie alert payload
+	// defaults: P1
+	Priority string `yaml:"priority"`
+
+	//Source define source to be used in opsgenie alert payload
+	// defaults: gatus
+	Source string `yaml:"source"`
+
+	//EntityPrefix is a prefix to be used in entity argument in opsgenie alert payload
+	// defaults: gatus-
+	EntityPrefix string `yaml:"entity-prefix"`
+
+	//AliasPrefix is a prefix to be used in alias argument in opsgenie alert payload
+	// defaults: gatus-healthcheck-
+	AliasPrefix string `yaml:"alias-prefix"`
+
+	//tags define tags to be used in opsgenie alert payload
+	// defaults: []
+	Tags []string `yaml:"tags"`
+
+	// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+	DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+}
+
+func (provider *AlertProvider) IsValid() bool {
+	return len(provider.APIKey) > 0
+}
+
+// Send an alert using the provider
+//
+// Relevant: https://docs.opsgenie.com/docs/alert-api
+func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
+	err := provider.createAlert(endpoint, alert, result, resolved)
+
+	if err != nil {
+		return err
+	}
+
+	if resolved {
+		err = provider.closeAlert(endpoint, alert)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	if alert.IsSendingOnResolved() {
+		if resolved {
+			// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
+			alert.ResolveKey = ""
+		} else {
+			alert.ResolveKey = provider.alias(buildKey(endpoint))
+		}
+	}
+
+	return nil
+}
+
+func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
+	payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved)
+
+	_, err := provider.sendRequest(restAPI, http.MethodPost, payload)
+
+	return err
+}
+
+func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error {
+	payload := provider.buildCloseRequestBody(endpoint, alert)
+	url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias"
+
+	_, err := provider.sendRequest(url, http.MethodPost, payload)
+
+	return err
+}
+
+func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) (*http.Response, error) {
+
+	body, err := json.Marshal(payload)
+
+	if err != nil {
+		return nil, fmt.Errorf("fail to build alert payload: %v", payload)
+	}
+
+	request, err := http.NewRequest(method, url, bytes.NewBuffer(body))
+
+	if err != nil {
+		return nil, err
+	}
+
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("Authorization", "GenieKey "+provider.APIKey)
+
+	res, err := client.GetHTTPClient(nil).Do(request)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if res.StatusCode > 399 {
+		rBody, _ := io.ReadAll(res.Body)
+		return nil, fmt.Errorf("call to provider alert returned status code %d: %s", res.StatusCode, string(rBody))
+	}
+
+	return res, nil
+}
+
+func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) opsgenieAlertCreateRequest {
+	var message, description, results string
+
+	if resolved {
+		message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
+		description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
+	} else {
+		message = fmt.Sprintf("%s - %s", endpoint.Name, alert.GetDescription())
+		description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
+	}
+
+
+	if endpoint.Group != "" {
+		message = fmt.Sprintf("[%s] %s", endpoint.Group, message)
+	}
+
+	for _, conditionResult := range result.ConditionResults {
+		var prefix string
+		if conditionResult.Success {
+			prefix = "▣"
+		} else {
+			prefix = "▢"
+		}
+		results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
+	}
+
+	description = description + "\n" + results
+
+	key := buildKey(endpoint)
+	details := map[string]string{
+		"endpoint:url":    endpoint.URL,
+		"endpoint:group":  endpoint.Group,
+		"result:hostname": result.Hostname,
+		"result:ip":       result.IP,
+		"result:dns_code": result.DNSRCode,
+		"result:errors":   strings.Join(result.Errors, ","),
+	}
+
+	for k, v := range details {
+		if v == "" {
+			delete(details, k)
+		}
+	}
+
+	if result.HTTPStatus > 0 {
+		details["result:http_status"] = strconv.Itoa(result.HTTPStatus)
+	}
+
+	return opsgenieAlertCreateRequest{
+		Message:     message,
+		Description: description,
+		Source:      provider.source(),
+		Priority:    provider.priority(),
+		Alias:       provider.alias(key),
+		Entity:      provider.entity(key),
+		Tags:        provider.Tags,
+		Details:     details,
+	}
+}
+
+func (provider *AlertProvider) buildCloseRequestBody(endpoint *core.Endpoint, alert *alert.Alert) opsgenieAlertCloseRequest {
+	return opsgenieAlertCloseRequest{
+		Source: buildKey(endpoint),
+		Note:   fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()),
+	}
+}
+
+func (provider *AlertProvider) source() string {
+	source := provider.Source
+
+	if source == "" {
+		return "gatus"
+	}
+
+	return source
+}
+
+func (provider *AlertProvider) alias(key string) string {
+	alias := provider.AliasPrefix
+
+	if alias == "" {
+		alias = "gatus-healthcheck-"
+	}
+
+	return alias + key
+}
+
+func (provider *AlertProvider) entity(key string) string {
+	alias := provider.EntityPrefix
+	if alias == "" {
+		alias = "gatus-"
+	}
+
+	return alias + key
+}
+
+func (provider *AlertProvider) priority() string {
+	priority := provider.Priority
+
+	if priority == "" {
+		return "P1"
+	}
+
+	return priority
+}
+
+func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
+	return provider.DefaultAlert
+}
+
+func buildKey(endpoint *core.Endpoint) string {
+	name := toKebabCase(endpoint.Name)
+
+	if endpoint.Group == "" {
+		return name
+	}
+
+	return toKebabCase(endpoint.Group) + "-" + name
+}
+
+func toKebabCase(val string) string {
+	return strings.ToLower(strings.ReplaceAll(val, " ", "-"))
+}


Test patch:
diff --git a/alerting/provider/opsgenie/opsgenie_test.go b/alerting/provider/opsgenie/opsgenie_test.go
new file mode 100644
index 00000000..b9d65f5c
--- /dev/null
+++ b/alerting/provider/opsgenie/opsgenie_test.go
@@ -0,0 +1,331 @@
+package opsgenie
+
+import (
+	"github.com/TwiN/gatus/v3/alerting/alert"
+	"github.com/TwiN/gatus/v3/client"
+	"github.com/TwiN/gatus/v3/core"
+	"github.com/TwiN/gatus/v3/test"
+	"net/http"
+	"reflect"
+	"testing"
+)
+
+func TestAlertProvider_IsValid(t *testing.T) {
+	invalidProvider := AlertProvider{APIKey: ""}
+	if invalidProvider.IsValid() {
+		t.Error("provider shouldn't have been valid")
+	}
+	validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"}
+	if !validProvider.IsValid() {
+		t.Error("provider should've been valid")
+	}
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+	defer client.InjectHTTPClient(nil)
+
+	description := "my bad alert description"
+
+	scenarios := []struct {
+		Name             string
+		Provider         AlertProvider
+		Alert            alert.Alert
+		Resolved         bool
+		MockRoundTripper test.MockRoundTripper
+		ExpectedError    bool
+	}{
+		{
+			Name:          "triggered",
+			Provider:      AlertProvider{},
+			Alert:         alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
+			Resolved:      false,
+			ExpectedError: false,
+			MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+				return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+			}),
+		},
+		{
+			Name:          "triggered-error",
+			Provider:      AlertProvider{},
+			Alert:         alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
+			Resolved:      false,
+			ExpectedError: true,
+			MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+				return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
+			}),
+		},
+		{
+			Name:          "resolved",
+			Provider:      AlertProvider{},
+			Alert:         alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
+			Resolved:      true,
+			ExpectedError: false,
+			MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+				return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+			}),
+		},
+		{
+			Name:          "resolved-error",
+			Provider:      AlertProvider{},
+			Alert:         alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
+			Resolved:      true,
+			ExpectedError: true,
+			MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+				return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
+			}),
+		},
+	}
+
+	for _, scenario := range scenarios {
+		t.Run(scenario.Name, func(t *testing.T) {
+			client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
+
+			err := scenario.Provider.Send(
+				&core.Endpoint{Name: "endpoint-name"},
+				&scenario.Alert,
+				&core.Result{
+					ConditionResults: []*core.ConditionResult{
+						{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
+						{Condition: "[STATUS] == 200", Success: scenario.Resolved},
+					},
+				},
+				scenario.Resolved,
+			)
+			if scenario.ExpectedError && err == nil {
+				t.Error("expected error, got none")
+			}
+			if !scenario.ExpectedError && err != nil {
+				t.Error("expected no error, got", err.Error())
+			}
+		})
+	}
+}
+
+func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
+	t.Parallel()
+
+	description := "alert description"
+
+	scenarios := []struct {
+		Name     string
+		Provider *AlertProvider
+		Alert    *alert.Alert
+		Endpoint *core.Endpoint
+		Result   *core.Result
+		Resolved bool
+		want     opsgenieAlertCreateRequest
+	}{
+		{
+			Name:     "missing all params (unresolved)",
+			Provider: &AlertProvider{},
+			Alert:    &alert.Alert{},
+			Endpoint: &core.Endpoint{},
+			Result:   &core.Result{},
+			Resolved: false,
+			want: opsgenieAlertCreateRequest{
+				Message:     " - ",
+				Priority:    "P1",
+				Source:      "gatus",
+				Entity:      "gatus-",
+				Alias:       "gatus-healthcheck-",
+				Description: "An alert for ** has been triggered due to having failed 0 time(s) in a row\n",
+				Tags:        nil,
+				Details:     map[string]string{},
+			},
+		},
+		{
+			Name:     "missing all params (resolved)",
+			Provider: &AlertProvider{},
+			Alert:    &alert.Alert{},
+			Endpoint: &core.Endpoint{},
+			Result:   &core.Result{},
+			Resolved: true,
+			want: opsgenieAlertCreateRequest{
+				Message:     "RESOLVED:  - ",
+				Priority:    "P1",
+				Source:      "gatus",
+				Entity:      "gatus-",
+				Alias:       "gatus-healthcheck-",
+				Description: "An alert for ** has been resolved after passing successfully 0 time(s) in a row\n",
+				Tags:        nil,
+				Details:     map[string]string{},
+			},
+		},
+		{
+			Name:     "with default options (unresolved)",
+			Provider: &AlertProvider{},
+			Alert: &alert.Alert{
+				Description:      &description,
+				FailureThreshold: 3,
+			},
+			Endpoint: &core.Endpoint{
+				Name: "my supper app",
+			},
+			Result: &core.Result{
+				ConditionResults: []*core.ConditionResult{
+					{
+						Condition: "[STATUS] == 200",
+						Success:   true,
+					},
+					{
+						Condition: "[BODY] == OK",
+						Success:   false,
+					},
+				},
+			},
+			Resolved: false,
+			want: opsgenieAlertCreateRequest{
+				Message:  "my supper app - " + description,
+				Priority: "P1",
+				Source:   "gatus",
+				Entity:   "gatus-my-supper-app",
+				Alias:    "gatus-healthcheck-my-supper-app",
+				Description: "An alert for *my supper app* has been triggered due to having failed 3 time(s) in a row\n" +
+					"▣ - `[STATUS] == 200`\n" +
+					"▢ - `[BODY] == OK`\n",
+				Tags:    nil,
+				Details: map[string]string{},
+			},
+		},
+		{
+			Name: "with custom options (resolved)",
+			Provider: &AlertProvider{
+				Priority:     "P5",
+				EntityPrefix: "oompa-",
+				AliasPrefix:  "loompa-",
+				Source:       "gatus-hc",
+				Tags:         []string{"do-ba-dee-doo"},
+			},
+			Alert: &alert.Alert{
+				Description:      &description,
+				SuccessThreshold: 4,
+			},
+			Endpoint: &core.Endpoint{
+				Name: "my mega app",
+			},
+			Result: &core.Result{
+				ConditionResults: []*core.ConditionResult{
+					{
+						Condition: "[STATUS] == 200",
+						Success:   true,
+					},
+				},
+			},
+			Resolved: true,
+			want: opsgenieAlertCreateRequest{
+				Message:  "RESOLVED: my mega app - " + description,
+				Priority: "P5",
+				Source:   "gatus-hc",
+				Entity:   "oompa-my-mega-app",
+				Alias:    "loompa-my-mega-app",
+				Description: "An alert for *my mega app* has been resolved after passing successfully 4 time(s) in a row\n" +
+					"▣ - `[STATUS] == 200`\n",
+				Tags:    []string{"do-ba-dee-doo"},
+				Details: map[string]string{},
+			},
+		},
+		{
+			Name: "with default options and details (unresolved)",
+			Provider: &AlertProvider{
+				Tags: []string{"foo"},
+			},
+			Alert: &alert.Alert{
+				Description:      &description,
+				FailureThreshold: 6,
+			},
+			Endpoint: &core.Endpoint{
+				Name:  "my app",
+				Group: "end game",
+				URL:   "https://my.go/app",
+			},
+			Result: &core.Result{
+				HTTPStatus: 400,
+				Hostname:   "my.go",
+				Errors:     []string{"error 01", "error 02"},
+				Success:    false,
+				ConditionResults: []*core.ConditionResult{
+					{
+						Condition: "[STATUS] == 200",
+						Success:   false,
+					},
+				},
+			},
+			Resolved: false,
+			want: opsgenieAlertCreateRequest{
+				Message:  "[end game] my app - " + description,
+				Priority: "P1",
+				Source:   "gatus",
+				Entity:   "gatus-end-game-my-app",
+				Alias:    "gatus-healthcheck-end-game-my-app",
+				Description: "An alert for *my app* has been triggered due to having failed 6 time(s) in a row\n" +
+					"▢ - `[STATUS] == 200`\n",
+				Tags: []string{"foo"},
+				Details: map[string]string{
+					"endpoint:url":       "https://my.go/app",
+					"endpoint:group":     "end game",
+					"result:hostname":    "my.go",
+					"result:errors":      "error 01,error 02",
+					"result:http_status": "400",
+				},
+			},
+		},
+	}
+
+	for _, scenario := range scenarios {
+		actual := scenario
+		t.Run(actual.Name, func(t *testing.T) {
+			if got := actual.Provider.buildCreateRequestBody(actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
+				t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want)
+			}
+		})
+	}
+}
+
+func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
+	t.Parallel()
+
+	description := "alert description"
+
+	scenarios := []struct {
+		Name     string
+		Provider *AlertProvider
+		Alert    *alert.Alert
+		Endpoint *core.Endpoint
+		want     opsgenieAlertCloseRequest
+	}{
+		{
+			Name:     "Missing all values",
+			Provider: &AlertProvider{},
+			Alert:    &alert.Alert{},
+			Endpoint: &core.Endpoint{},
+			want: opsgenieAlertCloseRequest{
+				Source: "",
+				Note:   "RESOLVED:  - ",
+			},
+		},
+
+		{
+			Name:     "Basic values",
+			Provider: &AlertProvider{},
+			Alert: &alert.Alert{
+				Description: &description,
+			},
+			Endpoint: &core.Endpoint{
+				Name: "endpoint name",
+			},
+			want: opsgenieAlertCloseRequest{
+				Source: "endpoint-name",
+				Note:   "RESOLVED: endpoint name - alert description",
+			},
+		},
+	}
+
+	for _, scenario := range scenarios {
+		actual := scenario
+		t.Run(actual.Name, func(t *testing.T) {
+			if got := actual.Provider.buildCloseRequestBody(actual.Endpoint, actual.Alert); !reflect.DeepEqual(got, actual.want) {
+				t.Errorf("buildCloseRequestBody() = %v, want %v", got, actual.want)
+			}
+		})
+	}
+}