multiclient_test.go

  1package server
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"net/http"
  8	"net/http/httptest"
  9	"testing"
 10
 11	"github.com/charmbracelet/crush/internal/backend"
 12	"github.com/charmbracelet/crush/internal/proto"
 13	"github.com/google/uuid"
 14	"github.com/stretchr/testify/require"
 15)
 16
 17// installSyntheticWorkspace creates a synthetic [backend.Workspace]
 18// registered with the controller's backend, suitable for handler-level
 19// tests that do not need a real [app.App]. The workspace's ID is a
 20// fresh UUID and its path is a tempdir; teardown is the caller's
 21// responsibility (handlers should not rely on synthetic workspaces
 22// disappearing automatically).
 23func installSyntheticWorkspace(t *testing.T, c *controllerV1) *backend.Workspace {
 24	t.Helper()
 25	ws := &backend.Workspace{
 26		ID:   uuid.New().String(),
 27		Path: t.TempDir(),
 28	}
 29	backend.InsertWorkspaceForTest(c.backend, ws)
 30	return ws
 31}
 32
 33// newTestController builds a controllerV1 around a backend without a
 34// real config store, suitable for handler-level 400 tests.
 35func newTestController() *controllerV1 {
 36	s := &Server{}
 37	s.backend = backend.New(context.Background(), nil, nil)
 38	return &controllerV1{backend: s.backend, server: s}
 39}
 40
 41func TestPostWorkspaces_RejectsMissingClientID(t *testing.T) {
 42	t.Parallel()
 43	c := newTestController()
 44
 45	body, err := json.Marshal(proto.Workspace{Path: t.TempDir()})
 46	require.NoError(t, err)
 47	req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/workspaces", bytes.NewReader(body))
 48	req.Header.Set("Content-Type", "application/json")
 49	rec := httptest.NewRecorder()
 50
 51	c.handlePostWorkspaces(rec, req)
 52
 53	require.Equal(t, http.StatusBadRequest, rec.Code)
 54	var perr proto.Error
 55	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &perr))
 56	require.Contains(t, perr.Message, "client_id")
 57}
 58
 59func TestPostWorkspaces_RejectsMalformedClientID(t *testing.T) {
 60	t.Parallel()
 61	c := newTestController()
 62
 63	body, err := json.Marshal(proto.Workspace{Path: t.TempDir(), ClientID: "not-a-uuid"})
 64	require.NoError(t, err)
 65	req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/workspaces", bytes.NewReader(body))
 66	req.Header.Set("Content-Type", "application/json")
 67	rec := httptest.NewRecorder()
 68
 69	c.handlePostWorkspaces(rec, req)
 70
 71	require.Equal(t, http.StatusBadRequest, rec.Code)
 72}
 73
 74func TestDeleteWorkspace_RejectsMissingClientID(t *testing.T) {
 75	t.Parallel()
 76	c := newTestController()
 77
 78	req := httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/v1/workspaces/abc", nil)
 79	req.SetPathValue("id", "abc")
 80	rec := httptest.NewRecorder()
 81
 82	c.handleDeleteWorkspaces(rec, req)
 83
 84	require.Equal(t, http.StatusBadRequest, rec.Code)
 85}
 86
 87func TestDeleteWorkspace_RejectsMalformedClientID(t *testing.T) {
 88	t.Parallel()
 89	c := newTestController()
 90
 91	req := httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/v1/workspaces/abc?client_id=nope", nil)
 92	req.SetPathValue("id", "abc")
 93	rec := httptest.NewRecorder()
 94
 95	c.handleDeleteWorkspaces(rec, req)
 96
 97	require.Equal(t, http.StatusBadRequest, rec.Code)
 98}
 99
