adding test containers
Some checks failed
Check scripts syntax / check-scripts-syntax (push) Successful in 3s
Test automated-nfs-backup / test-automated-nfs-backup (push) Successful in 4s
Haven Notify Build and Deploy / Build Haven Notify Image (pull_request) Has been cancelled
Haven Notify Build and Deploy / Build Haven Notify Image (PR) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Deploy Haven Notify (internal) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Test Haven Notify (pull_request) Has been cancelled
Some checks failed
Check scripts syntax / check-scripts-syntax (push) Successful in 3s
Test automated-nfs-backup / test-automated-nfs-backup (push) Successful in 4s
Haven Notify Build and Deploy / Build Haven Notify Image (pull_request) Has been cancelled
Haven Notify Build and Deploy / Build Haven Notify Image (PR) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Deploy Haven Notify (internal) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Test Haven Notify (pull_request) Has been cancelled
This commit is contained in:
322
haven-notify/e2e_test.go
Normal file
322
haven-notify/e2e_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
//go:build e2e
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/log"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mockserver"
|
||||
"github.com/testcontainers/testcontainers-go/network"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
const mockServerImage = "mockserver/mockserver:5.15.0@sha256:0f9ef78c94894ac3e70135d156193b25e23872575d58e2228344964273b4af6b"
|
||||
|
||||
type mockRecordedRequest struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
Body struct {
|
||||
RawBytes string `json:"rawBytes"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
func TestContainerE2E(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
defer cancel()
|
||||
logger := log.TestLogger(t)
|
||||
|
||||
testNetwork, err := network.New(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testcontainers.CleanupNetwork(t, testNetwork)
|
||||
|
||||
mock, err := mockserver.Run(ctx, mockServerImage,
|
||||
network.WithNetwork([]string{"discord-mock"}, testNetwork),
|
||||
testcontainers.WithLogger(logger),
|
||||
)
|
||||
testcontainers.CleanupContainer(t, mock)
|
||||
if err != nil {
|
||||
t.Fatalf("start MockServer: %v", err)
|
||||
}
|
||||
mockURL, err := mock.URL(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buildPlatform := "linux/amd64"
|
||||
targetOS := "linux"
|
||||
targetArch := "amd64"
|
||||
app, err := testcontainers.Run(ctx, "",
|
||||
testcontainers.WithDockerfile(testcontainers.FromDockerfile{
|
||||
Context: ".",
|
||||
Dockerfile: "Dockerfile",
|
||||
Repo: "haven-notify-e2e",
|
||||
Tag: "test",
|
||||
KeepImage: false,
|
||||
BuildArgs: map[string]*string{
|
||||
"BUILDPLATFORM": &buildPlatform,
|
||||
"TARGETOS": &targetOS,
|
||||
"TARGETARCH": &targetArch,
|
||||
},
|
||||
}),
|
||||
network.WithNetwork([]string{"haven-notify"}, testNetwork),
|
||||
testcontainers.WithEnv(map[string]string{"WEBHOOK_URL": "http://discord-mock:1080/discord"}),
|
||||
testcontainers.WithExposedPorts("8080/tcp"),
|
||||
testcontainers.WithWaitStrategy(wait.ForHTTP("/ready").WithPort("8080/tcp").WithStartupTimeout(45*time.Second)),
|
||||
testcontainers.WithLogger(logger),
|
||||
)
|
||||
testcontainers.CleanupContainer(t, app)
|
||||
if err != nil {
|
||||
t.Fatalf("start Haven Notify: %v", err)
|
||||
}
|
||||
appURL, err := app.PortEndpoint(ctx, "8080/tcp", "http")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inspect, err := app.Inspect(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspect.Config.User != "haven-notify" {
|
||||
t.Fatalf("container user = %q", inspect.Config.User)
|
||||
}
|
||||
|
||||
t.Run("health probes", func(t *testing.T) {
|
||||
for path, body := range map[string]string{"/ready": "Ready", "/live": "Alive"} {
|
||||
response, err := http.Get(appURL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents, _ := io.ReadAll(response.Body)
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != 200 || string(contents) != body {
|
||||
t.Fatalf("%s = (%d, %q)", path, response.StatusCode, contents)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if err := configureMock(mockURL, []mockExpectation{{Status: 204}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
path string
|
||||
body string
|
||||
want discordPayload
|
||||
}{
|
||||
{
|
||||
path: "/notify",
|
||||
body: `{"title":"Título \"quoted\"","message":"line 1\nC:\\backups @everyone"}`,
|
||||
want: discordPayload{
|
||||
Content: "**Título \"quoted\"**\nline 1\nC:\\backups @everyone",
|
||||
AllowedMentions: discordAllowedMentions{Parse: []string{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/template/notify/backup",
|
||||
body: `{"title":"Nightly","asset":"photos","backupSizeInMB":1536,"extra":[{"name":"Host","value":"nebula"}]}`,
|
||||
want: discordPayload{
|
||||
Embeds: []discordEmbed{{
|
||||
Title: "📦 Backup - Nightly",
|
||||
Description: "**photos** has been backed up successfully! ✅🫡\n",
|
||||
Color: 3066993,
|
||||
Fields: []discordEmbedField{
|
||||
{Name: "💾 Backup Size", Value: "1.50 GiB", Inline: true},
|
||||
{Name: "Host", Value: "nebula", Inline: true},
|
||||
},
|
||||
Footer: &discordEmbedFooter{Text: "✨ Haven Notify ✨"},
|
||||
}},
|
||||
AllowedMentions: discordAllowedMentions{Parse: []string{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/template/notify/update",
|
||||
body: `{"host":"nexus","asset":"k3s","time":0}`,
|
||||
want: discordPayload{
|
||||
Embeds: []discordEmbed{{
|
||||
Title: "🔄 Update - k3s",
|
||||
Description: "**nexus** has successfully updated **k3s**! ✅",
|
||||
Color: 3447003,
|
||||
Fields: []discordEmbedField{
|
||||
{Name: "⏱️ Time Taken", Value: "0 seconds", Inline: true},
|
||||
},
|
||||
Footer: &discordEmbedFooter{Text: "✨ Haven Notify ✨"},
|
||||
}},
|
||||
AllowedMentions: discordAllowedMentions{Parse: []string{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/template/notify/error",
|
||||
body: `{"caller":"backup","message":"line 1\n\"quoted\" <tag>","critical":true}`,
|
||||
want: discordPayload{
|
||||
Embeds: []discordEmbed{{
|
||||
Title: "❌ Error",
|
||||
Description: "**backup** encountered an error!",
|
||||
Color: 15158332,
|
||||
Fields: []discordEmbedField{
|
||||
{Name: "📄 Message", Value: "line 1\n\"quoted\" <tag>", Inline: false},
|
||||
},
|
||||
Footer: &discordEmbedFooter{Text: "✨ Haven Notify ✨"},
|
||||
}},
|
||||
AllowedMentions: discordAllowedMentions{Parse: []string{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range cases {
|
||||
response, err := postJSON(appURL+test.path, test.body)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", test.path, err)
|
||||
}
|
||||
contents, _ := io.ReadAll(response.Body)
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != 200 || string(contents) != "Notification sent" {
|
||||
t.Fatalf("POST %s = (%d, %q)", test.path, response.StatusCode, contents)
|
||||
}
|
||||
}
|
||||
recorded, err := retrieveMockRequests(mockURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recorded) != len(cases) {
|
||||
t.Fatalf("recorded requests = %d, want %d", len(recorded), len(cases))
|
||||
}
|
||||
for index, request := range recorded {
|
||||
if request.Method != http.MethodPost || request.Path != "/discord" || len(request.Headers["Content-Type"]) == 0 || request.Headers["Content-Type"][0] != "application/json" {
|
||||
t.Fatalf("recorded request %d = %+v", index, request)
|
||||
}
|
||||
body, err := base64.StdEncoding.DecodeString(request.Body.RawBytes)
|
||||
if err != nil || !json.Valid(body) || bytes.Contains(body, []byte(""")) {
|
||||
t.Fatalf("recorded body %d = %s, %v", index, body, err)
|
||||
}
|
||||
want, err := json.Marshal(cases[index].want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(body, want) {
|
||||
t.Fatalf("recorded body %d = %s, want %s", index, body, want)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("invalid request does not call webhook", func(t *testing.T) {
|
||||
if err := configureMock(mockURL, []mockExpectation{{Status: 204}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := postJSON(appURL+"/notify", `{"title":"missing message"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != 400 {
|
||||
t.Fatalf("status = %d", response.StatusCode)
|
||||
}
|
||||
recorded, err := retrieveMockRequests(mockURL)
|
||||
if err != nil || len(recorded) != 0 {
|
||||
t.Fatalf("recorded = %d, error = %v", len(recorded), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transient failure retries", func(t *testing.T) {
|
||||
if err := configureMock(mockURL, []mockExpectation{{Status: 500, Times: 1, Priority: 10}, {Status: 204}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := postJSON(appURL+"/notify", `{"title":"retry","message":"once"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != 200 {
|
||||
t.Fatalf("status = %d", response.StatusCode)
|
||||
}
|
||||
recorded, err := retrieveMockRequests(mockURL)
|
||||
if err != nil || len(recorded) != 2 {
|
||||
t.Fatalf("recorded = %d, error = %v", len(recorded), err)
|
||||
}
|
||||
})
|
||||
|
||||
bad, err := testcontainers.Run(ctx, "haven-notify-e2e:test",
|
||||
testcontainers.WithWaitStrategy(wait.ForExit().WithExitTimeout(15*time.Second)),
|
||||
testcontainers.WithLogger(logger),
|
||||
)
|
||||
testcontainers.CleanupContainer(t, bad)
|
||||
if err != nil {
|
||||
t.Fatalf("start unconfigured container: %v", err)
|
||||
}
|
||||
state, err := bad.State(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Running || state.ExitCode == 0 {
|
||||
t.Fatalf("unconfigured container state = %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
type mockExpectation struct {
|
||||
Status int
|
||||
Times int
|
||||
Priority int
|
||||
}
|
||||
|
||||
func configureMock(baseURL string, expectations []mockExpectation) error {
|
||||
request, _ := http.NewRequest(http.MethodPut, baseURL+"/mockserver/reset", nil)
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
for _, expectation := range expectations {
|
||||
payload := map[string]any{
|
||||
"httpRequest": map[string]any{"method": "POST", "path": "/discord"},
|
||||
"httpResponse": map[string]any{"statusCode": expectation.Status},
|
||||
"priority": expectation.Priority,
|
||||
}
|
||||
if expectation.Times > 0 {
|
||||
payload["times"] = map[string]any{"remainingTimes": expectation.Times, "unlimited": false}
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
request, _ = http.NewRequest(http.MethodPut, baseURL+"/mockserver/expectation", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err = http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("MockServer expectation returned %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func retrieveMockRequests(baseURL string) ([]mockRecordedRequest, error) {
|
||||
request, _ := http.NewRequest(http.MethodPut, baseURL+"/mockserver/retrieve?type=REQUESTS&format=JSON", strings.NewReader(`{"path":"/discord"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var recorded []mockRecordedRequest
|
||||
err = json.NewDecoder(response.Body).Decode(&recorded)
|
||||
return recorded, err
|
||||
}
|
||||
|
||||
func postJSON(url, body string) (*http.Response, error) {
|
||||
request, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
return http.DefaultClient.Do(request)
|
||||
}
|
||||
Reference in New Issue
Block a user