package web import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "image/png" "io" "net/http" "net/http/httptest" "reflect" "strings" "sync" "testing" "testing/synctest" "time" "mqtt_washingmachine/internal/sensor" "mqtt_washingmachine/internal/status" ) func TestStatusAPI(t *testing.T) { for _, tt := range []struct { name, state, message string readings []status.Sample }{ {"startup", "waiting", "Waiting for data...", []status.Sample{}}, {"waiting", "waiting", "Waiting for data...", []status.Sample{{Power: 47.7, Valid: true}}}, {"incomplete", "incomplete", "Incomplete data", []status.Sample{{Power: 47.7, Valid: true}, {}, {Valid: true}}}, {"running", "running", "Laundry not yet done", []status.Sample{{Valid: true}, {Power: 3, Valid: true}, {Valid: true}}}, {"done", "done", "Laundry done", []status.Sample{{Power: 2.99, Valid: true}, {Valid: true}, {Valid: true}}}, } { t.Run(tt.name, func(t *testing.T) { handler := NewHandler(func() status.Result { return status.Evaluate(tt.readings) }, &sensor.Hub{}) response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/status", nil)) if response.Code != http.StatusOK { t.Fatalf("status = %d", response.Code) } if got := response.Header().Get("Content-Type"); got != "application/json" { t.Errorf("content type = %q", got) } if got := response.Header().Get("Cache-Control"); got != "no-store" { t.Errorf("cache control = %q", got) } var got status.Result if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil { t.Fatal(err) } want := status.Result{State: tt.state, Message: tt.message, Readings: tt.readings} if !reflect.DeepEqual(got, want) { t.Fatalf("body = %+v; want %+v", got, want) } }) } } func TestPageAndRouting(t *testing.T) { handler := NewHandler(func() status.Result { return status.Evaluate(nil) }, &sensor.Hub{}) page := httptest.NewRecorder() handler.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/", nil)) if page.Code != http.StatusOK || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") { t.Fatalf("page response: %+v", page.Result()) } for _, text := range []string{`name="viewport"`, "Waiting for data...", "/api/status", "60000", "EventSource", "/api/events", "toFixed(2)", "--", "clamp(", ".done { color: #1a7f37; }", `Laundry status`, `rel="manifest" href="/manifest.webmanifest"`, `name="theme-color" content="#ffffff"`, `rel="apple-touch-icon" href="/apple-touch-icon.png"`, `rel="icon" href="/icons/icon.svg" type="image/svg+xml"`} { if !strings.Contains(page.Body.String(), text) { t.Errorf("page missing %q", text) } } for _, tt := range []struct { method, path string code int }{ {http.MethodGet, "/missing", http.StatusNotFound}, {http.MethodPost, "/api/status", http.StatusMethodNotAllowed}, {http.MethodPost, "/api/events", http.StatusMethodNotAllowed}, {http.MethodPost, "/", http.StatusMethodNotAllowed}, } { response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(tt.method, tt.path, nil)) if response.Code != tt.code { t.Errorf("%s %s = %d; want %d", tt.method, tt.path, response.Code, tt.code) } } } func TestHomeScreenAssets(t *testing.T) { handler := NewHandler(func() status.Result { return status.Evaluate(nil) }, &sensor.Hub{}) for _, tt := range []struct { path, contentType string size int }{ {"/manifest.webmanifest", "application/manifest+json", 0}, {"/icons/icon.svg", "image/svg+xml", 0}, {"/icons/icon-192.png", "image/png", 192}, {"/icons/icon-512.png", "image/png", 512}, {"/icons/icon-maskable-512.png", "image/png", 512}, {"/apple-touch-icon.png", "image/png", 180}, } { t.Run(tt.path, func(t *testing.T) { response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, tt.path, nil)) if response.Code != http.StatusOK { t.Fatalf("status = %d; want 200", response.Code) } if got := response.Header().Get("Content-Type"); got != tt.contentType { t.Errorf("content type = %q; want %q", got, tt.contentType) } if got := response.Header().Get("Cache-Control"); got != "no-cache" { t.Errorf("cache control = %q; want no-cache", got) } if response.Body.Len() == 0 { t.Fatal("empty asset") } if tt.size != 0 { if !bytes.HasPrefix(response.Body.Bytes(), []byte("\x89PNG\r\n\x1a\n")) { t.Fatal("missing PNG signature") } img, err := png.Decode(response.Body) if err != nil { t.Fatal(err) } if got := img.Bounds(); got.Dx() != tt.size || got.Dy() != tt.size { t.Errorf("PNG bounds = %v; want %dx%d", got, tt.size, tt.size) } } for _, request := range []struct { method, path string code int }{ {http.MethodPost, tt.path, http.StatusMethodNotAllowed}, {http.MethodGet, tt.path + "/missing", http.StatusNotFound}, } { response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(request.method, request.path, nil)) if response.Code != request.code { t.Errorf("%s %s = %d; want %d", request.method, request.path, response.Code, request.code) } } }) } } func TestHomeScreenManifest(t *testing.T) { handler := NewHandler(func() status.Result { return status.Evaluate(nil) }, &sensor.Hub{}) response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil)) var manifest struct { Name string `json:"name"` ShortName string `json:"short_name"` StartURL string `json:"start_url"` Scope string `json:"scope"` Display string `json:"display"` ThemeColor string `json:"theme_color"` BackgroundColor string `json:"background_color"` Icons []struct{ Src, Sizes, Type, Purpose string } `json:"icons"` } if err := json.Unmarshal(response.Body.Bytes(), &manifest); err != nil { t.Fatal(err) } if manifest.Name != "Home Lanundry" || manifest.ShortName != "Home Lanundry" || manifest.StartURL != "/" || manifest.Scope != "/" || manifest.Display != "standalone" || manifest.ThemeColor != "#ffffff" || manifest.BackgroundColor != "#ffffff" { t.Errorf("unexpected manifest: %+v", manifest) } var variants []string for _, icon := range manifest.Icons { response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, icon.Src, nil)) if response.Code != http.StatusOK || icon.Type != "image/png" || response.Header().Get("Content-Type") != icon.Type { t.Fatalf("icon %+v: status %d, content type %q", icon, response.Code, response.Header().Get("Content-Type")) } cfg, err := png.DecodeConfig(response.Body) if err != nil { t.Fatal(err) } if got := fmt.Sprintf("%dx%d", cfg.Width, cfg.Height); got != icon.Sizes { t.Errorf("icon %s: size %s; declared %s", icon.Src, got, icon.Sizes) } variants = append(variants, icon.Sizes+" "+icon.Purpose) } if want := []string{"192x192 any", "512x512 any", "512x512 maskable"}; !reflect.DeepEqual(variants, want) { t.Errorf("icon variants = %v; want %v", variants, want) } } func TestStatusAPIReadsCurrentSnapshot(t *testing.T) { var window status.Window handler := NewHandler(func() status.Result { return status.Evaluate(window.Readings()) }, &sensor.Hub{}) for range 3 { window.Add(status.Sample{Valid: true}) response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/status", nil)) var got status.Result if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil { t.Fatal(err) } if !reflect.DeepEqual(got.Readings, window.Readings()) { t.Fatal("handler returned stale readings") } } } type trackedEvents struct { events chan struct{} subscribed bool cancelled bool } func (e *trackedEvents) Subscribe() (<-chan struct{}, func()) { e.subscribed = true return e.events, func() { e.cancelled = true } } // Synthetic time orders timer wakeups but does not establish a race-detector // happens-before edge from a body inspection to the next timed write. type streamRecorder struct { *httptest.ResponseRecorder mu sync.Mutex } func (w *streamRecorder) Write(p []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() return w.ResponseRecorder.Write(p) } func (w *streamRecorder) body() string { w.mu.Lock() defer w.mu.Unlock() return w.Body.String() } func TestEventsInitialNotificationKeepAliveAndCleanup(t *testing.T) { synctest.Test(t, func(t *testing.T) { oldInterval := keepAliveInterval keepAliveInterval = time.Second defer func() { keepAliveInterval = oldInterval }() events := &trackedEvents{events: make(chan struct{}, 1)} handler := NewHandler(func() status.Result { return status.Evaluate(nil) }, events) ctx, cancel := context.WithCancel(t.Context()) defer cancel() request := httptest.NewRequest(http.MethodGet, "/api/events", nil).WithContext(ctx) response := &streamRecorder{ResponseRecorder: httptest.NewRecorder()} done := make(chan struct{}) go func() { handler.ServeHTTP(response, request); close(done) }() synctest.Wait() if !events.subscribed || !response.Flushed || response.Code != http.StatusOK { t.Fatal("stream did not subscribe and flush immediately") } if response.Header().Get("Content-Type") != "text/event-stream" || response.Header().Get("Cache-Control") != "no-store" { t.Fatalf("stream headers: %v", response.Header()) } if got := response.body(); got != "event: update\ndata: {}\n\n" { t.Fatalf("initial event = %q", got) } events.events <- struct{}{} synctest.Wait() if got := strings.Count(response.body(), "event: update\n"); got != 2 { t.Fatalf("update count = %d; want 2", got) } time.Sleep(time.Second) synctest.Wait() if !strings.HasSuffix(response.body(), ": keep-alive\n\n") { t.Fatalf("missing keep-alive: %q", response.body()) } cancel() synctest.Wait() select { case <-done: default: t.Fatal("handler did not exit after disconnect") } if !events.cancelled { t.Fatal("disconnected subscriber was not removed") } }) } type nonFlushingWriter struct{ http.ResponseWriter } func TestEventsRejectNonFlushingWriter(t *testing.T) { events := &trackedEvents{events: make(chan struct{}, 1)} handler := NewHandler(func() status.Result { return status.Evaluate(nil) }, events) response := httptest.NewRecorder() handler.ServeHTTP(nonFlushingWriter{response}, httptest.NewRequest(http.MethodGet, "/api/events", nil)) if response.Code != http.StatusInternalServerError || events.subscribed { t.Fatalf("unsupported stream: status %d, subscribed %v", response.Code, events.subscribed) } } type blockedStreamWriter struct { *httptest.ResponseRecorder started chan struct{} unblock chan struct{} once sync.Once cleared bool } func (w *blockedStreamWriter) Write(p []byte) (int, error) { close(w.started) <-w.unblock return 0, errors.New("write deadline exceeded") } func (w *blockedStreamWriter) SetWriteDeadline(deadline time.Time) error { if deadline.IsZero() { w.cleared = true } else { w.once.Do(func() { close(w.unblock) }) } return nil } func TestEventsCancellationInterruptsBlockedWrite(t *testing.T) { events := &trackedEvents{events: make(chan struct{}, 1)} handler := NewHandler(func() status.Result { return status.Evaluate(nil) }, events) ctx, cancel := context.WithCancel(t.Context()) defer cancel() response := &blockedStreamWriter{ResponseRecorder: httptest.NewRecorder(), started: make(chan struct{}), unblock: make(chan struct{})} done := make(chan struct{}) go func() { handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/events", nil).WithContext(ctx)) close(done) }() select { case <-response.started: case <-time.After(time.Second): t.Fatal("stream did not start writing") } cancel() select { case <-done: case <-time.After(time.Second): response.once.Do(func() { close(response.unblock) }) <-done t.Fatal("cancellation did not interrupt the blocked write") } if !events.cancelled || !response.cleared { t.Fatalf("cancelled=%v, write deadline cleared=%v", events.cancelled, response.cleared) } } func TestEventsSurviveWriteTimeoutAndBroadcastValidMessages(t *testing.T) { var hub sensor.Hub sampler := &sensor.Sampler{OnReceive: hub.Notify} server := httptest.NewUnstartedServer(NewHandler(sampler.Status, &hub)) server.Config.WriteTimeout = 50 * time.Millisecond server.Start() defer server.Close() client := &http.Client{Timeout: 3 * time.Second} readers := make([]*bufio.Reader, 2) for i := range readers { response, err := client.Get(server.URL + "/api/events") if err != nil { t.Fatal(err) } defer response.Body.Close() readers[i] = bufio.NewReader(response.Body) assertUpdate(t, readers[i]) } time.Sleep(100 * time.Millisecond) if err := sampler.Receive([]byte(`broken`)); err == nil { t.Fatal("invalid payload accepted") } if err := sampler.Receive([]byte(`{"ENERGY":{"Power":47.7}}`)); err != nil { t.Fatal(err) } for _, reader := range readers { assertUpdate(t, reader) } if server.Config.WriteTimeout != 50*time.Millisecond { t.Fatal("global write timeout changed") } } func assertUpdate(t *testing.T, reader io.Reader) { t.Helper() want := "event: update\ndata: {}\n\n" got := make([]byte, len(want)) if _, err := io.ReadFull(reader, got); err != nil { t.Fatal(err) } if string(got) != want { t.Fatalf("event = %q; want %q", got, want) } }