oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "testing"
12 "time"
13)
14
15// liveServer starts a real HTTP server for the app.
16//
17// httptest.ResponseRecorder cannot test this: it buffers everything and
18// returns when the handler does, so a handler that built its whole body
19// and returned would pass every assertion a recorder can make — while
20// being exactly the bug this endpoint must not have.
21func liveServer(t *testing.T, app *App) *httptest.Server {
22 t.Helper()
23 srv := httptest.NewServer(app.Router())
24 t.Cleanup(srv.Close)
25 return srv
26}
27
28// openStream connects to a tab's stream and returns the response.
29func openStream(t *testing.T, srv *httptest.Server, s SheetSummary, cookies []*http.Cookie) *http.Response {
30 t.Helper()
31 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
32 t.Cleanup(cancel)
33
34 url := fmt.Sprintf("%s/api/sheets/%d/tabs/%d/stream", srv.URL, s.ID, s.TabID)
35 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
36 if err != nil {
37 t.Fatalf("build stream request: %v", err)
38 }
39 for _, c := range cookies {
40 req.AddCookie(c)
41 }
42 resp, err := http.DefaultClient.Do(req)
43 if err != nil {
44 t.Fatalf("open stream: %v", err)
45 }
46 t.Cleanup(func() { resp.Body.Close() })
47 return resp
48}
49
50// readEventWithin reads SSE lines until a data line arrives, or fails.
51func readEventWithin(t *testing.T, body io.Reader, d time.Duration) string {
52 t.Helper()
53 type result struct{ line string }
54 ch := make(chan result, 1)
55 go func() {
56 sc := bufio.NewScanner(body)
57 for sc.Scan() {
58 if strings.HasPrefix(sc.Text(), "data: ") {
59 ch <- result{sc.Text()}
60 return
61 }
62 }
63 ch <- result{""}
64 }()
65 select {
66 case r := <-ch:
67 if r.line == "" {
68 t.Fatal("the stream ended without sending a data line")
69 }
70 return r.line
71 case <-time.After(d):
72 t.Fatalf("no event within %s", d)
73 return ""
74 }
75}
76
77// Spec §7: the SSE transport check is a streamed 200, not a 101. SSE is
78// text/event-stream over a normal response; 101 is the WebSocket
79// upgrade, and a check written against 101 would never pass.
80func TestStreamIsAStreamed200NotAnUpgrade(t *testing.T) {
81 app, cookies := signIn(t)
82 s, _ := newSheet(t, app, cookies, "Budget")
83 srv := liveServer(t, app)
84
85 resp := openStream(t, srv, s, cookies)
86 if resp.StatusCode != http.StatusOK {
87 t.Fatalf("stream = %d, want a streamed 200", resp.StatusCode)
88 }
89 if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
90 t.Fatalf("Content-Type = %q, want text/event-stream", ct)
91 }
92 // The header that stops an edge buffering a stream into a complete
93 // response — the failure that turns live sync into "everything
94 // arrives when you close the tab", and which no in-process test can
95 // otherwise see.
96 if got := resp.Header.Get("X-Accel-Buffering"); got != "no" {
97 t.Errorf("X-Accel-Buffering = %q, want %q", got, "no")
98 }
99}
100
101// The real assertion: an edit made after the stream was opened arrives
102// on it, without the response having to end. A handler that builds its
103// body and returns passes every status check above and is useless.
104func TestAnEditArrivesOnAnOpenStream(t *testing.T) {
105 app, cookies := signIn(t)
106 s, cells := newSheet(t, app, cookies, "Budget")
107 srv := liveServer(t, app)
108
109 resp := openStream(t, srv, s, cookies)
110
111 // Wait until the handler has actually subscribed. Posting before
112 // that races the subscription and loses the message — the test
113 // would be flaky rather than wrong, which is worse.
114 waitForSubscriber(t, app, s.TabID)
115
116 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"=6*7"}`)
117
118 line := readEventWithin(t, resp.Body, 5*time.Second)
119 if !strings.Contains(line, `"display":"42"`) {
120 t.Fatalf("first streamed event = %q, want the computed value", line)
121 }
122}
123
124// Two tabs on one sheet: the whole feature, at the layer below the
125// browser.
126func TestTwoStreamsBothReceiveAnEdit(t *testing.T) {
127 app, cookies := signIn(t)
128 s, cells := newSheet(t, app, cookies, "Budget")
129 srv := liveServer(t, app)
130
131 first := openStream(t, srv, s, cookies)
132 second := openStream(t, srv, s, cookies)
133 waitForSubscriberCount(t, app, s.TabID, 2)
134
135 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"hello"}`)
136
137 for i, resp := range []*http.Response{first, second} {
138 if line := readEventWithin(t, resp.Body, 5*time.Second); !strings.Contains(line, "hello") {
139 t.Errorf("stream %d received %q", i, line)
140 }
141 }
142}
143
144func TestStreamOfAnUnknownTabIs404(t *testing.T) {
145 app, cookies := signIn(t)
146 s, _ := newSheet(t, app, cookies, "Budget")
147
148 path := fmt.Sprintf("/api/sheets/%d/tabs/999999/stream", s.ID)
149 if rec := do(t, app, as(httptest.NewRequest(http.MethodGet, path, nil), cookies)); rec.Code != http.StatusNotFound {
150 t.Fatalf("streaming an unknown tab = %d, want 404", rec.Code)
151 }
152}
153
154func TestStreamRequiresASession(t *testing.T) {
155 app, cookies := signIn(t)
156 s, _ := newSheet(t, app, cookies, "Budget")
157
158 path := fmt.Sprintf("/api/sheets/%d/tabs/%d/stream", s.ID, s.TabID)
159 if rec := do(t, app, httptest.NewRequest(http.MethodGet, path, nil)); rec.Code == http.StatusOK {
160 t.Fatal("the stream served a request with no session")
161 }
162}
163
164// Closing the connection must unsubscribe, or an instance leaks one
165// subscription per browser tab that has ever been opened.
166func TestClosingAStreamUnsubscribes(t *testing.T) {
167 app, cookies := signIn(t)
168 s, _ := newSheet(t, app, cookies, "Budget")
169 srv := liveServer(t, app)
170
171 resp := openStream(t, srv, s, cookies)
172 waitForSubscriber(t, app, s.TabID)
173 resp.Body.Close()
174
175 deadline := time.Now().Add(5 * time.Second)
176 for time.Now().Before(deadline) {
177 if app.hub.Subscribers(s.TabID) == 0 {
178 return
179 }
180 time.Sleep(10 * time.Millisecond)
181 }
182 t.Fatalf("after the client closed, %d subscribers remain", app.hub.Subscribers(s.TabID))
183}
184
185func waitForSubscriber(t *testing.T, app *App, tabID uint64) {
186 t.Helper()
187 waitForSubscriberCount(t, app, tabID, 1)
188}
189
190func waitForSubscriberCount(t *testing.T, app *App, tabID uint64, want int) {
191 t.Helper()
192 deadline := time.Now().Add(5 * time.Second)
193 for time.Now().Before(deadline) {
194 if app.hub.Subscribers(tabID) >= want {
195 return
196 }
197 time.Sleep(5 * time.Millisecond)
198 }
199 t.Fatalf("only %d subscribers on tab %d after 5s, want %d",
200 app.hub.Subscribers(tabID), tabID, want)
201}
202