Nik Afiq 39b7d6d4b4
All checks were successful
CI / changes (push) Successful in 19s
CI / test (push) Successful in 31s
CI / build-ai-gateway (push) Successful in 1m8s
CI / build-ha-gateway (push) Successful in 1m19s
CI / build-discord-bot (push) Successful in 59s
CI / build-alexa-bridge (push) Successful in 1m8s
CI / build-alert-bridge (push) Successful in 1m17s
CI / build-tts-gateway (push) Successful in 1m10s
CI / build-tts-sidecar (push) Has been skipped
CI / build-tts-model (push) Has been skipped
feat: add alert-bridge service for external cronjob -> Discord alerts
New standalone service that receives authenticated HTTP POSTs (e.g. from a
cronjob elsewhere on the home network) and posts them to a Discord channel
via an Incoming Webhook. Fire-and-forget, no dependency on discord-bot's bot
session at all - mirrors alexa-bridge's precedent of a small bridge service
with its own HTTP listener and auth rather than adding an inbound edge to an
existing internal service.

Wires alert-bridge into go.work, CI (changes filter, test job, new
build-alert-bridge job), and the other five Dockerfiles' COPY lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:45:18 +09:00

121 lines
3.1 KiB
Go

package http
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain"
)
type fakeAlertHandler struct {
handleFn func(ctx context.Context, alert domain.Alert) error
got domain.Alert
called bool
}
func (f *fakeAlertHandler) Handle(ctx context.Context, alert domain.Alert) error {
f.called = true
f.got = alert
if f.handleFn != nil {
return f.handleFn(ctx, alert)
}
return nil
}
func doRequest(h *Handler, apiKey, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/alerts", strings.NewReader(body))
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestHandler_Unauthorized(t *testing.T) {
fake := &fakeAlertHandler{}
h := NewHandler(fake, "secret")
rec := doRequest(h, "wrong-key", `{"source":"x","message":"y"}`)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
if fake.called {
t.Fatal("app should not be called when unauthorized")
}
}
func TestHandler_MissingAuthHeader(t *testing.T) {
fake := &fakeAlertHandler{}
h := NewHandler(fake, "secret")
req := httptest.NewRequest(http.MethodPost, "/alerts", strings.NewReader(`{"source":"x","message":"y"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
}
func TestHandler_BadJSON(t *testing.T) {
fake := &fakeAlertHandler{}
h := NewHandler(fake, "secret")
rec := doRequest(h, "secret", `not json`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
func TestHandler_MissingFields(t *testing.T) {
fake := &fakeAlertHandler{}
h := NewHandler(fake, "secret")
rec := doRequest(h, "secret", `{"source":"","message":""}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
func TestHandler_InvalidLevel(t *testing.T) {
fake := &fakeAlertHandler{}
h := NewHandler(fake, "secret")
rec := doRequest(h, "secret", `{"source":"x","message":"y","level":"critical"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
func TestHandler_DefaultsLevelToInfo(t *testing.T) {
fake := &fakeAlertHandler{}
h := NewHandler(fake, "secret")
rec := doRequest(h, "secret", `{"source":"ba-cronjob","message":"Started"}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("expected 202, got %d", rec.Code)
}
if fake.got.Level != domain.LevelInfo {
t.Fatalf("expected default level info, got %q", fake.got.Level)
}
if fake.got.Source != "ba-cronjob" || fake.got.Message != "Started" {
t.Fatalf("unexpected alert passed through: %+v", fake.got)
}
}
func TestHandler_NotifierFailure(t *testing.T) {
fake := &fakeAlertHandler{handleFn: func(ctx context.Context, alert domain.Alert) error {
return errors.New("discord unreachable")
}}
h := NewHandler(fake, "secret")
rec := doRequest(h, "secret", `{"source":"x","message":"y","level":"error"}`)
if rec.Code != http.StatusBadGateway {
t.Fatalf("expected 502, got %d", rec.Code)
}
}