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
+28 -3
View File
@@ -25,7 +25,10 @@ const (
httpErrThreshold = 400 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. // Client wraps HTTP calls to the neoirc server API.
type Client struct { type Client struct {
@@ -206,13 +209,35 @@ func (client *Client) PollMessages(
}, nil }, 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 { 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 &Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdJoin, To: channel, 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. // PartChannel leaves a channel.
+73 -5
View File
@@ -1,6 +1,7 @@
package cli package cli
import ( import (
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -138,11 +139,9 @@ func TestOwnCommandShownOnce(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
t.Parallel() t.Parallel()
// The server accepts every command and answers nothing. // The server accepts every command. Only the response
server := httptest.NewServer(http.HandlerFunc( // to a JOIN is read, and it says the session joined.
func(http.ResponseWriter, *http.Request) {}, server := newServer(t, `{"status": "joined"}`)
))
t.Cleanup(server.Close)
app := &App{ app := &App{
ui: NewUI(), 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, // TestOwnEventFromOtherClient checks that a message, notice,
// join or part that the user's session makes from another // join or part that the user's session makes from another
// client shows as one line in the buffer it belongs to. // 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 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. // runUI runs ui on a simulated screen until the test ends.
func runUI(t *testing.T, ui *UI) { func runUI(t *testing.T, ui *UI) {
t.Helper() t.Helper()
+6 -2
View File
@@ -831,10 +831,11 @@ function App() {
// Fresh session — join any previously saved channels. // Fresh session — join any previously saved channels.
for (const ch of saved) { for (const ch of saved) {
try { try {
await api("/messages", { const res = await api("/messages", {
method: "POST", method: "POST",
body: JSON.stringify({ command: "JOIN", to: ch }), body: JSON.stringify({ command: "JOIN", to: ch }),
}); });
if (res?.status !== "joined") continue;
setTabs((prev) => { setTabs((prev) => {
if (prev.find((t) => t.type === "channel" && t.name === ch)) if (prev.find((t) => t.type === "channel" && t.name === ch))
return prev; return prev;
@@ -854,10 +855,13 @@ function App() {
name = name.trim(); name = name.trim();
if (!name.startsWith("#")) name = "#" + name; if (!name.startsWith("#")) name = "#" + name;
try { try {
await api("/messages", { const res = await api("/messages", {
method: "POST", method: "POST",
body: JSON.stringify({ command: "JOIN", to: name }), 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 // 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 // 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 // own JOIN event may already have added it, and other tabs may