100func TestSubscribeEvents_RejectsMissingClientID(t *testing.T) {
101	t.Parallel()
102	c := newTestController()
103
104	req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/workspaces/abc/events", nil)
105	req.SetPathValue("id", "abc")
106	rec := httptest.NewRecorder()
107
108	c.handleGetWorkspaceEvents(rec, req)
109
110	require.Equal(t, http.StatusBadRequest, rec.Code)
111}
112
113func TestSubscribeEvents_RejectsMalformedClientID(t *testing.T) {
114	t.Parallel()
115	c := newTestController()
116
117	req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/workspaces/abc/events?client_id=nope", nil)
118	req.SetPathValue("id", "abc")
119	rec := httptest.NewRecorder()
120
121	c.handleGetWorkspaceEvents(rec, req)
122
123	require.Equal(t, http.StatusBadRequest, rec.Code)
124}
125
126// postCurrentSession is a small helper that POSTs the JSON body to
127// /v1/workspaces/{id}/current-session?client_id=cid and returns the
128// recorder. It does not require a real listener.
129func postCurrentSession(t *testing.T, c *controllerV1, wsID, clientID, sessionID string) *httptest.ResponseRecorder {
130	t.Helper()
131	body, err := json.Marshal(proto.CurrentSession{SessionID: sessionID})
132	require.NoError(t, err)
133	url := "/v1/workspaces/" + wsID + "/current-session"
134	if clientID != "" {
135		url += "?client_id=" + clientID
136	}
137	req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader(body))
138	req.SetPathValue("id", wsID)
139	req.Header.Set("Content-Type", "application/json")
140	rec := httptest.NewRecorder()
141	c.handlePostWorkspaceCurrentSession(rec, req)
142	return rec
143}
144
145func TestPostCurrentSession_RejectsMissingClientID(t *testing.T) {
146	t.Parallel()
147	c := newTestController()
148
149	body, err := json.Marshal(proto.CurrentSession{SessionID: "S1"})
150	require.NoError(t, err)
151	req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/workspaces/abc/current-session", bytes.NewReader(body))
152	req.SetPathValue("id", "abc")
153	req.Header.Set("Content-Type", "application/json")
154	rec := httptest.NewRecorder()
155
156	c.handlePostWorkspaceCurrentSession(rec, req)
157
158	require.Equal(t, http.StatusBadRequest, rec.Code)
159}
160
161func TestPostCurrentSession_RejectsMalformedClientID(t *testing.T) {
162	t.Parallel()
163	c := newTestController()
164
165	rec := postCurrentSession(t, c, "abc", "not-a-uuid", "S1")
166	require.Equal(t, http.StatusBadRequest, rec.Code)
167}
168
169func TestPostCurrentSession_RejectsBadBody(t *testing.T) {
170	t.Parallel()
171	c := newTestController()
172
173	cid := uuid.New().String()
174	url := "/v1/workspaces/abc/current-session?client_id=" + cid
175	req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader([]byte("not-json")))
176	req.SetPathValue("id", "abc")
177	req.Header.Set("Content-Type", "application/json")
178	rec := httptest.NewRecorder()
179
180	c.handlePostWorkspaceCurrentSession(rec, req)
181
182	require.Equal(t, http.StatusBadRequest, rec.Code)
183}
184
185func TestPostCurrentSession_UnknownWorkspace(t *testing.T) {
186	t.Parallel()
187	c := newTestController()
188
189	rec := postCurrentSession(t, c, uuid.New().String(), uuid.New().String(), "S1")
190	require.Equal(t, http.StatusNotFound, rec.Code)
191}
192
193func TestPostCurrentSession_UnknownClient(t *testing.T) {
194	t.Parallel()
195	c := newTestController()
196	ws := installSyntheticWorkspace(t, c)
197
198	rec := postCurrentSession(t, c, ws.ID, uuid.New().String(), "S1")
199	require.Equal(t, http.StatusNotFound, rec.Code)
200}
201
202func TestPostCurrentSession_HoldOnly(t *testing.T) {
203	t.Parallel()
204	c := newTestController()
205	ws := installSyntheticWorkspace(t, c)
206
207	cid := uuid.New().String()
208	require.NoError(t, backend.RegisterClientForTesting(c.backend, ws, cid))
209	t.Cleanup(func() { _ = c.backend.DeleteWorkspace(ws.ID, cid) })
210
211	rec := postCurrentSession(t, c, ws.ID, cid, "S1")
212	require.Equal(t, http.StatusNotFound, rec.Code, "hold-only client must be rejected")
213}
214
215func TestPostCurrentSession_AttachedClientSucceeds(t *testing.T) {
216	t.Parallel()
217	c := newTestController()
218	ws := installSyntheticWorkspace(t, c)
219
220	cid := uuid.New().String()
221	require.NoError(t, c.backend.AttachClient(ws.ID, cid))
222	t.Cleanup(func() { c.backend.DetachClient(ws.ID, cid) })
223
224	rec := postCurrentSession(t, c, ws.ID, cid, "S1")
225	require.Equal(t, http.StatusOK, rec.Code)
226
227	// Clearing also returns 200.
228	rec = postCurrentSession(t, c, ws.ID, cid, "")
229	require.Equal(t, http.StatusOK, rec.Code)
230}