// SPDX-License-Identifier: AGPL-3.0-or-later
package app
import (
"bufio"
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"bnema.dev/ermite/internal/domain"
"bnema.dev/ermite/internal/ports"
)
// The owner's inbox end to end: two repositories with open merge requests and
// issues, the filter, the home button, the owner stream, and a 404 for
// everyone who is not the signed-in owner.
func TestWebInboxEndToEnd(t *testing.T) {
r := newMRRepo(t, domain.VisibilityPublic)
b := browser{r.harness}
token := r.signIn(t)
_, err := r.forge.Repos.Create(r.ctx, domain.Root(), createParams(r.harness, "other", domain.VisibilityPrivate))
require.NoError(t, err)
otherURL := r.urlAuth(adminToken, "/other.git")
r.mustGit(r.wt, "push", "-q", otherURL, "main")
r.mustGit(r.wt, "push", "-q", otherURL, "feat/search")
owner := domain.IssuesOwner
for _, n := range []string{"proj", "other"} {
_, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(n), ports.UpdateRepoParams{Issues: &owner})
require.NoError(t, err)
}
// Nothing is open yet.
empty := b.get("/-/inbox", as(token))
require.Equal(t, 200, empty.status, empty.body)
assert.Contains(t, empty.body, "no open merge requests.")
assert.Contains(t, empty.body, "no open issues.")
// A queued run is visible and its run-level events refresh the owner stream.
draft, err := r.forge.store.CI().CreateRun(r.ctx, r.name("proj"), ".github/workflows/build.yml", func(id, number int64) ports.CIRunDraft {
return ports.CIRunDraft{Run: domain.CIRun{Repo: r.name("proj"), ID: id, Number: number, Attempt: 1, WorkflowName: "Inbox build", Status: domain.CIQueued, Ref: "refs/heads/main", Event: "push", CreatedAt: time.Now()}}
})
require.NoError(t, err)
runs := b.get("/-/inbox", as(token))
assert.Contains(t, runs.body, "Inbox build")
assert.Contains(t, runs.body, "1 running")
assert.Contains(t, runs.body, `data-inbox href="/-/inbox"`)
// The owner stream sees both repositories.
stream := ownerStream(t, r.harness, token)
r.forge.Bus.Publish(r.ctx, domain.RunUpdated{Run: draft.Run, At: time.Now(), Visibility: domain.VisibilityPublic, CreatedAt: time.Now()})
var runFragments strings.Builder
for range 6 {
runFragments.WriteString(nextFragment(t, stream).data)
}
assert.Contains(t, runFragments.String(), "Inbox build")
// Completion removes the row and updates the total in the same live fragments.
draft.Run.Status, draft.Run.Conclusion = domain.CICompleted, domain.CISuccess
_, err = r.forge.store.CI().Apply(r.ctx, ports.CIWrite{Runs: []domain.CIRun{draft.Run}})
require.NoError(t, err)
r.forge.Bus.Publish(r.ctx, domain.RunUpdated{Run: draft.Run, At: time.Now(), Visibility: domain.VisibilityPublic, CreatedAt: time.Now()})
runFragments.Reset()
for range 6 {
runFragments.WriteString(nextFragment(t, stream).data)
}
assert.NotContains(t, runFragments.String(), "Inbox build")
assert.Contains(t, runFragments.String(), "no runs in progress.")
assert.Contains(t, runFragments.String(), "0 running")
root := domain.Root()
_, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
require.NoError(t, err)
ev := nextFragment(t, stream)
assert.Equal(t, "inbox", ev.name)
assert.Contains(t, ev.data, `id="inbox-counts"`)
for range 5 { // the other fragments of this event
nextFragment(t, stream)
}
_, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
require.NoError(t, err)
var seen strings.Builder
for range 6 { // the six fragments of this event
seen.WriteString(nextFragment(t, stream).data)
}
assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
_, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("other"), ports.CreateMergeRequestParams{Title: "other work", Source: "feat/search", Target: "main"})
require.NoError(t, err)
_, err = r.forge.Issues.Create(r.ctx, root, r.name("proj"), ports.CreateIssueParams{Title: "proj bug"})
require.NoError(t, err)
all := b.get("/-/inbox", as(token))
require.Equal(t, 200, all.status, all.body)
assert.Equal(t, "private, no-store", all.hdr.Get("Cache-Control"))
assert.Contains(t, all.body, "2 merge requests · 2 issues")
for _, want := range []string{"add search", "other work", "search is slow", "proj bug", `href="/other/issues/`, `href="/proj/merge-requests/`} {
assert.Contains(t, all.body, want)
}
only := b.get("/-/inbox?repo=other", as(token))
require.Equal(t, 200, only.status)
assert.Contains(t, only.body, "2 merge requests · 2 issues", "the counters ignore the filter")
assert.Contains(t, only.body, "other work")
assert.Contains(t, only.body, "search is slow")
assert.NotContains(t, only.body, "add search")
assert.NotContains(t, only.body, "proj bug")
// Issues off: that repository's issues leave the inbox, its merge requests stay.
off := domain.IssuesOff
_, err = r.forge.Repos.Update(r.ctx, root, r.name("other"), ports.UpdateRepoParams{Issues: &off})
require.NoError(t, err)
hidden := b.get("/-/inbox", as(token))
assert.Contains(t, hidden.body, "2 merge requests · 1 issues")
assert.NotContains(t, hidden.body, "search is slow")
// The home button, for the owner only.
assert.Contains(t, b.get("/", as(token)).body, `href="/-/inbox"`)
anonHome := b.get("/", nil)
assert.NotContains(t, anonHome.body, "/-/inbox")
assert.NotContains(t, anonHome.body, "Inbox")
// Anyone else: the 404 of an unknown page, and no stream.
missing := b.get("/-/no-such-page", nil)
anon := b.get("/-/inbox", nil)
assert.Equal(t, 404, anon.status)
assert.Equal(t, missing.body, anon.body)
assert.Equal(t, 404, b.get("/-/inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status, "a token is not a session")
unknown := b.get("/-/events?topic=nope&view=inbox", nil)
ownerAnon := b.get("/-/events?topic=owner&view=inbox", nil)
assert.Equal(t, unknown.status, ownerAnon.status, "the answer to an unknown topic")
assert.Equal(t, strings.ReplaceAll(unknown.body, "nope", "owner"), ownerAnon.body)
assert.Equal(t, unknown.status, b.get("/-/events?topic=owner&view=inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status)
}
// nextFragment returns the next event that carries a fragment. Events the
// inbox does not show (a mergeability check) still send their id alone, so a
// resumed stream starts after them; they are skipped.
func nextFragment(t *testing.T, s *sseStream) sseEvent {
t.Helper()
for {
if ev := s.next(t, watchWait); ev.name != "" {
return ev
}
}
}
// ownerStream opens the owner topic with the session cookie.
func ownerStream(t *testing.T, h *harness, token string) *sseStream {
t.Helper()
ctx, cancel := context.WithCancel(h.ctx)
t.Cleanup(cancel)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url("/-/events?topic=owner&view=inbox"), nil)
require.NoError(t, err)
req.Header.Set("Cookie", sessionCookie+"="+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
t.Cleanup(func() { _ = resp.Body.Close() })
require.Equal(t, http.StatusOK, resp.StatusCode)
s := &sseStream{resp: resp, events: make(chan sseEvent, 256), cancel: cancel}
go func() {
defer close(s.events)
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
var cur sseEvent
var data []string
for sc.Scan() {
line := sc.Text()
switch {
case line == "":
if cur.id != "" || cur.name != "" || len(data) > 0 {
cur.data = strings.Join(data, "\n")
s.events <- cur
}
cur, data = sseEvent{}, nil
case strings.HasPrefix(line, ":"):
cur.comment = true
case strings.HasPrefix(line, "event: "):
cur.name = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "id: "):
cur.id = strings.TrimPrefix(line, "id: ")
case strings.HasPrefix(line, "data: "):
data = append(data, strings.TrimPrefix(line, "data: "))
}
}
}()
return s
}