From 234d2a2c02c627ddbdc062902ede0fcf0ba2a0a2 Mon Sep 17 00:00:00 2001 From: Admin Bot Date: Tue, 15 Sep 2026 10:01:11 +0900 Subject: [PATCH] test: notification handler + gotify client unit tests - 24 tests covering all X-Resource routes - Mock Gotify server for message/application CRUD - Error cases: nil gotify, invalid JSON, missing fields, 429, 500 - Fix readError() to handle io.ReadAll failure - GotifyClient direct tests for all 7 methods --- internal/notification/gotify.go | 5 +- internal/notification/handler_test.go | 513 ++++++++++++++++++++++++++ 2 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 internal/notification/handler_test.go diff --git a/internal/notification/gotify.go b/internal/notification/gotify.go index 1167187..9939026 100644 --- a/internal/notification/gotify.go +++ b/internal/notification/gotify.go @@ -252,6 +252,9 @@ func (c *GotifyClient) DeleteApplication(id int) error { // --- Helpers --- func (c *GotifyClient) readError(resp *http.Response) error { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("gotify API error (HTTP %d): failed to read body: %w", resp.StatusCode, err) + } return fmt.Errorf("gotify API error (HTTP %d): %s", resp.StatusCode, string(body)) } diff --git a/internal/notification/handler_test.go b/internal/notification/handler_test.go new file mode 100644 index 0000000..c7a70a5 --- /dev/null +++ b/internal/notification/handler_test.go @@ -0,0 +1,513 @@ +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// mockGotifyServer creates a test server that simulates the Gotify API. +func mockGotifyServer() *httptest.Server { + mux := http.NewServeMux() + + // POST /message — send message + mux.HandleFunc("/message", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + var msg GotifyMessage + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + msg.ID = 42 + msg.AppID = 1 + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(msg) + + case http.MethodGet: + // list messages + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(GotifyMessageList{ + Messages: []GotifyMessage{ + {ID: 1, Title: "Test", Message: "hello", Priority: 3}, + {ID: 2, Title: "Alert", Message: "world", Priority: 7}, + }, + Paging: GotifyPaging{Size: 2, Limit: 50}, + }) + + case http.MethodDelete: + // delete all messages + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + + // DELETE /message/{id} + mux.HandleFunc("/message/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) + + // GET/POST/DELETE /application + mux.HandleFunc("/application", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]GotifyApplication{ + {ID: 1, Name: "app1", Token: "tok1"}, + {ID: 2, Name: "app2", Token: "tok2"}, + }) + + case http.MethodPost: + var app GotifyApplication + if err := json.NewDecoder(r.Body).Decode(&app); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + app.ID = 10 + app.Token = "new-token" + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(app) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + + // DELETE /application/{id} + mux.HandleFunc("/application/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) + + return httptest.NewServer(mux) +} + +func newTestHandler(gotifyURL string) *Handler { + h := &Handler{} + if gotifyURL != "" { + h.gotify = NewGotifyClient(gotifyURL, "test-app-token", "test-client-token") + } + return h +} + +func doRequest(h *Handler, method, resource string, body interface{}) *httptest.ResponseRecorder { + var reqBody io.Reader + if body != nil { + b, _ := json.Marshal(body) + reqBody = bytes.NewReader(b) + } + + req := httptest.NewRequest(method, "/", reqBody) + req.Header.Set("X-Resource", resource) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w +} + +func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) map[string]interface{} { + t.Helper() + var result map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("decode response: %v (body: %s)", err, w.Body.String()) + } + return result +} + +// ============================================================ +// Handler routing tests +// ============================================================ + +func TestHandler_UnknownResource(t *testing.T) { + h := newTestHandler("") + w := doRequest(h, "GET", "unknown-resource", nil) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } + data := decodeResponse(t, w) + if _, ok := data["error"]; !ok { + t.Error("expected error in response") + } +} + +func TestHandler_GotifyNotConfigured(t *testing.T) { + h := newTestHandler("") // no gotify + + resources := []struct { + method string + resource string + body interface{} + }{ + {"POST", "send-message", map[string]string{"title": "t", "message": "m"}}, + {"GET", "list-messages", nil}, + {"DELETE", "delete-message", map[string]int{"id": 1}}, + {"DELETE", "delete-all-messages", nil}, + {"GET", "list-applications", nil}, + {"POST", "create-application", map[string]string{"name": "app"}}, + {"DELETE", "delete-application", map[string]int{"id": 1}}, + } + + for _, tc := range resources { + t.Run(tc.resource, func(t *testing.T) { + w := doRequest(h, tc.method, tc.resource, tc.body) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } + }) + } +} + +// ============================================================ +// Gotify message tests (via handler) +// ============================================================ + +func TestHandler_SendMessage(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + h := newTestHandler(srv.URL) + + w := doRequest(h, "POST", "send-message", map[string]interface{}{ + "title": "Test Alert", + "message": "Something happened", + "priority": 5, + }) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + data := decodeResponse(t, w) + if data["title"] != "Test Alert" { + t.Errorf("expected title 'Test Alert', got %v", data["title"]) + } + if int(data["id"].(float64)) != 42 { + t.Errorf("expected id 42, got %v", data["id"]) + } +} + +func TestHandler_SendMessage_InvalidJSON(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + h := newTestHandler(srv.URL) + + req := httptest.NewRequest("POST", "/", bytes.NewReader([]byte("not json"))) + req.Header.Set("X-Resource", "send-message") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestHandler_ListMessages(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + h := newTestHandler(srv.URL) + + w := doRequest(h, "GET", "list-messages", nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var result GotifyMessageList + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("decode: %v", err) + } + if len(result.Messages) != 2 { + t.Errorf("expected 2 messages, got %d", len(result.Messages)) + } +} + +func TestHandler_DeleteMessage(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + h := newTestHandler(srv.URL) + + w := doRequest(h, "DELETE", "delete-message", map[string]int{"id": 1}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHandler_DeleteMessage_MissingID(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + h := newTestHandler(srv.URL) + + w := doRequest(h, "DELETE", "delete-message", map[string]int{"id": 0}) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestHandler_DeleteAllMessages(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + h := newTestHandler(srv.URL) + + w := doRequest(h, "DELETE", "delete-all-messages", nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +// ============================================================ +// Gotify application tests (via handler) +// ============================================================ + +func TestHandler_ListApplications(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + h := newTestHandler(srv.URL) + + w := doRequest(h, "GET", "list-applications", nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var apps []GotifyApplication + if err := json.Unmarshal(w.Body.Bytes(), &apps); err != nil { + t.Fatalf("decode: %v", err) + } + if len(apps) != 2 { + t.Errorf("expected 2 apps, got %d", len(apps)) + } +} + +func TestHandler_CreateApplication(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + h := newTestHandler(srv.URL) + + w := doRequest(h, "POST", "create-application", map[string]string{ + "name": "my-app", + "description": "test app", + }) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + data := decodeResponse(t, w) + if data["name"] != "my-app" { + t.Errorf("expected name 'my-app', got %v", data["name"]) + } +} + +func TestHandler_DeleteApplication(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + h := newTestHandler(srv.URL) + + w := doRequest(h, "DELETE", "delete-application", map[string]int{"id": 1}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHandler_DeleteApplication_MissingID(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + h := newTestHandler(srv.URL) + + w := doRequest(h, "DELETE", "delete-application", map[string]int{"id": 0}) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +// ============================================================ +// Email tests (routing only, no SMTP) +// ============================================================ + +func TestHandler_SendEmail_MissingTo(t *testing.T) { + h := newTestHandler("") + + w := doRequest(h, "POST", "send-email", map[string]string{ + "subject": "Test", + "body": "Hello", + }) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestHandler_SendEmail_InvalidJSON(t *testing.T) { + h := newTestHandler("") + + req := httptest.NewRequest("POST", "/", bytes.NewReader([]byte("{bad"))) + req.Header.Set("X-Resource", "send-email") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +// ============================================================ +// GotifyClient direct tests +// ============================================================ + +func TestGotifyClient_SendMessage(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + client := NewGotifyClient(srv.URL, "app-token", "client-token") + + msg, err := client.SendMessage(GotifyMessage{ + Title: "Direct Test", + Message: "Hello", + Priority: 3, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + if msg.ID != 42 { + t.Errorf("expected id 42, got %d", msg.ID) + } + if msg.Title != "Direct Test" { + t.Errorf("expected title 'Direct Test', got %s", msg.Title) + } +} + +func TestGotifyClient_ListMessages(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + client := NewGotifyClient(srv.URL, "app-token", "client-token") + + list, err := client.ListMessages(50) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list.Messages) != 2 { + t.Errorf("expected 2 messages, got %d", len(list.Messages)) + } +} + +func TestGotifyClient_DeleteMessage(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + client := NewGotifyClient(srv.URL, "app-token", "client-token") + + if err := client.DeleteMessage(1); err != nil { + t.Fatalf("delete: %v", err) + } +} + +func TestGotifyClient_DeleteAllMessages(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + client := NewGotifyClient(srv.URL, "app-token", "client-token") + + if err := client.DeleteAllMessages(); err != nil { + t.Fatalf("delete all: %v", err) + } +} + +func TestGotifyClient_ListApplications(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + client := NewGotifyClient(srv.URL, "app-token", "client-token") + + apps, err := client.ListApplications() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(apps) != 2 { + t.Errorf("expected 2 apps, got %d", len(apps)) + } +} + +func TestGotifyClient_CreateApplication(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + client := NewGotifyClient(srv.URL, "app-token", "client-token") + + app, err := client.CreateApplication(GotifyApplication{ + Name: "new-app", + Description: "test", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if app.ID != 10 { + t.Errorf("expected id 10, got %d", app.ID) + } +} + +func TestGotifyClient_DeleteApplication(t *testing.T) { + srv := mockGotifyServer() + defer srv.Close() + client := NewGotifyClient(srv.URL, "app-token", "client-token") + + if err := client.DeleteApplication(1); err != nil { + t.Fatalf("delete: %v", err) + } +} + +func TestGotifyClient_ErrorResponse(t *testing.T) { + // Server that returns 500 for everything + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "internal error") + })) + defer srv.Close() + client := NewGotifyClient(srv.URL, "app-token", "client-token") + + _, err := client.SendMessage(GotifyMessage{Title: "test"}) + if err == nil { + t.Fatal("expected error") + } + + _, err = client.ListMessages(10) + if err == nil { + t.Fatal("expected error") + } + + _, err = client.ListApplications() + if err == nil { + t.Fatal("expected error") + } +} + +func TestGotifyClient_RateLimited(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + fmt.Fprint(w, "rate limited") + })) + defer srv.Close() + client := NewGotifyClient(srv.URL, "app-token", "client-token") + + _, err := client.SendMessage(GotifyMessage{Title: "test"}) + if err == nil { + t.Fatal("expected error on 429") + } +} + +func TestGotifyClient_InvalidURL(t *testing.T) { + client := NewGotifyClient("http://localhost:1", "app-token", "client-token") + + _, err := client.SendMessage(GotifyMessage{Title: "test"}) + if err == nil { + t.Fatal("expected connection error") + } +}