Files
neoirc/internal/service/service_test.go
clawbot f24e33a310
All checks were successful
check / check (push) Successful in 56s
fix: resolve 43 lint findings blocking CI
- server.go: drop unused (*Server).serve int return (unparam) and
  remove the dead exitCode field so cleanShutdown no longer writes
  to a field nothing reads.
- service.go: rename range var ch -> modeChar in parseUserModeString
  and the isKnownUserModeChar parameter (varnamelen).
- service_test.go: rename tc -> testCase (varnamelen); lift the
  inline struct and caseState to package-level named types
  (applyUserModeCase, applyUserModeCaseState) with every field
  set explicitly (exhaustruct); split the 167-line case table into
  four categorised helpers (funlen); extract the per-case runner
  and outcome/state verifiers into helpers so TestApplyUserMode
  drops below gocognit 30 and flattens the wantErr nestif block.

No changes to .golangci.yml, Makefile, Dockerfile, or CI config.
No //nolint was used to silence any of these findings.

docker build --no-cache . passes clean: 0 lint issues, all tests
pass with -race, binary compiles.
2026-04-17 14:37:14 +00:00

801 lines
19 KiB
Go

// 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"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
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)
}
}
func TestQueryUserMode(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
// Fresh session has no modes.
modes := env.svc.QueryUserMode(ctx, sid)
if modes != "+" {
t.Errorf("expected +, got %s", modes)
}
// Set wallops.
_ = env.db.SetSessionWallops(ctx, sid, true)
modes = env.svc.QueryUserMode(ctx, sid)
if modes != "+w" {
t.Errorf("expected +w, got %s", modes)
}
// Set oper.
_ = env.db.SetSessionOper(ctx, sid, true)
modes = env.svc.QueryUserMode(ctx, sid)
if modes != "+ow" {
t.Errorf("expected +ow, got %s", modes)
}
}
// applyUserModeCaseState is the subset of session user-mode
// state the rigorous TestApplyUserMode suite asserts on. It
// mirrors the columns (oper, wallops) that the parser is
// permitted to mutate.
type applyUserModeCaseState struct {
oper bool
wallops bool
}
// applyUserModeCase describes one rigor-suite case for
// Service.ApplyUserMode: the pre-call DB state, the mode
// string input, and the expected post-call observable state
// (mode string on success, IRC error code on rejection, and
// persisted session state either way).
type applyUserModeCase struct {
name string
initialState applyUserModeCaseState
modeStr string
wantModes string
wantErr bool
wantErrCode irc.IRCMessageType
wantState applyUserModeCaseState
}
// applyUserModeCases returns every case listed in sneak's
// review of PR #96 plus a few adjacent ones. Split across
// helpers by category so each stays under funlen.
func applyUserModeCases() []applyUserModeCase {
cases := applyUserModeHappyPathCases()
cases = append(cases, applyUserModeSignTransitionCases()...)
cases = append(cases, applyUserModeMalformedCases()...)
cases = append(cases, applyUserModeUnknownLetterCases()...)
return cases
}
// applyUserModeHappyPathCases covers valid single-char and
// multi-char-without-sign-transition mode operations.
func applyUserModeHappyPathCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "+w from empty",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+w",
wantModes: "+w",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: true},
},
{
name: "-w from +w",
initialState: applyUserModeCaseState{oper: false, wallops: true},
modeStr: "-w",
wantModes: "+",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "-o from +o",
initialState: applyUserModeCaseState{oper: true, wallops: false},
modeStr: "-o",
wantModes: "+",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "-wo from +ow",
initialState: applyUserModeCaseState{oper: true, wallops: true},
modeStr: "-wo",
wantModes: "+",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
}
}
// applyUserModeSignTransitionCases covers multi-char mode
// strings where '+' and '-' flip partway through. +o is
// never legal via MODE, so strings containing it must be
// rejected atomically.
func applyUserModeSignTransitionCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "+w-o from +o",
initialState: applyUserModeCaseState{oper: true, wallops: false},
modeStr: "+w-o",
wantModes: "+w",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: true},
},
{
// +o is rejected before any op applies; wallops
// stays set.
name: "-w+o always rejects +o",
initialState: applyUserModeCaseState{oper: true, wallops: true},
modeStr: "-w+o",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: true, wallops: true},
},
{
// Wallops must NOT be cleared; oper must NOT be
// cleared. Rejection is fully atomic.
name: "+o-w+w rejects because of +o",
initialState: applyUserModeCaseState{oper: true, wallops: true},
modeStr: "+o-w+w",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: true, wallops: true},
},
}
}
// applyUserModeMalformedCases covers inputs that lack a
// leading '+' or '-' and inputs that consist of bare signs
// without mode letters. All must be rejected with no side
// effects.
func applyUserModeMalformedCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "w no prefix rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "w",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
// Prove wallops is NOT cleared — the whole point
// of sneak's review.
name: "xw no prefix rejects (would have been" +
" silently -w before)",
initialState: applyUserModeCaseState{oper: false, wallops: true},
modeStr: "xw",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: true},
},
{
name: "empty string rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "bare + rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "bare - rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "-",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+-+ rejects (no mode letters)",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+-+",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
}
}
// applyUserModeUnknownLetterCases covers well-formed prefix
// strings that contain unknown mode letters. Rejection must
// be atomic: any valid letters before the invalid one must
// not persist.
func applyUserModeUnknownLetterCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "-x+y rejects unknown -x",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "-x+y",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+y-x rejects unknown +y",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+y-x",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+z unknown mode rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+z",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
// Wallops must NOT be set.
name: "+wz rejects whole thing; +w side effect" +
" must NOT persist",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+wz",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+wo rejects whole thing; +w side effect" +
" must NOT persist",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+wo",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
}
}
// TestApplyUserMode is the rigorous table-driven suite for
// the shared user-mode parser. It covers every case listed
// in sneak's review of PR #96 plus a few adjacent ones.
// Each case asserts the resulting mode string AND the
// persisted session state, to prove that rejected input
// leaves no side effects.
func TestApplyUserMode(t *testing.T) {
for _, testCase := range applyUserModeCases() {
t.Run(testCase.name, func(t *testing.T) {
runApplyUserModeCase(t, testCase)
})
}
}
// runApplyUserModeCase executes one applyUserModeCase: seed
// the session state, invoke ApplyUserMode, and verify both
// the returned value and the post-call persisted state.
func runApplyUserModeCase(
t *testing.T, testCase applyUserModeCase,
) {
t.Helper()
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
seedApplyUserModeState(ctx, t, env.db, sid, testCase.initialState)
result, err := env.svc.ApplyUserMode(
ctx, sid, testCase.modeStr,
)
verifyApplyUserModeOutcome(t, testCase, result, err)
verifyApplyUserModeState(ctx, t, env.db, sid, testCase.wantState)
}
// seedApplyUserModeState installs the pre-call session
// state described by initialState.
func seedApplyUserModeState(
ctx context.Context,
t *testing.T,
database *db.Database,
sid int64,
initialState applyUserModeCaseState,
) {
t.Helper()
if initialState.oper {
if err := database.SetSessionOper(
ctx, sid, true,
); err != nil {
t.Fatalf("init oper: %v", err)
}
}
if initialState.wallops {
if err := database.SetSessionWallops(
ctx, sid, true,
); err != nil {
t.Fatalf("init wallops: %v", err)
}
}
}
// verifyApplyUserModeOutcome asserts the direct return
// value of ApplyUserMode. It dispatches to the error- or
// success-specific verifier based on wantErr.
func verifyApplyUserModeOutcome(
t *testing.T,
testCase applyUserModeCase,
result string,
err error,
) {
t.Helper()
if testCase.wantErr {
verifyApplyUserModeError(t, testCase, err)
return
}
verifyApplyUserModeSuccess(t, testCase, result, err)
}
// verifyApplyUserModeError checks that err is a
// *service.IRCError whose code matches wantErrCode.
func verifyApplyUserModeError(
t *testing.T, testCase applyUserModeCase, err error,
) {
t.Helper()
var ircErr *service.IRCError
if !errors.As(err, &ircErr) {
t.Fatalf("expected IRCError, got %v", err)
}
if ircErr.Code != testCase.wantErrCode {
t.Errorf(
"code: want %d got %d",
testCase.wantErrCode, ircErr.Code,
)
}
}
// verifyApplyUserModeSuccess checks that err is nil and the
// returned mode string matches wantModes.
func verifyApplyUserModeSuccess(
t *testing.T,
testCase applyUserModeCase,
result string,
err error,
) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != testCase.wantModes {
t.Errorf(
"modes: want %q got %q",
testCase.wantModes, result,
)
}
}
// verifyApplyUserModeState asserts the post-call persisted
// session state. This is the atomicity guarantee sneak
// demanded: whether the call succeeded or was rejected, the
// DB must match wantState exactly.
func verifyApplyUserModeState(
ctx context.Context,
t *testing.T,
database *db.Database,
sid int64,
wantState applyUserModeCaseState,
) {
t.Helper()
gotOper, err := database.IsSessionOper(ctx, sid)
if err != nil {
t.Fatalf("read oper: %v", err)
}
gotWallops, err := database.IsSessionWallops(ctx, sid)
if err != nil {
t.Fatalf("read wallops: %v", err)
}
if gotOper != wantState.oper {
t.Errorf(
"oper: want %v got %v",
wantState.oper, gotOper,
)
}
if gotWallops != wantState.wallops {
t.Errorf(
"wallops: want %v got %v",
wantState.wallops, gotWallops,
)
}
}