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 if err := json.Unmarshal(msg.Response.Result, &result); err != nil {
103 t.Fatalf("failed to unmarshal ping result: %v", err)
104 }
105 if !result.Pong {
106 t.Error("expected pong=true")
107 }
108}
109
110func TestDaemon_StatusHandler(t *testing.T) {
111 d := &Daemon{
112 startTime: time.Now().Add(-2 * time.Minute),
113 shutdown: make(chan struct{}),
114 config: &config.Config{},
115 }
116
117 msg := handlerTest(t, d, &daemonrpc.Request{ID: 1, Method: daemonrpc.MethodGetStatus})
118
119 var result daemonrpc.StatusResult
120 if err := json.Unmarshal(msg.Response.Result, &result); err != nil {
121 t.Fatalf("failed to unmarshal status result: %v", err)
122 }
123
124 if !result.Running {
125 t.Error("expected running=true")
126 }
127 if result.Uptime < 120 {
128 t.Errorf("uptime = %d, want >= 120", result.Uptime)
129 }
130}
131
132func TestDaemon_UnknownMethod(t *testing.T) {
133 d := &Daemon{shutdown: make(chan struct{})}
134 msg := handlerTest(t, d, &daemonrpc.Request{ID: 1, Method: "DoesNotExist"})
135
136 if msg.Response.Error == nil {
137 t.Fatal("expected error for unknown method")
138 }
139 if msg.Response.Error.Code != daemonrpc.ErrCodeNotFound {
140 t.Errorf("code = %d, want %d", msg.Response.Error.Code, daemonrpc.ErrCodeNotFound)
141 }
142}
143
144func TestDaemon_Subscribe(t *testing.T) {
145 d := &Daemon{
146 subscriptions: make(map[*daemonrpc.Conn]map[string]struct{}),
147 shutdown: make(chan struct{}),
148 }
149
150 clientConn, serverConn := net.Pipe()
151 defer clientConn.Close()
152 defer serverConn.Close()
153
154 server := daemonrpc.NewConn(serverConn)
155 client := daemonrpc.NewConn(clientConn)
156
157 params, _ := json.Marshal(daemonrpc.SubscribeParams{
158 AccountID: "acc1",
159 Folder: "INBOX",
160 })
161
162 go func() {
163 d.handleRequest(server, &daemonrpc.Request{
164 ID: 1,
165 Method: daemonrpc.MethodSubscribe,
166 Params: params,
167 })
168 }()
169
170 // Read response.
171 msg, err := client.ReceiveMessage()
172 if err != nil {
173 t.Fatal(err)
174 }
175 if msg.Response.Error != nil {
176 t.Errorf("unexpected error: %v", msg.Response.Error)
177 }
178
179 // Verify subscription was recorded.
180 d.subMu.RLock()
181 subs, ok := d.subscriptions[server]
182 d.subMu.RUnlock()
183
184 if !ok {
185 t.Fatal("expected subscription entry for connection")
186 }
187 if _, ok := subs["acc1:INBOX"]; !ok {
188 t.Error("expected subscription for acc1:INBOX")
189 }
190}
191
192func TestDaemon_BroadcastEvent(t *testing.T) {
193 d := &Daemon{
194 clients: make(map[*daemonrpc.Conn]struct{}),
195 shutdown: make(chan struct{}),
196 }
197
198 clientConn, serverConn := net.Pipe()
199 defer clientConn.Close()
200 defer serverConn.Close()
201
202 server := daemonrpc.NewConn(serverConn)
203 client := daemonrpc.NewConn(clientConn)
204
205 d.mu.Lock()
206 d.clients[server] = struct{}{}
207 d.mu.Unlock()
208
209 go func() {
210 d.broadcastEvent(daemonrpc.EventNewMail, daemonrpc.NewMailEvent{
211 AccountID: "acc1",
212 Folder: "INBOX",
213 })
214 }()
215
216 msg, err := client.ReceiveMessage()
217 if err != nil {
218 t.Fatal(err)
219 }
220 if msg.Event == nil {
221 t.Fatal("expected Event")
222 }
223 if msg.Event.Type != daemonrpc.EventNewMail {
224 t.Errorf("type = %q, want NewMail", msg.Event.Type)
225 }
226}