Files
neoirc/internal/ircserver/relay_test.go
T
clawbot 5100b9a665
check / check (push) Waiting to run
Remove the relay's broker wait channel after each tick (closes #151)
The IRC relay registers a broker wait channel on each pass of its
loop, and when the 100 ms ticker fired instead of a notification it
looped without removing that channel. An idle IRC listener connection
added ten channels a second to the broker, and all but the last stayed
after the connection closed. The ticker case now removes its channel,
as the HTTP long-poll does after its timer.

Broker.ListenerCount returns how many wait channels a user holds, so
the test can check that an idle connection holds at most one, and
none after it closes.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-20 18:02:03 +02:00

272 lines
6.8 KiB
Go

package ircserver_test
import (
"encoding/json"
"slices"
"strings"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/ircserver"
)
// queueMessage queues a message for the user with nick
// recipient the way the HTTP API sends one: stored once, then
// queued to the recipient's session.
func (env *testEnv) queueMessage(
t *testing.T,
recipient, command, from, to string,
body []string,
) {
t.Helper()
ctx := t.Context()
bodyJSON, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
msgID, _, err := env.database.InsertMessage(
ctx, command, from, to, nil, bodyJSON, nil,
)
if err != nil {
t.Fatalf("insert message: %v", err)
}
sessionID, err := env.database.GetSessionByNick(ctx, recipient)
if err != nil {
t.Fatalf("get session of %s: %v", recipient, err)
}
err = env.database.EnqueueToSession(ctx, sessionID, msgID)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
env.brk.Notify(sessionID)
}
// TestRelayKeepsOneWaitChannel checks that an idle IRC
// listener connection holds at most one broker wait channel
// across several relay ticks, and none once it closes.
func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Parallel()
const idleTicks = 5
env := newTestEnv(t)
client := env.dial(t)
client.register("idle")
sessionID, err := env.database.GetSessionByNick(t.Context(), "idle")
if err != nil {
t.Fatalf("get session of idle: %v", err)
}
for tick := range idleTicks {
time.Sleep(ircserver.PollInterval)
got := env.brk.ListenerCount(sessionID)
if got > 1 {
t.Fatalf("tick %d: got %d wait channels, want at most 1",
tick+1, got)
}
}
err = client.conn.Close()
if err != nil {
t.Fatalf("close: %v", err)
}
deadline := time.Now().Add(testTimeout)
for env.brk.ListenerCount(sessionID) != 0 {
if time.Now().After(deadline) {
t.Fatalf("after close: got %d wait channels, want 0",
env.brk.ListenerCount(sessionID))
}
time.Sleep(relayCheckStep)
}
}
// TestRelayMultiLineBody checks that each line of a PRIVMSG
// or NOTICE body sent over the HTTP API reaches an IRC
// listener user as its own line.
func TestRelayMultiLineBody(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
for _, command := range []string{"PRIVMSG", "NOTICE"} {
env.queueMessage(
t, "alice", command, "carol", "alice",
[]string{"first line", "second line"},
)
lines := alice.readUntil(func(l string) bool {
return strings.Contains(l, "second line")
})
want := []string{
":carol!carol@* " + command + " alice :first line",
":carol!carol@* " + command + " alice :second line",
}
got := lines[max(0, len(lines)-len(want)):]
if !slices.Equal(got, want) {
t.Errorf("%s: got %q, want %q", command, got, want)
}
}
}
// TestRelayModeParameters checks that a MODE change reaches
// IRC listener members with its values as separate
// parameters, both for the bodies the HTTP API stores and
// for a change made on the IRC listener.
func TestRelayModeParameters(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
for _, step := range []struct {
body []string
want string
}{
{[]string{"+k", "secret"}, "+k secret"},
{[]string{"-k", "*"}, "-k *"},
{[]string{"+l", "50"}, "+l 50"},
{[]string{"-l"}, "-l"},
{[]string{"+b", "*!*@*.example.com"}, "+b *!*@*.example.com"},
{[]string{"-b", "*!*@*.example.com"}, "-b *!*@*.example.com"},
{[]string{"+o bob"}, "+o bob"},
{[]string{"+m"}, "+m"},
} {
env.queueMessage(t, "alice", "MODE", "op", "#modes", step.body)
lines := alice.readUntil(func(l string) bool {
return strings.Contains(l, " MODE ")
})
got := lines[len(lines)-1]
want := ":op!op@* MODE #modes " + step.want
if got != want {
t.Errorf("body %q: got %q, want %q", step.body, got, want)
}
}
bob := env.dial(t)
bob.register("bob")
alice.joinAndDrain("#modes")
bob.joinAndDrain("#modes")
alice.send("MODE #modes +v bob")
const change = " MODE #modes +v bob"
for name, client := range map[string]*testClient{
"alice": alice, "bob": bob,
} {
lines := client.readUntil(func(l string) bool {
return strings.Contains(l, " MODE ")
})
got := lines[len(lines)-1]
if !strings.HasSuffix(got, change) {
t.Errorf("%s: got %q, want it to end in %q", name, got, change)
}
}
}
// TestRelayLineBreaks checks that a CR, LF or NUL in a stored
// body line, key or mask never reaches an IRC listener user
// as a line break, so no extra line arrives, and that a body
// line keeps its parameter and leading ':' once they are
// removed.
func TestRelayLineBreaks(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob", []string{
"one\r\n:admin!a@b PRIVMSG bob :hi",
"two\n:admin!a@b NOTICE bob :hi\x00",
})
env.queueMessage(t, "bob", "MODE", "op", "#breaks",
[]string{"+k", "key\r\nERROR :Closing link"})
env.queueMessage(t, "bob", "MODE", "op", "#breaks",
[]string{"+b", "*!*@*\r\n:admin!a@b KICK #breaks bob"})
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"\r\n", "\x00:hi"})
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"the end"})
lines := bob.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})
want := []string{
":carol!carol@* PRIVMSG bob :one:admin!a@b PRIVMSG bob :hi",
":carol!carol@* PRIVMSG bob :two:admin!a@b NOTICE bob :hi",
":op!op@* MODE #breaks +k :keyERROR :Closing link",
":op!op@* MODE #breaks +b :*!*@*:admin!a@b KICK #breaks bob",
":carol!carol@* PRIVMSG bob :",
":carol!carol@* PRIVMSG bob ::hi",
":carol!carol@* PRIVMSG bob :the end",
}
got := lines[max(0, len(lines)-len(want)):]
if !slices.Equal(got, want) {
t.Errorf("got %q, want %q", got, want)
}
}
// TestRelayNickDiffersOnlyInCase checks that an IRC listener
// user alice receives a JOIN, PRIVMSG, PART, NICK and QUIT
// from Alice in a channel they share: nicks are
// case-sensitive, so Alice is someone else.
func TestRelayNickDiffersOnlyInCase(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#case")
capitalAlice := env.dial(t)
capitalAlice.register("Alice")
for _, step := range []struct {
line, want string
}{
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"PRIVMSG #case :hi there", ":Alice!Alice@* PRIVMSG #case :hi there"},
{"PART #case :see you", ":Alice!Alice@* PART #case :see you"},
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"NICK Alicia", ":Alice!Alice@* NICK Alicia"},
{"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT
{"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"},
} {
capitalAlice.send(step.line)
got := alice.readLine()
if got != step.want {
t.Errorf("%s: got %q, want %q", step.line, got, step.want)
}
}
}