package main import ( "context" "encoding/json" "net/http" "strings" "testing" "time" "github.com/coder/websocket" ) // presenceState resolves the three states from its inputs; pinned away beats // activity, idleness only applies to people, and offline beats everything. func TestPresenceState(t *testing.T) { now := time.Now().Unix() stale := time.Now().Add(-presenceIdleAfter - time.Minute).Unix() cases := []struct { name string connected bool pinned string isAgent bool last int64 want string }{ {"disconnected", false, "", false, now, presenceOffline}, {"disconnected pinned away", false, presenceAway, false, now, presenceOffline}, {"connected + active", true, "", false, now, presenceOnline}, {"pinned away while active", true, presenceAway, false, now, presenceAway}, {"idle", true, "", false, stale, presenceAway}, {"never active", true, "", false, 0, presenceAway}, {"agent never idles", true, "", true, 0, presenceOnline}, } for _, c := range cases { if got := presenceState(c.connected, c.pinned, c.isAgent, c.last); got != c.want { t.Errorf("%s: got %s, want %s", c.name, got, c.want) } } } // dialWS opens a member socket and proves its registration landed with a // stored-message round-trip (Dial returns before the server's register does). func dialWS(t *testing.T, ctx context.Context, tsURL, tok, tid string) *websocket.Conn { t.Helper() wsURL := "ws" + strings.TrimPrefix(tsURL, "http") + "/ws?token=" + tok conn, _, err := websocket.Dial(ctx, wsURL, nil) if err != nil { t.Fatalf("ws dial: %v", err) } if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup"}`)); err != nil { t.Fatalf("warmup write: %v", err) } if _, _, err := conn.Read(ctx); err != nil { t.Fatalf("warmup read: %v", err) } return conn } // readPresenceFor drains frames until a presence frame for uid arrives. func readPresenceFor(t *testing.T, ctx context.Context, conn *websocket.Conn, uid, stage string) string { t.Helper() for { _, data, err := conn.Read(ctx) if err != nil { t.Fatalf("%s: read: %v", stage, err) } var msg struct { Type string `json:"type"` UserID string `json:"user_id"` Presence string `json:"presence"` } if json.Unmarshal(data, &msg) == nil && msg.Type == "presence" && msg.UserID == uid { return msg.Presence } } } // Presence end to end: connecting broadcasts online to a connection, pinning // Away flips it (and survives activity), unpinning restores online, and the // last socket closing broadcasts offline. The roster carries the same states. func TestPresenceLifecycle(t *testing.T) { ts, srv := newTestServer(t) aliceTok, aliceUID, _ := joinedMember(t, srv, "Alice") bobTok, bobUID, _ := joinedMember(t, srv, "Bob") // A shared thread makes them connections — the visibility gate presence // fan-out rides on. shared, err := srv.db.createThread("group", "", []ThreadMemberKey{ {UserID: aliceUID, Wrapped: "wa"}, {UserID: bobUID, Wrapped: "wb"}, }) if err != nil { t.Fatalf("createThread: %v", err) } rosterPresence := func(viewerTok, uid string) string { resp, err := http.Get(ts.URL + "/api/roster?token=" + viewerTok) if err != nil { t.Fatalf("roster: %v", err) } defer resp.Body.Close() var out struct{ Roster []RosterEntry } json.NewDecoder(resp.Body).Decode(&out) for _, e := range out.Roster { if e.UserID == uid { return e.Presence } } return "(absent)" } // Nobody connected: the roster reads offline. if p := rosterPresence(bobTok, aliceUID); p != presenceOffline { t.Fatalf("roster before connect: got %s, want offline", p) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() bobConn := dialWS(t, ctx, ts.URL, bobTok, shared) defer bobConn.CloseNow() // Alice connects → Bob sees her come online, live and in the roster. aliceConn := dialWS(t, ctx, ts.URL, aliceTok, shared) defer aliceConn.CloseNow() if p := readPresenceFor(t, ctx, bobConn, aliceUID, "connect"); p != presenceOnline { t.Fatalf("on connect: got %s, want online", p) } if p := rosterPresence(bobTok, aliceUID); p != presenceOnline { t.Fatalf("roster while connected: got %s, want online", p) } // Alice pins herself Away; activity no longer flips her back. resp, err := http.Post(ts.URL+"/api/presence", "application/json", strings.NewReader(`{"token":"`+aliceTok+`","away":true}`)) if err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("post presence: %v status %d", err, resp.StatusCode) } if p := readPresenceFor(t, ctx, bobConn, aliceUID, "away"); p != presenceAway { t.Fatalf("on pin: got %s, want away", p) } srv.markActive(aliceTok) // fresh activity while pinned — must stay away if p := rosterPresence(bobTok, aliceUID); p != presenceAway { t.Fatalf("roster pinned away: got %s, want away", p) } // getMe reports the pinned flag (what the toggle shows). meResp, _ := http.Get(ts.URL + "/api/me?token=" + aliceTok) var me struct { Away bool `json:"away"` } json.NewDecoder(meResp.Body).Decode(&me) if !me.Away { t.Fatal("getMe: away should be true while pinned") } // Unpin → online again. http.Post(ts.URL+"/api/presence", "application/json", strings.NewReader(`{"token":"`+aliceTok+`","away":false}`)) if p := readPresenceFor(t, ctx, bobConn, aliceUID, "unpin"); p != presenceOnline { t.Fatalf("on unpin: got %s, want online", p) } // Last socket closes → offline. aliceConn.CloseNow() if p := readPresenceFor(t, ctx, bobConn, aliceUID, "disconnect"); p != presenceOffline { t.Fatalf("on disconnect: got %s, want offline", p) } } // The sweep catches a connected member whose "active" beats stopped: they // drift to away without any request triggering it. func TestPresenceIdleSweep(t *testing.T) { ts, srv := newTestServer(t) aliceTok, aliceUID, _ := joinedMember(t, srv, "Alice") bobTok, bobUID, _ := joinedMember(t, srv, "Bob") shared, err := srv.db.createThread("group", "", []ThreadMemberKey{ {UserID: aliceUID, Wrapped: "wa"}, {UserID: bobUID, Wrapped: "wb"}, }) if err != nil { t.Fatalf("createThread: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() bobConn := dialWS(t, ctx, ts.URL, bobTok, shared) defer bobConn.CloseNow() aliceConn := dialWS(t, ctx, ts.URL, aliceTok, shared) defer aliceConn.CloseNow() if p := readPresenceFor(t, ctx, bobConn, aliceUID, "connect"); p != presenceOnline { t.Fatalf("on connect: got %s, want online", p) } // Age Alice's last activity past the idle window, then sweep. srv.activeUsers.Store(aliceUID, time.Now().Add(-presenceIdleAfter-time.Minute).Unix()) srv.sweepPresence() if p := readPresenceFor(t, ctx, bobConn, aliceUID, "idle"); p != presenceAway { t.Fatalf("after sweep: got %s, want away", p) } // Fresh activity brings them straight back. srv.markActive(aliceTok) if p := readPresenceFor(t, ctx, bobConn, aliceUID, "return"); p != presenceOnline { t.Fatalf("after activity: got %s, want online", p) } } // The endpoint is token-gated like every data endpoint. func TestPresenceRequiresToken(t *testing.T) { ts, _ := newTestServer(t) resp, err := http.Post(ts.URL+"/api/presence", "application/json", strings.NewReader(`{"token":"nope","away":true}`)) if err != nil { t.Fatalf("post: %v", err) } if resp.StatusCode != http.StatusForbidden { t.Fatalf("want 403, got %d", resp.StatusCode) } }