Open a channel only when the server accepts the join (closes #197)
check / check (push) Waiting to run

When the server refuses a JOIN, POST /api/v1/messages answers 200
with status "error" and sends the error numeric to the queue. Both
clients opened the channel anyway.

JoinChannel in internal/cli/api now returns an error unless the
status is "joined". cmdJoin then keeps its buffer and target and
prints its usual "Join failed" line. The web client opens no tab for
a refused join, whether it came from /join or from the saved
channels a fresh session joins again.

Model: opus-5-5
This commit was merged in pull request #198.
This commit is contained in:
2026-10-27 05:09:01 +01:00
parent fcc94ca700
commit 229c89e1f8
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