Open a channel only when the server accepts the join (closes #197) #198

Merged
clawbot merged 1 commits from issue-197-refused-join into next 2026-10-27 05:09:01 +01:00
3 changed files with 107 additions and 10 deletions
Showing only changes of commit 557f3ed86e - Show all commits
+28 -3
View File
@@ -25,7 +25,10 @@ const (
httpErrThreshold = 400
)
var errHTTP = errors.New("HTTP error")
var (
errHTTP = errors.New("HTTP error")
errJoinRefused = errors.New("the server refused the join")
)
// Client wraps HTTP calls to the neoirc server API.
type Client struct {
@@ -206,13 +209,35 @@ func (client *Client) PollMessages(
}, nil
}
// JoinChannel joins a channel.
// JoinChannel joins a channel. It returns an error unless the
// server answers that the session joined. When the server
// refuses the join, it sends the reason to the message queue as
// an error numeric.
func (client *Client) JoinChannel(channel string) error {
return client.SendMessage(
data, err := client.do(
http.MethodPost, "/api/v1/messages",
&Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdJoin, To: channel,
},
)
if err != nil {
return err
}
var resp struct {
Status string `json:"status"`
}
err = json.Unmarshal(data, &resp)
if err != nil {
return fmt.Errorf("decode join: %w", err)
}
if resp.Status != "joined" {
return errJoinRefused
}
return nil
}
// PartChannel leaves a channel.
+73 -5
View File
@@ -1,6 +1,7 @@
package cli
import (
"io"
"net/http"
"net/http/httptest"
"testing"
@@ -138,11 +139,9 @@ func TestOwnCommandShownOnce(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
// The server accepts every command and answers nothing.
server := httptest.NewServer(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {},
))
t.Cleanup(server.Close)
// The server accepts every command. Only the response
// to a JOIN is read, and it says the session joined.
server := newServer(t, `{"status": "joined"}`)
app := &App{
ui: NewUI(),
@@ -166,6 +165,60 @@ func TestOwnCommandShownOnce(t *testing.T) {
}
}
// TestJoinResponse checks that /join switches to the channel's
// buffer and makes it the target only when the server answers
// that the session joined.
func TestJoinResponse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
response string
// The buffer shown and the target after the join.
want string
}{
{
name: "joined",
response: `{"status": "joined", "channel": "#chan"}`,
want: testChannel,
},
{
name: "refused",
response: `{"status": "error"}`,
want: testOtherNick,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{
ui: NewUI(),
client: api.NewClient(newServer(t, test.response).URL),
nick: testNick,
target: testOtherNick,
connected: true,
}
runUI(t, app.ui)
app.ui.SwitchToBuffer(testOtherNick)
app.cmdJoin(testChannel)
var shown string
app.ui.app.QueueUpdate(func() {
shown = app.ui.buffers[app.ui.currentBuffer].Name
})
if shown != test.want || app.target != test.want {
t.Fatalf("got buffer %s and target %s, want %s",
shown, app.target, test.want)
}
})
}
}
// TestOwnEventFromOtherClient checks that a message, notice,
// join or part that the user's session makes from another
// client shows as one line in the buffer it belongs to.
@@ -248,6 +301,21 @@ func bufferState(ui *UI, name string) (int, int) {
return lines, unread
}
// newServer starts a server that answers every request with
// body until the test ends.
func newServer(t *testing.T, body string) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(writer, body)
},
))
t.Cleanup(server.Close)
return server
}
// runUI runs ui on a simulated screen until the test ends.
func runUI(t *testing.T, ui *UI) {
t.Helper()
+6 -2
View File
@@ -831,10 +831,11 @@ function App() {
// Fresh session — join any previously saved channels.
for (const ch of saved) {
try {
await api("/messages", {
const res = await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "JOIN", to: ch }),
});
if (res?.status !== "joined") continue;
setTabs((prev) => {
if (prev.find((t) => t.type === "channel" && t.name === ch))
return prev;
@@ -854,10 +855,13 @@ function App() {
name = name.trim();
if (!name.startsWith("#")) name = "#" + name;
try {
await api("/messages", {
const res = await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "JOIN", to: name }),
});
// A refused join opens no tab. The error numeric that the
// server sends to the message queue tells the user why.
if (res?.status !== "joined") return;
// Show the channel's tab. Preact runs this update at once on the
// newest tab list, so the tab is found there by name: the user's
// own JOIN event may already have added it, and other tabs may