eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/coder/websocket"
12)
13
14// presenceState resolves the three states from its inputs; pinned away beats
15// activity, idleness only applies to people, and offline beats everything.
16func TestPresenceState(t *testing.T) {
17 now := time.Now().Unix()
18 stale := time.Now().Add(-presenceIdleAfter - time.Minute).Unix()
19 cases := []struct {
20 name string
21 connected bool
22 pinned string
23 isAgent bool
24 last int64
25 want string
26 }{
27 {"disconnected", false, "", false, now, presenceOffline},
28 {"disconnected pinned away", false, presenceAway, false, now, presenceOffline},
29 {"connected + active", true, "", false, now, presenceOnline},
30 {"pinned away while active", true, presenceAway, false, now, presenceAway},
31 {"idle", true, "", false, stale, presenceAway},
32 {"never active", true, "", false, 0, presenceAway},
33 {"agent never idles", true, "", true, 0, presenceOnline},
34 }
35 for _, c := range cases {
36 if got := presenceState(c.connected, c.pinned, c.isAgent, c.last); got != c.want {
37 t.Errorf("%s: got %s, want %s", c.name, got, c.want)
38 }
39 }
40}
41
42// dialWS opens a member socket and proves its registration landed with a
43// stored-message round-trip (Dial returns before the server's register does).
44func dialWS(t *testing.T, ctx context.Context, tsURL, tok, tid string) *websocket.Conn {
45 t.Helper()
46 wsURL := "ws" + strings.TrimPrefix(tsURL, "http") + "/ws?token=" + tok
47 conn, _, err := websocket.Dial(ctx, wsURL, nil)
48 if err != nil {
49 t.Fatalf("ws dial: %v", err)
50 }
51 if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup"}`)); err != nil {
52 t.Fatalf("warmup write: %v", err)
53 }
54 if _, _, err := conn.Read(ctx); err != nil {
55 t.Fatalf("warmup read: %v", err)
56 }
57 return conn
58}
59
60// readPresenceFor drains frames until a presence frame for uid arrives.
61func readPresenceFor(t *testing.T, ctx context.Context, conn *websocket.Conn, uid, stage string) string {
62 t.Helper()
63 for {
64 _, data, err := conn.Read(ctx)
65 if err != nil {
66 t.Fatalf("%s: read: %v", stage, err)
67 }
68 var msg struct {
69 Type string `json:"type"`
70 UserID string `json:"user_id"`
71 Presence string `json:"presence"`
72 }
73 if json.Unmarshal(data, &msg) == nil && msg.Type == "presence" && msg.UserID == uid {
74 return msg.Presence
75 }
76 }
77}
78
79// Presence end to end: connecting broadcasts online to a connection, pinning
80// Away flips it (and survives activity), unpinning restores online, and the
81// last socket closing broadcasts offline. The roster carries the same states.
82func TestPresenceLifecycle(t *testing.T) {
83 ts, srv := newTestServer(t)
84 aliceTok, aliceUID, _ := joinedMember(t, srv, "Alice")
85 bobTok, bobUID, _ := joinedMember(t, srv, "Bob")
86 // A shared thread makes them connections — the visibility gate presence
87 // fan-out rides on.
88 shared, err := srv.db.createThread("group", "", []ThreadMemberKey{
89 {UserID: aliceUID, Wrapped: "wa"}, {UserID: bobUID, Wrapped: "wb"},
90 })
91 if err != nil {
92 t.Fatalf("createThread: %v", err)
93 }
94
95 rosterPresence := func(viewerTok, uid string) string {
96 resp, err := http.Get(ts.URL + "/api/roster?token=" + viewerTok)
97 if err != nil {
98 t.Fatalf("roster: %v", err)
99 }
100 defer resp.Body.Close()
101 var out struct{ Roster []RosterEntry }
102 json.NewDecoder(resp.Body).Decode(&out)
103 for _, e := range out.Roster {
104 if e.UserID == uid {
105 return e.Presence
106 }
107 }
108 return "(absent)"
109 }
110
111 // Nobody connected: the roster reads offline.
112 if p := rosterPresence(bobTok, aliceUID); p != presenceOffline {
113 t.Fatalf("roster before connect: got %s, want offline", p)
114 }
115
116 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
117 defer cancel()
118 bobConn := dialWS(t, ctx, ts.URL, bobTok, shared)
119 defer bobConn.CloseNow()
120
121 // Alice connects → Bob sees her come online, live and in the roster.
122 aliceConn := dialWS(t, ctx, ts.URL, aliceTok, shared)
123 defer aliceConn.CloseNow()
124 if p := readPresenceFor(t, ctx, bobConn, aliceUID, "connect"); p != presenceOnline {
125 t.Fatalf("on connect: got %s, want online", p)
126 }
127 if p := rosterPresence(bobTok, aliceUID); p != presenceOnline {
128 t.Fatalf("roster while connected: got %s, want online", p)
129 }
130
131 // Alice pins herself Away; activity no longer flips her back.
132 resp, err := http.Post(ts.URL+"/api/presence", "application/json",
133 strings.NewReader(`{"token":"`+aliceTok+`","away":true}`))
134 if err != nil || resp.StatusCode != http.StatusOK {
135 t.Fatalf("post presence: %v status %d", err, resp.StatusCode)
136 }
137 if p := readPresenceFor(t, ctx, bobConn, aliceUID, "away"); p != presenceAway {
138 t.Fatalf("on pin: got %s, want away", p)
139 }
140 srv.markActive(aliceTok) // fresh activity while pinned — must stay away
141 if p := rosterPresence(bobTok, aliceUID); p != presenceAway {
142 t.Fatalf("roster pinned away: got %s, want away", p)
143 }
144
145 // getMe reports the pinned flag (what the toggle shows).
146 meResp, _ := http.Get(ts.URL + "/api/me?token=" + aliceTok)
147 var me struct {
148 Away bool `json:"away"`
149 }
150 json.NewDecoder(meResp.Body).Decode(&me)
151 if !me.Away {
152 t.Fatal("getMe: away should be true while pinned")
153 }
154
155 // Unpin → online again.
156 http.Post(ts.URL+"/api/presence", "application/json",
157 strings.NewReader(`{"token":"`+aliceTok+`","away":false}`))
158 if p := readPresenceFor(t, ctx, bobConn, aliceUID, "unpin"); p != presenceOnline {
159 t.Fatalf("on unpin: got %s, want online", p)
160 }
161
162 // Last socket closes → offline.
163 aliceConn.CloseNow()
164 if p := readPresenceFor(t, ctx, bobConn, aliceUID, "disconnect"); p != presenceOffline {
165 t.Fatalf("on disconnect: got %s, want offline", p)
166 }
167}
168
169// The sweep catches a connected member whose "active" beats stopped: they
170// drift to away without any request triggering it.
171func TestPresenceIdleSweep(t *testing.T) {
172 ts, srv := newTestServer(t)
173 aliceTok, aliceUID, _ := joinedMember(t, srv, "Alice")
174 bobTok, bobUID, _ := joinedMember(t, srv, "Bob")
175 shared, err := srv.db.createThread("group", "", []ThreadMemberKey{
176 {UserID: aliceUID, Wrapped: "wa"}, {UserID: bobUID, Wrapped: "wb"},
177 })
178 if err != nil {
179 t.Fatalf("createThread: %v", err)
180 }
181
182 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
183 defer cancel()
184 bobConn := dialWS(t, ctx, ts.URL, bobTok, shared)
185 defer bobConn.CloseNow()
186 aliceConn := dialWS(t, ctx, ts.URL, aliceTok, shared)
187 defer aliceConn.CloseNow()
188 if p := readPresenceFor(t, ctx, bobConn, aliceUID, "connect"); p != presenceOnline {
189 t.Fatalf("on connect: got %s, want online", p)
190 }
191
192 // Age Alice's last activity past the idle window, then sweep.
193 srv.activeUsers.Store(aliceUID, time.Now().Add(-presenceIdleAfter-time.Minute).Unix())
194 srv.sweepPresence()
195 if p := readPresenceFor(t, ctx, bobConn, aliceUID, "idle"); p != presenceAway {
196 t.Fatalf("after sweep: got %s, want away", p)
197 }
198
199 // Fresh activity brings them straight back.
200 srv.markActive(aliceTok)
201 if p := readPresenceFor(t, ctx, bobConn, aliceUID, "return"); p != presenceOnline {
202 t.Fatalf("after activity: got %s, want online", p)
203 }
204}
205
206// The endpoint is token-gated like every data endpoint.
207func TestPresenceRequiresToken(t *testing.T) {
208 ts, _ := newTestServer(t)
209 resp, err := http.Post(ts.URL+"/api/presence", "application/json",
210 strings.NewReader(`{"token":"nope","away":true}`))
211 if err != nil {
212 t.Fatalf("post: %v", err)
213 }
214 if resp.StatusCode != http.StatusForbidden {
215 t.Fatalf("want 403, got %d", resp.StatusCode)
216 }
217}
218