daemon_test.go

  1package daemon
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"net"
  7	"os"
  8	"path/filepath"
  9	"testing"
 10	"time"
 11
 12	"github.com/floatpane/matcha/config"
 13	"github.com/floatpane/matcha/daemonrpc"
 14)
 15
 16func TestPIDFile(t *testing.T) {
 17	dir := t.TempDir()
 18	path := filepath.Join(dir, "test.pid")
 19
 20	if err := WritePID(path); err != nil {
 21		t.Fatal(err)
 22	}
 23
 24	pid, err := ReadPID(path)
 25	if err != nil {
 26		t.Fatal(err)
 27	}
 28	if pid != os.Getpid() {
 29		t.Errorf("pid = %d, want %d", pid, os.Getpid())
 30	}
 31
 32	gotPID, running := IsRunning(path)
 33	if !running {
 34		t.Error("expected running=true for current process")
 35	}
 36	if gotPID != os.Getpid() {
 37		t.Errorf("pid = %d, want %d", gotPID, os.Getpid())
 38	}
 39
 40	if err := RemovePID(path); err != nil {
 41		t.Fatal(err)
 42	}
 43
 44	_, running = IsRunning(path)
 45	if running {
 46		t.Error("expected running=false after remove")
 47	}
 48}
 49
 50func TestPIDFile_InvalidContent(t *testing.T) {
 51	dir := t.TempDir()
 52	path := filepath.Join(dir, "bad.pid")
 53
 54	os.WriteFile(path, []byte("notanumber"), 0644)
 55	_, err := ReadPID(path)
 56	if err == nil {
 57		t.Error("expected error for invalid PID content")
 58	}
 59}
 60
 61func TestPIDFile_DeadProcess(t *testing.T) {
 62	dir := t.TempDir()
 63	path := filepath.Join(dir, "dead.pid")
 64
 65	os.WriteFile(path, []byte("99999999"), 0644)
 66	_, running := IsRunning(path)
 67	if running {
 68		t.Error("expected running=false for dead PID")
 69	}
 70}
 71
 72// serveDaemon starts d's RPC server on a temporary unix socket and returns a
 73// connected client. The server and connection are torn down via t.Cleanup.
 74func serveDaemon(t *testing.T, d *Daemon) *daemonrpc.Conn {
 75	t.Helper()
 76	sock := filepath.Join(t.TempDir(), "d.sock")
 77	l, err := net.Listen("unix", sock)
 78	if err != nil {
 79		t.Fatalf("listen: %v", err)
 80	}
 81	ctx, cancel := context.WithCancel(context.Background())
 82	go func() { _ = d.server.Serve(ctx, l) }()
 83	t.Cleanup(func() {
 84		cancel()
 85		_ = l.Close()
 86	})
 87
 88	c, err := net.Dial("unix", sock)
 89	if err != nil {
 90		t.Fatalf("dial: %v", err)
 91	}
 92	conn := daemonrpc.NewConn(c)
 93	t.Cleanup(func() { _ = conn.Close() })
 94	return conn
 95}
 96
 97// roundTrip sends a request and returns the decoded response message.
 98func roundTrip(t *testing.T, conn *daemonrpc.Conn, req *daemonrpc.Request) daemonrpc.Message {
 99	t.Helper()
100	if err := conn.Send(req); err != nil {
101		t.Fatal(err)
102	}
103	msg, err := conn.ReceiveMessage()
104	if err != nil {
105		t.Fatal(err)
106	}
107	return msg
108}
109
110func TestDaemon_PingHandler(t *testing.T) {
111	d := New(&config.Config{})
112	res, err := d.handlePing(context.Background(), nil, nil)
113	if err != nil {
114		t.Fatalf("handlePing: %v", err)
115	}
116	if !res.(daemonrpc.PingResult).Pong {
117		t.Error("expected pong=true")
118	}
119}
120
121func TestDaemon_StatusHandler(t *testing.T) {
122	d := New(&config.Config{})
123	d.startTime = time.Now().Add(-2 * time.Minute)
124
125	res, err := d.handleGetStatus(context.Background(), nil, nil)
126	if err != nil {
127		t.Fatalf("handleGetStatus: %v", err)
128	}
129	result := res.(daemonrpc.StatusResult)
130
131	if !result.Running {
132		t.Error("expected running=true")
133	}
134	if result.Uptime < 120 {
135		t.Errorf("uptime = %d, want >= 120", result.Uptime)
136	}
137}
138
139func TestDaemon_UnknownMethod(t *testing.T) {
140	d := New(&config.Config{})
141	conn := serveDaemon(t, d)
142
143	msg := roundTrip(t, conn, &daemonrpc.Request{ID: 1, Method: "DoesNotExist"})
144	if msg.Response == nil || msg.Response.Error == nil {
145		t.Fatal("expected error for unknown method")
146	}
147	if msg.Response.Error.Code != daemonrpc.ErrCodeNotFound {
148		t.Errorf("code = %d, want %d", msg.Response.Error.Code, daemonrpc.ErrCodeNotFound)
149	}
150}
151
152func TestDaemon_Subscribe(t *testing.T) {
153	d := New(&config.Config{})
154	conn := serveDaemon(t, d)
155
156	params, _ := json.Marshal(daemonrpc.SubscribeParams{
157		AccountID: "acc1",
158		Folder:    "INBOX",
159	})
160
161	msg := roundTrip(t, conn, &daemonrpc.Request{
162		ID:     1,
163		Method: daemonrpc.MethodSubscribe,
164		Params: params,
165	})
166	if msg.Response.Error != nil {
167		t.Errorf("unexpected error: %v", msg.Response.Error)
168	}
169
170	// The response is sent after the handler records the subscription, so it
171	// is visible by the time we read the reply.
172	d.subMu.RLock()
173	defer d.subMu.RUnlock()
174	found := false
175	for _, subs := range d.subscriptions {
176		if _, ok := subs["acc1:INBOX"]; ok {
177			found = true
178		}
179	}
180	if !found {
181		t.Error("expected subscription for acc1:INBOX")
182	}
183}
184
185func TestDaemon_BroadcastEvent(t *testing.T) {
186	d := New(&config.Config{})
187	conn := serveDaemon(t, d)
188
189	// Ping round-trip ensures the client is registered with the server before
190	// we broadcast.
191	roundTrip(t, conn, &daemonrpc.Request{ID: 1, Method: daemonrpc.MethodPing})
192
193	d.broadcastEvent(daemonrpc.EventNewMail, daemonrpc.NewMailEvent{
194		AccountID: "acc1",
195		Folder:    "INBOX",
196	})
197
198	msg, err := conn.ReceiveMessage()
199	if err != nil {
200		t.Fatal(err)
201	}
202	if msg.Event == nil {
203		t.Fatal("expected Event")
204	}
205	if msg.Event.Type != daemonrpc.EventNewMail {
206		t.Errorf("type = %q, want NewMail", msg.Event.Type)
207	}
208}