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}