Stop the slow test packages waiting (closes #263)
check / check (push) Failing after 1m47s

- Tests hash passwords at bcrypt.MinCost, set through the
  PasswordCost field of db.Params.
- Test IRC servers check the queue only on broker notification;
  one test keeps the 100 ms fallback poll.
- The stop-timeout test shortens the timeout through a test-only
  setter.
- service and handlers tests build config.Config directly instead
  of through config.New, so they run in parallel: service had run
  serially, and handlers started its servers one at a time.
- Repeated race trials run as parallel subtests.
- Fixed sleeps become waits on the broker.
- A new test covers the relay's failed queue read, which only the
  100 ms poll had reached.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-10 21:05:55 +00:00
1 parent bc8aa6bb96
commit d39e0f2bb5
12 files changed
+362 -209

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ func (database *Database) SetPassword(
password string, password string,
) error { ) error {
hash, err := bcrypt.GenerateFromPassword( hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcrypt.DefaultCost, []byte(password), database.passwordCost,
) )
if err != nil { if err != nil {
return fmt.Errorf("hash password: %w", err) return fmt.Errorf("hash password: %w", err)
+18 -5
View File
@@ -15,6 +15,7 @@ import (
"strings" "strings"
"go.uber.org/fx" "go.uber.org/fx"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
@@ -39,13 +40,19 @@ type Params struct {
Logger *logger.Logger Logger *logger.Logger
Config *config.Config Config *config.Config
// PasswordCost is the bcrypt cost at which SetPassword hashes
// a password. The server leaves it unset, which means
// bcrypt.DefaultCost. Tests set a lower cost to save time.
PasswordCost int `optional:"true"`
} }
// Database manages the SQLite connection and migrations. // Database manages the SQLite connection and migrations.
type Database struct { type Database struct {
conn *sql.DB conn *sql.DB
log *slog.Logger log *slog.Logger
params *Params params *Params
passwordCost int
} }
// New creates a new Database and registers lifecycle hooks. // New creates a new Database and registers lifecycle hooks.
@@ -53,9 +60,15 @@ func New(
lifecycle fx.Lifecycle, lifecycle fx.Lifecycle,
params Params, params Params,
) (*Database, error) { ) (*Database, error) {
passwordCost := params.PasswordCost
if passwordCost == 0 {
passwordCost = bcrypt.DefaultCost
}
database := &Database{ //nolint:exhaustruct // conn set in OnStart database := &Database{ //nolint:exhaustruct // conn set in OnStart
params: &params, params: &params,
log: params.Logger.Get(), log: params.Logger.Get(),
passwordCost: passwordCost,
} }
database.log.Info("Database instantiated") database.log.Info("Database instantiated")
+6 -3
View File
@@ -8,10 +8,12 @@ import (
"testing" "testing"
"time" "time"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/dbtest"
) )
// NewTestDatabase creates an in-memory database for testing. // NewTestDatabase creates an in-memory database for testing. It
// hashes passwords at bcrypt.MinCost.
func NewTestDatabase(tb testing.TB) (*Database, error) { func NewTestDatabase(tb testing.TB) (*Database, error) {
tb.Helper() tb.Helper()
@@ -23,8 +25,9 @@ func NewTestDatabase(tb testing.TB) (*Database, error) {
} }
database := &Database{ //nolint:exhaustruct // test helper, params not needed database := &Database{ //nolint:exhaustruct // test helper, params not needed
conn: conn, conn: conn,
log: slog.Default(), log: slog.Default(),
passwordCost: bcrypt.MinCost,
} }
err = database.runMigrations(context.Background()) err = database.runMigrations(context.Background())
+88 -80
View File
@@ -145,13 +145,6 @@ type testServer struct {
handlers *handlers.Handlers handlers *handlers.Handlers
} }
// viperMu makes tests start their servers one at a time,
// because config.New writes the global viper instance and
// the server reads it as it starts.
//
//nolint:gochecknoglobals // guards a global, so must be one
var viperMu sync.Mutex
func newTestServer( func newTestServer(
t *testing.T, t *testing.T,
) *testServer { ) *testServer {
@@ -167,9 +160,6 @@ func newTestServerWith(
) *testServer { ) *testServer {
t.Helper() t.Helper()
viperMu.Lock()
defer viperMu.Unlock()
dbURL := dbtest.NewURL(t) dbURL := dbtest.NewURL(t)
copyTemplateDB(t, dbURL) copyTemplateDB(t, dbURL)
@@ -184,31 +174,20 @@ func newTestServerWith(
fx.Provide( fx.Provide(
newTestGlobals, newTestGlobals,
logger.New, logger.New,
func( // Built here rather than by config.New, which writes
lifecycle fx.Lifecycle, // the global viper instance, so servers start in
globs *globals.Globals, // parallel. Settings left out take their defaults.
log *logger.Logger, func() *config.Config {
) (*config.Config, error) { return &config.Config{
cfg, err := config.New( DBURL: dbURL,
lifecycle, config.Params{ //nolint:exhaustruct // One line: each line is a database write per new session.
Globals: globs, Logger: log, MOTD: "neoirc test server",
}, HashcashBits: hashcashBits,
) OperName: operName,
if err != nil { OperPassword: operPassword,
return nil, fmt.Errorf( MessageMaxAge: "720h",
"test config: %w", err, QueueMaxAge: "720h",
)
} }
cfg.DBURL = dbURL
cfg.Port = 0
// One line: each line is a database write per new session.
cfg.MOTD = "neoirc test server"
cfg.HashcashBits = hashcashBits
cfg.OperName = operName
cfg.OperPassword = operPassword
return cfg, nil
}, },
newTestDB, newTestDB,
broker.New, broker.New,
@@ -255,7 +234,7 @@ func newTestDB(
cfg *config.Config, cfg *config.Config,
) (*db.Database, error) { ) (*db.Database, error) {
database, err := db.New(lifecycle, db.Params{ //nolint:exhaustruct database, err := db.New(lifecycle, db.Params{ //nolint:exhaustruct
Logger: log, Config: cfg, Logger: log, Config: cfg, PasswordCost: bcrypt.MinCost,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("test db: %w", err) return nil, fmt.Errorf("test db: %w", err)
@@ -1750,52 +1729,67 @@ func TestConcurrentQuitsSendOneQuit(t *testing.T) {
// that the second session then holds one QUIT from the // that the second session then holds one QUIT from the
// first, and that the first session is deleted: its tokens // first, and that the first session is deleted: its tokens
// are refused and its nick is free. It does all this // are refused and its nick is free. It does all this
// quitRaceTrials times, each on a new server, because each // quitRaceTrials times, at once, each on a new server, because
// trial logs in and logins are rate limited. // each trial logs in and logins are rate limited.
func checkQuitRace( func checkQuitRace(
t *testing.T, t *testing.T,
leave func(tserver *testServer, tokens []string), leave func(tserver *testServer, tokens []string),
) { ) {
t.Helper() t.Helper()
for trial := range quitRaceTrials {
t.Run(fmt.Sprintf("trial %d", trial), func(t *testing.T) {
t.Parallel()
checkQuitRaceOnce(t, newTestServer(t), leave)
})
}
}
// checkQuitRaceOnce runs one trial of checkQuitRace on
// tserver.
func checkQuitRaceOnce(
t *testing.T,
tserver *testServer,
leave func(tserver *testServer, tokens []string),
) {
t.Helper()
const nick = "racingquitter" const nick = "racingquitter"
for trial := range quitRaceTrials { firstToken := tserver.createSession(nick)
tserver := newTestServer(t) observerToken := tserver.createSession("raceobserver")
firstToken := tserver.createSession(nick)
observerToken := tserver.createSession("raceobserver")
tserver.setPassword(nick) tserver.setPassword(nick)
tokens := []string{firstToken, tserver.login(nick)} tokens := []string{firstToken, tserver.login(nick)}
for _, token := range []string{firstToken, observerToken} { for _, token := range []string{firstToken, observerToken} {
tserver.sendCommand(token, map[string]any{ tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#quitrace", commandKey: joinCmd, toKey: "#quitrace",
}) })
}
_, lastID := tserver.pollMessages(observerToken, 0)
leave(tserver, tokens)
msgs, _ := tserver.pollMessages(observerToken, lastID)
quits := countQuits(msgs, nick)
if quits != 1 {
t.Errorf("trial %d: %d QUITs, want 1: %v", trial, quits, msgs)
}
for _, token := range tokens {
status, _ := tserver.getState(token)
if status != http.StatusUnauthorized {
t.Errorf("trial %d: state got %d, want 401", trial, status)
}
}
// createSession fails the test if the nick is taken.
tserver.createSession(nick)
} }
_, lastID := tserver.pollMessages(observerToken, 0)
leave(tserver, tokens)
msgs, _ := tserver.pollMessages(observerToken, lastID)
quits := countQuits(msgs, nick)
if quits != 1 {
t.Errorf("%d QUITs, want 1: %v", quits, msgs)
}
for _, token := range tokens {
status, _ := tserver.getState(token)
if status != http.StatusUnauthorized {
t.Errorf("state got %d, want 401", status)
}
}
// createSession fails the test if the nick is taken.
tserver.createSession(nick)
} }
// TestLogoutOfOneClientKeepsSession checks that when one of // TestLogoutOfOneClientKeepsSession checks that when one of
@@ -1933,11 +1927,28 @@ const (
// return once the session has left. // return once the session has left.
quitWakeDeadline = 5 * time.Second quitWakeDeadline = 5 * time.Second
// pollWaitStep is how often checkQuitWakesPoll checks // pollWaitStep is how often waitForLongPoll checks
// whether the long poll is waiting yet. // whether the long poll is waiting yet.
pollWaitStep = 10 * time.Millisecond pollWaitStep = 10 * time.Millisecond
) )
// waitForLongPoll waits until a long poll of the session
// sessionID is waiting, and fails the test if none is within
// quitWakeDeadline.
func (tserver *testServer) waitForLongPoll(sessionID int64) {
tserver.t.Helper()
deadline := time.Now().Add(quitWakeDeadline)
for tserver.broker.ListenerCount(sessionID) == 0 {
if time.Now().After(deadline) {
tserver.t.Fatal("the long poll did not start waiting")
}
time.Sleep(pollWaitStep)
}
}
// TestQuitWakesPeerLongPoll checks that a session's QUIT // TestQuitWakesPeerLongPoll checks that a session's QUIT
// wakes the long poll of a user who shares a channel with // wakes the long poll of a user who shares a channel with
// it. // it.
@@ -2012,15 +2023,7 @@ func checkQuitWakesPoll(
msgs, pollErr = tserver.longPoll(peerToken, lastID, quitWakePollTimeout) msgs, pollErr = tserver.longPoll(peerToken, lastID, quitWakePollTimeout)
}() }()
deadline := time.Now().Add(quitWakeDeadline) tserver.waitForLongPoll(peerID)
for tserver.broker.ListenerCount(peerID) == 0 {
if time.Now().After(deadline) {
t.Fatal("the long poll did not start waiting")
}
time.Sleep(pollWaitStep)
}
leave(tserver, token) leave(tserver, token)
@@ -2850,7 +2853,12 @@ func TestLongPoll(t *testing.T) {
pollMsgs = result.Messages pollMsgs = result.Messages
}() }()
time.Sleep(200 * time.Millisecond) bobID, err := tserver.database.GetSessionByNick(t.Context(), "lp_bob")
if err != nil {
t.Fatalf("get session of lp_bob: %v", err)
}
tserver.waitForLongPoll(bobID)
tserver.sendCommand(aliceToken, map[string]any{ tserver.sendCommand(aliceToken, map[string]any{
commandKey: privmsgCmd, commandKey: privmsgCmd,
+7
View File
@@ -70,6 +70,10 @@ type Conn struct {
closed bool closed bool
killed bool killed bool
// pollInterval is how often the relay checks the client
// output queue when no broker notification arrives.
pollInterval time.Duration
// quitReason is the reason in the QUIT that peers are // quitReason is the reason in the QUIT that peers are
// sent when the connection ends. The client's QUIT // sent when the connection ends. The client's QUIT
// replaces it. // replaces it.
@@ -83,6 +87,7 @@ func newConn(
brk *broker.Broker, brk *broker.Broker,
cfg *config.Config, cfg *config.Config,
svc *service.Service, svc *service.Service,
poll time.Duration,
) *Conn { ) *Conn {
host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String()) host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String())
@@ -101,6 +106,8 @@ func newConn(
serverSfx: srvName, serverSfx: srvName,
remoteIP: host, remoteIP: host,
quitReason: "Connection closed", quitReason: "Connection closed",
pollInterval: poll,
} }
conn.commands = conn.buildCommandMap() conn.commands = conn.buildCommandMap()
+13 -9
View File
@@ -14,16 +14,16 @@ import (
) )
// NewTestServer creates a Server suitable for testing, whose // NewTestServer creates a Server suitable for testing, whose
// connections use interval and deadline in place of // connections use interval, deadline and poll in place of
// pingInterval and pongDeadline, and resolve in place of // pingInterval, pongDeadline and pollInterval, and resolve in
// resolveHost. // place of resolveHost.
// The caller must call Stop() when finished. // The caller must call Stop() when finished.
func NewTestServer( func NewTestServer(
log *slog.Logger, log *slog.Logger,
cfg *config.Config, cfg *config.Config,
database *db.Database, database *db.Database,
brk *broker.Broker, brk *broker.Broker,
interval, deadline time.Duration, interval, deadline, poll time.Duration,
resolve func(ctx context.Context, addr string) string, resolve func(ctx context.Context, addr string) string,
) *Server { ) *Server {
globs := &globals.Globals{ globs := &globals.Globals{
@@ -46,10 +46,18 @@ func NewTestServer(
pingInterval: interval, pingInterval: interval,
pongDeadline: deadline, pongDeadline: deadline,
pollInterval: poll,
stopTimeout: stopTimeout,
resolveHost: resolve, resolveHost: resolve,
} }
} }
// SetStopTimeout makes Stop wait at most timeout for the
// connections' cleanup.
func (s *Server) SetStopTimeout(timeout time.Duration) {
s.stopTimeout = timeout
}
// ResolveHost is the reverse DNS lookup that a server made by // ResolveHost is the reverse DNS lookup that a server made by
// New does for each client. // New does for each client.
func ResolveHost(ctx context.Context, addr string) string { func ResolveHost(ctx context.Context, addr string) string {
@@ -99,10 +107,6 @@ const PingInterval = pingInterval
// for a line before it closes the connection. // for a line before it closes the connection.
const PongDeadline = pongDeadline const PongDeadline = pongDeadline
// StopTimeout is the longest Stop waits for the connections'
// cleanup.
const StopTimeout = stopTimeout
// NewTestConn returns a Conn for nick on tcpConn, without a // NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it. // server, database or service behind it.
func NewTestConn( func NewTestConn(
@@ -111,7 +115,7 @@ func NewTestConn(
tcpConn net.Conn, tcpConn net.Conn,
nick string, nick string,
) *Conn { ) *Conn {
conn := newConn(tcpConn, log, nil, nil, cfg, nil) conn := newConn(tcpConn, log, nil, nil, cfg, nil, pollInterval)
conn.nick = nick conn.nick = nick
return conn return conn
-4
View File
@@ -3,7 +3,6 @@ package ircserver_test
import ( import (
"strings" "strings"
"testing" "testing"
"time"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
) )
@@ -1508,9 +1507,6 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
bob.joinAndDrain("#trio") bob.joinAndDrain("#trio")
carol.joinAndDrain("#trio") carol.joinAndDrain("#trio")
// Drain join notifications.
time.Sleep(100 * time.Millisecond)
// alice sends; both bob and carol should receive. // alice sends; both bob and carol should receive.
alice.send("PRIVMSG #trio :hello trio") alice.send("PRIVMSG #trio :hello trio")
+1 -1
View File
@@ -17,7 +17,7 @@ import (
func (c *Conn) relayMessages(ctx context.Context) { func (c *Conn) relayMessages(ctx context.Context) {
// Use a ticker as a fallback; primary wakeup is via // Use a ticker as a fallback; primary wakeup is via
// broker notification. // broker notification.
ticker := time.NewTicker(pollInterval) ticker := time.NewTicker(c.pollInterval)
defer ticker.Stop() defer ticker.Stop()
for { for {
+63 -1
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
@@ -58,7 +59,11 @@ func (env *testEnv) queueMessage(
func TestRelayKeepsOneWaitChannel(t *testing.T) { func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Parallel() t.Parallel()
env := newTestEnv(t) env := newTestEnvWithResolver(
t, &config.Config{ServerName: testServerName},
ircserver.PingInterval, ircserver.PongDeadline,
ircserver.PollInterval, ircserver.ResolveHost,
)
client := env.dial(t) client := env.dial(t)
client.register("idle") client.register("idle")
@@ -119,6 +124,63 @@ func (env *testEnv) checkOneWaitChannel(
} }
} }
// TestRelayDeliversAfterFailedRead checks that when the relay
// cannot read the client output queue, it waits for the next
// notification, and delivers once the queue can be read again.
func TestRelayDeliversAfterFailedRead(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("reader")
sessionID, err := env.database.GetSessionByNick(t.Context(), "reader")
if err != nil {
t.Fatalf("get session of reader: %v", err)
}
dbtest.Exec(t, env.dbURL,
"ALTER TABLE client_queues RENAME TO hidden_queues")
// Each pass of the relay takes a wait channel and then reads
// the queue. Once it holds a channel after the second
// notification, the read after the first one has failed.
for range 2 {
env.waitForWaitChannel(t, sessionID)
env.brk.Notify(sessionID)
}
env.waitForWaitChannel(t, sessionID)
dbtest.Exec(t, env.dbURL,
"ALTER TABLE hidden_queues RENAME TO client_queues")
env.queueMessage(
t, "reader", "PRIVMSG", "carol", "reader", []string{"readable"},
)
client.readUntil(func(l string) bool {
return strings.Contains(l, "readable")
})
}
// waitForWaitChannel waits until the session sessionID holds a
// broker wait channel, and fails the test if it does not
// within testTimeout.
func (env *testEnv) waitForWaitChannel(t *testing.T, sessionID int64) {
t.Helper()
deadline := time.Now().Add(testTimeout)
for env.brk.ListenerCount(sessionID) == 0 {
if time.Now().After(deadline) {
t.Fatal("the relay is not waiting for a notification")
}
time.Sleep(relayCheckStep)
}
}
// TestRelayMultiLineBody checks that each line of a PRIVMSG // TestRelayMultiLineBody checks that each line of a PRIVMSG
// or NOTICE body sent over the HTTP API reaches an IRC // or NOTICE body sent over the HTTP API reaches an IRC
// listener user as its own line. // listener user as its own line.
+10 -3
View File
@@ -53,6 +53,11 @@ type Server struct {
pingInterval time.Duration pingInterval time.Duration
pongDeadline time.Duration pongDeadline time.Duration
// pollInterval and stopTimeout are the constants of the
// same names, except in tests, which change them.
pollInterval time.Duration
stopTimeout time.Duration
// resolveHost is the function of the same name, except in // resolveHost is the function of the same name, except in
// tests, which replace it with one that blocks. // tests, which replace it with one that blocks.
resolveHost func(ctx context.Context, addr string) string resolveHost func(ctx context.Context, addr string) string
@@ -79,6 +84,8 @@ func New(
pingInterval: pingInterval, pingInterval: pingInterval,
pongDeadline: pongDeadline, pongDeadline: pongDeadline,
pollInterval: pollInterval,
stopTimeout: stopTimeout,
resolveHost: resolveHost, resolveHost: resolveHost,
} }
@@ -154,10 +161,10 @@ func (s *Server) stop() {
select { select {
case <-done: case <-done:
case <-time.After(stopTimeout): case <-time.After(s.stopTimeout):
s.log.Error( s.log.Error(
"irc connections not cleaned up before stop timeout", "irc connections not cleaned up before stop timeout",
"timeout", stopTimeout, "timeout", s.stopTimeout,
) )
} }
} }
@@ -181,7 +188,7 @@ func (s *Server) acceptLoop(ctx context.Context) {
client := newConn( client := newConn(
tcpConn, s.log, tcpConn, s.log,
s.database, s.brk, s.cfg, s.svc, s.database, s.brk, s.cfg, s.svc, s.pollInterval,
) )
s.mu.Lock() s.mu.Lock()
+26 -13
View File
@@ -18,6 +18,7 @@ import (
"time" "time"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
@@ -32,6 +33,12 @@ import (
const testTimeout = 5 * time.Second const testTimeout = 5 * time.Second
// testPollInterval is how often the relay of a test server
// checks the client output queue without a broker
// notification. It is so long that the relay delivers only on
// notification, and a test fails when one is missing.
const testPollInterval = time.Hour
// testEnv holds the shared test infrastructure. // testEnv holds the shared test infrastructure.
type testEnv struct { type testEnv struct {
dbURL string dbURL string
@@ -91,16 +98,19 @@ func newTestEnvWithPingTimes(
t.Helper() t.Helper()
return newTestEnvWithResolver( return newTestEnvWithResolver(
t, cfg, interval, deadline, ircserver.ResolveHost, t, cfg, interval, deadline, testPollInterval,
ircserver.ResolveHost,
) )
} }
// newTestEnvWithResolver is newTestEnvWithPingTimes, except // newTestEnvWithResolver is newTestEnvWithPingTimes, except
// that the server looks up a client's hostname with resolve. // that the relay checks the client output queue every poll
// without a broker notification, and the server looks up a
// client's hostname with resolve.
func newTestEnvWithResolver( func newTestEnvWithResolver(
t *testing.T, t *testing.T,
cfg *config.Config, cfg *config.Config,
interval, deadline time.Duration, interval, deadline, poll time.Duration,
resolve func(ctx context.Context, addr string) string, resolve func(ctx context.Context, addr string) string,
) *testEnv { ) *testEnv {
t.Helper() t.Helper()
@@ -116,8 +126,9 @@ func newTestEnvWithResolver(
} }
database, err := db.New(lifecycle, db.Params{ database, err := db.New(lifecycle, db.Params{
Logger: dbLog, Logger: dbLog,
Config: &config.Config{DBURL: dbURL}, Config: &config.Config{DBURL: dbURL},
PasswordCost: bcrypt.MinCost,
}) })
if err != nil { if err != nil {
t.Fatalf("new database: %v", err) t.Fatalf("new database: %v", err)
@@ -134,7 +145,7 @@ func newTestEnvWithResolver(
)) ))
srv := ircserver.NewTestServer( srv := ircserver.NewTestServer(
log, cfg, database, brk, interval, deadline, resolve, log, cfg, database, brk, interval, deadline, poll, resolve,
) )
// Port 0 makes the system pick a free port. dial reads it // Port 0 makes the system pick a free port. dial reads it
@@ -430,7 +441,8 @@ func TestRegistrationWhileAnotherLookupIsPending(t *testing.T) {
env := newTestEnvWithResolver( env := newTestEnvWithResolver(
t, &config.Config{ServerName: testServerName}, t, &config.Config{ServerName: testServerName},
ircserver.PingInterval, ircserver.PongDeadline, resolve, ircserver.PingInterval, ircserver.PongDeadline,
testPollInterval, resolve,
) )
env.dial(t) env.dial(t)
@@ -1039,12 +1051,16 @@ func TestStopEndsSessions(t *testing.T) {
} }
// TestStopReturnsWhileCleanupIsStuck checks that Stop stops // TestStopReturnsWhileCleanupIsStuck checks that Stop stops
// waiting for a connection's cleanup after StopTimeout, so a // waiting for a connection's cleanup after its stop timeout,
// cleanup that cannot finish does not hold up stopping. // so a cleanup that cannot finish does not hold up stopping.
func TestStopReturnsWhileCleanupIsStuck(t *testing.T) { func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Parallel() t.Parallel()
const stopTimeout = 100 * time.Millisecond
env := newTestEnv(t) env := newTestEnv(t)
env.srv.SetStopTimeout(stopTimeout)
client := env.dial(t) client := env.dial(t)
client.register("stuck") client.register("stuck")
@@ -1070,7 +1086,7 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
select { select {
case <-stopped: case <-stopped:
case <-time.After(ircserver.StopTimeout + testTimeout): case <-time.After(stopTimeout + testTimeout):
t.Error("Stop did not return while a cleanup was stuck") t.Error("Stop did not return while a cleanup was stuck")
} }
@@ -1122,9 +1138,6 @@ func TestDirectMessage(t *testing.T) {
receiver := env.dial(t) receiver := env.dial(t)
receiver.register("dmreceiver") receiver.register("dmreceiver")
// Give relay goroutines time to start.
time.Sleep(100 * time.Millisecond)
sender.send("PRIVMSG dmreceiver :hello privately") sender.send("PRIVMSG dmreceiver :hello privately")
lines := receiver.readUntil(func(line string) bool { lines := receiver.readUntil(func(line string) bool {
+129 -89
View File
@@ -1,7 +1,3 @@
// Tests use a global viper instance for configuration,
// making parallel execution unsafe.
//
//nolint:paralleltest
package service_test package service_test
import ( import (
@@ -59,28 +55,12 @@ func newTestEnv(t *testing.T) *testEnv {
} }
}, },
logger.New, logger.New,
func( func() *config.Config {
lifecycle fx.Lifecycle, return &config.Config{
globs *globals.Globals, DBURL: dbURL,
log *logger.Logger, OperName: "admin",
) (*config.Config, error) { OperPassword: "secret",
cfg, err := config.New(
lifecycle, config.Params{ //nolint:exhaustruct
Globals: globs, Logger: log,
},
)
if err != nil {
return nil, fmt.Errorf(
"test config: %w", err,
)
} }
cfg.DBURL = dbURL
cfg.Port = 0
cfg.OperName = "admin"
cfg.OperPassword = "secret"
return cfg, nil
}, },
func( func(
lifecycle fx.Lifecycle, lifecycle fx.Lifecycle,
@@ -136,6 +116,8 @@ func createSession(
// environment starts on an empty database while another one // environment starts on an empty database while another one
// is still open. // is still open.
func TestEachTestEnvHasItsOwnDatabase(t *testing.T) { func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
t.Parallel()
ctx := t.Context() ctx := t.Context()
first := newTestEnv(t) first := newTestEnv(t)
@@ -146,6 +128,8 @@ func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
} }
func TestFanOut(t *testing.T) { func TestFanOut(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -173,6 +157,8 @@ func TestFanOut(t *testing.T) {
} }
func TestJoinChannel(t *testing.T) { func TestJoinChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -213,6 +199,8 @@ func TestJoinChannel(t *testing.T) {
} }
func TestPartChannel(t *testing.T) { func TestPartChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -313,6 +301,8 @@ func joinConcurrently(
// concurrent JOINs of a +l 2 channel with one member, one // concurrent JOINs of a +l 2 channel with one member, one
// gets in and the other gets ERR_CHANNELISFULL. // gets in and the other gets ERR_CHANNELISFULL.
func TestConcurrentJoinsRespectUserLimit(t *testing.T) { func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
t.Parallel()
const userLimit = 2 const userLimit = 2
env := newTestEnv(t) env := newTestEnv(t)
@@ -375,6 +365,8 @@ func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
// two concurrent JOINs that create a channel, exactly one // two concurrent JOINs that create a channel, exactly one
// makes its session the operator. // makes its session the operator.
func TestConcurrentFirstJoinsMakeOneOperator(t *testing.T) { func TestConcurrentFirstJoinsMakeOneOperator(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -426,6 +418,8 @@ func TestConcurrentFirstJoinsMakeOneOperator(t *testing.T) {
// either joins a channel that still exists or gets // either joins a channel that still exists or gets
// ERR_NOSUCHCHANNEL, and never fails otherwise. // ERR_NOSUCHCHANNEL, and never fails otherwise.
func TestJoinConcurrentWithLastPart(t *testing.T) { func TestJoinConcurrentWithLastPart(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -491,6 +485,8 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
// quits, gets in only once the channel is deleted, into a // quits, gets in only once the channel is deleted, into a
// new channel of that name with no key. // new channel of that name with no key.
func TestJoinConcurrentWithLastQuit(t *testing.T) { func TestJoinConcurrentWithLastQuit(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -569,81 +565,95 @@ const quitCrowdSize = 10
// ahead of that JOIN. The quitting session is also on a // ahead of that JOIN. The quitting session is also on a
// channel with quitCrowdSize other sessions, made before the // channel with quitCrowdSize other sessions, made before the
// watcher's channel, so that its QUIT reaches the watcher // watcher's channel, so that its QUIT reaches the watcher
// last. // last. Each trial runs on a database of its own.
func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) { func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
env := newTestEnv(t) t.Parallel()
ctx := t.Context()
for trial := range concurrentJoinTrials { for trial := range concurrentJoinTrials {
nick := fmt.Sprintf("reused%d", trial) t.Run(fmt.Sprintf("trial %d", trial), func(t *testing.T) {
crowd := fmt.Sprintf("#crowd%d", trial) t.Parallel()
shared := fmt.Sprintf("#shared%d", trial)
quitterSID := createSession(ctx, t, env.db, nick)
for _, channel := range []string{crowd, shared} { checkQuitQueuedAheadOfJoin(t, newTestEnv(t))
_, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "") })
if err != nil { }
t.Fatalf("join %s: %v", channel, err) }
}
// checkQuitQueuedAheadOfJoin runs one trial of
// TestQuitQueuedAheadOfNewNickHoldersJoin on env.
func checkQuitQueuedAheadOfJoin(t *testing.T, env *testEnv) {
t.Helper()
const (
nick = "reused"
crowd = "#crowd"
shared = "#shared"
watcherNick = "watcher"
)
ctx := t.Context()
quitterSID := createSession(ctx, t, env.db, nick)
for _, channel := range []string{crowd, shared} {
_, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "")
if err != nil {
t.Fatalf("join %s: %v", channel, err)
} }
}
for member := range quitCrowdSize { for member := range quitCrowdSize {
memberNick := fmt.Sprintf("crowd%dx%d", trial, member) memberNick := fmt.Sprintf("crowd%d", member)
memberSID := createSession(ctx, t, env.db, memberNick) memberSID := createSession(ctx, t, env.db, memberNick)
_, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "") _, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "")
if err != nil { if err != nil {
t.Fatalf("join %s: %v", memberNick, err) t.Fatalf("join %s: %v", memberNick, err)
}
} }
}
watcherNick := fmt.Sprintf("watcher%d", trial) watcherSID, watcherClientID, _, err := env.db.CreateSession(
ctx, watcherNick, watcherNick, "localhost", "127.0.0.1",
)
if err != nil {
t.Fatalf("create session %s: %v", watcherNick, err)
}
watcherSID, watcherClientID, _, err := env.db.CreateSession( _, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "")
ctx, watcherNick, watcherNick, "localhost", "127.0.0.1", if err != nil {
t.Fatalf("join %s: %v", watcherNick, err)
}
_, lastQueueID, err := env.db.PollMessages(ctx, watcherClientID, 0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
errs := runConcurrently(
func() error {
env.svc.BroadcastQuit(ctx, quitterSID, nick, "")
return nil
},
func() error {
return takeNickAndJoin(ctx, env, nick, shared)
},
)
if errs[1] != nil {
t.Fatalf("new session: %v", errs[1])
}
msgs, _, err := env.db.PollMessages(
ctx, watcherClientID, lastQueueID, 0,
)
if err != nil {
t.Fatalf("poll: %v", err)
}
commands := commandsFrom(msgs, nick)
if !slices.Equal(commands, []string{irc.CmdQuit, irc.CmdJoin}) {
t.Errorf(
"watcher got %v from %s, want QUIT then JOIN",
commands, nick,
) )
if err != nil {
t.Fatalf("create session %s: %v", watcherNick, err)
}
_, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "")
if err != nil {
t.Fatalf("join %s: %v", watcherNick, err)
}
_, lastQueueID, err := env.db.PollMessages(ctx, watcherClientID, 0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
errs := runConcurrently(
func() error {
env.svc.BroadcastQuit(ctx, quitterSID, nick, "")
return nil
},
func() error {
return takeNickAndJoin(ctx, env, nick, shared)
},
)
if errs[1] != nil {
t.Fatalf("trial %d: new session: %v", trial, errs[1])
}
msgs, _, err := env.db.PollMessages(
ctx, watcherClientID, lastQueueID, 0,
)
if err != nil {
t.Fatalf("poll: %v", err)
}
commands := commandsFrom(msgs, nick)
if !slices.Equal(commands, []string{irc.CmdQuit, irc.CmdJoin}) {
t.Errorf(
"trial %d: watcher got %v from %s, want QUIT then JOIN",
trial, commands, nick,
)
}
} }
} }
@@ -691,6 +701,8 @@ func commandsFrom(msgs []db.IRCMessage, nick string) []string {
// into a +i channel uses the invite up, so that a JOIN // into a +i channel uses the invite up, so that a JOIN
// after leaving gets ERR_INVITEONLYCHAN. // after leaving gets ERR_INVITEONLYCHAN.
func TestJoinUsesUpInvite(t *testing.T) { func TestJoinUsesUpInvite(t *testing.T) {
t.Parallel()
const channel = "#club" const channel = "#club"
env := newTestEnv(t) env := newTestEnv(t)
@@ -736,6 +748,8 @@ func TestJoinUsesUpInvite(t *testing.T) {
// session's nick, username and hostname refuses its JOIN // session's nick, username and hostname refuses its JOIN
// with ERR_BANNEDFROMCHAN. // with ERR_BANNEDFROMCHAN.
func TestJoinRefusedByHostmaskBan(t *testing.T) { func TestJoinRefusedByHostmaskBan(t *testing.T) {
t.Parallel()
const channel = "#club" const channel = "#club"
env := newTestEnv(t) env := newTestEnv(t)
@@ -774,6 +788,8 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) {
// keeps a channel other members are still on and deletes // keeps a channel other members are still on and deletes
// the channel when its last member parts. // the channel when its last member parts.
func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) { func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
t.Parallel()
const channel = "#room" const channel = "#room"
env := newTestEnv(t) env := newTestEnv(t)
@@ -821,6 +837,8 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
// TestKickOfLastMemberDeletesChannel checks that a KICK // TestKickOfLastMemberDeletesChannel checks that a KICK
// that leaves a channel with no members deletes it. // that leaves a channel with no members deletes it.
func TestKickOfLastMemberDeletesChannel(t *testing.T) { func TestKickOfLastMemberDeletesChannel(t *testing.T) {
t.Parallel()
const channel = "#room" const channel = "#room"
env := newTestEnv(t) env := newTestEnv(t)
@@ -845,6 +863,8 @@ func TestKickOfLastMemberDeletesChannel(t *testing.T) {
} }
func TestSendChannelMessage(t *testing.T) { func TestSendChannelMessage(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -892,6 +912,8 @@ func TestSendChannelMessage(t *testing.T) {
} }
func TestBroadcastQuit(t *testing.T) { func TestBroadcastQuit(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -929,6 +951,8 @@ func TestBroadcastQuit(t *testing.T) {
// error as a DM to an unknown nick. The trigger deletes the // error as a DM to an unknown nick. The trigger deletes the
// target's session just before the DM is stored. // target's session just before the DM is stored.
func TestSendDirectMessageTargetQuits(t *testing.T) { func TestSendDirectMessageTargetQuits(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -960,6 +984,8 @@ func TestSendDirectMessageTargetQuits(t *testing.T) {
// trigger deletes the channel just before the message is // trigger deletes the channel just before the message is
// stored. // stored.
func TestSendChannelMessageChannelDeleted(t *testing.T) { func TestSendChannelMessageChannelDeleted(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -991,6 +1017,8 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
} }
func TestSendChannelMessage_Moderated(t *testing.T) { func TestSendChannelMessage_Moderated(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -1042,6 +1070,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
// not spend its hashcash stamp: the operator can then send // not spend its hashcash stamp: the operator can then send
// the same message with it. // the same message with it.
func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) { func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
t.Parallel()
const ( const (
channel = "#paid" channel = "#paid"
bits = 2 bits = 2
@@ -1067,6 +1097,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
for _, refusal := range refusals { for _, refusal := range refusals {
t.Run(refusal.name, func(t *testing.T) { t.Run(refusal.name, func(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -1119,6 +1151,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
} }
func TestQueryUserMode(t *testing.T) { func TestQueryUserMode(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -1169,6 +1203,8 @@ func TestQueryUserMode(t *testing.T) {
} }
func TestUserhostReply(t *testing.T) { func TestUserhostReply(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
@@ -1274,10 +1310,14 @@ func applyUserModeTests() []applyUserModeTest {
} }
func TestApplyUserMode(t *testing.T) { func TestApplyUserMode(t *testing.T) {
t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
for i, test := range applyUserModeTests() { for i, test := range applyUserModeTests() {
t.Run(test.modeStr, func(t *testing.T) { t.Run(test.modeStr, func(t *testing.T) {
t.Parallel()
ctx := t.Context() ctx := t.Context()
sid := createSession( sid := createSession(
ctx, t, env.db, fmt.Sprintf("user%d", i), ctx, t, env.db, fmt.Sprintf("user%d", i),