daemon_test.go

  1package daemon
  2
  3import (
  4	"encoding/json"
  5	"net"
  6	"os"
  7	"path/filepath"
  8	"testing"
  9	"time"
 10
 11	"github.com/floatpane/matcha/config"
 12	"github.com/floatpane/matcha/daemonrpc"
 13)
 14
 15func TestPIDFile(t *testing.T) {
 16	dir := t.TempDir()
 17	path := filepath.Join(dir, "test.pid")
 18
 19	if err := WritePID(path); err != nil {
 20		t.Fatal(err)
 21	}
 22
 23	pid, err := ReadPID(path)
 24	if err != nil {
 25		t.Fatal(err)
 26	}
 27	if pid != os.Getpid() {
 28		t.Errorf("pid = %d, want %d", pid, os.Getpid())
 29	}
 30
 31	gotPID, running := IsRunning(path)
 32	if !running {
 33		t.Error("expected running=true for current process")
 34	}
 35	if gotPID != os.Getpid() {
 36		t.Errorf("pid = %d, want %d", gotPID, os.Getpid())
 37	}
 38
 39	if err := RemovePID(path); err != nil {
 40		t.Fatal(err)
 41	}
 42
 43	_, running = IsRunning(path)
 44	if running {
 45		t.Error("expected running=false after remove")
 46	}
 47}
 48
 49func TestPIDFile_InvalidContent(t *testing.T) {
 50	dir := t.TempDir()
 51	path := filepath.Join(dir, "bad.pid")
 52
 53	os.WriteFile(path, []byte("notanumber"), 0644)
 54	_, err := ReadPID(path)
 55	if err == nil {
 56		t.Error("expected error for invalid PID content")
 57	}
 58}
 59
 60func TestPIDFile_DeadProcess(t *testing.T) {
 61	dir := t.TempDir()
 62	path := filepath.Join(dir, "dead.pid")
 63
 64	os.WriteFile(path, []byte("99999999"), 0644)
 65	_, running := IsRunning(path)
 66	if running {
 67		t.Error("expected running=false for dead PID")
 68	}
 69}
 70
 71// handlerTest sets up a client/server pipe and runs a single RPC exchange.
 72// The handler runs in a goroutine so the pipe doesn't deadlock.
 73func handlerTest(t *testing.T, d *Daemon, req *daemonrpc.Request) daemonrpc.Message {
 74	t.Helper()
 75	clientConn, serverConn := net.Pipe()
 76	defer clientConn.Close()
 77	defer serverConn.Close()
 78
 79	server := daemonrpc.NewConn(serverConn)
 80	client := daemonrpc.NewConn(clientConn)
 81
 82	// Handle request in goroutine (SendResponse blocks until client reads).
 83	go func() {
 84		d.handleRequest(server, req)
 85	}()
 86
 87	msg, err := client.ReceiveMessage()
 88	if err != nil {
 89		t.Fatal(err)
 90	}
 91	return msg
 92}
 93
 94func TestDaemon_PingHandler(t *testing.T) {
 95	d := &Daemon{shutdown: make(chan struct{})}
 96	msg := handlerTest(t, d, &daemonrpc.Request{ID: 1, Method: daemonrpc.MethodPing})
 97
 98	if msg.Response == nil {
 99		t.Fatal("expected Response")
100	}
101	var result daemonrpc.PingResult
102	json.Unmarshal(msg.Response.Result, &result)
103	if !result.Pong {
104		t.Error("expected pong=true")
105	}
106}
107
108func TestDaemon_StatusHandler(t *testing.T) {
109	d := &Daemon{
110		startTime: time.Now().Add(-2 * time.Minute),
111		shutdown:  make(chan struct{}),
112		config:    &config.Config{},
113	}
114
115	msg := handlerTest(t, d, &daemonrpc.Request{ID: 1, Method: daemonrpc.MethodGetStatus})
116
117	var result daemonrpc.StatusResult
118	json.Unmarshal(msg.Response.Result, &result)
119
120	if !result.Running {
121		t.Error("expected running=true")
122	}
123	if result.Uptime < 120 {
124		t.Errorf("uptime = %d, want >= 120", result.Uptime)
125	}
126}
127
128func TestDaemon_UnknownMethod(t *testing.T) {
129	d := &Daemon{shutdown: make(chan struct{})}
130	msg := handlerTest(t, d, &daemonrpc.Request{ID: 1, Method: "DoesNotExist"})
131
132	if msg.Response.Error == nil {
133		t.Fatal("expected error for unknown method")
134	}
135	if msg.Response.Error.Code != daemonrpc.ErrCodeNotFound {
136		t.Errorf("code = %d, want %d", msg.Response.Error.Code, daemonrpc.ErrCodeNotFound)
137	}
138}
139
140func TestDaemon_Subscribe(t *testing.T) {
141	d := &Daemon{
142		subscriptions: make(map[*daemonrpc.Conn]map[string]struct{}),
143		shutdown:      make(chan struct{}),
144	}
145
146	clientConn, serverConn := net.Pipe()
147	defer clientConn.Close()
148	defer serverConn.Close()
149
150	server := daemonrpc.NewConn(serverConn)
151	client := daemonrpc.NewConn(clientConn)
152
153	params, _ := json.Marshal(daemonrpc.SubscribeParams{
154		AccountID: "acc1",
155		Folder:    "INBOX",
156	})
157
158	go func() {
159		d.handleRequest(server, &daemonrpc.Request{
160			ID:     1,
161			Method: daemonrpc.MethodSubscribe,
162			Params: params,
163		})
164	}()
165
166	// Read response.
167	msg, err := client.ReceiveMessage()
168	if err != nil {
169		t.Fatal(err)
170	}
171	if msg.Response.Error != nil {
172		t.Errorf("unexpected error: %v", msg.Response.Error)
173	}
174
175	// Verify subscription was recorded.
176	d.subMu.RLock()
177	subs, ok := d.subscriptions[server]
178	d.subMu.RUnlock()
179
180	if !ok {
181		t.Fatal("expected subscription entry for connection")
182	}
183	if _, ok := subs["acc1:INBOX"]; !ok {
184		t.Error("expected subscription for acc1:INBOX")
185	}
186}
187
188func TestDaemon_BroadcastEvent(t *testing.T) {
189	d := &Daemon{
190		clients:  make(map[*daemonrpc.Conn]struct{}),
191		shutdown: make(chan struct{}),
192	}
193
194	clientConn, serverConn := net.Pipe()
195	defer clientConn.Close()
196	defer serverConn.Close()
197
198	server := daemonrpc.NewConn(serverConn)
199	client := daemonrpc.NewConn(clientConn)
200
201	d.mu.Lock()
202	d.clients[server] = struct{}{}
203	d.mu.Unlock()
204
205	go func() {
206		d.broadcastEvent(daemonrpc.EventNewMail, daemonrpc.NewMailEvent{
207			AccountID: "acc1",
208			Folder:    "INBOX",
209		})
210	}()
211
212	msg, err := client.ReceiveMessage()
213	if err != nil {
214		t.Fatal(err)
215	}
216	if msg.Event == nil {
217		t.Fatal("expected Event")
218	}
219	if msg.Event.Type != daemonrpc.EventNewMail {
220		t.Errorf("type = %q, want NewMail", msg.Event.Type)
221	}
222}