feat: add IRC wire protocol listener with shared service layer
Some checks failed
check / check (push) Failing after 46s
Some checks failed
check / check (push) Failing after 46s
Add a traditional IRC wire protocol listener (RFC 1459/2812) on configurable port (default :6667), sharing business logic with the HTTP API via a new service layer. - IRC listener: NICK, USER, PASS, JOIN, PART, PRIVMSG, NOTICE, TOPIC, MODE, KICK, QUIT, NAMES, LIST, WHOIS, WHO, AWAY, OPER, INVITE, LUSERS, MOTD, PING/PONG, CAP - Service layer: shared logic for both transports including channel join (with Tier 2 checks: ban/invite/key/limit), message send (with ban + moderation checks), nick change, topic, kick, mode, quit broadcast, away, oper, invite - BroadcastQuit uses FanOut pattern (one insert, N enqueues) - HTTP handlers delegate to service for all command logic - Tier 2 mode operations (+b/+i/+s/+k/+l) use service methods Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
365
internal/service/service_test.go
Normal file
365
internal/service/service_test.go
Normal file
@@ -0,0 +1,365 @@
|
||||
// Tests use a global viper instance for configuration,
|
||||
// making parallel execution unsafe.
|
||||
//
|
||||
//nolint:paralleltest
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
db.SetBcryptCost(bcrypt.MinCost)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// testEnv holds all dependencies for a service test.
|
||||
type testEnv struct {
|
||||
svc *service.Service
|
||||
db *db.Database
|
||||
broker *broker.Broker
|
||||
app *fxtest.App
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
dbURL := fmt.Sprintf(
|
||||
"file:svc_test_%p?mode=memory&cache=shared",
|
||||
t,
|
||||
)
|
||||
|
||||
var (
|
||||
database *db.Database
|
||||
svc *service.Service
|
||||
)
|
||||
|
||||
brk := broker.New()
|
||||
|
||||
app := fxtest.New(t,
|
||||
fx.Provide(
|
||||
func() *globals.Globals {
|
||||
return &globals.Globals{ //nolint:exhaustruct
|
||||
Appname: "neoirc-test",
|
||||
Version: "test",
|
||||
}
|
||||
},
|
||||
logger.New,
|
||||
func(
|
||||
lifecycle fx.Lifecycle,
|
||||
globs *globals.Globals,
|
||||
log *logger.Logger,
|
||||
) (*config.Config, error) {
|
||||
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(
|
||||
lifecycle fx.Lifecycle,
|
||||
log *logger.Logger,
|
||||
cfg *config.Config,
|
||||
) (*db.Database, error) {
|
||||
return db.New(lifecycle, db.Params{ //nolint:exhaustruct
|
||||
Logger: log, Config: cfg,
|
||||
})
|
||||
},
|
||||
func() *broker.Broker { return brk },
|
||||
service.New,
|
||||
),
|
||||
fx.Populate(&database, &svc),
|
||||
)
|
||||
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(func() {
|
||||
app.RequireStop()
|
||||
})
|
||||
|
||||
return &testEnv{
|
||||
svc: svc,
|
||||
db: database,
|
||||
broker: brk,
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
// createSession is a test helper that creates a session
|
||||
// and returns the session ID.
|
||||
func createSession(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
database *db.Database,
|
||||
nick string,
|
||||
) int64 {
|
||||
t.Helper()
|
||||
|
||||
sessionID, _, _, err := database.CreateSession(
|
||||
ctx, nick, nick, "localhost", "127.0.0.1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create session %s: %v", nick, err)
|
||||
}
|
||||
|
||||
return sessionID
|
||||
}
|
||||
|
||||
func TestFanOut(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid1 := createSession(ctx, t, env.db, "alice")
|
||||
sid2 := createSession(ctx, t, env.db, "bob")
|
||||
|
||||
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
|
||||
|
||||
dbID, uuid, err := env.svc.FanOut(
|
||||
ctx, irc.CmdPrivmsg, "alice", "#test",
|
||||
nil, body, nil,
|
||||
[]int64{sid1, sid2},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FanOut: %v", err)
|
||||
}
|
||||
|
||||
if dbID == 0 {
|
||||
t.Error("expected non-zero dbID")
|
||||
}
|
||||
|
||||
if uuid == "" {
|
||||
t.Error("expected non-empty UUID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinChannel(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid := createSession(ctx, t, env.db, "alice")
|
||||
|
||||
result, err := env.svc.JoinChannel(
|
||||
ctx, sid, "alice", "#general",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("JoinChannel: %v", err)
|
||||
}
|
||||
|
||||
if result.ChannelID == 0 {
|
||||
t.Error("expected non-zero channel ID")
|
||||
}
|
||||
|
||||
if !result.IsCreator {
|
||||
t.Error("first joiner should be creator")
|
||||
}
|
||||
|
||||
// Second user joins — not creator.
|
||||
sid2 := createSession(ctx, t, env.db, "bob")
|
||||
|
||||
result2, err := env.svc.JoinChannel(
|
||||
ctx, sid2, "bob", "#general",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("JoinChannel bob: %v", err)
|
||||
}
|
||||
|
||||
if result2.IsCreator {
|
||||
t.Error("second joiner should not be creator")
|
||||
}
|
||||
|
||||
if result2.ChannelID != result.ChannelID {
|
||||
t.Error("both should join the same channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartChannel(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid := createSession(ctx, t, env.db, "alice")
|
||||
|
||||
_, err := env.svc.JoinChannel(
|
||||
ctx, sid, "alice", "#general",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("JoinChannel: %v", err)
|
||||
}
|
||||
|
||||
err = env.svc.PartChannel(
|
||||
ctx, sid, "alice", "#general", "bye",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("PartChannel: %v", err)
|
||||
}
|
||||
|
||||
// Parting a non-existent channel returns error.
|
||||
err = env.svc.PartChannel(
|
||||
ctx, sid, "alice", "#nonexistent", "",
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent channel")
|
||||
}
|
||||
|
||||
var ircErr *service.IRCError
|
||||
if !errors.As(err, &ircErr) {
|
||||
t.Errorf("expected IRCError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendChannelMessage(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid1 := createSession(ctx, t, env.db, "alice")
|
||||
sid2 := createSession(ctx, t, env.db, "bob")
|
||||
|
||||
_, err := env.svc.JoinChannel(
|
||||
ctx, sid1, "alice", "#chat",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join alice: %v", err)
|
||||
}
|
||||
|
||||
_, err = env.svc.JoinChannel(
|
||||
ctx, sid2, "bob", "#chat",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join bob: %v", err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
|
||||
|
||||
dbID, uuid, err := env.svc.SendChannelMessage(
|
||||
ctx, sid1, "alice",
|
||||
irc.CmdPrivmsg, "#chat", body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SendChannelMessage: %v", err)
|
||||
}
|
||||
|
||||
if dbID == 0 {
|
||||
t.Error("expected non-zero dbID")
|
||||
}
|
||||
|
||||
if uuid == "" {
|
||||
t.Error("expected non-empty UUID")
|
||||
}
|
||||
|
||||
// Non-member cannot send.
|
||||
sid3 := createSession(ctx, t, env.db, "charlie")
|
||||
|
||||
_, _, err = env.svc.SendChannelMessage(
|
||||
ctx, sid3, "charlie",
|
||||
irc.CmdPrivmsg, "#chat", body, nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-member send")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastQuit(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid1 := createSession(ctx, t, env.db, "alice")
|
||||
sid2 := createSession(ctx, t, env.db, "bob")
|
||||
|
||||
_, err := env.svc.JoinChannel(
|
||||
ctx, sid1, "alice", "#room",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join alice: %v", err)
|
||||
}
|
||||
|
||||
_, err = env.svc.JoinChannel(
|
||||
ctx, sid2, "bob", "#room",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join bob: %v", err)
|
||||
}
|
||||
|
||||
// BroadcastQuit should not panic and should clean up.
|
||||
env.svc.BroadcastQuit(
|
||||
ctx, sid1, "alice", "Goodbye",
|
||||
)
|
||||
|
||||
// Session should be deleted.
|
||||
_, lookupErr := env.db.GetSessionByNick(ctx, "alice")
|
||||
if lookupErr == nil {
|
||||
t.Error("expected session to be deleted after quit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendChannelMessage_Moderated(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid1 := createSession(ctx, t, env.db, "alice")
|
||||
sid2 := createSession(ctx, t, env.db, "bob")
|
||||
|
||||
result, err := env.svc.JoinChannel(
|
||||
ctx, sid1, "alice", "#modchat",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join alice: %v", err)
|
||||
}
|
||||
|
||||
_, err = env.svc.JoinChannel(
|
||||
ctx, sid2, "bob", "#modchat",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join bob: %v", err)
|
||||
}
|
||||
|
||||
// Set channel to moderated.
|
||||
chID := result.ChannelID
|
||||
_ = env.svc.SetChannelFlag(ctx, chID, 'm', true)
|
||||
|
||||
body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson
|
||||
|
||||
// Bob (non-op, non-voiced) should fail to send.
|
||||
_, _, err = env.svc.SendChannelMessage(
|
||||
ctx, sid2, "bob",
|
||||
irc.CmdPrivmsg, "#modchat", body, nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-voiced user in moderated channel")
|
||||
}
|
||||
|
||||
// Alice (operator) should succeed.
|
||||
_, _, err = env.svc.SendChannelMessage(
|
||||
ctx, sid1, "alice",
|
||||
irc.CmdPrivmsg, "#modchat", body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Errorf("operator should be able to send in moderated channel: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user