+package main
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDaemonLogFormatsLevelsAndPrefixes(t *testing.T) {
+ testCases := []struct {
+ name string
+ level slog.Level
+ prefix string
+ priority string
+ }{
+ {
+ name: "error names the failed part",
+ level: slog.LevelError,
+ prefix: "onechat: ",
+ priority: "3",
+ },
+ {
+ name: "warning names the retried part",
+ level: slog.LevelWarn,
+ prefix: "telegram: ",
+ priority: "4",
+ },
+ {
+ name: "info names the connected part",
+ level: slog.LevelInfo,
+ prefix: "whatsapp: ",
+ priority: "6",
+ },
+ {
+ name: "debug has its own priority",
+ level: slog.LevelDebug,
+ prefix: "onechat: ",
+ priority: "7",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, journal := range []bool{false, true} {
+ var out strings.Builder
+
+ handler := &DaemonLogHandler{
+ out: &out,
+ prefix: tc.prefix,
+ journal: journal,
+ mutex: &sync.Mutex{},
+ }
+
+ record := slog.NewRecord(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), tc.level, "ready", 0)
+ require.True(t, handler.Enabled(t.Context(), tc.level))
+ require.NoError(t, handler.Handle(t.Context(), record))
+
+ expected := "2026/01/02 03:04:05 " + tc.level.String() + " " + tc.prefix + "ready\n"
+ if journal {
+ expected = "<" + tc.priority + ">" + tc.prefix + "ready\n"
+ }
+
+ require.Equal(t, expected, out.String())
+ }
+ })
+ }
+}
+
+func TestDaemonLogKeepsAttributesInOrderAndCauseLast(t *testing.T) {
+ var out strings.Builder
+
+ handler := &DaemonLogHandler{
+ out: &out,
+ prefix: "telegram: ",
+ journal: true,
+ mutex: &sync.Mutex{},
+ }
+
+ logger := slog.New(handler).With("first", "two words", slog.Attr{})
+ logger.Warn("receive failed", "err", errors.New("502 Bad Gateway"), "retry", 2040*time.Millisecond, "count", 3, "short", 1500*time.Microsecond)
+ require.Equal(t, "<4>telegram: receive failed first=two words retry=2s count=3 short=2ms: 502 Bad Gateway\n", out.String())
+}
+
+func TestDaemonLogOmitsEmptyAttributesAndNilCause(t *testing.T) {
+ var out strings.Builder
+
+ handler := &DaemonLogHandler{
+ out: &out,
+ prefix: "onechat: ",
+ journal: true,
+ mutex: &sync.Mutex{},
+ }
+
+ slog.New(handler).Info("ready", slog.Attr{}, slog.Group("empty"), "err", nil)
+ require.Equal(t, "<6>onechat: ready\n", out.String())
+}
+
+func TestDaemonLogGroupsDoNotChangeOtherLoggers(t *testing.T) {
+ var out strings.Builder
+
+ handler := &DaemonLogHandler{
+ out: &out,
+ journal: true,
+ mutex: &sync.Mutex{},
+ }
+
+ logger := slog.New(handler)
+ grouped := logger.With("before", 1).WithGroup("outer").With("saved", 2).WithGroup("inner")
+ grouped.Info("done", slog.Group("", "count", 3))
+ logger.Info("ready")
+ require.Equal(t, "<6>done before=1 outer.saved=2 outer.inner.count=3\n<6>ready\n", out.String())
+}
+
+func TestDaemonLogReturnsWriteFailure(t *testing.T) {
+ handler := &DaemonLogHandler{
+ out: DaemonLogBrokenWriter{},
+ mutex: &sync.Mutex{},
+ }
+
+ err := handler.Handle(context.Background(), slog.NewRecord(time.Now(), slog.LevelInfo, "ready", 0))
+ require.ErrorIs(t, err, io.ErrClosedPipe)
+ require.ErrorContains(t, err, "write daemon log")
+}
+
+type DaemonLogBrokenWriter struct{}
+
+func (w DaemonLogBrokenWriter) Write(_ []byte) (int, error) {
+ return 0, io.ErrClosedPipe
+}