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.
287 lines
9.1 KiB
287 lines
9.1 KiB
package web
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"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("} {
|
|
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 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)
|
|
}
|
|
}
|
|
|