You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

388 lines
14 KiB

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; }", `<title>Laundry status</title>`, `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)
}
}