// Package fixtures provides shared test utilities for all tests package tests import ( "encoding/json" "fmt" "net/http" "os" "sync" "testing" "time" "github.com/gorilla/websocket" "github.com/jfraeys/fetch_ml/internal/scheduler" "github.com/stretchr/testify/require" ) // MockWorker simulates a worker connection for testing type MockWorker struct { Conn *websocket.Conn ID string RecvCh chan scheduler.Message SendCh chan scheduler.Message Done chan struct{} // Closed when worker disconnects wg sync.WaitGroup mu sync.RWMutex closed bool T testing.TB } // NewMockWorker creates a new mock worker connected to the scheduler func NewMockWorker(t testing.TB, hub *scheduler.SchedulerHub, workerID string) *MockWorker { addr := hub.Addr() require.NotEmpty(t, addr, "hub not started") // Use correct protocol based on hub TLS configuration protocol := "ws" if hub.IsUsingTLS() { protocol = "wss" } wsURL := protocol + "://" + addr + "/ws/worker" // Add test token to headers header := http.Header{} header.Set("Authorization", "Bearer test-token-"+workerID) conn, _, err := websocket.DefaultDialer.Dial(wsURL, header) require.NoError(t, err) mw := &MockWorker{ Conn: conn, ID: workerID, RecvCh: make(chan scheduler.Message, 100), SendCh: make(chan scheduler.Message, 100), Done: make(chan struct{}), T: t, } // Start receive goroutine mw.wg.Go(func() { defer close(mw.Done) // Signal disconnect when goroutine exits for { var msg scheduler.Message err := conn.ReadJSON(&msg) if err != nil { close(mw.RecvCh) return } mw.RecvCh <- msg } }) // Start send goroutine mw.wg.Go(func() { for msg := range mw.SendCh { if err := conn.WriteJSON(msg); err != nil { return } } }) return mw } // Register sends worker registration message and waits for ack func (mw *MockWorker) Register(capabilities scheduler.WorkerCapabilities) { mw.Send(scheduler.Message{ Type: scheduler.MsgRegister, Payload: MustMarshal(scheduler.WorkerRegistration{ ID: mw.ID, Capabilities: capabilities, }), }) msg := mw.RecvTimeout(2 * time.Second) require.Equal(mw.T, scheduler.MsgAck, msg.Type, "expected registration ack") } // Send sends a message to the scheduler func (mw *MockWorker) Send(msg scheduler.Message) { select { case mw.SendCh <- msg: case <-time.After(time.Second): mw.T.Fatal("timeout sending message") } } // Recv receives a message from the scheduler (blocks) func (mw *MockWorker) Recv() scheduler.Message { select { case msg := <-mw.RecvCh: return msg case <-time.After(5 * time.Second): require.Fail(mw.T, "timeout waiting for message") return scheduler.Message{Type: "timeout"} } } // RecvTimeout receives a message with a custom timeout func (mw *MockWorker) RecvTimeout(timeout time.Duration) scheduler.Message { select { case msg := <-mw.RecvCh: return msg case <-time.After(timeout): require.Fail(mw.T, "timeout waiting for message") return scheduler.Message{Type: "timeout"} } } // RecvNonBlock tries to receive without blocking func (mw *MockWorker) RecvNonBlock() (scheduler.Message, bool) { select { case msg := <-mw.RecvCh: return msg, true default: return scheduler.Message{}, false } } // SignalReady sends ready for work message func (mw *MockWorker) SignalReady(slots scheduler.SlotStatus, reason string) { mw.Send(scheduler.Message{ Type: scheduler.MsgReadyForWork, Payload: MustMarshal(scheduler.ReadyPayload{ WorkerID: mw.ID, Slots: slots, Reason: reason, }), }) } // SendHeartbeat sends a heartbeat message func (mw *MockWorker) SendHeartbeat(slots scheduler.SlotStatus) { mw.Send(scheduler.Message{ Type: scheduler.MsgHeartbeat, Payload: MustMarshal(scheduler.HeartbeatPayload{ WorkerID: mw.ID, Slots: slots, }), }) } // AcceptJob accepts a job assignment func (mw *MockWorker) AcceptJob(taskID string) { mw.Send(scheduler.Message{ Type: scheduler.MsgJobAccepted, Payload: MustMarshal(scheduler.JobResultPayload{ TaskID: taskID, State: "accepted", }), }) } // CompleteJob sends job completion func (mw *MockWorker) CompleteJob(taskID string, exitCode int, output string) { mw.Send(scheduler.Message{ Type: scheduler.MsgJobResult, Payload: MustMarshal(scheduler.JobResultPayload{ TaskID: taskID, State: "completed", ExitCode: exitCode, Error: output, }), }) } // SendHealth sends service health update func (mw *MockWorker) SendHealth(taskID string, healthy bool, message string) { mw.Send(scheduler.Message{ Type: scheduler.MsgServiceHealth, Payload: MustMarshal(scheduler.ServiceHealthPayload{ TaskID: taskID, Healthy: healthy, Message: message, }), }) } // Close closes the worker connection func (mw *MockWorker) Close() { mw.mu.Lock() if mw.closed { mw.mu.Unlock() return } mw.closed = true mw.mu.Unlock() close(mw.SendCh) if err := mw.Conn.Close(); err != nil { // Log but don't fail in cleanup fmt.Fprintf(os.Stderr, "failed to close connection: %v\n", err) } mw.wg.Wait() } // WaitForDisconnect waits for the connection to close func (mw *MockWorker) WaitForDisconnect(timeout time.Duration) bool { done := make(chan struct{}) go func() { mw.wg.Wait() close(done) }() select { case <-done: return true case <-time.After(timeout): return false } } // MustMarshal marshals a value to JSON, panicking on error func MustMarshal(v any) []byte { b, _ := json.Marshal(v) return b }