// 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, ) } }