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") } }