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}