blob: cd97b12cc0e51dc175f49eeb9c85673dc1b37ead [file] [log] [blame]
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001package server_test
2
3import (
4 "bufio"
5 "context"
6 "net/http"
7 "net/http/httptest"
8 "slices"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "sketch.dev/llm/conversation"
15 "sketch.dev/loop"
16 "sketch.dev/loop/server"
Philip Zeyliger5f26a342025-07-04 01:30:29 +000017 "tailscale.com/portlist"
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000018)
19
20// mockAgent is a mock implementation of loop.CodingAgent for testing
21type mockAgent struct {
Philip Zeyligereab12de2025-05-14 02:35:53 +000022 mu sync.RWMutex
23 messages []loop.AgentMessage
24 messageCount int
25 currentState string
26 subscribers []chan *loop.AgentMessage
27 stateTransitionListeners []chan loop.StateTransition
bankseancad67b02025-06-27 21:57:05 +000028 gitUsername string
Philip Zeyligereab12de2025-05-14 02:35:53 +000029 initialCommit string
Philip Zeyligereab12de2025-05-14 02:35:53 +000030 branchName string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000031 branchPrefix string
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000032 workingDir string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070033 sessionID string
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -070034 slug string
35 retryNumber int
Philip Zeyliger0113be52025-06-07 23:53:41 +000036 skabandAddr string
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000037}
38
39func (m *mockAgent) NewIterator(ctx context.Context, nextMessageIdx int) loop.MessageIterator {
40 m.mu.RLock()
41 // Send existing messages that should be available immediately
42 ch := make(chan *loop.AgentMessage, 100)
43 iter := &mockIterator{
44 agent: m,
45 ctx: ctx,
46 nextMessageIdx: nextMessageIdx,
47 ch: ch,
48 }
49 m.mu.RUnlock()
50 return iter
51}
52
53type mockIterator struct {
54 agent *mockAgent
55 ctx context.Context
56 nextMessageIdx int
57 ch chan *loop.AgentMessage
58 subscribed bool
59}
60
61func (m *mockIterator) Next() *loop.AgentMessage {
62 if !m.subscribed {
63 m.agent.mu.Lock()
64 m.agent.subscribers = append(m.agent.subscribers, m.ch)
65 m.agent.mu.Unlock()
66 m.subscribed = true
67 }
68
69 for {
70 select {
71 case <-m.ctx.Done():
72 return nil
73 case msg := <-m.ch:
74 return msg
75 }
76 }
77}
78
79func (m *mockIterator) Close() {
80 // Remove from subscribers using slices.Delete
81 m.agent.mu.Lock()
82 for i, ch := range m.agent.subscribers {
83 if ch == m.ch {
84 m.agent.subscribers = slices.Delete(m.agent.subscribers, i, i+1)
85 break
86 }
87 }
88 m.agent.mu.Unlock()
89 close(m.ch)
90}
91
92func (m *mockAgent) Messages(start int, end int) []loop.AgentMessage {
93 m.mu.RLock()
94 defer m.mu.RUnlock()
95
96 if start >= len(m.messages) || end > len(m.messages) || start < 0 || end < 0 {
97 return []loop.AgentMessage{}
98 }
99 return slices.Clone(m.messages[start:end])
100}
101
102func (m *mockAgent) MessageCount() int {
103 m.mu.RLock()
104 defer m.mu.RUnlock()
105 return m.messageCount
106}
107
108func (m *mockAgent) AddMessage(msg loop.AgentMessage) {
109 m.mu.Lock()
110 msg.Idx = m.messageCount
111 m.messages = append(m.messages, msg)
112 m.messageCount++
113
114 // Create a copy of subscribers to avoid holding the lock while sending
115 subscribers := make([]chan *loop.AgentMessage, len(m.subscribers))
116 copy(subscribers, m.subscribers)
117 m.mu.Unlock()
118
119 // Notify subscribers
120 msgCopy := msg // Create a copy to avoid race conditions
121 for _, ch := range subscribers {
122 ch <- &msgCopy
123 }
124}
125
Philip Zeyligereab12de2025-05-14 02:35:53 +0000126func (m *mockAgent) NewStateTransitionIterator(ctx context.Context) loop.StateTransitionIterator {
127 m.mu.Lock()
128 ch := make(chan loop.StateTransition, 10)
129 m.stateTransitionListeners = append(m.stateTransitionListeners, ch)
130 m.mu.Unlock()
131
132 return &mockStateTransitionIterator{
133 agent: m,
134 ctx: ctx,
135 ch: ch,
136 }
137}
138
139type mockStateTransitionIterator struct {
140 agent *mockAgent
141 ctx context.Context
142 ch chan loop.StateTransition
143}
144
145func (m *mockStateTransitionIterator) Next() *loop.StateTransition {
146 select {
147 case <-m.ctx.Done():
148 return nil
149 case transition, ok := <-m.ch:
150 if !ok {
151 return nil
152 }
153 transitionCopy := transition
154 return &transitionCopy
155 }
156}
157
158func (m *mockStateTransitionIterator) Close() {
159 m.agent.mu.Lock()
160 for i, ch := range m.agent.stateTransitionListeners {
161 if ch == m.ch {
162 m.agent.stateTransitionListeners = slices.Delete(m.agent.stateTransitionListeners, i, i+1)
163 break
164 }
165 }
166 m.agent.mu.Unlock()
167 close(m.ch)
168}
169
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000170func (m *mockAgent) CurrentStateName() string {
171 m.mu.RLock()
172 defer m.mu.RUnlock()
173 return m.currentState
174}
175
Philip Zeyligereab12de2025-05-14 02:35:53 +0000176func (m *mockAgent) TriggerStateTransition(from, to loop.State, event loop.TransitionEvent) {
177 m.mu.Lock()
178 m.currentState = to.String()
179 transition := loop.StateTransition{
180 From: from,
181 To: to,
182 Event: event,
183 }
184
185 // Create a copy of listeners to avoid holding the lock while sending
186 listeners := make([]chan loop.StateTransition, len(m.stateTransitionListeners))
187 copy(listeners, m.stateTransitionListeners)
188 m.mu.Unlock()
189
190 // Notify listeners
191 for _, ch := range listeners {
192 ch <- transition
193 }
194}
195
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000196func (m *mockAgent) InitialCommit() string {
197 m.mu.RLock()
198 defer m.mu.RUnlock()
199 return m.initialCommit
200}
201
Philip Zeyliger49edc922025-05-14 09:45:45 -0700202func (m *mockAgent) SketchGitBase() string {
203 m.mu.RLock()
204 defer m.mu.RUnlock()
205 return m.initialCommit
206}
207
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000208func (m *mockAgent) SketchGitBaseRef() string {
209 return "sketch-base-test-session"
210}
211
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000212func (m *mockAgent) BranchName() string {
213 m.mu.RLock()
214 defer m.mu.RUnlock()
215 return m.branchName
216}
217
218// Other required methods of loop.CodingAgent with minimal implementation
219func (m *mockAgent) Init(loop.AgentInit) error { return nil }
220func (m *mockAgent) Ready() <-chan struct{} { ch := make(chan struct{}); close(ch); return ch }
221func (m *mockAgent) URL() string { return "http://localhost:8080" }
222func (m *mockAgent) UserMessage(ctx context.Context, msg string) {}
223func (m *mockAgent) Loop(ctx context.Context) {}
224func (m *mockAgent) CancelTurn(cause error) {}
225func (m *mockAgent) CancelToolUse(id string, cause error) error { return nil }
226func (m *mockAgent) TotalUsage() conversation.CumulativeUsage { return conversation.CumulativeUsage{} }
227func (m *mockAgent) OriginalBudget() conversation.Budget { return conversation.Budget{} }
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000228func (m *mockAgent) WorkingDir() string { return m.workingDir }
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000229func (m *mockAgent) RepoRoot() string { return m.workingDir }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000230func (m *mockAgent) Diff(commit *string) (string, error) { return "", nil }
231func (m *mockAgent) OS() string { return "linux" }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700232func (m *mockAgent) SessionID() string { return m.sessionID }
philip.zeyliger8773e682025-06-11 21:36:21 -0700233func (m *mockAgent) SSHConnectionString() string { return "sketch-" + m.sessionID }
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000234func (m *mockAgent) BranchPrefix() string { return m.branchPrefix }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700235func (m *mockAgent) CurrentTodoContent() string { return "" } // Mock returns empty for simplicity
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000236func (m *mockAgent) OutstandingLLMCallCount() int { return 0 }
237func (m *mockAgent) OutstandingToolCalls() []string { return nil }
238func (m *mockAgent) OutsideOS() string { return "linux" }
239func (m *mockAgent) OutsideHostname() string { return "test-host" }
240func (m *mockAgent) OutsideWorkingDir() string { return "/app" }
241func (m *mockAgent) GitOrigin() string { return "" }
bankseancad67b02025-06-27 21:57:05 +0000242func (m *mockAgent) GitUsername() string { return m.gitUsername }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000243func (m *mockAgent) OpenBrowser(url string) {}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700244func (m *mockAgent) CompactConversation(ctx context.Context) error {
245 // Mock implementation - just return nil
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000246 return nil
247}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700248func (m *mockAgent) IsInContainer() bool { return false }
249func (m *mockAgent) FirstMessageIndex() int { return 0 }
250func (m *mockAgent) DetectGitChanges(ctx context.Context) error { return nil }
Philip Zeyligerb5739402025-06-02 07:04:34 -0700251
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700252func (m *mockAgent) Slug() string {
253 m.mu.RLock()
254 defer m.mu.RUnlock()
255 return m.slug
256}
257
258func (m *mockAgent) IncrementRetryNumber() {
259 m.mu.Lock()
260 defer m.mu.Unlock()
261 m.retryNumber++
262}
263
Philip Zeyligerda623b52025-07-04 01:12:38 +0000264func (m *mockAgent) SkabandAddr() string { return m.skabandAddr }
265func (m *mockAgent) LinkToGitHub() bool { return false }
266func (m *mockAgent) DiffStats() (int, int) { return 0, 0 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000267func (m *mockAgent) GetPorts() []portlist.Port {
268 // Mock returns a few test ports
269 return []portlist.Port{
270 {Proto: "tcp", Port: 22, Process: "sshd", Pid: 1234},
271 {Proto: "tcp", Port: 80, Process: "nginx", Pid: 5678},
272 {Proto: "tcp", Port: 8080, Process: "test-server", Pid: 9012},
273 }
274}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700275
276// TestSSEStream tests the SSE stream endpoint
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000277func TestSSEStream(t *testing.T) {
278 // Create a mock agent with initial messages
279 mockAgent := &mockAgent{
Philip Zeyligereab12de2025-05-14 02:35:53 +0000280 messages: []loop.AgentMessage{},
281 messageCount: 0,
282 currentState: "Ready",
283 subscribers: []chan *loop.AgentMessage{},
284 stateTransitionListeners: []chan loop.StateTransition{},
285 initialCommit: "abcd1234",
Philip Zeyligereab12de2025-05-14 02:35:53 +0000286 branchName: "sketch/test-branch",
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000287 branchPrefix: "sketch/",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700288 slug: "test-slug",
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000289 }
290
291 // Add the initial messages before creating the server
292 // to ensure they're available in the Messages slice
293 msg1 := loop.AgentMessage{
294 Type: loop.UserMessageType,
295 Content: "Hello, this is a test message",
296 Timestamp: time.Now(),
297 }
298 mockAgent.messages = append(mockAgent.messages, msg1)
299 msg1.Idx = mockAgent.messageCount
300 mockAgent.messageCount++
301
302 msg2 := loop.AgentMessage{
303 Type: loop.AgentMessageType,
304 Content: "This is a response message",
305 Timestamp: time.Now(),
306 EndOfTurn: true,
307 }
308 mockAgent.messages = append(mockAgent.messages, msg2)
309 msg2.Idx = mockAgent.messageCount
310 mockAgent.messageCount++
311
312 // Create a server with the mock agent
313 srv, err := server.New(mockAgent, nil)
314 if err != nil {
315 t.Fatalf("Failed to create server: %v", err)
316 }
317
318 // Create a test server
319 ts := httptest.NewServer(srv)
320 defer ts.Close()
321
322 // Create a context with cancellation for the client request
323 ctx, cancel := context.WithCancel(context.Background())
324
325 // Create a request to the /stream endpoint
326 req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/stream?from=0", nil)
327 if err != nil {
328 t.Fatalf("Failed to create request: %v", err)
329 }
330
331 // Execute the request
332 res, err := http.DefaultClient.Do(req)
333 if err != nil {
334 t.Fatalf("Failed to execute request: %v", err)
335 }
336 defer res.Body.Close()
337
338 // Check response status
339 if res.StatusCode != http.StatusOK {
340 t.Fatalf("Expected status OK, got %v", res.Status)
341 }
342
343 // Check content type
344 if contentType := res.Header.Get("Content-Type"); contentType != "text/event-stream" {
345 t.Fatalf("Expected Content-Type text/event-stream, got %s", contentType)
346 }
347
348 // Read response events using a scanner
349 scanner := bufio.NewScanner(res.Body)
350
351 // Track events received
352 eventsReceived := map[string]int{
353 "state": 0,
354 "message": 0,
355 "heartbeat": 0,
356 }
357
358 // Read for a short time to capture initial state and messages
359 dataLines := []string{}
360 eventType := ""
361
362 go func() {
363 // After reading for a while, add a new message to test real-time updates
364 time.Sleep(500 * time.Millisecond)
365
366 mockAgent.AddMessage(loop.AgentMessage{
367 Type: loop.ToolUseMessageType,
368 Content: "This is a new real-time message",
369 Timestamp: time.Now(),
370 ToolName: "test_tool",
371 })
372
Philip Zeyligereab12de2025-05-14 02:35:53 +0000373 // Trigger a state transition to test state updates
374 time.Sleep(200 * time.Millisecond)
375 mockAgent.TriggerStateTransition(loop.StateReady, loop.StateSendingToLLM, loop.TransitionEvent{
376 Description: "Agent started thinking",
377 Data: "start_thinking",
378 })
379
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000380 // Let it process for longer
381 time.Sleep(1000 * time.Millisecond)
382 cancel() // Cancel to end the test
383 }()
384
385 // Read events
386 for scanner.Scan() {
387 line := scanner.Text()
388
389 if strings.HasPrefix(line, "event: ") {
390 eventType = strings.TrimPrefix(line, "event: ")
391 eventsReceived[eventType]++
392 } else if strings.HasPrefix(line, "data: ") {
393 dataLines = append(dataLines, line)
394 } else if line == "" && eventType != "" {
395 // End of event
396 eventType = ""
397 }
398
399 // Break if context is done
400 if ctx.Err() != nil {
401 break
402 }
403 }
404
405 if err := scanner.Err(); err != nil && ctx.Err() == nil {
406 t.Fatalf("Scanner error: %v", err)
407 }
408
409 // Simplified validation - just make sure we received something
410 t.Logf("Events received: %v", eventsReceived)
411 t.Logf("Data lines received: %d", len(dataLines))
412
413 // Basic validation that we received at least some events
414 if eventsReceived["state"] == 0 && eventsReceived["message"] == 0 {
415 t.Errorf("Did not receive any events")
416 }
417}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000418
419func TestGitRawDiffHandler(t *testing.T) {
420 // Create a mock agent
421 mockAgent := &mockAgent{
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000422 workingDir: t.TempDir(), // Use a temp directory
423 branchPrefix: "sketch/",
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000424 }
425
426 // Create the server with the mock agent
427 server, err := server.New(mockAgent, nil)
428 if err != nil {
429 t.Fatalf("Failed to create server: %v", err)
430 }
431
432 // Create a test HTTP server
433 testServer := httptest.NewServer(server)
434 defer testServer.Close()
435
436 // Test missing parameters
437 resp, err := http.Get(testServer.URL + "/git/rawdiff")
438 if err != nil {
439 t.Fatalf("Failed to make HTTP request: %v", err)
440 }
441 if resp.StatusCode != http.StatusBadRequest {
442 t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
443 }
444
445 // Test with commit parameter (this will fail due to no git repo, but we're testing the API, not git)
446 resp, err = http.Get(testServer.URL + "/git/rawdiff?commit=HEAD")
447 if err != nil {
448 t.Fatalf("Failed to make HTTP request: %v", err)
449 }
450 // We expect an error since there's no git repository, but the request should be processed
451 if resp.StatusCode != http.StatusInternalServerError {
452 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
453 }
454
455 // Test with from/to parameters
456 resp, err = http.Get(testServer.URL + "/git/rawdiff?from=HEAD~1&to=HEAD")
457 if err != nil {
458 t.Fatalf("Failed to make HTTP request: %v", err)
459 }
460 // We expect an error since there's no git repository, but the request should be processed
461 if resp.StatusCode != http.StatusInternalServerError {
462 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
463 }
464}
465
466func TestGitShowHandler(t *testing.T) {
467 // Create a mock agent
468 mockAgent := &mockAgent{
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000469 workingDir: t.TempDir(), // Use a temp directory
470 branchPrefix: "sketch/",
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000471 }
472
473 // Create the server with the mock agent
474 server, err := server.New(mockAgent, nil)
475 if err != nil {
476 t.Fatalf("Failed to create server: %v", err)
477 }
478
479 // Create a test HTTP server
480 testServer := httptest.NewServer(server)
481 defer testServer.Close()
482
483 // Test missing parameter
484 resp, err := http.Get(testServer.URL + "/git/show")
485 if err != nil {
486 t.Fatalf("Failed to make HTTP request: %v", err)
487 }
488 if resp.StatusCode != http.StatusBadRequest {
489 t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
490 }
491
492 // Test with hash parameter (this will fail due to no git repo, but we're testing the API, not git)
493 resp, err = http.Get(testServer.URL + "/git/show?hash=HEAD")
494 if err != nil {
495 t.Fatalf("Failed to make HTTP request: %v", err)
496 }
497 // We expect an error since there's no git repository, but the request should be processed
498 if resp.StatusCode != http.StatusInternalServerError {
499 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
500 }
501}
502
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700503func TestCompactHandler(t *testing.T) {
504 // Test that mock CompactConversation works
505 mockAgent := &mockAgent{
506 messages: []loop.AgentMessage{},
507 messageCount: 0,
508 sessionID: "test-session",
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000509 branchPrefix: "sketch/",
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700510 }
511
512 ctx := context.Background()
513 err := mockAgent.CompactConversation(ctx)
514 if err != nil {
515 t.Errorf("Mock CompactConversation failed: %v", err)
516 }
517
518 // No HTTP endpoint to test anymore - compaction is done via /compact message
519 t.Log("Mock CompactConversation works correctly")
520}
Sean McCullough138ec242025-06-02 22:42:06 +0000521
Philip Zeyligera9710d72025-07-02 02:50:14 +0000522func TestParsePortProxyHost(t *testing.T) {
523 tests := []struct {
524 name string
525 host string
526 wantPort string
527 }{
528 {
529 name: "valid port proxy host",
530 host: "p8000.localhost",
531 wantPort: "8000",
532 },
533 {
534 name: "valid port proxy host with port suffix",
535 host: "p8000.localhost:8080",
536 wantPort: "8000",
537 },
538 {
539 name: "different port",
540 host: "p3000.localhost",
541 wantPort: "3000",
542 },
543 {
544 name: "regular localhost",
545 host: "localhost",
546 wantPort: "",
547 },
548 {
549 name: "different domain",
550 host: "p8000.example.com",
551 wantPort: "",
552 },
553 {
554 name: "missing p prefix",
555 host: "8000.localhost",
556 wantPort: "",
557 },
558 {
559 name: "invalid port",
560 host: "pabc.localhost",
561 wantPort: "",
562 },
563 {
564 name: "just p prefix",
565 host: "p.localhost",
566 wantPort: "",
567 },
568 {
569 name: "port too high",
570 host: "p99999.localhost",
571 wantPort: "",
572 },
573 {
574 name: "port zero",
575 host: "p0.localhost",
576 wantPort: "",
577 },
578 {
579 name: "negative port",
580 host: "p-1.localhost",
581 wantPort: "",
582 },
583 }
584
585 // Create a test server to access the method
586 s, err := server.New(nil, nil)
587 if err != nil {
588 t.Fatalf("Failed to create server: %v", err)
589 }
590
591 for _, tt := range tests {
592 t.Run(tt.name, func(t *testing.T) {
593 gotPort := s.ParsePortProxyHost(tt.host)
594 if gotPort != tt.wantPort {
595 t.Errorf("parsePortProxyHost(%q) = %q, want %q", tt.host, gotPort, tt.wantPort)
596 }
597 })
598 }
599}
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000600
601// TestStateEndpointIncludesPorts tests that the /state endpoint includes port information
602func TestStateEndpointIncludesPorts(t *testing.T) {
603 mockAgent := &mockAgent{
604 messages: []loop.AgentMessage{},
605 messageCount: 0,
606 currentState: "initial",
607 subscribers: []chan *loop.AgentMessage{},
608 gitUsername: "test-user",
609 initialCommit: "abc123",
610 branchName: "test-branch",
611 branchPrefix: "test-",
612 workingDir: "/tmp/test",
613 sessionID: "test-session",
614 slug: "test-slug",
615 skabandAddr: "http://localhost:8080",
616 }
617
618 // Create a test server
619 server, err := server.New(mockAgent, nil)
620 if err != nil {
621 t.Fatal(err)
622 }
623
624 // Create a test request to the /state endpoint
625 req, err := http.NewRequest("GET", "/state", nil)
626 if err != nil {
627 t.Fatal(err)
628 }
629
630 // Create a response recorder
631 rr := httptest.NewRecorder()
632
633 // Execute the request
634 server.ServeHTTP(rr, req)
635
636 // Check the response
637 if status := rr.Code; status != http.StatusOK {
638 t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
639 }
640
641 // Check that the response contains port information
642 responseBody := rr.Body.String()
643 t.Logf("Response body: %s", responseBody)
644
645 // Verify the response contains the expected ports
646 if !strings.Contains(responseBody, `"open_ports"`) {
647 t.Error("Response should contain 'open_ports' field")
648 }
649
650 if !strings.Contains(responseBody, `"port": 22`) {
651 t.Error("Response should contain port 22 from mock")
652 }
653
654 if !strings.Contains(responseBody, `"port": 80`) {
655 t.Error("Response should contain port 80 from mock")
656 }
657
658 if !strings.Contains(responseBody, `"port": 8080`) {
659 t.Error("Response should contain port 8080 from mock")
660 }
661
662 if !strings.Contains(responseBody, `"process": "sshd"`) {
663 t.Error("Response should contain process name 'sshd'")
664 }
665
666 if !strings.Contains(responseBody, `"process": "nginx"`) {
667 t.Error("Response should contain process name 'nginx'")
668 }
669
670 if !strings.Contains(responseBody, `"proto": "tcp"`) {
671 t.Error("Response should contain protocol 'tcp'")
672 }
673
674 t.Log("State endpoint includes port information correctly")
675}