Repository: TwiN/gatus

Issue:
feat(alerting): Support for ilert as alerting provider
### Describe the feature request

This is a request to add support for [ilert](https://ilert.com) in Gatus.


### Why do you personally want this feature to be implemented?

Integrate ilert with Gatus.

### How long have you been using this project?

_No response_

### Additional information

Just noting that I created this issue and will take care of the implementation. PR coming soon!

Hints from issue comments:
@TwiN: Sounds reasonable to me!

Solution patch:
diff --git a/README.md b/README.md
index bc52cf04..11809690 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
     - [Configuring Google Chat alerts](#configuring-google-chat-alerts)
     - [Configuring Gotify alerts](#configuring-gotify-alerts)
     - [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts)
+    - [Configuring Ilert alerts](#configuring-ilert-alerts)
     - [Configuring Incident.io alerts](#configuring-incidentio-alerts)
     - [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts)
     - [Configuring Matrix alerts](#configuring-matrix-alerts)
@@ -590,6 +591,7 @@ endpoints:
 | `alerting.gitlab`          | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts).                           | `{}`    |
 | `alerting.googlechat`      | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts).             | `{}`    |
 | `alerting.gotify`          | Configuration for alerts of type `gotify`. <br />See [Configuring Gotify alerts](#configuring-gotify-alerts).                           | `{}`    |
+| `alerting.ilert`           | Configuration for alerts of type `ilert`. <br />See [Configuring ilert alerts](#configuring-ilert-alerts).                              | `{}`    |
 | `alerting.incident-io`     | Configuration for alerts of type `incident-io`. <br />See [Configuring Incident.io alerts](#configuring-incidentio-alerts).             | `{}`    |
 | `alerting.jetbrainsspace`  | Configuration for alerts of type `jetbrainsspace`. <br />See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}`    |
 | `alerting.matrix`          | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts).                           | `{}`    |
@@ -897,6 +899,51 @@ endpoints:
 | `alerting.gotify.title`                       | Title of the notification                                                                   | `"Gatus: <endpoint>"` |
 | `alerting.gotify.default-alert`               | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A                   |
 
+#### Configuring ilert alerts
+| Parameter                              | Description                                                                                | Default |
+|:---------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
+| `alerting.ilert`                   | Configuration for alerts of type `ilert`                                               | `{}`    |
+| `alerting.ilert.integration-key`   | ilert Alert Source integration key                                                    | `""`    |
+| `alerting.ilert.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A     |
+| `alerting.ilert.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`    |
+| `alerting.ilert.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `""`    |
+| `alerting.ilert.overrides[].*`     | See `alerting.ilert.*` parameters                                                      | `{}`    |
+
+It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
+of type `ilert`, because unlike other alerts, the operation resulting from setting said
+parameter to `true` will not create another alert but mark the alert as resolved on
+ilert instead.
+
+Behavior:
+- By default, `alerting.ilert.integration-key` is used as the integration key
+- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.ilert.overrides[].group`, the provider will use that override's integration key instead of `alerting.ilert.integration-key`'s
+
+```yaml
+alerting:
+  ilert:
+    integration-key: "********************************"
+    # You can also add group-specific integration keys, which will
+    # override the integration key above for the specified groups
+    overrides:
+      - group: "core"
+        integration-key: "********************************"
+
+endpoints:
+  - name: website
+    url: "https://twin.sh/health"
+    interval: 30s
+    conditions:
+      - "[STATUS] == 200"
+      - "[BODY].status == UP"
+      - "[RESPONSE_TIME] < 300"
+    alerts:
+      - type: ilert
+        failure-threshold: 3
+        success-threshold: 5
+        send-on-resolved: true
+        description: "healthcheck failed"
+```
+
 ```yaml
 alerting:
   gotify:
diff --git a/alerting/alert/type.go b/alerting/alert/type.go
index 1f36d6e1..1ec49c32 100644
--- a/alerting/alert/type.go
+++ b/alerting/alert/type.go
@@ -32,8 +32,11 @@ const (
 	// TypeGotify is the Type for the gotify alerting provider
 	TypeGotify Type = "gotify"
 
-	// TypeHomeAssistant is the Type for the homeassistant alerting provider
+  // TypeHomeAssistant is the Type for the homeassistant alerting provider
 	TypeHomeAssistant Type = "homeassistant"
+  
+	// TypeIlert is the Type for the ilert alerting provider
+	TypeIlert Type = "ilert"
 
 	// TypeIncidentIO is the Type for the incident-io alerting provider
 	TypeIncidentIO Type = "incident-io"
diff --git a/alerting/config.go b/alerting/config.go
index 58c1d647..30ca1e07 100644
--- a/alerting/config.go
+++ b/alerting/config.go
@@ -15,7 +15,8 @@ import (
 	"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
 	"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
 	"github.com/TwiN/gatus/v5/alerting/provider/gotify"
-	"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
+  "github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
+  "github.com/TwiN/gatus/v5/alerting/provider/ilert"
 	"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
 	"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
 	"github.com/TwiN/gatus/v5/alerting/provider/matrix"
@@ -62,9 +63,12 @@ type Config struct {
 
 	// Gotify is the configuration for the gotify alerting provider
 	Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
-
+  
 	// HomeAssistant is the configuration for the homeassistant alerting provider
 	HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
+  
+	// Ilert is the configuration for the ilert alerting provider
+	Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"`
 
 	// IncidentIO is the configuration for the incident-io alerting provider
 	IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`
diff --git a/alerting/provider/ilert/ilert.go b/alerting/provider/ilert/ilert.go
new file mode 100644
index 00000000..ba1822b5
--- /dev/null
+++ b/alerting/provider/ilert/ilert.go
@@ -0,0 +1,168 @@
+package ilert
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+
+	"github.com/TwiN/gatus/v5/alerting/alert"
+	"github.com/TwiN/gatus/v5/client"
+	"github.com/TwiN/gatus/v5/config/endpoint"
+	"gopkg.in/yaml.v3"
+)
+
+const (
+	restAPIUrl = "https://api.ilert.com/api/v1/events/gatus/"
+)
+
+var (
+	ErrIntegrationKeyNotSet   = errors.New("integration key is not set")
+	ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+	IntegrationKey string `yaml:"integration-key"`
+}
+
+func (cfg *Config) Validate() error {
+	if len(cfg.IntegrationKey) == 0 {
+		return ErrIntegrationKeyNotSet
+	}
+	return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+	if len(override.IntegrationKey) > 0 {
+		cfg.IntegrationKey = override.IntegrationKey
+	}
+}
+
+// AlertProvider is the configuration necessary for sending an alert using ilert
+type AlertProvider struct {
+	DefaultConfig Config `yaml:",inline"`
+
+	// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+	DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+	// Overrides is a list of Override that may be prioritized over the default configuration
+	Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+type Override struct {
+	Group  string `yaml:"group"`
+	Config `yaml:",inline"`
+}
+
+func (provider *AlertProvider) Validate() error {
+	registeredGroups := make(map[string]bool)
+	if provider.Overrides != nil {
+		for _, override := range provider.Overrides {
+			if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+				return ErrDuplicateGroupOverride
+			}
+			registeredGroups[override.Group] = true
+		}
+	}
+	return provider.DefaultConfig.Validate()
+}
+
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+	cfg, err := provider.GetConfig(ep.Group, alert)
+	if err != nil {
+		return err
+	}
+	buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
+
+	req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", restAPIUrl, cfg.IntegrationKey), buffer)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	response, err := client.GetHTTPClient(nil).Do(req)
+	if err != nil {
+		return err
+	}
+	defer response.Body.Close()
+	if response.StatusCode > 399 {
+		body, _ := io.ReadAll(response.Body)
+		return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
+	}
+
+	return err
+}
+
+type Body struct {
+	Alert            alert.Alert                 `json:"alert"`
+	Name             string                      `json:"name"`
+	Group            string                      `json:"group"`
+	Status           string                      `json:"status"`
+	Title            string                      `json:"title"`
+	Details          string                      `json:"details,omitempty"`
+	ConditionResults []*endpoint.ConditionResult `json:"condition_results"`
+	URL              string                      `json:"url"`
+}
+
+func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
+	var details, status string
+	if resolved {
+		status = "resolved"
+	} else {
+		status = "firing"
+	}
+
+	if len(alert.GetDescription()) > 0 {
+		details = alert.GetDescription()
+	} else {
+		details = "No description"
+	}
+
+	var body []byte
+	body, _ = json.Marshal(Body{
+		Alert:            *alert,
+		Name:             ep.Name,
+		Group:            ep.Group,
+		Title:            ep.DisplayName(),
+		Status:           status,
+		Details:          details,
+		ConditionResults: result.ConditionResults,
+		URL:              ep.URL,
+	})
+	return body
+}
+
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+	return provider.DefaultAlert
+}
+
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+	cfg := provider.DefaultConfig
+	// Handle group overrides
+	if provider.Overrides != nil {
+		for _, override := range provider.Overrides {
+			if group == override.Group {
+				cfg.Merge(&override.Config)
+				break
+			}
+		}
+	}
+	// Handle alert overrides
+	if len(alert.ProviderOverride) != 0 {
+		overrideConfig := Config{}
+		if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+			return nil, err
+		}
+		cfg.Merge(&overrideConfig)
+	}
+	// Validate the configuration
+	err := cfg.Validate()
+	return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+	_, err := provider.GetConfig(group, alert)
+	return err
+}
diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go
index 227fc6df..1b76dfd5 100644
--- a/alerting/provider/provider.go
+++ b/alerting/provider/provider.go
@@ -10,8 +10,9 @@ import (
 	"github.com/TwiN/gatus/v5/alerting/provider/github"
 	"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
 	"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
-	"github.com/TwiN/gatus/v5/alerting/provider/gotify"
+  "github.com/TwiN/gatus/v5/alerting/provider/gotify"
 	"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
+  "github.com/TwiN/gatus/v5/alerting/provider/ilert"
 	"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
 	"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
 	"github.com/TwiN/gatus/v5/alerting/provider/matrix"
@@ -82,8 +83,9 @@ var (
 	_ AlertProvider = (*github.AlertProvider)(nil)
 	_ AlertProvider = (*gitlab.AlertProvider)(nil)
 	_ AlertProvider = (*googlechat.AlertProvider)(nil)
-	_ AlertProvider = (*gotify.AlertProvider)(nil)
+  _ AlertProvider = (*gotify.AlertProvider)(nil)
 	_ AlertProvider = (*homeassistant.AlertProvider)(nil)
+  _ AlertProvider = (*ilert.AlertProvider)(nil)
 	_ AlertProvider = (*incidentio.AlertProvider)(nil)
 	_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
 	_ AlertProvider = (*matrix.AlertProvider)(nil)
@@ -109,8 +111,9 @@ var (
 	_ Config[github.Config]         = (*github.Config)(nil)
 	_ Config[gitlab.Config]         = (*gitlab.Config)(nil)
 	_ Config[googlechat.Config]     = (*googlechat.Config)(nil)
-	_ Config[gotify.Config]         = (*gotify.Config)(nil)
+  _ Config[gotify.Config]         = (*gotify.Config)(nil)
 	_ Config[homeassistant.Config]  = (*homeassistant.Config)(nil)
+  _ Config[ilert.Config]          = (*ilert.Config)(nil)
 	_ Config[incidentio.Config]     = (*incidentio.Config)(nil)
 	_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
 	_ Config[matrix.Config]         = (*matrix.Config)(nil)
diff --git a/config/config.go b/config/config.go
index 40b50d72..608a1384 100644
--- a/config/config.go
+++ b/config/config.go
@@ -411,7 +411,8 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
 		alert.TypeGitea,
 		alert.TypeGoogleChat,
 		alert.TypeGotify,
-		alert.TypeHomeAssistant,
+    alert.TypeHomeAssistant,
+    alert.TypeIlert,
 		alert.TypeIncidentIO,
 		alert.TypeJetBrainsSpace,
 		alert.TypeMatrix,



Test patch:
diff --git a/alerting/provider/ilert/ilert_test.go b/alerting/provider/ilert/ilert_test.go
new file mode 100644
index 00000000..3f18d60e
--- /dev/null
+++ b/alerting/provider/ilert/ilert_test.go
@@ -0,0 +1,322 @@
+package ilert
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"testing"
+
+	"github.com/TwiN/gatus/v5/alerting/alert"
+	"github.com/TwiN/gatus/v5/client"
+	"github.com/TwiN/gatus/v5/config/endpoint"
+	"github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+	scenarios := []struct {
+		name     string
+		provider AlertProvider
+		expected bool
+	}{
+		{
+			name: "valid",
+			provider: AlertProvider{
+				DefaultConfig: Config{
+					IntegrationKey: "some-random-key",
+				},
+			},
+			expected: true,
+		},
+		{
+			name: "invalid-integration-key",
+			provider: AlertProvider{
+				DefaultConfig: Config{
+					IntegrationKey: "",
+				},
+			},
+			expected: false,
+		},
+	}
+	for _, scenario := range scenarios {
+		t.Run(scenario.name, func(t *testing.T) {
+			err := scenario.provider.Validate()
+			if scenario.expected && err != nil {
+				t.Error("expected no error, got", err.Error())
+			}
+			if !scenario.expected && err == nil {
+				t.Error("expected error, got none")
+			}
+		})
+	}
+}
+
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
+	providerWithInvalidOverrideGroup := AlertProvider{
+		DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
+		Overrides: []Override{
+			{
+				Config: Config{IntegrationKey: "00000000000000000000000000000002"},
+				Group:  "",
+			},
+		},
+	}
+	if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
+		t.Error("provider Group shouldn't have been valid")
+	}
+	providerWithValidOverride := AlertProvider{
+		DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
+		Overrides: []Override{
+			{
+				Config: Config{IntegrationKey: "00000000000000000000000000000002"},
+				Group:  "group",
+			},
+		},
+	}
+	if err := providerWithValidOverride.Validate(); err != nil {
+		t.Error("provider should've been valid, got error:", err.Error())
+	}
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+	defer client.InjectHTTPClient(nil)
+	firstDescription := "description-1"
+	secondDescription := "description-2"
+	sendOnResolved := true
+	scenarios := []struct {
+		Name             string
+		Provider         AlertProvider
+		Alert            alert.Alert
+		Resolved         bool
+		MockRoundTripper test.MockRoundTripper
+		ExpectedError    bool
+	}{
+		{
+			Name: "triggered",
+			Provider: AlertProvider{DefaultConfig: Config{
+				IntegrationKey: "some-integration-key",
+			}},
+			Alert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
+			Resolved: false,
+			MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+				var b bytes.Buffer
+
+				reader := io.NopCloser(&b)
+				return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
+			}),
+			ExpectedError: false,
+		},
+		{
+			Name: "triggered-error",
+			Provider: AlertProvider{DefaultConfig: Config{
+				IntegrationKey: "some-integration-key",
+			}},
+			Alert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
+			Resolved: false,
+			MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+				return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
+			}),
+			ExpectedError: true,
+		},
+		{
+			Name: "resolved",
+			Provider: AlertProvider{DefaultConfig: Config{
+				IntegrationKey: "some-integration-key",
+			}},
+			Alert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
+			Resolved: true,
+			MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+				var b bytes.Buffer
+				reader := io.NopCloser(&b)
+				return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
+			}),
+			ExpectedError: false,
+		},
+	}
+	for _, scenario := range scenarios {
+		t.Run(scenario.Name, func(t *testing.T) {
+			client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
+			err := scenario.Provider.Send(
+				&endpoint.Endpoint{Name: "endpoint-name"},
+				&scenario.Alert,
+				&endpoint.Result{
+					ConditionResults: []*endpoint.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_BuildRequestBody(t *testing.T) {
+	firstDescription := "description-1"
+	secondDescription := "description-2"
+	sendOnResolved := true
+
+	scenarios := []struct {
+		Name         string
+		Provider     AlertProvider
+		Alert        alert.Alert
+		Resolved     bool
+		ExpectedBody string
+	}{
+		{
+			Name:         "triggered",
+			Provider:     AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
+			Alert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
+			Resolved:     false,
+			ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":3,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
+		},
+		{
+			Name:         "resolved",
+			Provider:     AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
+			Alert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 4, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
+			Resolved:     true,
+			ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":4,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"resolved","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":true},{"condition":"[STATUS] == 200","success":true}],"url":""}`,
+		},
+		{
+			Name:         "group-override",
+			Provider:     AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}, Overrides: []Override{{Group: "g", Config: Config{IntegrationKey: "different-integration-key"}}}},
+			Alert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
+			Resolved:     false,
+			ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":5,"Description":"description-2","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-2","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
+		},
+	}
+
+	for _, scenario := range scenarios {
+		t.Run(scenario.Name, func(t *testing.T) {
+			cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
+			if err != nil {
+				t.Error("expected no error, got", err.Error())
+			}
+			body := scenario.Provider.buildRequestBody(
+				cfg,
+				&endpoint.Endpoint{Name: "endpoint-name"},
+				&scenario.Alert,
+				&endpoint.Result{
+					ConditionResults: []*endpoint.ConditionResult{
+						{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
+						{Condition: "[STATUS] == 200", Success: scenario.Resolved},
+					},
+				},
+				scenario.Resolved,
+			)
+			if string(body) != scenario.ExpectedBody {
+				t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
+			}
+			out := make(map[string]interface{})
+			if err := json.Unmarshal(body, &out); err != nil {
+				t.Error("expected body to be valid JSON, got error:", err.Error())
+			}
+		})
+	}
+}
+
+func TestAlertProvider_GetDefaultAlert(t *testing.T) {
+	if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+		t.Error("expected default alert to be not nil")
+	}
+	if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+		t.Error("expected default alert to be nil")
+	}
+}
+
+func TestAlertProvider_GetConfig(t *testing.T) {
+	scenarios := []struct {
+		Name           string
+		Provider       AlertProvider
+		InputGroup     string
+		InputAlert     alert.Alert
+		ExpectedOutput Config
+	}{
+		{
+			Name: "provider-no-override-specify-no-group-should-default",
+			Provider: AlertProvider{
+				DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
+				Overrides:     nil,
+			},
+			InputGroup:     "",
+			InputAlert:     alert.Alert{},
+			ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
+		},
+		{
+			Name: "provider-no-override-specify-group-should-default",
+			Provider: AlertProvider{
+				DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
+				Overrides:     nil,
+			},
+			InputGroup:     "group",
+			InputAlert:     alert.Alert{},
+			ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
+		},
+		{
+			Name: "provider-with-override-specify-no-group-should-default",
+			Provider: AlertProvider{
+				DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
+				Overrides: []Override{
+					{
+						Group:  "group",
+						Config: Config{IntegrationKey: "00000000000000000000000000000002"},
+					},
+				},
+			},
+			InputGroup:     "",
+			InputAlert:     alert.Alert{},
+			ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
+		},
+		{
+			Name: "provider-with-override-specify-group-should-override",
+			Provider: AlertProvider{
+				DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
+				Overrides: []Override{
+					{
+						Group:  "group",
+						Config: Config{IntegrationKey: "00000000000000000000000000000002"},
+					},
+				},
+			},
+			InputGroup:     "group",
+			InputAlert:     alert.Alert{},
+			ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000002"},
+		},
+		{
+			Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
+			Provider: AlertProvider{
+				DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
+				Overrides: []Override{
+					{
+						Group:  "group",
+						Config: Config{IntegrationKey: "00000000000000000000000000000002"},
+					},
+				},
+			},
+			InputGroup:     "group",
+			InputAlert:     alert.Alert{ProviderOverride: map[string]any{"integration-key": "00000000000000000000000000000003"}},
+			ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000003"},
+		},
+	}
+	for _, scenario := range scenarios {
+		t.Run(scenario.Name, func(t *testing.T) {
+			got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
+			if err != nil {
+				t.Fatalf("unexpected error: %s", err)
+			}
+			if got.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {
+				t.Errorf("expected %s, got %s", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)
+			}
+			// Test ValidateOverrides as well, since it really just calls GetConfig
+			if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+				t.Errorf("unexpected error: %s", err)
+			}
+		})
+	}
+}