Return sink write errors from every handler (closes #22)
check / check (push) Successful in 53s
check / check (pull_request) Successful in 43s

The console and JSON handlers threw away the error from their write to
stdout, so a lost log line looked delivered. They now return it,
wrapped. The webhook handler also returns an error for a status outside
2xx. The multiplex handler passes the record to every handler and
returns their errors joined with errors.Join instead of stopping at the
first.

Both stdout handlers gain an unexported writer, nil meaning os.Stdout at
write time, so tests can supply a sink that fails. The README says what
each handler returns and that the slog.Logger methods discard a
handler's error.

Model: opus-5-5
This commit is contained in:
2026-10-06 00:32:23 +00:00
parent b28c0dca0c
commit b73e1c59be
7 changed files with 184 additions and 14 deletions
+104
View File
@@ -0,0 +1,104 @@
package simplelog
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// These tests sit inside the package so they can point a handler at a sink
// that fails, which callers outside the package cannot do.
// errSinkFailed is what failingWriter returns from every write.
var errSinkFailed = errors.New("sink failed")
// failingWriter is a sink whose every write fails.
type failingWriter struct{}
func (failingWriter) Write(_ []byte) (int, error) {
return 0, errSinkFailed
}
func errorTestRecord() slog.Record {
return slog.NewRecord(time.Now(), slog.LevelInfo, "casting", 0)
}
// The handler is derived with WithAttrs and WithGroup, so the test also
// fails if either of them drops the sink.
func TestJSONHandlerReturnsWriteError(t *testing.T) {
t.Parallel()
handler := (&JSONHandler{out: failingWriter{}}).
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithGroup("cast")
err := handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errSinkFailed) {
t.Fatalf("Handle returned %v, want the sink's error", err)
}
}
func TestConsoleHandlerReturnsWriteError(t *testing.T) {
t.Parallel()
handler := (&ConsoleHandler{out: failingWriter{}}).
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithGroup("cast")
err := handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errSinkFailed) {
t.Fatalf("Handle returned %v, want the sink's error", err)
}
}
// A failing handler must not stop the record reaching the handlers after
// it, and its error must still reach the caller.
func TestMultiplexHandlerDeliversPastFailingHandler(t *testing.T) {
t.Parallel()
var delivered bytes.Buffer
handler := &MultiplexHandler{handlers: []ExtendedHandler{
&JSONHandler{out: failingWriter{}},
&JSONHandler{out: &delivered},
}}
err := handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errSinkFailed) {
t.Fatalf("Handle returned %v, want the failing handler's error", err)
}
if !strings.Contains(delivered.String(), `"Message":"casting"`) {
t.Fatalf(
"second handler did not receive the record: %q",
delivered.String(),
)
}
}
func TestWebhookHandlerReturnsErrorOnServerError(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
},
))
defer server.Close()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
err = handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errWebhookStatus) {
t.Fatalf("Handle returned %v, want an error for the 500 answer", err)
}
}