171 lines
5.4 KiB
Go
171 lines
5.4 KiB
Go
package push_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdh"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"g.fc.run/misc/byre/internal/api"
|
|
"g.fc.run/misc/byre/internal/push"
|
|
"g.fc.run/misc/byre/internal/store"
|
|
)
|
|
|
|
type received struct {
|
|
urgency string
|
|
auth string
|
|
}
|
|
|
|
func TestBurstIsOneNotification(t *testing.T) {
|
|
ctx := context.Background()
|
|
var notifier *push.Notifier
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), store.DefaultPortFirst, func(ev api.Event) { notifier.Observe(ev) })
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
|
|
notifier, err = push.New(st, 100*time.Millisecond)
|
|
if err != nil {
|
|
t.Fatalf("new notifier: %v", err)
|
|
}
|
|
t.Cleanup(notifier.Close)
|
|
|
|
got := make(chan received, 10)
|
|
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got <- received{urgency: r.Header.Get("Urgency"), auth: r.Header.Get("Authorization")}
|
|
w.WriteHeader(http.StatusCreated)
|
|
}))
|
|
t.Cleanup(endpoint.Close)
|
|
gone := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusGone)
|
|
}))
|
|
t.Cleanup(gone.Close)
|
|
|
|
now := time.Now().UTC()
|
|
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
|
if err != nil {
|
|
t.Fatalf("ensure user: %v", err)
|
|
}
|
|
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
|
|
if err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "Fix login", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
|
|
if err != nil {
|
|
t.Fatalf("create task: %v", err)
|
|
}
|
|
err = st.CreateSession(ctx, api.AgentSession{Id: "s1", TaskId: "t1", Vendor: api.Claude, VendorSessionId: "s1", State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}, "p1")
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
|
|
for _, url := range []string{endpoint.URL, gone.URL} {
|
|
err = st.AddPushSubscription(ctx, store.PushSubscription{Endpoint: url, UserLogin: "alice", P256dh: clientKey(t), Auth: secret(t), Subscriber: "https://byre.example"})
|
|
if err != nil {
|
|
t.Fatalf("add subscription: %v", err)
|
|
}
|
|
}
|
|
|
|
create := func(id string, kind api.InboxItemKind, blocking bool, payload map[string]any) {
|
|
t.Helper()
|
|
err := st.CreateInboxItem(ctx, api.InboxItem{Id: id, ProjectId: "p1", TaskId: "t1", AgentSessionId: "s1", ThreadId: id,
|
|
Kind: kind, Priority: api.Blocked, Blocking: blocking, Payload: payload, State: api.Open, CreatedAt: now})
|
|
if err != nil {
|
|
t.Fatalf("create inbox item: %v", err)
|
|
}
|
|
}
|
|
|
|
create("r1", api.Report, false, map[string]any{"text": "done"})
|
|
expectNone(t, got)
|
|
|
|
create("q1", api.Question, true, map[string]any{"question": "Which port?"})
|
|
create("q2", api.Question, true, map[string]any{"question": "Which host?"})
|
|
create("r2", api.Report, false, map[string]any{"text": "done"})
|
|
|
|
select {
|
|
case r := <-got:
|
|
if r.urgency != "high" || !strings.HasPrefix(r.auth, "vapid t=") {
|
|
t.Fatalf("unexpected push request %+v", r)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatalf("no push delivered")
|
|
}
|
|
expectNone(t, got)
|
|
|
|
targets, err := st.PushTargets(ctx)
|
|
if err != nil {
|
|
t.Fatalf("push targets: %v", err)
|
|
}
|
|
if len(targets) != 1 || targets[0].Subscription.Endpoint != endpoint.URL {
|
|
t.Fatalf("expired subscription not removed: %+v", targets)
|
|
}
|
|
}
|
|
|
|
func TestCompose(t *testing.T) {
|
|
titles := map[string]string{"t1": "Fix login", "t2": "Add search"}
|
|
one := push.Compose([]api.InboxItem{
|
|
{TaskId: "t1", Kind: api.Question, Payload: map[string]any{"question": "Which port?\nmore"}},
|
|
}, titles)
|
|
if one != (push.Message{Title: "byre · Fix login", Body: "Which port?", URL: "/#/task/t1"}) {
|
|
t.Fatalf("unexpected single message %+v", one)
|
|
}
|
|
|
|
burst := push.Compose([]api.InboxItem{
|
|
{TaskId: "t2", Kind: api.Question, Payload: map[string]any{"question": "Which index?"}},
|
|
{TaskId: "t1", Kind: api.SessionError, Payload: map[string]any{"error": "exit 1"}},
|
|
}, titles)
|
|
want := push.Message{Title: "byre · 2 new inbox items", Body: "Add search: Which index?\nFix login: Error: exit 1", URL: "/#/task/t2"}
|
|
if burst != want {
|
|
t.Fatalf("unexpected burst message %+v", burst)
|
|
}
|
|
}
|
|
|
|
func TestWantsDefaults(t *testing.T) {
|
|
prefs := api.NotificationPrefs{BlockingQuestions: true}
|
|
cases := []struct {
|
|
item api.InboxItem
|
|
want bool
|
|
}{
|
|
{api.InboxItem{Kind: api.Question, Blocking: true}, true},
|
|
{api.InboxItem{Kind: api.Question}, false},
|
|
{api.InboxItem{Kind: api.Report}, false},
|
|
{api.InboxItem{Kind: api.SessionError, Blocking: true}, false},
|
|
{api.InboxItem{Kind: api.RateLimited}, false},
|
|
}
|
|
for _, c := range cases {
|
|
if push.Wants(prefs, c.item) != c.want {
|
|
t.Fatalf("wants %+v: got %v", c.item, !c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func expectNone(t *testing.T, got chan received) {
|
|
t.Helper()
|
|
select {
|
|
case r := <-got:
|
|
t.Fatalf("unexpected push %+v", r)
|
|
case <-time.After(400 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func clientKey(t *testing.T) string {
|
|
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes())
|
|
}
|
|
|
|
func secret(t *testing.T) string {
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|