feat: presença por sessão, eventos SSE em tempo real e autosave

- presence.go: hub SSE + registro de presença (TTL 30s por heartbeat)
- GET /api/events (EventSource, auth cookie/Bearer) transmite saved/presence;
  POST /api/presence publica {session, notes[]}; create/update/restore/delete
  publicam savedEvent com a session de origem (eco ignorado pelo autor)
- PUT/POST/restore aceitam campo opcional session
- UI: id de sessão em sessionStorage; selo 👥 na aba, na lista de notas e no
  topo do editor quando outra sessão tem a mesma nota aberta; aba LIMPA
  recarrega sozinha ao receber saved; aba SUJA abre o diálogo de conflito
  automaticamente (conflictPending evita diálogos empilhados)
- Autosave: debounce de 1,5s em qualquer alteração (rascunho vira nota),
  mensagem de revisão 'autosave', status 'salvando…', toggle nas Preferências
- Testes: SSE hello/presence/saved + presença 400/401 via httptest
This commit is contained in:
lgcosta committed 2026-11-04 13:35:33 -03:00
1 parent 3b91c1a509
commit a55591d208
9 files changed
+543 -15

No files matched your search

+77
View File
@@ -1,6 +1,7 @@
package api_test
import (
"bufio"
"bytes"
"encoding/json"
"io"
@@ -10,6 +11,7 @@ import (
"strings"
"testing"
"testing/fstest"
"time"
"github.com/lgcosta/notas-online/internal/api"
"github.com/lgcosta/notas-online/internal/store"
@@ -377,6 +379,81 @@ func TestSaveConflictViaAPI(t *testing.T) {
resp.Body.Close()
}
func TestPresenceAndSSEEvents(t *testing.T) {
srv := newTestServer(t)
call := func(method, path, body string) *http.Response {
return do(t, srv, method, path, body, testToken, "")
}
// abre o stream SSE
req, err := http.NewRequest("GET", srv.URL+"/api/events?session=s1", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+testToken)
streamResp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer streamResp.Body.Close()
if ct := streamResp.Header.Get("Content-Type"); ct != "text/event-stream" {
t.Fatalf("content-type SSE: %q", ct)
}
sc := bufio.NewScanner(streamResp.Body)
readEvent := func(contains string) string {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for sc.Scan() && time.Now().Before(deadline) {
line := sc.Text()
if strings.HasPrefix(line, "data: ") && strings.Contains(line, contains) {
return line
}
}
if err := sc.Err(); err != nil {
t.Fatalf("scanner SSE: %v", err)
}
t.Fatalf("evento contendo %q não chegou", contains)
return ""
}
// hello inicial com snapshot de presença
readEvent(`"type":"hello"`)
// heartbeat de presença → broadcast presence com s2
resp := call("POST", "/api/presence", `{"session":"s2","notes":["nota1"]}`)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("presence: %s", resp.Status)
}
readEvent(`"type":"presence"`)
// presença sem session → 400
resp = call("POST", "/api/presence", `{"notes":[]}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("presence sem session: %s", resp.Status)
}
resp.Body.Close()
// criar nota com session → evento saved para os ouvintes
resp = call("POST", "/api/notes", `{"title":"E","format":"txt","categories":[],"content":"x","author":"","message":"","session":"s3"}`)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %s", resp.Status)
}
var created noteView
decode(t, resp, &created)
ev := readEvent(`"type":"saved"`)
if !strings.Contains(ev, `"session":"s3"`) || !strings.Contains(ev, created.ID) {
t.Errorf("evento saved incompleto: %s", ev)
}
// SSE sem token → 401
resp = do(t, srv, "GET", "/api/events", "", "", "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("SSE sem auth: %s", resp.Status)
}
resp.Body.Close()
}
func TestInvalidPayloads(t *testing.T) {
srv := newTestServer(t)
resp := do(t, srv, "POST", "/api/notes", `{"format":"rtf","content":"x"}`, testToken, "")