- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
188 lines
4.3 KiB
Go
188 lines
4.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// recorderFlusher wraps httptest.ResponseRecorder to satisfy http.Flusher.
|
|
type recorderFlusher struct {
|
|
*httptest.ResponseRecorder
|
|
}
|
|
|
|
func (rf *recorderFlusher) Flush() {}
|
|
|
|
func newRecorderFlusher() *recorderFlusher {
|
|
return &recorderFlusher{httptest.NewRecorder()}
|
|
}
|
|
|
|
func TestStreamManager_SubscribeAndSend(t *testing.T) {
|
|
sm := newStreamManager()
|
|
|
|
ch := sm.Subscribe("stream-1")
|
|
defer sm.Unsubscribe("stream-1", ch)
|
|
|
|
// Send event
|
|
sm.Send("stream-1", "output", map[string]any{"line": "hello"})
|
|
|
|
select {
|
|
case evt := <-ch:
|
|
if evt.Type != "output" {
|
|
t.Errorf("event type = %q, want %q", evt.Type, "output")
|
|
}
|
|
if evt.Data["line"] != "hello" {
|
|
t.Errorf("event data = %v, want line=hello", evt.Data)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for event")
|
|
}
|
|
}
|
|
|
|
func TestStreamManager_MultipleSubscribers(t *testing.T) {
|
|
sm := newStreamManager()
|
|
|
|
ch1 := sm.Subscribe("stream-1")
|
|
ch2 := sm.Subscribe("stream-1")
|
|
defer sm.Unsubscribe("stream-1", ch1)
|
|
defer sm.Unsubscribe("stream-1", ch2)
|
|
|
|
sm.Send("stream-1", "test", map[string]any{"value": 1})
|
|
|
|
// Both should receive
|
|
for i, ch := range []chan streamEvent{ch1, ch2} {
|
|
select {
|
|
case evt := <-ch:
|
|
if evt.Type != "test" {
|
|
t.Errorf("subscriber %d: event type = %q, want %q", i, evt.Type, "test")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("subscriber %d: timed out", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStreamManager_Close(t *testing.T) {
|
|
sm := newStreamManager()
|
|
|
|
ch := sm.Subscribe("stream-1")
|
|
|
|
sm.Close("stream-1")
|
|
|
|
// Channel should be closed
|
|
_, ok := <-ch
|
|
if ok {
|
|
t.Error("channel should be closed after Close()")
|
|
}
|
|
}
|
|
|
|
func TestStreamManager_SendToNonexistentStream(t *testing.T) {
|
|
sm := newStreamManager()
|
|
// Should not panic
|
|
sm.Send("nonexistent", "test", map[string]any{})
|
|
}
|
|
|
|
func TestStreamManager_Unsubscribe(t *testing.T) {
|
|
sm := newStreamManager()
|
|
|
|
ch1 := sm.Subscribe("stream-1")
|
|
ch2 := sm.Subscribe("stream-1")
|
|
|
|
sm.Unsubscribe("stream-1", ch1)
|
|
|
|
// ch1 should be closed
|
|
_, ok := <-ch1
|
|
if ok {
|
|
t.Error("ch1 should be closed after Unsubscribe")
|
|
}
|
|
|
|
// ch2 should still receive
|
|
sm.Send("stream-1", "test", map[string]any{})
|
|
select {
|
|
case evt := <-ch2:
|
|
if evt.Type != "test" {
|
|
t.Errorf("event type = %q, want %q", evt.Type, "test")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("ch2 timed out")
|
|
}
|
|
|
|
sm.Unsubscribe("stream-1", ch2)
|
|
}
|
|
|
|
func TestWriteSSE(t *testing.T) {
|
|
rf := newRecorderFlusher()
|
|
|
|
writeSSE(rf.ResponseRecorder, rf, "output", map[string]any{"line": "hello"})
|
|
|
|
body := rf.Body.String()
|
|
if !strings.Contains(body, "event: output\n") {
|
|
t.Errorf("missing event line in SSE output: %s", body)
|
|
}
|
|
if !strings.Contains(body, "data: ") {
|
|
t.Errorf("missing data line in SSE output: %s", body)
|
|
}
|
|
// Should not have id line
|
|
if strings.Contains(body, "id: ") {
|
|
t.Errorf("should not have id line without ID: %s", body)
|
|
}
|
|
|
|
// Verify data is valid JSON
|
|
lines := strings.Split(body, "\n")
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "data: ") {
|
|
jsonStr := strings.TrimPrefix(line, "data: ")
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
|
|
t.Errorf("data is not valid JSON: %v", err)
|
|
}
|
|
if parsed["line"] != "hello" {
|
|
t.Errorf("data[line] = %v, want hello", parsed["line"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWriteSSEWithID(t *testing.T) {
|
|
rf := newRecorderFlusher()
|
|
|
|
writeSSEWithID(rf.ResponseRecorder, rf, "evt-123", "complete", map[string]any{"exit_code": 0})
|
|
|
|
body := rf.Body.String()
|
|
if !strings.Contains(body, "id: evt-123\n") {
|
|
t.Errorf("missing id line in SSE output: %s", body)
|
|
}
|
|
if !strings.Contains(body, "event: complete\n") {
|
|
t.Errorf("missing event line in SSE output: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestStreamManager_FullChannel(t *testing.T) {
|
|
sm := newStreamManager()
|
|
|
|
ch := sm.Subscribe("stream-1")
|
|
|
|
// Fill the channel (buffer size is 100)
|
|
for i := 0; i < 100; i++ {
|
|
sm.Send("stream-1", "test", map[string]any{"i": i})
|
|
}
|
|
|
|
// Next send should not block (dropped)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
sm.Send("stream-1", "test", map[string]any{"i": 100})
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Good - did not block
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Send blocked on full channel")
|
|
}
|
|
|
|
sm.Unsubscribe("stream-1", ch)
|
|
}
|